An editorial team at a mid-size publisher adds a custom in-review post status to their workflow. Three weeks later, their scheduled-publication email notifications stop firing. Their object cache invalidation logic silently skips reviewed posts. Category counts drift out of sync with what’s actually published. Nothing throws a PHP error. Nothing logs a warning. The site just gets quietly wrong in ways that compound until an editor notices the homepage hasn’t updated in two days.
This is a systems-engineering failure, not a plugin bug. WordPress’s post status transition pipeline makes specific assumptions about which statuses exist and which transitions are meaningful. When you introduce a custom status without understanding that pipeline, you create silent failures in every subsystem that hooks into wp_transition_post_status. This article traces that failure from symptom to root cause, shows how to instrument wp_posts.post_status transitions with a lightweight audit table, and builds a transition pipeline that survives custom status additions.
The Failure Mode: What Breaks When You Add a Custom Status
WordPress core ships with five post statuses: publish, future, draft, pending, and private (plus trash and inherit for non-editorial contexts). The transition system—wp_transition_post_status()—fires three actions when a post changes status: {$old_status}_to_{$new_status}, {$old_status}_to_{$new_status} (dynamic), and transition_post_status. These hooks power critical infrastructure: cache invalidation, term count recalculation, scheduled post handling, pings, and notifications.
The assumption baked into this system is that statuses form a known, finite set. When you register a custom status like in-review via register_post_status(), WordPress knows the status exists for query purposes. But every callback hooked to a specific transition—say, draft_to_publish—has no equivalent in-review_to_publish handler unless you explicitly write one. The infrastructure that fires on draft_to_publish doesn’t fire on in-review_to_publish because the hook name is constructed from the literal status strings.
Here’s the specific breakage pattern I traced on a production site:
- Cache invalidation: A
transition_post_statuscallback calledclean_post_cache()only when$new_status === 'publish'and$old_status !== 'publish'. Posts moving fromin-reviewtopublishtriggered this correctly. But posts moving fromdrafttoin-reviewdidn’t invalidate caches on archive pages that now showed stale “draft” post listings—because a separate plugin had been surfacingin-reviewposts in a custom query that cached its results. - Term count recalculation: WordPress’s
wp_update_term_count()fires ontransition_post_statusbut only updates counts for posts moving to or frompublish. When a post goes fromdrafttoin-review, the term relationship is already inwp_term_relationships, but the term count inwp_term_taxonomyreflects only published posts. If your custom archive showsin-reviewposts, the count is wrong. - Scheduled publication emails: A notification plugin hooked to
draft_to_publishto send “your post is live” emails. Posts that wentdraft→in-review→publishnever triggereddraft_to_publish—the old status wasin-review, notdraft. The transition that fired wasin-review_to_publish, a hook the plugin never registered.
Each failure is silent. No error log entry. No failed query. The post_status column in wp_posts correctly says publish. The supporting infrastructure just didn’t get the memo.
Tracing the Transition Hook Firing Order
Before instrumenting anything, you need to see exactly which hooks fire and in what order when a post moves through your custom status pipeline. You can do this with a WP-CLI command that hooks into every transition-related action and logs the firing order.
Create a temporary must-use plugin at wp-content/mu-plugins/transition-tracer.php:
<?php
// mu-plugins/transition-tracer.php
add_action('transition_post_status', function($new, $old, $post) {
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::log(sprintf(
'[transition_post_status] old=%s new=%s post_id=%d',
$old, $new, $post->ID
));
}
}, 1);
// Catch every dynamic transition
$core_statuses = ['publish', 'future', 'draft', 'pending', 'private', 'trash'];
foreach ($core_statuses as $status) {
add_action("{$status}_to_publish", function($post) use ($status) {
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::log(sprintf('[%s_to_publish] post_id=%d', $status, $post->ID));
}
}, 1);
}
add_action('save_post', function($post_id, $post, $update) {
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::log(sprintf('[save_post] post_id=%d status=%s update=%s', $post_id, $post->post_status, $update ? 'yes' : 'no'));
}
}, 1, 3);
add_action('wp_after_insert_post', function($post_id, $post, $update) {
if (defined('WP_CLI') && WP_CLI) {
WP_CLI::log(sprintf('[wp_after_insert_post] post_id=%d status=%s update=%s', $post_id, $post->post_status, $update ? 'yes' : 'no'));
}
}, 1, 3);
Now run a status transition from the CLI to observe the hook firing order:
wp post update 42 --post_status=in-review
wp post update 42 --post_status=publish
The output reveals the hook sequence. On a stock install, you’ll see something like:
[save_post] post_id=42 status=in-review update=yes
[transition_post_status] old=draft new=in-review post_id=42
[wp_after_insert_post] post_id=42 status=in-review update=yes
Then on the second command:
[save_post] post_id=42 status=publish update=yes
[transition_post_status] old=in-review new=publish post_id=42
[wp_after_insert_post] post_id=42 status=publish update=yes
Notice what’s missing: no draft_to_publish fires. No pending_to_publish fires. The only dynamic transition that fires is in-review_to_publish—a hook that almost no plugin registers. If your notification, cache, or count infrastructure is hooked to draft_to_publish, it never runs. The SRE principle of treating incidents as learning opportunities applies directly here—this is a postmortem scenario where the failure lives in the system’s assumptions, not in any single component. As Google’s SRE book frames it, postmortem culture is about learning from failure systematically, not patching symptoms (Google SRE Book, Chapter 15: Postmortem Culture).
save_post vs. wp_after_insert_post: Which One Tells the Truth?
A common mistake in transition-pipeline code is hooking to save_post for status-dependent logic. save_post fires inside wp_insert_post()—before wp_after_insert_post, and crucially, before taxonomies and meta are saved. If your callback reads wp_get_post_terms() or get_post_meta() inside a save_post handler, you’re reading stale data from before the current save operation completed.
The wp_after_insert_post hook, introduced in WordPress 5.6, fires after all related data—terms, meta, revisions—is persisted. For transition logic that needs to inspect the post’s full state (terms for count recalculation, meta for cache keys, etc.), wp_after_insert_post is the correct hook. But even wp_after_insert_post doesn’t solve the core problem: it fires on every save, not just status transitions. You still need to compare the previous status to the current status to detect a transition.
The previous status is available inside transition_post_status as the $old_status argument, but not inside wp_after_insert_post. If you need both the “after all data is saved” guarantee and the “this was a transition” signal, you have to bridge them:
add_action('transition_post_status', function($new, $old, $post) {
if ($new === $old) return;
// Stash the transition info for wp_after_insert_post
wp_cache_set("transition_{$post->ID}", [
'old' => $old,
'new' => $new,
], 'transition_audit', 300);
}, 1, 3);
add_action('wp_after_insert_post', function($post_id, $post, $update) {
$transition = wp_cache_get("transition_{$post_id}", 'transition_audit');
if (false === $transition) return;
// Now you have: full post data saved + transition context
do_action('my_transition_complete', $transition['old'], $transition['new'], $post);
wp_cache_delete("transition_{$post_id}", 'transition_audit');
}, 10, 3);
This pattern gives you a single reliable hook—my_transition_complete—that fires only on actual status transitions and only after all post data is persisted. Every downstream system (cache invalidation, term counts, notifications) should hook here, not to save_post or transition_post_status directly.
Building a Lightweight Transition Audit Table
To diagnose stuck workflows in production, you need a record of every status transition. Querying wp_posts alone only shows the current state, not the history. Revisions store post_status but not in a way that makes transition reconstruction easy. A dedicated audit table is the lightweight solution.
Create the table with a direct SQL migration (run via a WP-CLI eval or an activation hook):
CREATE TABLE wp_post_status_audit (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
old_status varchar(20) NOT NULL DEFAULT '',
new_status varchar(20) NOT NULL DEFAULT '',
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
source varchar(20) NOT NULL DEFAULT '',
transitioned_at datetime NOT NULL DEFAULT '1970-01-01 00:00:00',
PRIMARY KEY (id),
KEY post_id (post_id),
KEY transitioned_at (transitioned_at),
KEY new_status (new_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Write to it from the transition hook:
add_action('transition_post_status', function($new, $old, $post) {
if ($new === $old) return;
if (wp_is_post_revision($post->ID)) return;
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'post_status_audit',
[
'post_id' => $post->ID,
'old_status' => $old,
'new_status' => $new,
'user_id' => get_current_user_id(),
'source' => defined('WP_CLI') && WP_CLI ? 'cli' : 'web',
'transitioned_at' => current_time('mysql'),
]
);
}, 10, 3);
The source column distinguishes CLI-triggered transitions (deployments, scripts, imports) from web-triggered transitions (editor actions in wp-admin). This distinction matters when diagnosing stuck workflows: if a post is stuck in in-review and the audit shows the last transition was from a CLI script, the problem isn’t editorial indecision—it’s an import that didn’t complete the pipeline.
With this table, you can query for stuck posts directly:
SELECT p.ID, p.post_title, p.post_status, p.post_modified
FROM wp_posts p
LEFT JOIN wp_post_status_audit a ON p.ID = a.post_id
WHERE p.post_type = 'post'
AND p.post_status = 'in-review'
AND p.post_modified < DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY p.post_modified ASC;
This query returns every post stuck in in-review for more than seven days. Run it via WP-CLI to generate a weekly report:
wp db query "SELECT p.ID, p.post_title, p.post_status, p.post_modified FROM wp_posts p WHERE p.post_type='post' AND p.post_status='in-review' AND p.post_modified < DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY p.post_modified ASC;" --skip-column-names | while read -r id title modified; do echo "STUCK: #$id - $title (modified: $modified)"; done
The Transition Pipeline: Making Custom Statuses First-Class Citizens
Once you can see transitions, you need to make your downstream infrastructure aware of custom statuses. The approach is to stop hooking to specific named transitions (draft_to_publish) and instead hook to transition_post_status with explicit old/new status comparison.
For cache invalidation:
add_action('transition_post_status', function($new, $old, $post) {
if ($new === $old) return;
// Any transition involving 'publish' or a custom visible status
$visible_statuses = ['publish', 'in-review', 'future'];
$was_visible = in_array($old, $visible_statuses, true);
$is_visible = in_array($new, $visible_statuses, true);
if ($was_visible || $is_visible) {
clean_post_cache($post->ID);
// Invalidate archive caches for this post's terms
$terms = wp_get_post_terms($post->ID, ['category', 'post_tag']);
if (!is_wp_error($terms)) {
foreach ($terms as $term) {
wp_cache_delete("archive_{$term->taxonomy}_{$term->slug}", 'custom_archives');
}
}
}
}, 10, 3);
For term count recalculation with custom visible statuses:
add_action('transition_post_status', function($new, $old, $post) {
if ($new === $old) return;
$countable_statuses = ['publish', 'in-review'];
$was_countable = in_array($old, $countable_statuses, true);
$is_countable = in_array($new, $countable_statuses, true);
if ($was_countable !== $is_countable) {
$terms = wp_get_post_terms($post->ID, ['category', 'post_tag'], ['fields' => 'ids']);
if (!is_wp_error($terms)) {
foreach ($terms as $term_id) {
wp_update_term_count($term_id, 'category');
}
}
}
}, 10, 3);
For notifications, the key insight is that you must decide which transitions count as “publish events” for your workflow. If in-review is a visible status that editors consider “live enough to notify,” then the notification callback should trigger on any transition into publish regardless of the old status—not just draft_to_publish.
add_action('transition_post_status', function($new, $old, $post) {
if ($new !== 'publish' || $old === 'publish') return;
if (wp_is_post_revision($post->ID)) return;
// This fires on draft_to_publish, in-review_to_publish, pending_to_publish, etc.
wp_schedule_single_event(time() + 60, 'send_publication_notification', [$post->ID]);
}, 10, 3);
The 60-second delay via wp_schedule_single_event gives wp_after_insert_post time to fire and ensures terms and meta are saved before the notification reads them.
Documenting the State Machine
Once your transition pipeline handles custom statuses, the next failure mode is human: editors and developers who don’t share the same mental model of which transitions are valid. A post can go from draft to in-review to publish, but can it go from in-review back to draft? What about publish to in-review? Without a documented state machine, each editor guesses, and the audit table fills with transitions that break assumptions in downstream code.
The fix is to codify the state machine in code and enforce it. Register valid transitions as an array, then validate every transition before it happens:
function my_valid_transitions() {
return [
'draft' => ['in-review', 'publish', 'trash'],
'in-review' => ['publish', 'draft', 'trash'],
'publish' => ['draft', 'trash'],
'pending' => ['in-review', 'publish', 'draft', 'trash'],
];
}
add_filter('wp_insert_post_data', function($data, $postarr) {
if (empty($postarr['ID'])) return $data;
$old_status = get_post_field('post_status', $postarr['ID']);
$new_status = $data['post_status'];
if ($old_status === $new_status) return $data;
$valid = my_valid_transitions();
$allowed = $valid[$old_status] ?? [];
if (!in_array($new_status, $allowed, true)) {
// Log the rejected transition for audit
do_action('my_rejected_transition', $old_status, $new_status, $postarr['ID']);
// Force the post back to its previous status
$data['post_status'] = $old_status;
}
return $data;
}, 10, 2);
This filter runs before the post is saved, so rejected transitions never hit the database. The my_rejected_transition action lets you log the attempt for audit purposes—useful for identifying editors who are confused about the workflow or plugins that are trying to force invalid transitions.
The documentation that accompanies this state machine should be explicit enough that both human editors and tooling understand which transitions are valid and which are destructive. When editorial teams adopt AI-assisted drafting tools, the same state-machine documentation becomes the contract that defines how an AI writing app that fits the draft workflow participates in the editorial pipeline—what statuses it can write to, what transitions it can trigger, and where human review must intervene. Professional writing organizations are actively establishing guidelines for how AI tools participate in editorial workflows, reflecting the broader tension between AI-assisted drafting and maintaining professional writing standards (Authors Guild: AI Best Practices for Authors). The state machine is where that policy becomes enforceable code.
Reading the Audit Table as a Systems Map
Once the audit table has been running for a few weeks, it becomes a diagnostic tool for the entire editorial system. Here are the queries I run most often when diagnosing stuck workflows.
Find posts that bounce between statuses (a sign of editorial indecision or a broken auto-save that resets the status):
SELECT post_id, COUNT(*) as transition_count
FROM wp_post_status_audit
WHERE transitioned_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY post_id
HAVING transition_count > 3
ORDER BY transition_count DESC;
Find transitions that bypassed the expected pipeline (e.g., draft straight to publish, skipping in-review):
SELECT a.*
FROM wp_post_status_audit a
WHERE a.old_status = 'draft'
AND a.new_status = 'publish'
AND a.transitioned_at > DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY a.transitioned_at DESC;
If your workflow requires in-review before publish, these transitions indicate either a plugin that’s forcing the status or an editor who found a way around the UI restriction.
Find the average time spent in each status to identify workflow bottlenecks:
SELECT
a.post_id,
a.old_status,
a.new_status,
TIMESTAMPDIFF(HOUR, a.transitioned_at, b.transitioned_at) as hours_in_status
FROM wp_post_status_audit a
LEFT JOIN wp_post_status_audit b ON a.post_id = b.post_id AND b.id > a.id
WHERE a.new_status != 'publish'
AND b.id IS NOT NULL
ORDER BY hours_in_status DESC
LIMIT 20;
This query pairs each transition with the next one for the same post, giving you the time spent in each intermediate status. Posts that sit in in-review for 200 hours reveal an editorial bottleneck; posts that sit for 2 hours reveal a smooth pipeline.
Cleaning Up the Audit Table
The audit table grows by one row per transition. For a site with 50 posts per week and an average of 4 transitions per post, that’s 200 rows per week—roughly 10,000 rows per year. This is negligible. But if you have a bulk import or a plugin that fires transitions in a loop, the table can grow fast. Schedule a weekly cleanup via wp-cron that keeps only the last 90 days:
add_action('wp', function() {
if (!wp_next_scheduled('prune_status_audit')) {
wp_schedule_event(time(), 'weekly', 'prune_status_audit');
}
});
add_action('prune_status_audit', function() {
global $wpdb;
$wpdb->query($wpdb->prepare(
"DELETE FROM {$wpdb->prefix}post_status_audit WHERE transitioned_at < %s",
gmdate('Y-m-d H:i:s', time() - 90 * DAY_IN_SECONDS)
));
});
For a more aggressive approach, keep only the most recent transition per post, plus any transitions involving publish:
DELETE a FROM wp_post_status_audit a
LEFT JOIN (
SELECT post_id, MAX(id) as max_id
FROM wp_post_status_audit
GROUP BY post_id
) b ON a.post_id = b.post_id AND a.id = b.max_id
WHERE b.max_id IS NOT NULL
AND a.new_status NOT IN ('publish', 'trash')
AND a.transitioned_at < DATE_SUB(NOW(), INTERVAL 30 DAY);
Conclusion
The post status transition system is one of WordPress’s most critical and least-documented subsystems. It works silently when you use the five core statuses, and it breaks silently when you add custom ones. The failure isn’t in the code—it’s in the assumption that statuses form a known, finite set with predictable transition names.
The fix is systems engineering, not plugin selection. Instrument the transitions with an audit table. Map the hook firing order with WP-CLI. Build a transition pipeline that treats custom statuses as first-class citizens. Codify the valid state machine in code so that both editors and automated tooling share the same contract. When the next developer joins the team, the audit table is the documentation—the system’s behavior is recorded, not guessed at.
The posts that get stuck in in-review for three weeks aren’t an editorial problem. They’re a systems problem that you can now see, trace, and fix.