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\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
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
)]
#[ApiFilter(SearchFilter::class, properties: ['title' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, 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.

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), or auto-generated by the fixture scaffold — see 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.

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 undefined — not a ref — until a page data IRI has resolved, so always reach for it with ?.value. It is undefined for the whole lifetime of a page that isn't backed by page data.

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

Route Generation

Route auto-generation from the title slug is a fixture/scaffold feature only — it is handled by CwaFixtureBuilder via RouteGenerator::create() at fixture time. When you create a PageData record directly via the REST API or admin panel, no route is created automatically; you must create a Route explicitly via POST /_/routes or use the admin UI.

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

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.

Fetching a Route's Children

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

GET /_/routes//events/children

Response:

{
  "children": [
    {
      "route": "/_/routes//events/2024",
      "path": "/events/2024",
      "children": [
        {
          "route": "/_/routes//events/2024/spring-conference",
          "path": "/events/2024/spring-conference",
          "children": []
        }
      ]
    },
    {
      "route": "/_/routes//events/2025",
      "path": "/events/2025",
      "children": []
    }
  ]
}

Each node has the route IRI, the resolved path, and a recursive children array.

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": "/_/routes/events",
      "children": [
        {
          "iri": "/_/pages/018e4b9a-…",
          "children": [
            { "iri": "/_/component_groups/018e4c11-…", "children": [] }
          ]
        }
      ]
    },
    {
      "iri": "/_/routes/events-2024",
      "children": [
        { "iri": "/page_data/event_year_datas/018e4d02-…", "children": [] }
      ]
    }
  ]
}

Depth 0 is the top-level Route and everything it contains; depth 1 is the child Route and its Page or PageData; depth 2 and beyond continue the pattern. parentPage/parentPageData references are what split one depth from the next, so a parent never appears inside its child's tree.

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.