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