Migrating from Toolset Types to Advanced Custom Fields

By John Ouellet
July 07, 2020

Blog Post - Migrating from Toolset Types to Advanced Custom Fields

Overview

We recently finished a project retheming a legacy WordPress site. On top of the retheme, the site needed a lot of love in the admin experience arena. Typically, when we build out new WordPress sites, Advanced Custom Fields (ACF) is one of our go-to plugins. The site we were fixing used Toolset Types for their Custom Post Types (CPT) and Fields. This presented a new challenge: how to migrate existing field data from the custom post types and fields created with Toolset Types to Advanced Custom Fields.

Fortunately, I was able to discover a solution using a little Google-fu combined with a healthy dose of trial and error.

Setup

To start this "retheme" on a clean slate, we had created a new WordPress site and migrated the tables we needed over. We have an internal plugin we use that utilizes multiple database containers via Lando and WP CLI to help migrate data between WordPress instances; unfortunately I haven't had the time to turn this into a public plugin. For now, use any migration plugin you're comfortable with. I recommend Migrate DB Pro if you don't have a go-to plugin.

All the data we will need is stored in the wp_postmeta table. We will be recreating the CPTs manually, so any standalone Toolset tables are not needed. Obviously you will move other tables if you are rebuilding the site. Theoretically, you could do this on the original website that has Toolset instead of migrating over to a new WordPress instance. However, I didn't try it that way, so tread with caution if you do.

Create CPTs Manually

The first thing we need to do is recreate the CPTs manually. When you create these, the slugs have to be exactly how they are in Toolset. I noticed that Toolset uses hyphens instead of underscores. Regardless of how they are formatted on your instance, make careful note of the format.

We use a pretty standard action hook for registering CPTs throughout our projects:

/**
 * Register Custom Post Types.
 */
function register_cpts() {
  $types = [
    'type' => [
      'icon' => 'dashicons-location-alt',
    ],
    'stuff' => [
      'icon' => 'dashicons-admin-site-alt',
      'slug_base' => '/stuff/'
    ],
    'things' => [
      'icon' => 'dashicons-format-status',
      'plural' => 'things',
      'has_archive' => 'things'
    ],
  ];

  foreach ($types as $type => $data) {
    $slug = str_replace(["/", "  ", " "], ["", " ", "-"], $type);
    $plural = isset($data['plural']) ? $data['plural'] : $type . 's';
    $labels = [
      'name' => ucwords($plural),
      'singular_name' => ucwords($type),
      'add_new_item' => 'Add New ' . ucwords($type),
      'edit_item' => 'Edit ' . ucwords($type),
      'new_item' => 'New ' . ucwords($type),
      'view_item' => 'View ' . ucwords($type),
      'search_items' => 'Search ' . ucwords($plural),
      'not_found' => 'No ' . strtolower($plural) . ' found',
      'not_found_in_trash' => 'No ' . strtolower($plural) . ' found in Trash',
      'parent_item_colon' => 'Parent ' . ucwords($type) . ':',
      'all_items' => 'All ' . ucwords($plural),
      'archives' => ucwords($type) . ' Archives',
    ];

    $args = [
      'labels' => $labels,
      'description' => 'Sortable/filterable ' . $plural,
      'public' => true,
      'has_archive' => isset($data['has_archive']) ? $data['has_archive'] : false,
      'show_ui' => isset($data['show']) ? $data['show'] : true,
      'show_in_nav_menus' => isset($data['show']) ? $data['show'] : true,
      'show_in_menu' => isset($data['show']) ? $data['show'] : true,
      'show_in_admin_bar' => isset($data['show']) ? $data['show'] : true,
      'menu_position' => 20,
      'menu_icon' => $data['icon'],
      'hierarchical' => true,
      'rewrite' => [
        'slug' => isset($data['slug_base']) ? $data['slug_base'] . $slug : $slug,
        'with_front' => false,
        'feeds' => true,
      ],
      'query_var' => true,
      'show_in_rest' => true,
      'taxonomies'  => [
        'category',
        'post_tag'
      ],
      'supports' => [
        'title',
        'editor',
        'author',
        'thumbnail',
        'excerpt',
        'revisions',
        'custom-fields',
        'page-attributes'
      ],
    ];

    register_post_type($slug, $args);
    flush_rewrite_rules();
  }
}
add_action('init', 'register_cpts');

As you can see in the $types array, we are defining the slug name and then some options. We found that you really only need to tweak a couple settings on a CPT. I used some generic examples so you get a gist of what's going on. Also note we are using a hyphen instead of an underscore in the str_replace function.

Also note, we set show_in_rest to true. Since Gutenberg was introduced I've noticed some developers setting show_in_rest to FALSE to disable the Gutenberg editor. While that works, doing so will disable the entire REST API for that CPT and that could hurt you down the road. The correct way to turn off Gutenberg for CPTs is as follows:

/**
 * Turns off Gutenberg for custom post types.
 */
function turn_off_gutenberg($use_block_editor, $post_type) {
  switch ($post_type) {
    case 'type':
    case 'stuff':
      return false;
      break;
  }
  return $use_block_editor;
}
add_filter('use_block_editor_for_post_type', 'turn_off_gutenberg', 10, 2);

As you can see, I am turning off Gutenberg for the "type" and "stuff" CPTs. The "things" CPT will have Gutenberg on in this example. With that, our CPTs are setup as they were with Toolset's Types. Just make sure your settings are the same and you are ready to move onto the next step.

Create ACF Groups & Fields

The next step in this journey is to manually recreate your Toolset Fields and Groups in ACF. This is more tedious than anything, especially if you have a bunch of fields. While there are programmatic ways to generate ACF fields, it is better to just do this manually.

The first thing I did was create the groups and made sure they were named the same as the CPT's. I then added the Location rules so they were mapped to their respective CPTs.

So the next part was going through and creating all the fields within those groups. The biggest take away from this is to make sure your field name has wpcf- before it. For example: If the field slug on the Toolset config was contact-type; the ACF field name will be wpcf-contact-type. That is the magic that will line up the wp_postmeta correctly.

Make sure all your fields data is exactly the same. Especially in checkboxes, radios, selects, etc. If you don't then the data won't match up properly and you will have empty fields.

With that, we are almost done. The only thing left to do is some data cleanup on specific field types.

Fixing Certain Field Meta Values

ACF stores some field types meta values different in the wp_postmeta table. For our use case, the two field types we came across were checkboxes and files. In Toolset, the checkboxes were stored in a serialized multidimensional array that had wpcf-fields-checkboxes-option as a key. Files stored the whole file url as a meta value. In contrast, ACF only stores the serialized checkbox values and the attachment post id for files.

The easiest way to fix this is either via WP CLI or through a settings form. I whipped up a standard settings form so you can just slap this in place:

/**
 * Used to cleanup the checkboxes/files on the toolset types to acf migration.
 */

/**
 * Class MigrationCleanup
 */
class MigrationCleanup {

  /**
   * WordPress DB class.
   *
   * @var wpdb
   */
  protected $wpdb;

  /**
   * The checkbox fields.
   *
   * @var array
   */
  protected $fields = [
    'wpcf-field-one',
    'wpcf-field-two',
    'wpcf-field-three',
  ];

  /**
   * MigrationCleanup constructor.
   */
  public function __construct() {
    global $wpdb;
    $this->wpdb = $wpdb;
    add_action('admin_menu', [$this, 'createSettingsPage']);
  }

  /**
   * Sets up the settings sub page.
   */
  public function createSettingsPage() {
    $parent_slug = 'options-general.php';
    $page_title = 'Migration Cleanup';
    $menu_title = 'Migration Cleanup';
    $capability = 'manage_options';
    $slug = 'migration cleanup';
    $callback = [$this, 'settingsPageRender'];
    $position = 100;
    add_submenu_page( $parent_slug, $page_title, $menu_title, $capability, $slug, $callback, $position );
  }

  /**
   * Renders our settings page.
   */
  public function settingsPageRender() {
    ?>
      <div class="wrap">
        <h2>Migration Cleanup</h2>
        <p>This page is used to fix the typeset checkboxes that do not work within ACF.</p>
        <?php
          if (isset($_POST['submit'])) {
            $this->fixCheckboxes();
            $this->fixFiles();
          }
        ?>
        <form method="POST" action="options-general.php?page=migration+cleanup">
          <?php submit_button( 'Fix Migrations' ); ?>
        </form>
      </div>
    <?php
  }

  /**
   * Fix our checkboxes.
   */
  public function fixCheckboxes() {
    // Formats our IN query properly.
    $final = array_map(function($field) {
      return "'" . esc_sql($field) . "'";
    }, $this->fields);

    // Grabs all our post meta by the fields.
    $query = sprintf("SELECT * FROM `%s` WHERE meta_key IN (%s)",
      $this->wpdb->postmeta,
      implode(",", $final)
    );

    // Now lets go through the results.
    foreach ($this->wpdb->get_results($query) as $postmeta) {
      $results = [];
      $meta_data = unserialize($postmeta->meta_value);

      // If no data, then continue
      if ($meta_data === false || empty($meta_data)) {
        continue;
      }

      // Now populate our array to reuse.
      foreach ($meta_data as $key => $data) {
        // Only process sad ones.
        if (strpos($key, 'wpcf-fields-checkboxes-option') !== false) {
          foreach ($data as $datum) {
            $results[] = $datum;
          }
        }
      }

      // Engage.
      if (!empty($results)) {
        update_post_meta($postmeta->post_id, $postmeta->meta_key, $results);
      }
    }
  }

  /**
   * Fixes our file fields.
   */
  public function fixFiles() {
    $query = sprintf("SELECT * FROM `%s` WHERE meta_key = '%s'",
      $this->wpdb->postmeta,
      'wpcf-associated-file'
    );

    foreach ($this->wpdb->get_results($query) as $postmeta) {
      $meta = str_replace('https://YOUR-OLD-SITE-URL/wp-content/uploads/', '', $postmeta->meta_value);
      $id = attachment_url_to_postid($meta);
      if ($id !== 0) {
        update_post_meta($postmeta->post_id, $postmeta->meta_key, $id);
      }
    }
  }
}
new MigrationCleanup();

Outside of the typical settings form setup, the code does a few things. First, in our fields property, we are defining the field names that are checkboxes. In our constructor, we are hooking into our database via $wpdb and setting it as a property. Hitting "Submit" on the settings form will run the magic that fixes the data. Also note, in the fixFiles method, make sure you update the url in str_replace to match the data in your old site.

You can see by the code that we are just massaging the data to fit how ACF stores the meta values. If you come across any other field types that need some TLC, then just use this code to add your own method.

Conclusion

While there is a some coding heavy lifting to do, migrating from Toolset to ACF isn't impossible. If you have any questions on this post or need help with complex WordPress work, please fill out the form below.


Let's Talk!