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 make: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 make: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 make:migration 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.
Renaming a field#
rename() takes a path and a new name. The field stays exactly where it is:
->rename('media.background', 'image')
It is not sugar over move(). A rename relabels meta keys and never runs ACF's writer: no field type's write filter sees the value, and the repeater count and the layout list are left as they are. Three things follow from that. It is idempotent. It works on a container, so renaming a group or a flexible-content field carries its whole subtree in one line. And because a name cannot change which row a value is in, it may reach inside a repeater where a move() may not.
A rename onto a name a sibling already holds is refused: both fields would end up at one address and one of the two values would be lost. Rename the occupant first.
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.
A rewrite() changes a value in place. If it receives a value already in the new shape, it changes that value again. On the supported path, this affects at most one entity, and only after a crash. Limits and data-loss risks describes each case and how to prevent it.
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.
Reshaping into a new address#
rewrite() works in place and move() refuses a shape change, so a value that changes its name and its shape has a verb of its own:
->reshape('background.images', 'banners', fn (array $rows): array => array_map(
fn (array $row): array => ['acf_fc_layout' => 'image', ...$row],
$rows,
))
Read it as a move() with a transform, or a rewrite() that may land somewhere else. Both endpoints resolve the way move()'s do β the left against the snapshot, the right against your code (or the next migration's snapshot, when another migration on the group follows) β and the shape check that move() applies is skipped, because the closure is what reconciles the two.
Reaching inside a repeater or flexible-content field#
A path that reaches through a repeater or a flexible-content field has to say so, with a row selector:
// One layout's copy of the field.
->rename('background[layout=image].device', 'responsive')
// Or every layout that has one. Not both at once: they overlap.
->rename('background[layout=*].device', 'responsive')
// Two indices deep, through a repeater inside a layout.
->rewrite('background[layout=gradient].stops[].color', $toHex)
[]β every row of a repeater.[layout=image]β the rows of a flexible-content field that areimagerows.[layout=*]β its rows whatever their layout. For a rename, that means every layout holding a field of that name.
A selector is required wherever a path crosses, and unavailable everywhere else. So there is one legal spelling per address, and when you get it wrong the error prints the one that would have worked.
That rule costs a few keystrokes and buys something back: the depth of an address is visible where the address is. background[layout=gradient].stops[].color says on sight that it touches one field, of one layout, of every gradient row. Worth knowing about a line whose next run edits production data.
A layout is not a dotted segment here. find(), modify() and conditional targets spell it background.image.device, because the definition tree has no rows to choose between and the image segment already says everything. A migration writes background[layout=image].device. Two vocabularies for two jobs, not two spellings of one.
Addressing the repeating field itself stays selector-free, because it crosses nothing β its own row is the row count or the layout list:
->rename('background.images', 'layers')
Selection is structural, never by value. A row's layout decides which fields it has, so [layout=image] narrows a structure; a filter on a stored value would be a query language. There is no way to target one particular row either. [] means all of them, and a migration that needs only row 3 is a migration doing something else.
Which crossings are allowed#
A value may cross a boundary when both ends sit in the same rows. rename() and rewrite() guarantee that by their signatures β a name cannot change the parent, and a rewrite has one address β so they cross freely. move() and reshape() are checked, and refused when the two ends disagree.
Which makes moving a field from one layout into another an error:
// Refused: those are different rows.
->move('background[layout=image].device', 'background[layout=color].device')
Both render the meta key background_%d_device, and they are still two different fields: an image row and a colour row are not the same row, so there is no row on the right that corresponds to a row on the left. The same goes for moving a field out of a repeater, or into one β one value per row on one side and one value per entity on the other.
When that is what you want, rewrite the field that holds both. The closure sees every row and can decide for each:
->rewrite('background', fn (array $rows): array => array_map($merge, $rows))
Renaming a layout is not supported#
There is no verb for it, and no selector spelling that would mean it. Renaming the image layout to photo moves no meta keys at all β a layout contributes nothing to an address β but it does change the contents of the flexible-content field's own row, which records each row's layout by name, and the _background_layout_meta sidecar beside it. Different mechanics, and not something this feature does.
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. make:migration 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 before a single row is read β the runner checks every operation against both structures when it reaches the migration, and refuses the lot rather than starting:
- Crossings between different rows. Moving a value from one layout of a flexible-content field into another, or out of a repeater, has no answer to βwhich row?β β see which crossings are allowed. Crossing a boundary is otherwise fine, as long as the path says it does.
- Paths that cross without a row selector, and selectors on paths that cross nothing. One spelling per address; the error names the other one.
- Renames onto an occupied name. Two fields would end up at one address, and the later write would win.
- 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
reshape(), which is the same move with a transform between the ends. - 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. Register the verb first: a migration reads a compiled group, where the only clue is the ACF type, and the verb is what tells ACF Chef which type your class emits.
use AcfChef\Storage;
Chef::register('acfeColumn', AcfeColumn::class);
Chef::storage('acfeColumn', Storage::Transparent);
Set against a verb with no class behind it, the override is unreachable from a migration: nothing maps the type in the committed JSON back to the verb you named, so the field is read as an ordinary one.
Everything ACF itself defines is classified already, and a verb registered onto Repeater::class inherits the repeater's behaviour without being told. The cases are:
Valueβ a leaf with a meta row of its own. The default for a type nobody has classified.Markerβ presentational, no row and no contribution to any address.tab,accordion,message.Nestedβ agroup: its name prefixes its children's, and it holds no row itself.Repeatingβ arepeater: it prefixes its children's names, puts a row index between the two, and its own row is the number of rows.Layeredβ aflexible_contentfield. It repeats the same way, but its own row is the positional list of layout names.Transparentβ alayout: it holds fields and contributes nothing to an address.Clonedβ aclone, recognised so it can be refused. Where a clone puts its data depends on a setting rather than on its type.
Pick Layered, not Repeating, for anything that stores rows of differing layouts. They look interchangeable and are not: a repeater's own row is a count, a flexible-content field's is the record of which layout each row is, and a migration that carries one onto the other writes no rows and then sweeps the old ones. Getting this wrong is quiet, which is why the two are separate cases rather than one.
Next#
Writing a migration is only part of the work. See Running migrations for applying one in maintenance mode, checking the result in a deploy script, and rehearsing it first.