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) |
| Admin in edit mode | Draft version |
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.
To unpublish: PATCH with "publishedAt": null.
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'])]
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')"
Non-admin users can still PATCH a resource's other fields — they just can't flip the publish switch.
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) - 2 nullable self-referencing foreign keys (published_resource_id, draft_resource_id)
Always review the generated Doctrine migration. The self-referencing keys use ON DELETE SET NULL so deleting a draft doesn't cascade to the published version.
API Response Metadata
Every publishable component response includes a _metadata.publishable object visible to admin users:
{
"@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 the published twin exists |
publishedAt | string | null | ISO 8601 timestamp of last publish, or null if unpublished |
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. Anonymous visitors do not receive _metadata at all.
Interaction with Mercure
When a resource is published (or unpublished), 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.