Running migrations#

ACF Chef finds your migrations, works out what each one would do, applies them in batches, and keeps the site readable the whole time. What it never does is decide when — that is the application's business, for the same reason registration is.

Wiring it up#

Two lines, alongside wherever you declare your groups:

use AcfChef\Chef;

Chef::migrations(get_template_directory() . '/database/acf-migrations');
Chef::serveMigrations();

The first says where the migrations live. The second is the one that makes a deploy safe, and it is explained below.

Neither runs anything. On a site with nothing pending they cost a single autoloaded option between them: no filter is registered, the migrations directory is never read, and no migration file is loaded.

Correct data before anything runs#

There is always a window between new field definitions going live and the migration finishing. With a manual theme install there is no atomic deploy at all — files are replaced under a running site — so that window can be hours.

Chef::serveMigrations() closes it. While a migration is pending, ACF's metadata reads are answered with the values the migration would write, so the front end reads correct data from the moment the theme is deployed. The background job only makes it permanent and reclaims the old rows.

What makes that trustworthy is that the shim serves exactly the projection the job will persist — the same computation, not a second implementation of it. The two cannot disagree.

Because it intercepts below the field layer, it works the same for simple fields, groups, repeaters and flexible content: ACF simply sees meta that looks already-migrated. An editor who opens and saves a post during the window migrates it for free, and the run tidies the old rows when it gets there.

Running them#

A run is a series of batches. Each one migrates as many entities as it safely can and reports whether there is more to do:

use AcfChef\Migration\AcfValueStore;
use AcfChef\Migration\Migrations;
use AcfChef\Migration\MySqlLock;
use AcfChef\Migration\Runner;
use AcfChef\Migration\WordPressEntities;

$runner = new Runner(
    new AcfValueStore(),
    new WordPressEntities(),
    Chef::ledger(),
    Chef::menu(),
);

$batch = $runner->drainAll(Migrations::in(Chef::migrations()));

if ($batch->more) {
    // Come back: schedule another turn.
}

Call that from a cron event, from an admin AJAX handler while somebody watches a progress bar, or from WP-CLI. They are three entry points to one method, not three implementations — the position lives in the ledger, so nothing is held between calls and a request that dies costs one batch rather than the run.

Migrations run in filename order, and the queue stops at the first one that has more to do. They are cumulative: a migration written against the structure an earlier one produces must not overtake it.

Batches end early on purpose#

A batch stops at whichever comes first: its size, its time budget, or a memory ceiling. The time guard extrapolates from the average so far rather than waiting to hit the wall, because noticing at the limit means already being past it.

use AcfChef\Migration\BatchLimits;

new Runner(
    $store,
    $entities,
    Chef::ledger(),
    Chef::menu(),
    new BatchLimits(size: 50, seconds: 20.0),
);

A drain killed by PHP mid-batch would leave the ledger holding a cursor from before the batch that died, so the work would be repeated rather than resumed — and on a site slow enough to hit the limit, repeated forever.

Two runs at once#

Pass a lock and overlapping runs stop being a problem:

new Runner($store, $entities, Chef::ledger(), Chef::menu(), new BatchLimits(), new MySqlLock());

A site with DISABLE_WP_CRON and a real crontab can fire two requests at once easily. A drain that cannot take the lock returns rather than queueing behind the one that has it — the other process is already doing this work.

MySqlLock uses GET_LOCK() rather than a transient, because a transient mutex has a read-then-write race that shows up exactly under the load where the lock matters. It also releases itself if the connection dies, so a killed request leaves nothing to clear by hand.

Rehearsing first#

rehearse() is the same computation with the writes taken out:

$batch = $runner->rehearse($migrations->find('2026_08_16_143000_hero_media_group'));

echo $batch->migrated, ' of ', $batch->visited, " entities would change.\n";

It takes no lock and moves no cursor, so it is safe to run while a real drain is in progress — it simply reports on what that drain has not reached yet.

Progress and notices#

The ledger records how far each migration has got, and asking whether anything is pending is deliberately cheap:

add_action('admin_notices', function () {
    $pending = Chef::pendingMigrations();

    if ($pending->isEmpty()) {
        return;
    }

    $progress = Chef::ledger()->progress($pending->ids()[0]);

    printf(
        '<div class="notice notice-warning"><p>ACF Chef: %d pending migration(s), %d of %d entities.</p></div>',
        count($pending->ids()),
        $progress['done'] ?? 0,
        $progress['total'] ?? 0,
    );
});

What makes it safe to interrupt#

There are no transactions. WordPress's $wpdb handles them weakly, and the object cache cannot roll back with the database — so a rollback would leave correct rows sitting behind poisoned caches.

Three properties do the work instead:

  • Every migration is idempotent. It reads the old address and writes the new one, so a second pass over the same entity finds nothing to do.
  • The cursor only moves forward. Two overlapping drains cannot have the slower one rewind the faster.
  • Writes happen before deletions. Interrupted between the two, an entity has its data at both addresses and the next pass tidies it — the old rows are still exactly what the old structure claims. The other order would leave data at neither.

Take a backup anyway. A migration rewrites production data on somebody else's schedule, and the failure this design prevents is the interrupted run — not the transform that was wrong about what it wanted.

Keeping the lockfile honest#

The lockfile is what lets the next migration know the shape this one produced, so a stale one is a problem you find out about much later. Add it to CI:

wp acf-chef lock --check

It exits non-zero and names the groups that drifted.

These commands compile field groups, which goes through WordPress's own sanitize_title() — a function that consults the site locale and can be filtered by any plugin. There is no honest way to reproduce it from outside, so they run under WP-CLI rather than approximating it. WordPress is already a dependency of anything using this package.

Next#

For writing the migrations themselves, see Data migrations. For the exceptions these commands can raise, see Errors.