The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
Api

Dynamic & Nested Pages

Build blogs, event listings, and multi-level page hierarchies using AbstractPageData and nested Page relationships.

Static Pages

A static page is a one-to-one relationship: one Route, one Page, one Layout. The content regions are fixed to that page.

Dynamic Pages

A dynamic page maps many URLs to different PageData records, all rendering through one shared Page template. This is how a blog works — one BlogDetail template serves every article, each with its own URL, title, meta tags, and content.

Creating a PageData Entity

Extend AbstractPageData. It inherits from AbstractPage, which provides title and metaDescription used by the SEO system:

// src/Entity/BlogArticleData.php
namespace App\Entity;

use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrFilter;
use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\SortFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\QueryParameter;
use Doctrine\ORM\Mapping as ORM;
use Silverback\ApiComponentsBundle\Annotation as Silverback;
use Silverback\ApiComponentsBundle\Entity\Core\AbstractPageData;

#[ORM\Entity]
#[ApiResource(
    mercure: true,
    order: ['createdAt' => 'DESC'],
    paginationItemsPerPage: 12,
    parameters: [
        'search' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new PartialSearchFilter())), properties: ['title']),        'order[:property]' => new QueryParameter(filter: new SortFilter(), properties: ['title', 'createdAt']),    ],
)]
class BlogArticleData extends AbstractPageData
{
    #[ORM\Column(type: 'text', nullable: true)]     public ?string $headline = null;

    #[ORM\Column(type: 'text', nullable: true)]     public ?string $body = null;
}
Your own properties need an explicit Doctrine mapping (#[ORM\Column], or #[ORM\ManyToOne] for a component relation). Without one, Doctrine ignores the property — it never persists and never appears in the API payload.

Searching and Sorting

The example declares two query parameters. Declare filters this way, with QueryParameters. API Platform has deprecated #[ApiFilter], SearchFilter and OrderFilter.

  • ?search=news matches part of the title, ignoring case. To search more fields, add them to properties. A record matches if any of them matches.
  • ?order[title]=asc or ?order[createdAt]=desc sorts. Any direction other than asc or desc returns a 422.

search takes one value. The old per-field parameters, such as ?title=news, are ignored rather than refused, so a client that still sends them gets every record back. Update your front end to send search.

The admin's page data list (/_cwa/data/{type}) sends search, and sorts with order[createdAt] and order[title]. Declare both parameters on every page data class you want to search and sort there. For now the admin also sends the old per-field parameter (title), so a class that still uses #[ApiFilter] keeps working.

Always wrap OrFilter in FreeTextQueryFilter. A bare OrFilter ORs against the whole WHERE clause. That throws away the publication, go-live and route security checks, so drafts and scheduled records show up in results.The same applies if you write your own Doctrine filter: never call orWhere(). API Platform applies those checks before filters, and orWhere() ORs against everything already on the query. Collect your clauses and apply them once with andWhere() wrapping an Expr\Orx. See Publishable → Filtered Collections.
Keep nested properties out of an OR search.PartialSearchFilter joins a property such as author.name with an inner join. Records without that relation then drop out of every search, even when another field matches. The bundle removed layout.reference from the Page search for this reason.

Parameters declared on #[ApiResource] also filter single-item requests: /page_data/blog_article_datas/{id}?search=zzz returns a 404. The Nuxt module only sends the page's query to collection fetches, so this doesn't affect CWA's own requests. If it matters to other clients, declare the parameters on a GetCollection operation instead.

For the other client-visible differences between API Platform 4.4 and 5, see Bundle Setup → Installation.

The core collections

The bundle's own collections take the same kind of parameters. Each has a single search parameter, case-insensitive and matching part of a value:

Collectionsearch matchesorder[…]Other
/_/layoutsreference, uiComponentcreatedAt, reference
/_/pagestitle, reference, uiComponentcreatedAt, referenceisTemplate=true or false (1/0 also work), or a list such as isTemplate[]=true&isTemplate[]=false
/_/routespathcreatedAt, path

Each lists newest first when no order is given.

Upgrading: the per-field parameters (?reference=, ?uiComponent=, ?title=, ?path=) are now ignored. Sending several values for one field (?path[]=a&path[]=b) no longer works either. The Page search no longer matches layout.reference. Sort parameters are unchanged.
Upgrading: OrSearchFilter has been removed. An entity that declares #[ApiFilter(OrSearchFilter::class, ...)] no longer compiles against the current bundle (api-components-bundle#303). Replace it with a search parameter, as in the example above. It was deprecated for a short time before that, in the same release as the search parameters.

What AbstractPageData Provides

PropertySourcePurpose
titleAbstractPageRequired. Overrides the page template's <title> for this record
metaDescriptionAbstractPagePer-record meta description
pageAbstractPageDataManyToOne → the Page template (required)
routeAbstractPageThe Route pointing to this record
parentPageAbstractPageOptional. The parent Page this record lives under (for nested hierarchies)
parentPageDataAbstractPageOptional. The parent PageData this record lives under

title is required (nullable: false is set via #[AttributeOverride] in AbstractPageData).

URL Prefix Convention

All PageData endpoints are prefixed with /page_data/. The segment after it is derived by API Platform from the class short name — BlogArticleData becomes blog_article_datas:

GET    /page_data/blog_article_datas        # collection (with pagination, filtering)
POST   /page_data/blog_article_datas        # create a new article
GET    /page_data/blog_article_datas/{id}   # single article
PATCH  /page_data/blog_article_datas/{id}   # update
DELETE /page_data/blog_article_datas/{id}   # delete

Run bin/console debug:router to confirm the exact segment for your own classes.

Setting Up in the Admin

  1. Create the Page template — in /_cwa/pages, create a Page with isTemplate: true, set uiComponent to your Vue component name (e.g. BlogDetail), and assign a Layout
  2. Create PageData records — in /_cwa/data/BlogArticleData, create articles. Each article has a title (required for SEO) and your custom fields
  3. Routes must be created explicitly (via the admin or REST API), generated with POST /_/routes/generate, or auto-generated by the fixture scaffold — see Route Generation and Data Fixtures

Component Positions from PageData

You can link a component group position to a field on the PageData record. This is how a page template's "hero image" slot gets its data from blogArticle.image:

In your fixture:

$cwa->page('blog-detail', 'BlogDetail', 'main', null, null, true)
    ->group('content', function (GroupBuilder $group) {
        $group->pageDataPosition(BlogArticleData::class, 'htmlContent');  // links to BlogArticleData::$htmlContent
        $group->pageDataPosition(BlogArticleData::class, 'image');        // links to BlogArticleData::$image
    });

pageDataPosition() takes the FQCN of the AbstractPageData subclass as its first argument, then the property name. At render time the ComponentPosition resolves to the component held in that field on the current PageData record. The API validates on write that the class is a known PageData resource, the property is component-typed, and (if allowedComponents is set on the group) the resolved type is permitted.

pageDataClass and pageDataProperty must always be set together. Sending one without the other returns a 422.

The API works out which PageData record "the current one" is from the path request header, which the Nuxt module sends for you. It accepts either a route path (/blog/my-first-post) or a page data IRI (/page_data/blog_article_datas/018e…). The IRI form is what lets a nested page resolve these positions at a depth whose PageData has no Route of its own.

Combining with Publishable

Add #[Silverback\Publishable] to your PageData class to give articles a draft/publish workflow. Draft articles are only visible to admins:

#[Silverback\Publishable]
#[ORM\Entity]
#[ApiResource(mercure: true, order: ['createdAt' => 'DESC'])]
class BlogArticleData extends AbstractPageData
{
    use PublishableTrait;

    #[ORM\Column(type: 'text', nullable: true)]
    public ?string $headline = null;
}

Timestamps come free — #[Silverback\Timestamped] and TimestampedTrait are already applied by AbstractPage, which AbstractPageData extends. You only need to add Publishable.

Accessing PageData on the Front-End

In your page template Vue component:

const cwa = useCwa()

// The current page data resource (populated by middleware)
const pageData = computed(() => cwa.resources.pageData.value)
const pageDataIri = computed(() => cwa.resources.pageDataIri.value)

// Access your custom fields
const headline = computed(() => pageData.value?.data?.headline)
cwa.resources.pageData is always a ComputedRef, but its value is undefined until a page data IRI resolves — and stays undefined for the whole lifetime of a page that isn't backed by page data. Guard every read from it.

pageData.value?.data?.title and pageData.value?.data?.metaDescription are automatically applied as SEO meta tags by the module middleware.

Route Generation

Creating a PageData record directly via the REST API or admin panel never creates a route for it. Either create a Route explicitly via POST /_/routes, or ask the API to generate one from the title slug with POST /_/routes/generate, passing the record's IRI as pageData (or a Page's IRI as page):

POST /_/routes/generate

{ "pageData": "/page_data/blog_article_datas/018e…" }

If the record already had a route, the old route becomes a redirect to the new one. CwaFixtureBuilder uses the same generator (RouteGenerator::create()) for records you don't give an explicit route.

Upgrading: a custom route generator must implement generatePath().RouteGeneratorInterface gains generatePath(RoutableInterface $object): string (api-components-bundle#335, in 2.0.0-alpha.5). It returns the path create() would give the record, before any -1 suffix for a conflict. The fixture builder uses it to find existing page data on an appended load. If your application replaces or decorates the RouteGeneratorInterface service with a class of its own, add the method. Without it, PHP refuses to load the class, with a fatal error for the missing interface method. A decorator can pass the call on to the inner generator.
A route can only be generated when the parent has one. For a record with a parentPage or parentPageData whose parent has no Route, generation fails: POST /_/routes/generate returns a 422, and a fixture throws UnroutedParentException on flush(). Give the parent a route first, or give the child an explicit path (POST /_/routes, or route: in a fixture).

In fixtures, a blog article titled "My First Post" gets the route /my-first-post (or /parent-path/my-first-post when nested) unless you set one explicitly:

$cwa->pageData($article, 'blog-detail', '/blog/custom-path', 'blog_article_custom')

Nested Pages

Some content naturally lives at more than one level — an event site might have /events, /events/2024, and /events/2024/conference. CWA handles this with a parent-child hierarchy on the PHP entities, automatic route path prefixing, and the <CwaPage /> component on the front end that renders the next depth level.

The PHP Fields

Both AbstractPage and AbstractPageData (which extends AbstractPage) carry two optional parent references:

FieldTypeUse
parentPage?PageThis record is a child of a static Page
parentPageData?AbstractPageDataThis record is a child of a dynamic PageData record

Set one or the other — not both. A child can be either a Page or a PageData record; the parent can be either type too.

You don't need to declare these fields in your entity classes — they are inherited from AbstractPage.

Setting up Hierarchy in Fixtures

Use ->nested() on a PageBuilder or PageDataBuilder. The closure receives a CwaFixtureBuilder scoped to that parent — anything created inside automatically becomes a child:

// Static parent at /events, children are EventYear pages
$cwa->page('event-list', 'EventList', 'main', '/events')
    ->nested(function (CwaFixtureBuilder $cwa) {
        // Title "2024" + parent path /events → route auto-generated as /events/2024
        $cwa->page('event-year-2024', 'EventYear', 'main')
            ->title('2024');

        $cwa->page('event-year-2025', 'EventYear', 'main')
            ->title('2025');
    });

Dynamic children (PageData records) work the same way:

// Static listing page at /blog, children are BlogArticleData records
$cwa->page('blog-listing', 'BlogListing', 'main', '/blog')
    ->nested(function (CwaFixtureBuilder $cwa) use ($article1, $article2) {
        // Each pageData gets its route auto-prefixed to /blog/<title-slug>
        $cwa->pageData($article1, 'blog-detail');
        $cwa->pageData($article2, 'blog-detail');
    });

You can also set the fields directly on the entity, useful when building relationships outside of the fixture scaffold:

// Child Page under a parent Page
$childPage->setParentPage($parentPage);

// Child PageData under a parent Page
$childPageData->setParentPage($parentPage);

// Child PageData under a parent PageData (e.g. blog post under a category)
$childPageData->setParentPageData($parentCategoryData);

Route Auto-Prefixing

RouteGenerator reads the parent's route path and prepends it to the child's generated slug. This cascades through any number of levels:

EntityParent pathTitleGenerated route
EventYear/events2024/events/2024
ConferenceData/events/2024Spring Conference/events/2024/spring-conference
Prefixing applies only to auto-generated routes. An explicit route: is used exactly as written — include the parent segments yourself if you want it nested, or pass a bare path to put the child at a custom top-level URL.

Cascading Route Changes

When an admin renames a parent route, PATCH /_/routes//events with cascadeChildPaths: true updates the entire subtree and creates redirects from every old path:

PATCH /_/routes//events
Content-Type: application/merge-patch+json

{
  "path": "/happenings",
  "cascadeChildPaths": true
}

Result:

  • /events → redirects to /happenings
  • /events/2024 → redirects to /happenings/2024
  • /events/2024/spring-conference → redirects to /happenings/2024/spring-conference

The cascade also passes through pages that have no Route. They add no path segment, so their routed descendants are updated against the nearest routed ancestor.

Without cascadeChildPaths: true only the one route is updated. Child routes keep working at their old paths, but those paths no longer share the parent's prefix and no redirects are created for them.

Parents Without a Route

A parent page does not need a Route of its own. While you are drafting, or for a grouping page that should never be addressable itself, leave the parent unrouted and give only the children URLs. Those children need explicit paths, because a route can't be generated under an unrouted parent.

A child's live Route makes its whole ancestor chain publicly readable: the parent's Page or PageData, its template Page and their components. So /events/2024/conference renders every depth for anonymous visitors even when /events/2024 has no Route. An unrouted parent is also never a go-live gate — see Inheritance below.

The rule behind this is covered in Users & Security → Public Access Follows Routes.

Fetching a Route's Children

Retrieve the full child tree for a route (requires ROLE_ADMIN):

GET /_/routes//events/children

Response:

{
  "children": [
    {
      "route": "/_/routes/018e4d02-…",
      "path": "/events/2024",
      "children": [
        {
          "route": "/_/routes/018e4d7f-…",
          "path": "/events/2024/spring-conference",
          "children": []
        }
      ]
    },
    {
      "route": "/_/routes/018e4e11-…",
      "path": "/events/2025",
      "children": []
    }
  ]
}

Each node has the route IRI, the resolved path, and a recursive children array. A page with no Route adds no node of its own, but the walk passes through it: its routed descendants appear under the nearest routed ancestor.

The Resource Manifest

When the Nuxt module resolves a nested URL, it fetches a depth-grouped manifest from /_/resource_manifest/{id} (accepts both the route path and the admin UUID). The resource_iris field is an array indexed by rendering depth, root first. Each element is a single recursive { iri, children } node that mirrors the containment of the resources at that depth:

{
  "resource_iris": [
    {
      "iri": "/_/pages/018e4b9a-…",
      "children": [
        { "iri": "/_/component_groups/018e4c11-…", "children": [] }
      ]
    },
    {
      "iri": "/_/routes/018e4d01-…",
      "children": [
        { "iri": "/page_data/event_year_datas/018e4d02-…", "children": [] }
      ]
    }
  ]
}

The last depth is rooted at the requested Route and holds its Page or PageData. Each depth before it is rooted at an ancestor Page or PageData, root first. parentPage/parentPageData references are what split one depth from the next, so a parent never appears inside its child's tree.

A depth also includes its page's layout, with the layout's component groups, their positions and their components. A component's own component groups are included too, to any depth of nesting. A layout that a parent and a child page share is listed once, at the shallowest depth that uses it. A template page shared across depths is still listed at each depth. GET /_/layouts/{id} and a component's own response are unchanged and still return bare group IRIs. The groups are only expanded in the manifest.

Bundle versions before api-components-bundle#307 left the layout's groups out of the manifest, and versions before 2.0.0-alpha.3 (#309) left out a component's own groups. The module then found them one round trip per level later.
Known limitation: a template page shared by a parent and a child page data. The same template page is listed at both depths, but the module records one depth per resource, the deepest. A dynamic component position on that template page is then fetched once, with the child's path, so the parent's depth shows the child's component. The module logs Manifest resources are listed at more than one depth when this happens. Until it is fixed, give the parent and child different template pages.

The module keeps the tree (for laying out placeholders while resources load) and flattens each depth node into a flat IRI list to fetch in parallel, passing the right IRI to each <CwaPage /> level.

SEO Titles and Meta Descriptions for Nested Pages

The module's <CwaPage /> template sets the page <title> and meta description automatically from title and metaDescription fields on your Page and PageData entities. For nested routes the behaviour is:

Title — collected from every depth level, leaf-first, joined with |:

NestingTitles collected (leaf → root)<title> output
Flat page"Conference"Conference
Two-level"Programme" (depth 1) + "Conference" (depth 0)Programme | Conference
Three-level"Talk" + "Programme" + "Conference"Talk | Programme | Conference

Meta description — the deepest depth that has a non-empty metaDescription wins. This means the most specific page data always takes precedence over the parent.

For flat (single-depth) pages this is identical to the previous behaviour — just the page or pageData title with no separator.


Scheduling and Taking Routes Offline

Every Route has a liveAt date. It decides whether the page behind that URL is public, which makes it the way to schedule a launch, and the way to take a page offline without deleting its Route and losing the path, its redirects and the children hanging off it.

liveAtStatePublic visitors
A past dateLiveSee the page
A future dateScheduledGet a 404 until that moment, then see the page. Nothing needs to run at go-live
nullNot live (draft, or taken offline)Get a 404. The path stays reserved

liveAt is readable and writable only by ROLE_ADMIN. Set it with a normal update:

PATCH /_/routes//events/conference
Content-Type: application/merge-patch+json

{
  "liveAt": "2026-10-01T09:00:00+01:00"
}

Send "liveAt": null to take a page offline. In the admin, the same control is the route's Visibility setting — see The Admin Panel → Visibility.

Upgrading: generate and run a migration.liveAt adds one nullable live_at column to the route table (_acb_route with the default table_prefix), with a CURRENT_TIMESTAMP default, so the migration marks every existing route live. New routes also default to "now", however they are created (the API, RouteGenerator, CwaFixtureBuilder, fixtures), so nothing changes until someone sets a date or null.

Inheritance

A route's go-live date is inherited down the page hierarchy. The effective date is the latest of the route's own liveAt and the liveAt of every ancestor page that has a Route. If any of them is null, the route is not live. Ancestors with no Route are skipped, not treated as drafts.

AncestorsChild's own liveAtChild goes live
Parent live since January1 November1 November, its own date
Parent scheduled for 1 NovemberAlready past1 November, held back by the parent
Parent 1 November1 December1 December, the later date
Parent nullAny dateNot until the parent has a date
Parent has no RouteAlready pastNow, because the parent is skipped
Grandparent 1 November, parent has no RouteAlready past1 November

Scheduling a parent therefore schedules its whole subtree, and taking a parent offline takes every child offline with it.

What Visitors and Admins See

For anyone who fails the publishable.permission expression (typically ROLE_ADMIN), a route that is not live, whether by its own date or an ancestor's, behaves like this:

RequestResponse
GET /_/routes/{path}404
GET /_/resource_manifest/{id}404
GET /_/routesOmitted, and totalItems counts only what is returned
GET /_/pages and page-data collectionsOmitted, when routable_security is set
The route's Page, PageData or components, fetched directly401, when routable_security is set

Without routable_security, only the route and its manifest are gated; the records behind them stay readable by IRI. The template app sets it.

The 401 is a known boundary: only the route and manifest endpoints turn the denial into a 404. A component's access is checked through an internal sub-request, which deliberately still sees the underlying 403.

A route that redirects to a gated target still returns its redirectPath. The client follows it and gets the 404 there.

Admins resolve, preview and edit gated routes throughout, and see every route in GET /_/routes, so a scheduled page can be picked in link and route selectors before launch. Admin route responses also carry _metadata.effectiveLiveAt, the inherited date that actually applies.

The sitemap follows automatically. The Nuxt module builds the sitemap from an anonymous GET /_/routes, so scheduled and offline routes, and routes held back by an ancestor, never reach search engines. The collection query uses a recursive CTE, so check your database server meets the minimum versions.

Caching

A request for a route or manifest that is not live is marked private, no-store, so a shared cache never stores either the 404 or an admin's preview. And because nothing is written at the moment a scheduled route goes live, anonymous route, page, page-data and manifest responses that carry s-maxage or max-age have s-maxage and max-age capped at the soonest upcoming liveAt across all routes. A cached navigation therefore refreshes when any page launches, not only one it links to. The list of capped resource classes is configurable: see Scheduled Publication and Cache Lifetime.