Skip to content

PHP Framework Migration

Migrate a Terra WordPress theme onto the shared PHP framework: a config-driven structure where ACF fields, post types, taxonomies, blocks, admin pages, AJAX, and endpoints are declared as config and built by the framework. The end goal is a functions/ directory with only two folders โ€” framework (cloned shared code) and project (site-specific code).

The work is organized in five phases: Setup โ†’ Config โ†’ Utilities & cleanup โ†’ Data migration โ†’ Deploy.


Have these ready before you start โ€” several steps stall without them:

  • Access to the shared framework repo (to clone in step 1).
  • ACF Pro active on the project.
  • The exported ACF field-group JSON for the project, split into four groups youโ€™ll feed into the config steps:
    • all post type fields (everything except flexible modules, flexible heros, and general options),
    • general options,
    • flexible heros,
    • flexible modules.
  • A working dev environment (the data-migration scripts in Phase 4 are dev-only).
  • A database backup โ€” Phase 4 writes directly to wp_postmeta.

functions/
โ”œโ”€โ”€ framework/ โ† cloned shared repo (gitignored)
โ””โ”€โ”€ project/
โ”œโ”€โ”€ admin-pages/
โ”‚ โ”œโ”€โ”€ dashboard.php โ† custom admin dashboard screen
โ”‚ โ””โ”€โ”€ csv-importer.php โ† CSV importer admin screen
โ”œโ”€โ”€ deploy/
โ”‚ โ”œโ”€โ”€ enqueues.php โ† moved from project/
โ”‚ โ”œโ”€โ”€ hash.php โ† moved from project/
โ”‚ โ””โ”€โ”€ local-variable.php โ† moved from project/
โ”œโ”€โ”€ config/
โ”‚ โ”œโ”€โ”€ index.php
โ”‚ โ”œโ”€โ”€ admin-controller_config.php
โ”‚ โ”œโ”€โ”€ admin-pages_config.php
โ”‚ โ”œโ”€โ”€ default_config.php
โ”‚ โ”œโ”€โ”€ post-types_config.php โ† replaces post-types.php
โ”‚ โ”œโ”€โ”€ taxonomy_config.php โ† replaces taxonomies.php
โ”‚ โ”œโ”€โ”€ post-type-fields_config.php
โ”‚ โ”œโ”€โ”€ dashboard_config.php
โ”‚ โ”œโ”€โ”€ wysiwyg-toolbars_config.php
โ”‚ โ”œโ”€โ”€ default-blocks_config.php
โ”‚ โ”œโ”€โ”€ custom-blocks_config.php
โ”‚ โ”œโ”€โ”€ ajax_config.php
โ”‚ โ”œโ”€โ”€ endpoint_config.php
โ”‚ โ”œโ”€โ”€ general-options/
โ”‚ โ”‚ โ””โ”€โ”€ index.php
โ”‚ โ”œโ”€โ”€ flexible-modules/
โ”‚ โ”‚ โ””โ”€โ”€ index.php
โ”‚ โ””โ”€โ”€ flexible-heros/
โ”‚ โ””โ”€โ”€ index.php
โ””โ”€โ”€ utilities/
โ”œโ”€โ”€ index.php
โ””โ”€โ”€ acf/ โ† custom ACF moved here
  • functions/project/post-types.php โ†’ moved into config/post-types_config.php
  • functions/project/taxonomies.php โ†’ moved into config/taxonomy_config.php
FromTo
functions/project/enqueues.phpfunctions/project/deploy/enqueues.php
functions/project/hash.phpfunctions/project/deploy/hash.php
functions/project/local-variable.phpfunctions/project/deploy/local-variable.php

Clone the shared framework repo into functions/framework/.

Then add it to .gitignore so the framework code is not tracked by the project repo:

functions/framework

Create the folder functions/project/admin-pages/ and add the following admin-screen files (copy-paste from the template):

  • dashboard.php โ€” custom admin dashboard screen
  • csv-importer.php โ€” CSV importer admin screen

Create functions/project/deploy/ and move these files into it from functions/project/:

  • enqueues.php
  • hash.php
  • local-variable.php

Update any require / include paths that pointed at the old locations.


This phase replaces the themeโ€™s hand-written declarations with config files under functions/project/config/, all wired up from config/index.php.

Create functions/project/config/ and add:

  • index.php โ€” requires all the config files below
  • admin-controller_config.php โ€” minimal; registers the projectโ€™s admin pages
  • admin-pages_config.php โ€” copy-paste from template
  • default_config.php โ€” copy-paste from template
  • post-types_config.php โ€” move all post types here, then delete post-types.php
  • taxonomy_config.php โ€” move all taxonomies here, then delete taxonomies.php

Create functions/project/config/post-type-fields_config.php for the post-type ACF fields.

Following the ACF-from-JSON workflow, use the post type JSON (everything except flexible modules, flexible heros, and general options) to generate this single config file.

Create functions/project/config/general-options/ with an index.php.

Using the general options JSON, ask Claude Code to generate one PHP file per tab inside general-options/ and to register each of them in general-options/index.php (one require line per file).

Create functions/project/config/flexible-modules/ with an index.php.

Using the flexible modules JSON, ask Claude Code to generate one PHP file per module inside flexible-modules/ and to register each of them in flexible-modules/index.php (one require line per file).

Create functions/project/config/flexible-heros/ with an index.php.

Using the flexible heros JSON, ask Claude Code to generate one PHP file per hero inside flexible-heros/ and to register each of them in flexible-heros/index.php (one require line per file).

FileHow
dashboard_config.phpCopy-paste, then update to the projectโ€™s general options
wysiwyg-toolbars_config.phpCopy-paste
default-blocks_config.phpCopy-paste
custom-blocks_config.phpCopy-paste, then add the projectโ€™s custom blocks here
ajax_config.phpMove the projectโ€™s AJAX actions here
endpoint_config.phpMove the projectโ€™s REST API endpoints here

Create functions/project/utilities/index.php and import all of the projectโ€™s utilities from there.

Move the projectโ€™s custom ACF into functions/project/utilities/acf/ and require them from the utilities index.php.

  • Confirm functions/ contains only the framework and project folders.
  • Confirm the projectโ€™s own custom ACFs are actually being used.
  • Replace generate_image_tag with render_wp_image across the project.

These two scripts repair existing post data so it resolves against the frameworkโ€™s field names. They are dev-only, one-shot scripts.

Run this only if there is a mismatch between the projectโ€™s spacing field name (e.g. spacing) and the frameworkโ€™s field name, which is always section_spacing. The frameworkโ€™s ACF_Builder::spacing() produces section_spacing, so legacy data stored under *_spacing keys wonโ€™t resolve.

Purpose: Renames legacy ACF spacing meta keys from *_spacing to *_section_spacing site-wide โ€” but only inside the flexible content fields modules and heros (matches modules_*_spacing, heros_*_spacing and their _modules_* / _heros_* field-key siblings, at any nesting depth). Includes drafts, pending, private, and future; excludes trash and revisions.

How to run: Create functions/project/utilities/migrate-spacing-global.php with the script below and require it from functions/project/utilities/index.php. Then โ€” logged in as an administrator (manage_options) โ€” visit:

/wp-admin/?run_migration=spacing_global

It processes 50 posts per request and prints a Next batch link until no posts remain. When you see โ€œNothing left to migrateโ€, remove the require line and delete the file.

Script: migrate-spacing-global.php
<?php
/**
* One-shot DEV migration: rename ACF meta keys `..._spacing` โ†’ `..._section_spacing`
* site-wide, but ONLY inside the flexible content fields `modules` and `heros`
* (matches `modules_*_spacing`, `heros_*_spacing` and their `_modules_*`,
* `_heros_*` field-key siblings, at any nesting depth).
*
* Includes drafts, pending, private, future. Excludes trash and revisions.
*
* USAGE (run in dev, then remove the require + this file):
* 1. Add to functions/project/utilities/index.php (temporarily):
* require THEME_PATH . '/functions/project/utilities/migrate-spacing-global.php';
* 2. Logged in as admin, visit:
* /wp-admin/?run_migration=spacing_global
* (the script will paginate; click "Next batch" link at the bottom of each page).
* 3. When you see "Nothing left to migrate", remove the require + this file.
*/
if (!defined('ABSPATH')) exit;
add_action('admin_init', function () {
if (empty($_GET['run_migration']) || $_GET['run_migration'] !== 'spacing_global') return;
if (!current_user_can('manage_options')) wp_die('Unauthorized');
global $wpdb;
$per_batch = 50;
$batch = max(1, intval($_GET['batch'] ?? 1));
header('Content-Type: text/plain; charset=utf-8');
// ---------------------------------------------------------------------
// Build the meta_key filter:
// key starts with one of {modules_, _modules_, heros_, _heros_}
// AND ends with _spacing
// AND does NOT end with _section_spacing
// ---------------------------------------------------------------------
$prefix_patterns = array(
$wpdb->esc_like('modules_') . '%' . $wpdb->esc_like('_spacing'),
$wpdb->esc_like('_modules_') . '%' . $wpdb->esc_like('_spacing'),
$wpdb->esc_like('heros_') . '%' . $wpdb->esc_like('_spacing'),
$wpdb->esc_like('_heros_') . '%' . $wpdb->esc_like('_spacing'),
);
$exclude_pattern = '%' . $wpdb->esc_like('_section_spacing');
$like_or = '(' . implode(' OR ', array_fill(0, count($prefix_patterns), 'meta_key LIKE %s')) . ')';
// ---------------------------------------------------------------------
// Find the next $per_batch post_ids that still have un-migrated keys.
// ---------------------------------------------------------------------
$ids_sql = "SELECT DISTINCT pm.post_id
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE {$like_or}
AND pm.meta_key NOT LIKE %s
AND p.post_type != 'revision'
AND p.post_status != 'trash'
ORDER BY pm.post_id ASC
LIMIT %d";
$ids_params = array_merge($prefix_patterns, array($exclude_pattern, $per_batch));
$post_ids = $wpdb->get_col($wpdb->prepare($ids_sql, $ids_params));
echo "Batch #{$batch} โ€” posts with un-migrated spacing in this batch: " . count($post_ids) . "\n\n";
if (empty($post_ids)) {
echo "Nothing left to migrate. โœ“\n";
echo "Safe to remove the require line and this file now.\n";
exit;
}
$total_renamed = 0;
$total_deleted = 0;
foreach ($post_ids as $post_id) {
$rows_sql = "SELECT meta_id, meta_key
FROM {$wpdb->postmeta}
WHERE post_id = %d
AND {$like_or}
AND meta_key NOT LIKE %s";
$rows_params = array_merge(array($post_id), $prefix_patterns, array($exclude_pattern));
$rows = $wpdb->get_results($wpdb->prepare($rows_sql, $rows_params));
if (empty($rows)) continue;
$post = get_post($post_id);
$title = $post ? ($post->post_title ?: '(no title)') : '(missing)';
$ptype = $post ? $post->post_type : '?';
echo "Post #{$post_id} [{$ptype}] โ€” {$title}\n";
foreach ($rows as $row) {
$old_key = $row->meta_key;
$new_key = preg_replace('/_spacing$/', '_section_spacing', $old_key);
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT meta_id FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1",
$post_id,
$new_key
));
if ($exists) {
$wpdb->delete($wpdb->postmeta, array('meta_id' => $row->meta_id));
$total_deleted++;
echo " - DELETED stale '{$old_key}' (target already exists)\n";
} else {
$wpdb->update(
$wpdb->postmeta,
array('meta_key' => $new_key),
array('meta_id' => $row->meta_id)
);
$total_renamed++;
echo " - RENAMED '{$old_key}' โ†’ '{$new_key}'\n";
}
}
clean_post_cache($post_id);
wp_cache_delete($post_id, 'post_meta');
echo "\n";
}
echo "------------------------------------------\n";
echo "Batch #{$batch} done. Renamed: {$total_renamed} | Stale duplicates deleted: {$total_deleted}\n\n";
// ---------------------------------------------------------------------
// Check if there's still work and show next batch link
// ---------------------------------------------------------------------
$remaining_sql = "SELECT COUNT(DISTINCT pm.post_id)
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE {$like_or}
AND pm.meta_key NOT LIKE %s
AND p.post_type != 'revision'
AND p.post_status != 'trash'";
$remaining = (int) $wpdb->get_var($wpdb->prepare($remaining_sql, array_merge($prefix_patterns, array($exclude_pattern))));
if ($remaining > 0) {
$next_url = admin_url('/?run_migration=spacing_global&batch=' . ($batch + 1));
echo "Posts still pending: {$remaining}\n";
echo "Next batch: {$next_url}\n";
} else {
echo "All done. โœ“ Safe to remove the require line and this file now.\n";
}
exit;
});

Run this so all migrated ACF values are saved/resolved correctly without manually re-saving every page in the editor.

Purpose: Repairs the ACF field-key reference rows (the _fieldname meta entries) so they point to the current field key, without modifying any stored data value.

Why itโ€™s needed: When an ACF field is renamed via ACF_Builder (e.g. spacing โ†’ section_spacing), the reference rows in wp_postmeta keep pointing to the old field key. ACF can then no longer resolve the field on read, breaking the front end until the post is manually re-saved in the editor. This script reproduces that re-save at the data level.

How to run: Create functions/project/utilities/refresh-acf-field-key-refs.php with the script below and require it from functions/project/utilities/index.php. Admin-only (manage_options). Runs in dry-run mode by default (reports changes without writing); add &apply=1 to persist.

  • Single post: /wp-admin/?run_refresh_refs=1&test_post_id=NN[&apply=1]
  • Site-wide: /wp-admin/?run_refresh_refs=1&per=100[&apply=1] โ€” processes in batches and prints a Next batch link until complete.
Script: refresh-acf-field-key-refs.php
<?php
/**
* One-shot DEV script: refresh the `_fieldname` field-key reference postmeta
* entries so they point to the CURRENT field_key of each ACF field โ€” without
* touching any actual data values.
*
* Why this exists: after renaming an ACF field via ACF_Builder (e.g. spacing
* โ†’ section_spacing), the reference rows in postmeta like `_modules_0_section_spacing`
* still contain the OLD field_key. ACF then can't resolve the field on read
* and the template breaks until you manually click Update in the editor (which
* refreshes the references).
*
* SAFETY:
* - Default mode is DRY RUN: shows what would change, writes nothing.
* - Pass `&apply=1` to actually persist changes.
* - Only writes to keys that START WITH UNDERSCORE (`_modules_*`, `_heros_*`,
* `_section_spacing`, etc.). The actual data values are never touched.
*
* USAGE:
* 1. Add to functions/project/utilities/index.php (temporarily):
* require THEME_PATH . '/functions/project/utilities/refresh-acf-field-key-refs.php';
*
* 2. TEST on one post first (dry run):
* /wp-admin/?run_refresh_refs=1&test_post_id=NN
* Then with apply:
* /wp-admin/?run_refresh_refs=1&test_post_id=NN&apply=1
* Open the post's front URL (without clicking Update) and verify.
*
* 3. SITE-WIDE dry run:
* /wp-admin/?run_refresh_refs=1&per=100
* Site-wide apply:
* /wp-admin/?run_refresh_refs=1&per=100&apply=1
*
* 4. When done, remove the require and this file.
*/
if (!defined('ABSPATH')) exit;
// -------------------------------------------------------------------------
// Recursive walker: for each registered ACF field that applies to $post_id,
// check if the reference postmeta `_<path>` value matches the current field_key.
// If not, mark for update (or apply if !$dry_run).
//
// Returns array of changes: each item = ['ref_key' => ..., 'old' => ..., 'new' => ...]
// -------------------------------------------------------------------------
if (!function_exists('rb_refresh_refs_for_post')) {
function rb_refresh_refs_for_post($post_id, $dry_run = true) {
if (!function_exists('acf_get_field_groups')) return array();
$changes = array();
$groups = acf_get_field_groups(array('post_id' => $post_id));
foreach ($groups as $group) {
$fields = acf_get_fields($group['key']);
if (!$fields) continue;
foreach ($fields as $field) {
rb_refresh_refs_walk($field, $post_id, '', $dry_run, $changes);
}
}
return $changes;
}
}
if (!function_exists('rb_refresh_refs_walk')) {
function rb_refresh_refs_walk($field, $post_id, $parent_path, $dry_run, &$changes) {
$name = isset($field['name']) ? $field['name'] : '';
$key = isset($field['key']) ? $field['key'] : '';
$type = isset($field['type']) ? $field['type'] : '';
if ($name === '' || $key === '') return;
$data_key = $parent_path . $name; // e.g. modules_0_section_spacing
$ref_key = '_' . $data_key; // e.g. _modules_0_section_spacing
// Only update the ref if the underlying data row exists (don't add new refs
// for fields the post never had data for โ€” those are handled by ACF on read).
if (metadata_exists('post', $post_id, $data_key)) {
$current_ref = get_post_meta($post_id, $ref_key, true);
if ($current_ref !== $key) {
$changes[] = array(
'ref_key' => $ref_key,
'old' => $current_ref,
'new' => $key,
);
if (!$dry_run) {
update_post_meta($post_id, $ref_key, $key);
}
}
}
// Recurse into containers
if ($type === 'flexible_content') {
// The data row for flex content stores an array of layout names (in order)
$rows = get_post_meta($post_id, $data_key, true);
if (!is_array($rows)) return;
foreach ($rows as $i => $layout_name) {
if (!is_string($layout_name)) continue;
foreach (($field['layouts'] ?? array()) as $layout) {
if (($layout['name'] ?? '') !== $layout_name) continue;
foreach (($layout['sub_fields'] ?? array()) as $sub) {
rb_refresh_refs_walk($sub, $post_id, $data_key . '_' . $i . '_', $dry_run, $changes);
}
}
}
return;
}
if ($type === 'repeater') {
// Repeater data row stores the number of rows as an integer
$count = (int) get_post_meta($post_id, $data_key, true);
for ($i = 0; $i < $count; $i++) {
foreach (($field['sub_fields'] ?? array()) as $sub) {
rb_refresh_refs_walk($sub, $post_id, $data_key . '_' . $i . '_', $dry_run, $changes);
}
}
return;
}
if ($type === 'group') {
foreach (($field['sub_fields'] ?? array()) as $sub) {
rb_refresh_refs_walk($sub, $post_id, $data_key . '_', $dry_run, $changes);
}
return;
}
}
}
add_action('admin_init', function () {
if (empty($_GET['run_refresh_refs'])) return;
if (!current_user_can('manage_options')) wp_die('Unauthorized');
if (!function_exists('acf_get_field_groups')) wp_die('ACF not active.');
header('Content-Type: text/plain; charset=utf-8');
$apply = !empty($_GET['apply']) && $_GET['apply'] === '1';
$dry_run = !$apply;
echo "MODE: " . ($dry_run ? "DRY RUN (no changes written)" : "APPLY (changes will be persisted)") . "\n";
echo str_repeat('=', 60) . "\n\n";
// -------------------------------------------------------------------------
// TEST MODE: single post
// -------------------------------------------------------------------------
if (!empty($_GET['test_post_id'])) {
$test_id = intval($_GET['test_post_id']);
$post = get_post($test_id);
if (!$post) {
echo "Post #{$test_id} not found.\n";
exit;
}
echo "Post #{$test_id} [{$post->post_type}] โ€” {$post->post_title}\n";
echo "Edit URL: " . admin_url('post.php?post=' . $test_id . '&action=edit') . "\n";
echo "Front URL: " . get_permalink($test_id) . "\n\n";
clean_post_cache($test_id);
wp_cache_delete($test_id, 'post_meta');
$changes = rb_refresh_refs_for_post($test_id, $dry_run);
if (empty($changes)) {
echo "โœ“ No reference rows need updating on this post.\n";
echo " (Either nothing changed, or all references already point to current field keys.)\n";
exit;
}
echo (count($changes)) . " reference row(s) " . ($dry_run ? "WOULD be updated" : "updated") . ":\n";
foreach ($changes as $c) {
$old = $c['old'] !== '' ? $c['old'] : '(empty)';
echo " {$c['ref_key']}\n";
echo " old: {$old}\n";
echo " new: {$c['new']}\n";
}
if ($dry_run) {
echo "\nโ†’ Re-run with &apply=1 to persist these changes.\n";
} else {
echo "\nโœ“ Applied. Open the Front URL above in incognito (no editor click) and verify the page renders.\n";
}
exit;
}
// -------------------------------------------------------------------------
// SITE-WIDE mode (paginated)
// -------------------------------------------------------------------------
$per_batch = max(1, min(1000, intval($_GET['per'] ?? 50)));
$batch = max(1, intval($_GET['batch'] ?? 1));
$offset = isset($_GET['offset']) ? max(0, intval($_GET['offset'])) : ($batch - 1) * $per_batch;
$post_types = array('page');
$cpt_config_path = get_template_directory() . '/functions/project/config/post-types_config.php';
if (file_exists($cpt_config_path)) {
$cpt_config = include $cpt_config_path;
if (is_array($cpt_config)) {
foreach ($cpt_config as $pt) {
if (!empty($pt['post_type'])) $post_types[] = $pt['post_type'];
}
}
}
$post_types = array_values(array_unique($post_types));
$statuses = array('publish', 'draft', 'pending', 'private', 'future', 'trash');
$post_ids = get_posts(array(
'post_type' => $post_types,
'post_status' => $statuses,
'posts_per_page' => $per_batch,
'offset' => $offset,
'fields' => 'ids',
'orderby' => 'ID',
'order' => 'ASC',
'suppress_filters' => true,
'no_found_rows' => true,
));
$total_query = new WP_Query(array(
'post_type' => $post_types,
'post_status' => $statuses,
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => false,
'suppress_filters' => true,
));
$total = (int) $total_query->found_posts;
echo "Total posts in scope: {$total}\n";
echo "Batch (offset {$offset}, per_batch {$per_batch}): " . count($post_ids) . " posts\n\n";
if (empty($post_ids)) {
echo "Nothing left. โœ“ All done.\n";
exit;
}
$posts_with_changes = 0;
$total_ref_changes = 0;
foreach ($post_ids as $post_id) {
clean_post_cache($post_id);
wp_cache_delete($post_id, 'post_meta');
$changes = rb_refresh_refs_for_post($post_id, $dry_run);
if (empty($changes)) {
echo "ยท #{$post_id} โ€” no changes needed\n";
continue;
}
$posts_with_changes++;
$total_ref_changes += count($changes);
$post = get_post($post_id);
$ptype = $post ? $post->post_type : '?';
$title = $post ? ($post->post_title ?: '(no title)') : '(missing)';
$verb = $dry_run ? 'would update' : 'updated';
echo ($dry_run ? '?' : 'โœ“') . " #{$post_id} [{$ptype}] {$title} โ€” {$verb} " . count($changes) . " ref row(s)\n";
}
echo "\n" . str_repeat('-', 60) . "\n";
echo "Batch done. Posts with changes: {$posts_with_changes} | Total ref rows " . ($dry_run ? "to update" : "updated") . ": {$total_ref_changes}\n\n";
$processed_so_far = $offset + count($post_ids);
if ($processed_so_far < $total) {
$next_offset = $processed_so_far;
$next_qs = "run_refresh_refs=1&per={$per_batch}&offset={$next_offset}" . ($apply ? '&apply=1' : '');
echo "Progress: {$processed_so_far} / {$total}\n";
echo "Next batch: " . admin_url('/?' . $next_qs) . "\n";
} else {
if ($dry_run) {
echo "Dry run complete. Re-run with &apply=1 (from offset=0) to persist changes.\n";
} else {
echo "All done. โœ“ Safe to remove the require line and this file now.\n";
}
}
exit;
});

Before pushing the migrated project, keep these in mind:

  • Update the hash.php path in gulpfile.js to its new location functions/project/deploy/hash.php, so the build hash is written to the correct file after the move in step 3.
  • Run a full PHP deploy (deploy all PHP, not just changed files) so the new framework/ and restructured project/ files all land on the server.
  • Manually move any asset files Gulp may have skipped into functions/framework/ and functions/project/. The deploy task can omit non-standard assets, so verify they exist on the server and copy them by hand if missing.
  • Delete every folder and file removed during the migration from the repo so the project doesnโ€™t carry unused files (old post-types.php, taxonomies.php, the pre-move deploy files, the one-shot migration scripts, etc.).

  • functions/framework/ is cloned and listed in .gitignore
  • functions/ contains only framework/ and project/
  • admin-pages/ has dashboard.php and csv-importer.php
  • deploy/ holds enqueues.php, hash.php, local-variable.php and all paths still resolve
  • OPENAI_API_KEY is defined and not committed as a real value
  • post-types.php and taxonomies.php are deleted; their content lives in config/
  • config/index.php requires every config file
  • Post-type, general-options, flexible-modules, and flexible-heros ACFs are generated from JSON via the templates
  • general-options/ has one file per tab; flexible-modules/ has one file per module; flexible-heros/ has one file per hero
  • AJAX actions live in ajax_config.php; REST endpoints in endpoint_config.php
  • Custom blocks are registered in custom-blocks_config.php
  • utilities/index.php imports all utilities; custom ACF moved to utilities/acf/
  • No remaining calls to generate_image_tag โ€” all replaced by render_wp_image
  • (If spacing field names mismatched) spacing migration run until โ€œNothing left to migrateโ€
  • ACF field-key reference refresh applied site-wide; a page renders from its front URL without re-saving in the editor
  • Both one-shot scripts and their require lines removed after running
  • Full PHP deploy run so all framework/ and restructured project/ files are on the server
  • Any assets Gulp skipped manually copied into functions/framework/ and functions/project/ on the server
  • All folders/files removed during the migration are deleted from the repo โ€” no unused files left behind

Knowledge Check

Test your understanding of this section

Loading questions...