WP Manifestindependent plugin directory
manifest / content / save-without-publish

Save without Publish

Stage edits to published WordPress posts instead of publishing them on save.

by Automattic · github.com/automattic/save-without-publish · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/automattic/save-without-publish/archive/refs/heads/main.zip

Readme

Save without Publish

Updating a published story shouldn't mean gambling with what readers see.

Save without Publish lets editors revise and review published content without it going live as soon as they hit save. A change to a published post is staged as a private staged copy, and stays staged until someone publishes it deliberately. The published post keeps serving, unchanged, until someone deliberately publishes the change.

What it delivers is that nothing publishes without a deliberate act. It is not an approval workflow: there is no reviewer role, no queue, and no sign-off. Anyone who can edit a staged copy can publish it, and that is on purpose.

Review happens in WordPress's own revisions view, inside the editor, where the change is marked on the blocks that carry it. There is no custom diff tool, no editorial dashboard, and no new admin screens.

Vocabulary

These are the words the interface and the code use, one per thing.

Term Means Where it is used
published post The post readers see. Keeps its ID, URL, date, author, comments, and terms throughout. Everywhere. Never "live post" in an editor-facing string.
staged The state: a change that exists, is complete, and is waiting for a deliberate act to go live. The post status, short labels, and sentences alike.
staged copy The private post row holding the change. Code and docs: Staged_Copy_Repository, $staged_copy, _swpub_staged_copy_id.
publishing Applying the staged copy's content to the published post, then deleting the copy. Everywhere. Never "merging" in an editor-facing string.
drift The published post changed after the copy was staged, so publishing would overwrite someone else's edit. Code and docs only. Editors are told what happened, not the word.
stage changes The deliberate act: starting a staged copy when your save would otherwise publish. The editor's Summary panel and the posts list row action, as a verb.
publish directly Writing to a published post without staging the change. The capability deciding it is granted to no role. swpub_publish_directly_posts on a role, swpub_publish_directly resolved per post, swpub_can_publish_directly to filter it.

Requirements

  • WordPress 6.8+
  • PHP 8.2+
  • The block editor

Who stages, and who publishes

Two rules, in this order.

Once a staged copy exists, the staged copy owns those three fields

A write that would change the title, content, or excerpt of a published post that has a staged copy is refused. On any transport: the block editor, the classic editor, Quick Edit, WP-CLI, XML-RPC, an application password or OAuth client, or a plugin calling wp_update_post(). Whatever the writer's capability, and whether or not anyone is logged in. Nothing is written on either side, and the published post keeps serving.

Nothing exempts this. Not a role, not a capability grant, not a filter. The plugin's own publishing write is the single exception, and it is scoped to the one post it is publishing.

The refusal is swpub_live_locked, HTTP 409, over REST and the staging route. 409 rather than 403 because nothing is wrong with the caller: the post is in a state that has to be resolved, and the same request succeeds once it is. Its error data carries live_id, staged_copy_id, and edit_url.

Three things follow that are easy to miss:

  • It is scoped to three fields, not to the post. A write that touches no staged field (terms, meta, slug, a featured image, a sticky toggle) applies to the published post exactly as core, with no staging, no refusal, and no events. That is deliberate, and load-bearing: those fields cannot live on a staged copy at all, so the published post is the only place to change them. A write carrying both kinds is refused whole rather than half-applied.
  • The published post is never edited from a distance. This rule used to divert instead of refuse: the write's staged fields were redirected into the copy and answered 200. It was withdrawn because the value being diverted is composed against the published row. Someone editing the published post is reading published words, so their save carries the published body with one change in it, and that body has never seen the staged edits. Writing it into the copy reverted all of them. Since post_content is a single field, one edited paragraph replaced the whole staged body. Making the divert safe would mean merging two bodies of text, which is a diff tool this plugin does not have.
  • A refusal changes nothing, so it cannot register as drift. The published post's post_modified is untouched, so a refused write never makes the next publish ask about a change that did not happen.

Before there is a copy, the first save decides

The question is answered per post: does this user hold swpub_publish_directly_posts?

No role holds it. The plugin ships the capability granted to nobody, so on a fresh install every save to a published post stages, whoever makes it. An administrator gets the same staged copy a contributor does. Granting it is how a site names its exceptions:

get_role( 'editor' )->add_cap( 'swpub_publish_directly_posts' );

Each post type has its own, built from the type's own publish capability, so a grant on posts is not silently a grant on pages: publish_pages gives swpub_publish_directly_pages, and a type declaring capability_type => 'brief' gives swpub_publish_directly_briefs.

It is the plugin's own capability rather than the post type's publish_posts, which decided this until now, because the two answer different questions. publish_posts asks whether this user may put content in front of readers at all, and every editor and author holds it. This asks whether their ordinary save may go straight to readers with no staged copy in between. A site that installs this plugin has already said the answer is usually no, and a typo fix that does not need staging is a smaller exception than a role.

  • A user holding the capability publishes, exactly as without the plugin. Staging is theirs to ask for: Stage changes in the editor's Summary panel carries whatever they have typed, unsaved included, onto a staged copy without touching the published post, and the same action on the posts list starts a copy from the list.
  • Everyone else stages, in the block editor and the classic editor alike. Their save writes to a private staged copy instead of the published post, they land on that copy and are told why, and every later save continues it. This needs no setup and no interface: in the block editor their primary button says Stage changes and does exactly that, and in the classic editor the save arrives on the staged copy with a notice saying where it went. Once a copy already exists, the button reads Save instead, for everyone: the title, content, and excerpt are refused whoever tries to save them, so "Stage changes" would promise the one thing that click cannot do, and saving is disabled outright while any of those three is dirty. The title cannot be typed into on that screen, and the excerpt field is not shown there at all. A category, a tag, or a featured image still saves normally, exactly as the warning notice on that screen says.
  • Quick Edit refuses. Its row is rebuilt from the published post, so a staged Quick Edit would redisplay the old title with no path to the copy: an edit that looks like it vanished. Instead nothing is written on either side, and the row shows a message pointing to the editor. The refusal is keyed on whether the save would stage, not on the capability, so a user holding it is refused too when the post already has a staged copy.
  • A write with nobody logged in publishes, as core does. Cron, an importer, or code running as user 0 has no capability to read and no person waiting on a staged copy. Once the post has a copy, the first rule governs these writes like every other.
  • A programmatic write by a user without the capability publishes, and says so. That is the seam below, and with no role holding the capability it is the path every programmatic write now takes.
  • Anything else stages, including a transport the plugin could not identify. A front-end editor, a custom admin screen, or a plain wp_update_post() by a logged-in user stages rather than publishes, because a default that published would be the hole this rule exists to close.

A site that wants staging even for the users it granted, belt and braces, filters the bypass off:

add_filter( 'swpub_can_publish_directly', '__return_false' );

The programmatic first-save seam

A first write to a post that has no staged copy, made by a user without the direct-publish capability, arriving over REST with a token (application password, OAuth), XML-RPC, or WP-CLI, publishes as core does. Since no role holds that capability, this covers every programmatic write, an administrator's included.

It is open by default deliberately. Closing it by default would divert the writes of every sync plugin, headless publisher, and scheduled script already running as a low-capability user into staged copies nobody asked for and nobody is watching.

The price is disclosed rather than hidden. Every use of the seam fires swpub_published_via_carveout naming the channel and the user, and one filter closes it site-wide:

add_filter( 'swpub_enforce_programmatic_first_save', '__return_true' );

The seam reopens. Containment is scoped to the copy, so publishing or discarding a staged copy returns that post to its first-save state, where the carve-out applies again. A post is not permanently closed by having been staged once. Closing the seam with the filter is the remedy for a site that needs it shut.

The plugin ships no log of its own, so on a default install the seam's use leaves no trail unless something is listening. The Actions section carries a subscriber to paste in.

What this means for an integration

An integration that writes the title, content, or excerpt of a published post which happens to have a staged copy has that write refused. Its content does not reach readers until someone clears the copy.

There is no filter for this, by design: the first rule above has no exemptions. Over REST the refusal is swpub_live_locked with HTTP 409, which a client can match on. From WP-CLI, XML-RPC, or an in-process wp_update_post() it is core's own empty_content failure, whose message names the wrong problem — the universal abort inside wp_insert_post() carries no error of its own. Subscribe to swpub_write_blocked for the real reason on every transport.

This is louder than what it replaced, and deliberately so. It used to be an ordinary 200 with the write folded into the staged copy, which kept integrations quiet by spending somebody's staged work: the write had been composed against the published content, so it reverted every staged change it did not carry.

Writes that touch none of the three staged fields — terms, meta, slug, featured image — are unaffected and never were.

The remedies are:

  • Publish or discard the staged copy. The post returns to its first-save state.
  • Turn the plugin off with swpub_is_enabled, which stops staging site-wide while leaving existing copies contained.

That is the whole list, and it is worth being clear about what is not on it. Granting the integration's account the direct-publish bypass with swpub_can_publish_directly does not help here. It decides first saves, and a granted account writing to a post that already has a copy is refused like any other. There is no setting that lets a caller write past a staged copy.

A site that runs integrations against published content should decide about this before adopting the plugin, not after.

How it works

  1. A change is staged: automatically on save for anyone without the direct-publish capability, which by default is everyone, or by choosing Stage changes for someone who holds it.
  2. The staged copy is a private post holding the change. The published post stays editable for everything the copy cannot hold — terms, featured image, slug, meta — and says so: opening it shows a notice naming whose staged changes are waiting, locks the canvas and the title, takes the excerpt field off the screen, and states that all three are edited on the copy.
  3. They keep editing. Every save is an ordinary WordPress revision on the staged copy, with its own author and timestamp.
  4. Anyone with permission reviews the change in the editor's own revisions view, which shows the staged words against the words as published, marked on the blocks that carry them. It is core's screen with the parts that do not apply to a staged copy taken out: there is no timeline to scrub, because a staged copy has two states rather than a history, and no sidebar, because it listed those same two states.
  5. Publishing applies the staged content to the published post, adopts the staged revisions into its history, and removes the staged copy.

The published post keeps its ID, URL, publish date, author, comments, terms, and featured image throughout. Only the title, content, and excerpt change.

What can be staged

Title, content, and excerpt.

Everything else, including status, slug, publish date, author, terms, and featured image, is copied to the staged copy for fidelity but locked. Changing any of them means editing the published post directly, which takes effect immediately — and stays available while a staged copy exists, since the published post is the only place those changes can be made.

This is a deliberate limit rather than an unfinished one. The review surface is core's revision screen, and core revisions only store those three fields. Staging a term change would mean staging something the review cannot show and the pre-publish snapshot cannot roll back.

Read the full README on GitHub →