Running migrations#

ACF Chef finds your migrations, determines what each one will change, and applies them. There is one supported way to run them: wp acf-chef migrate, with the site in maintenance mode immediately after the theme files are updated. The new field structure is never served early, so the drain is the only process changing field data.

Wiring it up#

Add one line wherever you declare your groups:

use AcfChef\Chef;

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

This tells ACF Chef where to find migrations. It does not run anything or read the database.

The deploy sequence#

On every site, whenever the theme changes, use this order:

  1. Enable maintenance mode. From this point, web requests receive WordPress's maintenance page, so no one can read or write field data.
  2. Back up the database.
  3. Install the new theme version.
  4. Run wp acf-chef migrate.
  5. Run wp acf-chef migrate:status and confirm that it exits 0.
  6. If either command fails, restore the backup and reinstall the old theme version.
  7. Disable maintenance mode.
wp maintenance-mode activate
# back up the database, then install the new theme version
wp acf-chef migrate
wp acf-chef migrate:status
wp maintenance-mode deactivate

The first and last steps make this safe. On a live site, visitors could receive data halfway through a structural change, and an editor could save the new structure to an entity the drain has not yet reached. The drain could then migrate that entity again. Maintenance mode prevents both cases.

Take the backup even after a successful rehearsal. The design below handles interrupted runs; it cannot protect against a transform based on the wrong assumption.

wp acf-chef migrate#

The command refuses to start unless the site is in maintenance mode, and explains why and what to run instead. It checks only once, when it starts, so a long drain is not stopped when WordPress's ten-minute maintenance expiry passes. That creates a separate risk; see A drain longer than ten minutes.

After the check, it records migration files included in the new theme version, validates all pending migrations before running any of them, then drains them in filename order until none remain. A site that skipped releases can catch up in a single migrate run because each migration resolves its targets against the next migration’s snapshot of the same group, not against code that has already advanced beyond it. Migrations are cumulative, so a migration written for the structure created by an earlier migration cannot run before it. ACF's value cache is cleared before each migration, so a later migration cannot read a value an earlier migration cached under the same name.

When it finishes, it reports what it changed:

$ wp acf-chef migrate
Migrations complete. Entities: 376 visited, 74 migrated.

It exits 1 if a migration fails validation or throws, another process holds the migration lock, or a batch makes no progress. In each case, the ledger retains the point reached by the run, and the message explains what to do next.

Local databases#

On a local or scratch database, --force bypasses the maintenance-mode check:

wp acf-chef migrate --force

Nothing else about the run changes. Do not use it on a site reachable by visitors or editors.

wp acf-chef migrate:status#

This is a read-only report for every migration in the migrations directory. It writes nothing, including when the ledger differs from files on disk, so checking status can never start or modify a migration.

Each migration has one of these states:

  • complete: finished.
  • pending: not started, including a migration file the ledger has never seen because it arrived with a new theme version.
  • running: started, with a drain holding its lock.
  • stalled: started, with no lock held. The drain stopped partway through and needs investigation.

The exit code is the result:

ExitMeans
0Every migration is complete. The site is idle.
2At least one migration is pending, running or stalled.
1WP-CLI or the command itself failed.

Run it before an update to confirm that the site is idle, and after migrate to confirm that the run completed. A deploy script should treat 2 as “not yet” and 1 as a failure. Treating both alike could leave a site with a fatal boot error deferred forever.

--format=json prints one record for each migration, for logging:

$ wp acf-chef migrate:status --format=json
[{"id":"2026_08_16_143000_hero_media_group","status":"complete","phase":"post","done":376,"total":376},{"id":"2026_11_02_091500_gallery_layers","status":"pending","phase":"","done":0,"total":0}]

Progress and notices#

The ledger records each migration's progress. Chef::pendingMigrations() reads the migrations directory and the ledger and changes nothing, so it reports a file that a new release shipped before anything has run it. It loads every migration file to do that, so ask it on admin screens rather than on every request. It works well for an admin notice on a site that was not migrated:

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 interruption safe#

There are no transactions. WordPress's $wpdb provides weak transaction support, and the object cache cannot roll back with the database. A rollback could therefore leave correct database rows hidden behind invalid cached values.

Instead, safety relies on three properties:

  • Progress is recorded after every entity. If a run stops, it repeats at most the entity it was processing. Run migrate again and it resumes there.
  • The cursor only moves forward. Two overlapping drains cannot let the slower one move progress backward.
  • Writes happen before deletions. If a run stops between those operations, an entity has data at both addresses. The next pass removes the old data because the old rows still match the old structure. Reversing that order could leave data at neither address.

Repeating an entity is safe for move() and rename(). They read from the old address and write to a new one, so a second pass finds nothing to do. A rewrite() is different: it transforms a value in place, so a second pass gives the transform its own output. On the supported path, that can happen only after a crash and affects at most one entity. A transform need not be idempotent, but if yours is not, read A crash during a drain.

Limits and data-loss risks#

Every known way a migration can lose or corrupt data, including its trigger, consequence, and prevention.

Running outside maintenance mode#

  • Trigger. migrate --force on a site with visitors or editors, or a drain started from a web request or cron event.
  • Consequence. Visitors may receive values halfway through a structural change. An editor who saves an entity before the drain reaches it writes the new structure, and an in-place rewrite() then transforms it again: [value 1] becomes [[value 1]]. A request that has already rendered fields can also leave a value in ACF's cache under the same name, which the drain reads as old data and wraps a second time.
  • Prevention. Run migrate without --force on every site that anyone else can access.

A crash during a drain#

  • Trigger. The drain stops partway through: a fatal error, memory limit, killed process, or dropped database connection.
  • Consequence. The current entity may have been written without recording its progress, so the next run processes it again. That is safe for move() and rename(). An in-place rewrite() runs its transform twice for that one entity.
  • Prevention. When possible, make the transform recognise a value already in the new structure and leave it unchanged. When that is not possible, restore the backup instead of running migrate again.

Writes that bypass ACF#

  • Trigger. Meta is written without ACF while migrations are pending: update_post_meta(), raw SQL, an import, or a sync job. Maintenance mode does not stop these because they do not require a web request.
  • Consequence. The migration cannot account for them. It reads whatever is stored when it reaches an entity, so a value written in the new structure before the drain arrives is transformed as though it were old. A value written in the old structure after the drain passes is stored at an address that is no longer read.
  • Prevention. Schedule imports and direct writes outside the deployment, and target the new structure once migrate:status exits 0.

A drain longer than ten minutes#

  • Trigger. A drain is still running ten minutes after maintenance mode starts. WordPress considers an older maintenance file stale and resumes serving requests.
  • Consequence. The site reopens during the drain, and every risk under Running outside maintenance mode applies. migrate does not detect this because it checks only at startup.
  • Prevention. Measure the drain against a copy of the site's data first. Set a deployment timeout below ten minutes that stops the drain and restores the backup, rather than allowing the site to reopen during it.

Calling the Runner directly#

  • Trigger. Your code constructs a Runner and calls drain() or drainAll().
  • Consequence. The command's protections do not apply. There is no maintenance check, no verification that it runs under WP-CLI, and no validation of the entire queue before the first migration starts. When called from a web request or cron event, it restores every risk under Running outside maintenance mode.
  • Prevention. Deploy with wp acf-chef migrate. Limit direct use of the Runner to tests and scratch databases.

Using the Runner directly#

migrate loops around Runner, which remains public for tests and custom tooling. The Runner does not check where it runs, so using it means accepting the risks that the command prevents.

use AcfChef\Migration\AcfValueStore;
use AcfChef\Migration\Migrations;
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()));

Each drainAll() call runs one batch, and $batch->more indicates whether work remains.

Batches end early on purpose#

A batch stops when it reaches its size limit, time budget, or memory ceiling, whichever comes first. The time guard estimates from the average time so far instead of waiting for the limit, because detecting the limit means it has already been exceeded.

use AcfChef\Migration\BatchLimits;

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

Two runs at once#

When given a lock, an overlapping run returns instead of waiting behind the process that holds it, because that process is already doing the work. migrate always supplies a lock.

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

MySqlLock uses GET_LOCK() instead of a transient because a transient mutex has a read-then-write race that appears precisely under the load where the lock matters. It also releases automatically if the connection dies, so a killed process needs no manual cleanup.

Rehearsing first#

rehearse() performs the same computation without writes:

$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 does not advance the cursor, so it is safe while a real drain is running. It reports on whatever that drain has not yet reached.

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 make: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.