Publishable
#[Silverback\Publishable] gives your component a twin-resource draft/publish lifecycle. An admin edits the draft privately; visitors always see the published version. Publishing is atomic — the switch happens at the database level with no stale cache window.
Setup
use Silverback\ApiComponentsBundle\Annotation as Silverback;
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
use Silverback\ApiComponentsBundle\Entity\Utility\PublishableTrait;
#[Silverback\Publishable]
#[ORM\Entity]
#[ApiResource(mercure: true)]
class Title extends AbstractComponent
{
use PublishableTrait;
#[ORM\Column(type: 'text', nullable: true)]
public ?string $title = null;
}
That's all that's required. The trait and annotation together wire up the full lifecycle.
What PublishableTrait Adds
| Property | Type | Description |
|---|---|---|
publishedAt | ?DateTimeInterface | null = unpublished draft |
publishedResource | ?self | Points from draft → published twin |
draftResource | ?self | Points from published → draft twin |
The bundle creates and maintains these twins automatically. You never create the published twin manually.
How It Works at the API Level
The bundle intercepts requests and routes based on the caller's role and the published query parameter:
| Caller | Gets |
|---|---|
| Anonymous visitor | Published version (or 404 if never published) |
Caller matching publishable.permission | Draft version if one exists, otherwise the published one |
Direct ?published=true | Published version explicitly |
The Nuxt module handles the ?published=false parameter automatically in edit mode — you don't add it manually.
Publishing a Resource
Send a PATCH with publishedAt set to a date string:
PATCH /component/titles/018e-...
Content-Type: application/merge-patch+json
{
"publishedAt": "2024-01-15T10:30:00+00:00"
}
The bundle creates or updates the published twin and broadcasts a Mercure update if mercure: true is on the entity.
A future date schedules the draft: it stays a draft until that moment, then replaces the published version on the next request that reads it. The admin's Publish tab sets this date for you, see Scheduling a Publish.
There is no unpublish switch. Once a resource is live, a PATCH to it with "publishedAt": null (or any other change) creates or updates a draft and leaves the published version live, and a past date on a live resource is ignored. To take a published resource offline, DELETE it with ?published=true: that removes the published version and keeps any draft.
Publish-Only Validation
Some fields should only be required when going live, not while saving a draft:
#[ORM\Column(type: 'text', nullable: true)]
#[Assert\NotBlank(groups: ['Title:published'])]
public ?string $title = null;
The convention for the publish validation group is ClassName:published. The bundle applies this group automatically when publishedAt is being set.
Customise the group name:
#[Silverback\Publishable(validationGroups: ['my_custom_group'])]
Filtered Collections
A collection of a publishable resource returns drafts only to callers who may read them, and that holds when the request carries filter parameters too.
OrSearchFilter, which was then the search filter on Route.path, on Page and Layout, and on any resource of yours that declares it. It built its clauses with Doctrine's orWhere(), which ORs against the whole query, so supplying any filter parameter discarded the publication checks. Anonymous GET /_/routes?path=… returned scheduled and draft routes, and a filtered collection of a #[Silverback\Publishable] resource returned drafts. That is fixed: the filter now ANDs with everything else on the query. If anything relied on a filtered listing exposing drafts or scheduled resources, it will stop seeing them.OrSearchFilter has since been removed. The bundle's own collections take a single search parameter instead, and so must yours; see Dynamic Pages → Searching and Sorting.The same trap applies to a bare OrFilter (always wrap it in FreeTextQueryFilter) and to filters you write yourself. Never call orWhere() in an API Platform Doctrine filter. API Platform applies query extensions (drafts, go-live dates, route security) before filters, so an orWhere() silently undoes them. Collect your clauses and apply them with a single andWhere():
$orX = $queryBuilder->expr()->orX();
foreach ($values as $value) {
$param = $queryNameGenerator->generateParameterName($property);
$orX->add($queryBuilder->expr()->like("$alias.$property", ':' . $param));
$queryBuilder->setParameter($param, '%' . $value . '%');
}
$queryBuilder->andWhere($orX);Configuring Who Can Publish
Set the security expression in the bundle config. Only users matching this expression can set publishedAt:
silverback_api_components:
publishable:
permission: "is_granted('ROLE_ADMIN')"
Users who don't match it never see drafts (a draft IRI is a 404 for them), and their writes skip the draft stage: a POST is published straight away with publishedAt set for them, and a PATCH updates the published version directly.
To override the permission for a single resource, pass isGranted on the attribute. It takes precedence over publishable.permission for that class only:
#[Silverback\Publishable(isGranted: "is_granted('ROLE_EDITOR')")]
Custom Property Names
The default property names (publishedAt, publishedResource, draftResource) match what PublishableTrait declares. Override them when names conflict with your own fields:
#[Silverback\Publishable(
fieldName: 'publicationDate',
associationName: 'original',
reverseAssociationName: 'draft'
)]
If you customise names, don't use PublishableTrait — declare the properties yourself using the matching names.
Migration Notes
PublishableTrait adds:
- 1 nullable datetime column (
published_at) - 1 nullable self-referencing foreign key (
published_resource_id)
draftResource is the inverse side of the same one-to-one relation, so it adds no column of its own — don't go looking for a draft_resource_id in the migration.
Always review the generated Doctrine migration. The foreign key sits on the draft and uses ON DELETE SET NULL, so deleting the published version keeps its draft and just clears the draft's pointer.
API Response Metadata
Every publishable component response includes a _metadata.publishable object:
{
"@id": "/component/titles/018e-...",
"title": "My Heading",
"_metadata": {
"publishable": {
"published": true,
"publishedAt": "2024-01-15T10:30:00+00:00",
"locationCount": 3
}
}
}
| Field | Type | Description |
|---|---|---|
published | boolean | Whether this resource is live: its publishedAt is set and not in the future. A draft reports false even when it has a published twin |
publishedAt | string | null | This resource's publication date (a future date on a scheduled draft), or null for a draft with no date |
locationCount | integer | null | Total number of places this component appears — counts both direct ComponentPosition references and AbstractPageData instances that hold it as a typed property (resolved via pageDataProperty positions at render time). Useful for showing admins "used in N places" before deleting. |
locationCount is null for component types that have no getComponentPositions() method.
_metadata.publishable is returned to every caller, including anonymous visitors — don't treat its presence as an authentication signal. What the publish permission gates is the soft-validation output: _metadata.violations is only populated for callers who satisfy the publishable.permission expression.Interaction with Mercure
When a resource is published, if mercure: true is on the #[ApiResource], the API broadcasts an update to the hub. Every browser that has loaded the resource receives the new content in real time with no page reload needed.