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\Filter\OrderFilter;
use ApiPlatform\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
{
    public ?string $headline = null;
    public ?string $body = null;
}

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/:

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

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]
#[Silverback\Timestamped]
#[ORM\Entity]
#[ApiResource(mercure: true, order: ['createdAt' => 'DESC'])]
class BlogArticleData extends AbstractPageData
{
    use PublishableTrait;
    use TimestampedTrait;

    public ?string $headline = null;
}

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)

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

An explicit route: on the child still gets prefixed — only the top-level / is preserved as-is. If you need a child at a completely custom path, set its route explicitly after the parent path has been established.

Cascading Route Changes

When an admin renames a parent route, PATCH /_/routes//events with cascadeChildPaths: true updates the entire subtree and creates 301 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 paths keep their old prefixes and become orphaned.

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 of arrays — each inner array holds the IRIs for one depth level:

{
  "resource_iris": [
    ["/events", "/layouts/primary"],
    ["/events/2024", "/layouts/primary"],
    ["/page_data/event_years/456"]
  ]
}

Depth 0 contains the top-level Page and Layout IRIs. Depth 1 contains the child Page or PageData IRI. Depth 2 and beyond continue the pattern. The module fetches all of these in parallel and passes 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.