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.phpGuard 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