Creating Components
A CWA component is a PHP entity that extends AbstractComponent. It defines the data model for one type of content block — a title, an image, an article body. The front-end Nuxt module resolves these by class name and renders the matching Vue component.
What AbstractComponent Gives You
You get these for free. Don't redefine them:
| Property | Type | Purpose |
|---|---|---|
id | UuidInterface | Auto-generated UUID identifier |
uiComponent | string | The Vue component name to render (Title, Image, etc.) |
uiClassNames | string|null | CSS class name(s) chosen by the admin in the CMS |
componentGroups | Collection | Which component groups contain this instance |
componentPositions | Collection | Ordered positions within those groups |
The _metadata envelope in every API response carries rendering hints, media objects (for uploadable components), and publish state.
Step 1: Create the Entity
// src/Entity/Component/Title.php
namespace App\Entity\Component;
use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity]
#[ApiResource(mercure: true)]
class Title extends AbstractComponent
{
#[ORM\Column(type: 'text', nullable: true)]
#[Assert\NotBlank(groups: ['Title:published'])]
public ?string $title = null;
}
mercure: true on #[ApiResource] means every change is broadcast over the Mercure hub in real time — open browser sessions update without a page refresh.
Step 2: Run Migrations
bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
The diff produces a migration that creates a joined table for your component (CWA uses JOINED inheritance on AbstractComponent). Always review before running.
URL Prefix Convention
The bundle automatically prefixes all component endpoints with /component/:
GET /component/titles # collection
POST /component/titles # create
GET /component/titles/{id} # single item
PATCH /component/titles/{id} # update
DELETE /component/titles/{id} # delete
This prefix is applied by the bundle's metadata factory — no manual routePrefix needed unless you want to change it.
The Class Name → Vue Component Convention
The PHP class name determines which Vue component renders it:
| PHP class | API @type | Vue component file |
|---|---|---|
Title | Title | app/cwa/components/Title/Title.vue |
HeroImage | HeroImage | app/cwa/components/HeroImage/HeroImage.vue |
BlogCard | BlogCard | app/cwa/components/BlogCard/BlogCard.vue |
The Nuxt module resolves CwaComponentTitle from the @type in the API response. If a matching Vue component doesn't exist, the CMS shows a placeholder in edit mode.
A Complete Example
Here is a component with publish workflow, timestamps, and validation — equivalent to Title.php in the playground:
namespace App\Entity\Component;
use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Silverback\ApiComponentsBundle\Annotation as Silverback;
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
use Silverback\ApiComponentsBundle\Entity\Utility\PublishableTrait;
use Silverback\ApiComponentsBundle\Entity\Utility\TimestampedTrait;
use Symfony\Component\Validator\Constraints as Assert;
#[Silverback\Publishable]
#[Silverback\Timestamped]
#[ORM\Entity]
#[ApiResource(mercure: true)]
class Title extends AbstractComponent
{
use PublishableTrait;
use TimestampedTrait;
#[ORM\Column(type: 'text', nullable: true)]
#[Assert\NotBlank(groups: ['Title:published'])]
public ?string $title = null;
}
What the API Returns
A GET to /component/titles/{id} returns something like:
{
"@context": "/contexts/Title",
"@id": "/component/titles/018e-...",
"@type": "Title",
"title": "Welcome to CWA",
"uiComponent": "Title",
"uiClassNames": "centered",
"publishedAt": "2024-01-15T10:30:00+00:00",
"_metadata": {
"publishable": {
"published": true,
"draftResource": null
}
}
}
The _metadata key is your component's runtime state envelope. It is never stored directly — the bundle computes it during serialization.
Restricting Positions
By default, a component can be placed anywhere. Override isPositionRestricted() to lock it to specific groups:
public function isPositionRestricted(): bool
{
return true;
}
When true, the admin UI prevents dragging this component out of its designated group.