Idempotent Seed Scripts with WP-CLI: Reproducible WordPress Content for Every Environment

Every WordPress developer has a version of this problem: a fresh environment needs pages, menus, sample content, reading settings — and clicking through wp-admin to recreate them is slow, undocumented and unrepeatable. My answer on recent projects is a pair of seed scripts run through WP-CLI. The pattern is simple, but the details — especially idempotency — are where it pays off or bites you. I know, because one of the bites is in this post.

The shape: wp eval-file

No custom CLI command registration needed — a plain PHP file executed in a full WordPress context:

wp eval-file wp-content/themes/minhazi/bin/seed.php

Guard the top of the file so it can’t run outside WP-CLI, log with WP_CLI::log(), and fail loudly with WP_CLI::error(). Keep it in the repo next to the code it seeds — it’s documentation that executes.

Idempotency rule 1: upsert by slug, never create blindly

function seed_upsert_post( array $args, array $meta = [] ) {
    $existing = get_page_by_path( $args['post_name'], OBJECT, $args['post_type'] );
    if ( $existing instanceof WP_Post ) {
        $args['ID'] = $existing->ID; // update, don't duplicate
    }
    $args['post_status'] = 'publish';
    $id = wp_insert_post( $args, true );
    ...
}

Re-running the seeder must converge to the same state, not append “Home-2” pages. Match on something stable — slug plus post type — and update in place.

Idempotency rule 2: derived structures get rebuilt, not appended

Menus are the classic trap: wp_update_nav_menu_item() with fresh args happily duplicates items on every run. For anything that’s a pure function of the seed data — menus, term assignments — delete and rebuild:

foreach ( (array) wp_get_nav_menu_items( $menu_id ) as $item ) {
    wp_delete_post( $item->ID, true );
}
// ...then recreate every item from the seed definition.

Idempotency rule 3: samples must know when real data has arrived

Here’s the bite. My seeder created sample projects; a separate import script later replaced them with real content migrated from another plugin. Weeks of muscle memory said “re-run the seeder any time, it’s idempotent” — and it was, against its own data. Against the imported data it resurrected samples and, worse, one sample shared a slug with a real project and silently overwrote it.

The fix is one line of humility — the import script sets a flag, and the seeder respects it:

// import script, on success:
update_option( 'minhazi_projects_imported', 1 );

// seeder:
$samples = get_option( 'minhazi_projects_imported' ) ? [] : $sample_projects;

Generalized: idempotency is relative to a data set. The moment two scripts write to the same post type, they need a protocol between them.

What goes in the seed

  • Pages, with content as pattern reference blocks (<!-- wp:pattern {"slug":"minhazi/hero"} /-->) so copy changes in the theme propagate without re-seeding
  • Reading settings (show_on_front, page_on_front, page_for_posts)
  • The primary menu and its location assignment
  • Placeholder media (a second script, so content and binary assets stay independent)

Ten minutes after cloning the repo, a colleague — or an AI coding agent, which is how this particular site got built — has a fully structured site. That’s the real win: the environment itself becomes reviewable code. More on that workflow in the rest of the blog.

Leave a Reply

Your email address will not be published. Required fields are marked *