Publishing is opt-in per content type. A type without publishable: true has no
publish state at all — every row is simply live.
export const article = collection('article', {
publishable: true,
paranoid: true,
fields: { title: field.text({ required: true }) }
});The states
| State | What it is |
|---|---|
| Draft | status = 'draft', published_at null. Never been live. |
| Published | status = 'published', published_at set. Live. |
| Modified | status = 'draft', published_at set. Live content exists, and there are unpublished edits on top of it. |
“Modified” is not a fourth column value — it is the combination of a draft
status and a non-null published_at. Saving an edit to a live entry moves it
back to draft but keeps published_at, which is what makes the distinction
between “never published” and “published, then edited” expressible.
If you are querying this yourself: status eq draft on its own counts
never-published drafts as edits. Modified is status eq draft and
publishedAt is not null.
Required means required to publish
On a publishable type, required: true does not stop a save. The column stays
nullable, and the rule is checked when the entry is published.
That is what lets an author save an incomplete draft and come back to it. The
same declaration on a non-publishable type produces a real NOT NULL
column, because there is no draft stage in which the value could legitimately be
missing.
The publish gate
Publishing re-checks the stored row against the type’s rules — every required field present, every present value valid. The admin mirrors the same predicate to enable or disable its Publish button, so an author is not offered a button that will fail.
The gate covers the values bag. A required link-managed relation — an owning many-to-many, or the inverse of one — cannot be judged from values, because its links never travel there, so those are enforced separately by counting links. The two together are the full server-side precondition.
A failed publish is a 422 naming the fields, and the entry stays where it was.
When a content type is protected
The gate above is the second of three checks. Where a workspace has written a protection rule for the type, publication additionally needs a number of approvals on the entry’s current revision — by default from somebody other than whoever wrote that revision.
It is a separate question from this one, and deliberately so: the publish gate owns whether the entry is complete, protection owns whether this person may ship it now. Approvals never make an incomplete entry publishable, and an administrator’s bypass passes protection only — the 422 above still stands.
A blocked publish is a 409 naming what was required and what had been given.
With no rule for the type, nothing on this page changes and the publish path
runs exactly as it did before the plugin existed.
Publishing endpoints
| Route | Effect |
|---|---|
POST /api/content/:type/:id/publish | Runs the gate and goes live. |
POST /api/content/:type/:id/unpublish | Back to draft. |
POST /api/content/:type/bulk/publish | Publish many. |
POST /api/content/:type/bulk/publish/preview | What would happen, without doing it. |
POST /api/content/:type/bulk/unpublish | Unpublish many. |
The bulk preview exists because a bulk publish across a mixed selection is otherwise a guess: some entries will pass the gate and some will not, and an editor deserves to know which before pressing the button rather than after.
Publishing also promotes the entry’s version history:
the relevant revision becomes published and any previously published one is
demoted to superseded, so at most one version of an entry is live at a time.
Deletion
With paranoid: true, deleting is a soft delete: deleted_at is stamped and
the row stays. Without it, delete removes the row.
| Route | Effect |
|---|---|
DELETE /api/content/:type/:id | Soft delete — tombstones the row. |
POST /api/content/:type/:id/restore | Clears deleted_at. |
DELETE /api/content/:type/:id/permanent | Removes the row for good. |
POST /api/content/:type/bulk/delete | Soft delete many. |
POST /api/content/:type/bulk/restore | Restore many. |
POST /api/content/:type/bulk/purge | Permanently delete many. |
Permanent deletion is permanent
/permanent and bulk/purge remove the row. The entry’s revision history goes
with it, and a relation with onDelete: 'cascade' pointing at it will take its
own rows down too. There is no undo and no trash beyond the soft-delete stage.
On a localized type, deletion is per row — that is, per language. Deleting the German translation of an article does not delete the English one. The group survives as long as any sibling does.
What a soft-deleted row is invisible to
A tombstoned row is excluded from list reads, from the public API entirely, and
from relation targets. It still occupies its (locale_group_id, locale) slot —
the unique index on that pair is partial, WHERE deleted_at IS NULL, so a
trashed row does not block a replacement translation from being created.
The same partial-index treatment applies to one-to-one relation uniqueness, so a trashed row cannot hold a relation target hostage.
No scheduled publishing
An entry goes live when somebody or something publishes it. There is no publish-at date, no scheduler and no worker process. If you need timed publishing, it has to come from outside — a cron job hitting the publish endpoint with an API token is the honest workaround.