Dynamic & Nested Pages
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
| Property | Source | Purpose |
|---|---|---|
title | AbstractPage | Required. Overrides the page template's <title> for this record |
metaDescription | AbstractPage | Per-record meta description |
page | AbstractPageData | ManyToOne → the Page template (required) |
route | AbstractPage | The Route pointing to this record |
parentPage | AbstractPage | Optional. The parent Page this record lives under (for nested hierarchies) |
parentPageData | AbstractPage | Optional. 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
- Create the Page template — in
/_cwa/pages, create a Page withisTemplate: true, setuiComponentto your Vue component name (e.g.BlogDetail), and assign a Layout - Create PageData records — in
/_cwa/data/BlogArticleData, create articles. Each article has atitle(required for SEO) and your custom fields - 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:
| Field | Type | Use |
|---|---|---|
parentPage | ?Page | This record is a child of a static Page |
parentPageData | ?AbstractPageData | This 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:
| Entity | Parent path | Title | Generated route |
|---|---|---|---|
| EventYear | /events | 2024 | /events/2024 |
| ConferenceData | /events/2024 | Spring 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 |:
| Nesting | Titles 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.