Technote

Development workflow Practical

Series WP-CLI automation recipes Part 5 of 8

Idempotent seeds: running twice must create nothing

A seed script never runs only once. Get the lookup wrong and the check passes, the insert runs, and WordPress quietly appends -2 to the slug.

A seed script builds your baseline structure — pages, terms, settings — in code. The thing is, a seed never runs only once. It runs ten times while you develop, once on staging, and again when a new environment is set up. Every run has to end in the same state. That is idempotence.

The shape is simple: look it up, and skip if it exists. Every trap is on the lookup side.

The trap: post_status => any misses drafts

Start with the most common form — checking whether a page with a slug already exists.

// this lookup will not find a draft page
$found = get_posts( [
    'name'        => 'pricing',
    'post_type'   => 'page',
    'post_status' => 'any',
] );

A lookup by name is treated as a single-post query and considers public statuses only. So a page you created as a draft comes back as zero results.

The symptom is nasty. Zero results means “create it”, the insert runs, and WordPress avoids the slug collision by quietly appending -2. No error, no warning, and your log says “created”. You find out later when /pricing/ redirects to /pricing-2/.

The fix is to spell the statuses out.

'post_status' => [ 'publish', 'future', 'draft', 'pending', 'private', 'trash' ],

Two entries are easy to forget and both matter. Drop future and every scheduled post gets a duplicate. Drop trash and a re-run resurrects records somebody deliberately threw away. Something in the trash is not missing; it has been put away.

Lookup design — the top three keep a re-run from creating duplicates

Plant a stable key, then read back what saved

Slug lookups have a structural weakness: once a slug drifts even once, every later run misses it forever. So make the primary lookup use a key you planted yourself.

update_post_meta( $id, '_seed_key', 'pricing:en' );

That key survives a changed title, a drifted slug and a changed status. Keep the slug lookup as a secondary net.

Then, immediately after inserting, read back the slug that actually saved and warn if it differs from the one you asked for. It is the only way to make a silent -2 audible.

if ( get_post_field( 'post_name', $id ) !== $slug ) {
    WP_CLI::warning( "slug drifted: {$slug}" );
}

kses — the illustration disappears under a success log

Here is the problem flagged in the previous part. WP-CLI runs as user 0, and user 0 has no unfiltered_html capability. Consequently the kses filter on the save path strips any tag outside the allowed list.

If your content contains an inline <svg> illustration, it is saved with the graphic removed entirely. The insert succeeds, the return value is a normal ID, the log says “created”. Nobody knows until somebody opens the page.

For trusted content the repository itself owns, drop the filters.

kses_remove_filters();

Never do this for user input. The only defensible case is “we are inserting markup we committed to this repository ourselves”.

Dates — the stale GMT value survives an update

Posts carry two date columns: local (post_date) and GMT (post_date_gmt). On insert, core derives the GMT value for you. On update it does not — leave it out and the old GMT value stays.

That matters because the cron job that actually publishes scheduled posts decides the moment from the GMT column. Change only the local date and the list shows the new date while publication happens at the old time. Which is why it is worth passing explicitly even on insert.

'post_date'     => $date,
'post_date_gmt' => get_gmt_from_date( $date ),

Why that cron so often does not fire on time is the subject of part seven.

A seed does not delete

One last principle: a seed creates or skips, nothing else. Deletion belongs behind a separate argument or a separate command. The moment destructive behaviour becomes the default in a script that touches a shared database, nobody can run it with confidence again.

The same principle applied to real client work is published on our process page, and related articles sit in the development workflow archive.

Next part

Once a script wants more than three options, it is time to promote it. The next part adds a real WP-CLI command to a plugin, and the heart of it is using log, warning and error deliberately.

More on this topic

All technotes

Development workflow Advanced

The design QA checklist to run before release

Most design problems found after release are catchable before it, in order. Here is the pass, in three stages: rules, states and real devices.

Designers 6 min read

Development workflow Advanced

Never deploy without a way back

A rollback is not one button. It is two tracks — code and database — with an order to reverse them in, written down before you deploy.

Designers 9 min read

Development workflow Advanced

Adding your own hooks: extension points instead of edits

Code with no extension points gets forked or edited in place. Where you put do_action and apply_filters — and how many — decides how long that code survives.

Developers 7 min read

₩270,000 · Join the program