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\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;
}
#[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=newsmatches part of thetitle, ignoring case. To search more fields, add them toproperties. A record matches if any of them matches.?order[title]=ascor?order[createdAt]=descsorts. Any direction other thanascordescreturns a422.
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.
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.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:
| Collection | search matches | order[…] | Other |
|---|---|---|---|
/_/layouts | reference, uiComponent | createdAt, reference | |
/_/pages | title, reference, uiComponent | createdAt, reference | isTemplate=true or false (1/0 also work), or a list such as isTemplate[]=true&isTemplate[]=false |
/_/routes | path | createdAt, path |
Each lists newest first when no order is given.
?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.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
| 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/. 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
- 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), 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.
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.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:
| 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 |
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.
2.0.0-alpha.3 (#309) left out a component's own groups. The module then found them one round trip per level later.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 |:
| 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.
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.
liveAt | State | Public visitors |
|---|---|---|
| A past date | Live | See the page |
| A future date | Scheduled | Get a 404 until that moment, then see the page. Nothing needs to run at go-live |
null | Not 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.
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.
| Ancestors | Child's own liveAt | Child goes live |
|---|---|---|
| Parent live since January | 1 November | 1 November, its own date |
| Parent scheduled for 1 November | Already past | 1 November, held back by the parent |
| Parent 1 November | 1 December | 1 December, the later date |
Parent null | Any date | Not until the parent has a date |
| Parent has no Route | Already past | Now, because the parent is skipped |
| Grandparent 1 November, parent has no Route | Already past | 1 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:
| Request | Response |
|---|---|
GET /_/routes/{path} | 404 |
GET /_/resource_manifest/{id} | 404 |
GET /_/routes | Omitted, and totalItems counts only what is returned |
GET /_/pages and page-data collections | Omitted, when routable_security is set |
| The route's Page, PageData or components, fetched directly | 401, 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.
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.