Data migrations#
Renaming a field, moving one into a group, or converting a repeater to flexible content all leave the saved data behind. ACF reports nothing: the values are still in wp_postmeta under their old keys, and nothing looks for them there any more.
This happens because ACF addresses data by name path, not by field key. A field called image at the top of a group stores its value under image. Move it into a media group and ACF starts looking under media_image — a row that does not exist.
ACF Chef can close that gap because it holds the field group as a tree rather than as an array, so the old shape and the new shape are comparable. A migration is a description of the difference.
What you need first#
Two things, both one-time.
Declare your field groups so the tooling can find them. A group built inside an acf/init callback is a local variable that ran once; from a CLI process it never existed.
use AcfChef\Chef;
Chef::groups([
HeroGroup::class,
ThemeSettings::group(),
fn () => Modules::for($post_types),
]);
Class names, instances and callables are all accepted. Declaring is not registering — nothing here reaches ACF — so put the call somewhere both a web request and WP-CLI will run it, and register from the same list.
Then record how those groups compile today, and commit the result:
wp acf-chef lock
That writes acf-chef.lock.json. It is the only thing that will remember a group's old shape once your code has moved on, which is what makes writing a migration possible at all.
Keep it current. wp acf-chef lock --check compares instead of writing and exits non-zero when the lockfile is stale, which makes it a one-line CI job.
Writing a migration#
Change the field group first, then ask for a migration:
wp acf-chef migrate-make hero_media_group
Two files appear in database/acf-migrations:
- a
.phpfile — the migration you are about to write - a
.jsonfile — the structure your data was written under, frozen
The snapshot is taken from the committed lockfile before the lockfile is brought up to date. That ordering is the whole trick: at that moment the lockfile still remembers the old shape, and a minute later nothing does.
The generated migration tells you what moved:
/**
* 2026_08_16_143000_hero_media_group
*
* Gone: image
* Appeared: media.background
*/
$migration = Migration::make()
->group(HeroGroup::class);
// This reads as one field moving. Check it, then uncomment:
// $migration->move('image', 'media.background');
return $migration;
Where exactly one row disappeared and one appeared with the same field type, the move() is written for you — commented out. Uncomment it once you agree. A generator that guessed and was wrong would produce a file that looks reviewed and moves the wrong data.
Moving a field#
move() is for a field that changed address but not shape:
use AcfChef\Migration\Migration;
use App\Fields\HeroGroup;
return Migration::make()
->group(HeroGroup::class)
->scope(fn ($scope) => $scope->postType('page', 'post'))
->move('image', 'media.background');
Paths are relative to the named group and use the same dots find() and modify() take. The left side resolves against the snapshot, the right against the group as your code describes it now — so a typo on either side is an error when the migration loads, not a surprise against production data.
Rewriting a value#
When the shape changes, hand it a closure:
return Migration::make()
->group(Hero::class)
->scope(fn ($scope) => $scope->postType('page', 'floorplan'))
->rewrite('background', function (array $old): array {
$layers = [];
foreach ($old['images'] ?? [] as $image) {
$layers[] = [
'acf_fc_layout' => 'image',
'image' => $image['image'],
'type' => $image['type'] ?? 'fill',
];
}
if (! empty($old['color'])) {
$layers[] = ['acf_fc_layout' => 'color', 'color' => $old['color']];
}
return $layers;
});
The closure receives the old value and returns the new one. What it never mentions is a meta key, a row index, a field key or a reference row — those are the engine's business, and cleaning up what the old structure leaves behind is a comparison it makes itself.
What a transform receives#
Values arrive keyed by field name and unformatted.
Names, because keys are exactly what a restructure changes, and because you never chose them. Unformatted, because a migration moves data rather than displaying it: an image field arrives as its attachment ID, not as the array get_field() would hand a template, and a post object arrives as an ID rather than a WP_Post. Those formatted shapes cannot be written back.
A flexible-content row keeps its acf_fc_layout, which is ACF's own vocabulary and the only record of which layout a row is.
Choosing what to migrate#
scope() narrows the entities a migration may touch:
->scope(fn ($scope) => $scope
->postType('page', 'post')
->options()
->users()
->terms('category'))
It is a safety filter rather than a selector. The meta keys already identify the rows; the scope is what stops a migration for hero_image touching an unrelated hero_image some other plugin wrote. An untouched scope means every entity of every kind.
ACF location rules are deliberately not used here. Several of them — post_template, page_parent, user_role — are evaluated against an editing screen and have no query form at all, so a scope built from them could not be applied. migrate-make does read your group's post_type rules and writes the matching scope into the generated file.
Revisions are included#
Revisions and autosaves are migrated by default. Skipping them would leave an author one “restore this revision” click away from putting the old shape back into a field group that no longer reads it — breaking their own page with a button WordPress told them was safe.
The cost is volume: forty revisions on a post is forty times the rows. ->withoutRevisions() opts out.
What is refused#
Some things a migration could be asked to do have more than one reasonable reading, and guessing at them would move data quietly to the wrong place. Those throw when the migration loads:
- Paths inside a repeater or flexible-content field. Restructuring across a repeating boundary needs index arithmetic and an answer to “which row?” that neither structure supplies. The field itself may be rewritten; a path through it may not.
- Tabs, accordions and messages. They store nothing, so there is nothing to migrate — reorganise them freely.
- Clone fields. Where a clone puts its data depends on its
prefix_namesetting rather than on its type. - Moves that change shape. Moving a group onto a repeater carries a value across untouched, and the two ends do not agree about what a value is. Use
rewrite()with a transform. - Operations that overlap. Two operations addressing the same rows are applied in order, so the later silently wins — which of the two survives would depend on the order you declared them.
Moving a tab migrates nothing#
Worth knowing before you write a migration you do not need. ACF builds a meta key by concatenating field names down the tree, but only some containers take part.
groupnests its children under its own name.repeaterandflexibleContentnest a name and a row index.layoutcontributes nothing: a layout’s subfields hang off the flexible-content field, never off the layout name.tab,accordionandmessagecontribute nothing at all.
So reorganising tabs — a large share of real field group churn — moves no data whatsoever, while moving one field into a group moves all of it.
If you register a third-party field type whose behaviour its ACF type name does not give away, you can say so:
use AcfChef\Storage;
Chef::storage('acfe_column', Storage::Transparent);
Everything ACF itself defines is classified already, and a verb registered onto Repeater::class inherits the repeater's behaviour without being told.
Next#
Writing a migration is half of it. See Running migrations for how one is applied, what your site serves while it is still running, and how to rehearse it first.