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.
DraftApi

Data Fixtures

Use AbstractCwaScaffold and CwaFixtureBuilder to seed your database with layouts, pages, routes, components, and page data in a fluent API.

The API bundle ships a fluent fixture scaffold that handles all the complexity of seeding CWA data: entity persistence ordering, phase-aware flushing, route generation, and ComponentPosition wiring. You describe what you want, call flush(), and the builder handles the rest.

Setup

Install doctrine/doctrine-fixtures-bundle if not already present:

composer require --dev doctrine/doctrine-fixtures-bundle

Extend AbstractCwaScaffold instead of implementing FixtureInterface directly:

<?php

namespace App\DataFixtures;

use Silverback\ApiComponentsBundle\Fixture\AbstractCwaScaffold;
use Silverback\ApiComponentsBundle\Fixture\CwaFixtureBuilder;

class AppFixtures extends AbstractCwaScaffold
{
    public function build(CwaFixtureBuilder $cwa): void
    {
        $cwa->layout('main', 'Primary');
        $cwa->page('home', 'PrimaryPageTemplate', layout: 'main', route: '/');
        $cwa->flush();
    }
}

AbstractCwaScaffold implements FixtureInterface. Its load() method injects the ObjectManager, calls your build(), then calls flush() again. CwaFixtureBuilder is injected automatically via Symfony's service container.

With autoconfigure: true (the Symfony default), your fixture class is detected and tagged as a Doctrine fixture automatically — no services.yaml entry needed. If autoconfigure is disabled, register manually:

# config/services.yaml
App\DataFixtures\AppFixtures:
    tags:
        - { name: doctrine.fixture.orm }

Run your fixtures:

bin/console doctrine:fixtures:load

That empties the database first. To add a scaffold's content to a database that already has some, append instead. See Appending to an Existing Site.


The Builder API

layout()

$cwa->layout(string $ref, string $uiComponent, ?array $uiClassNames = null): LayoutBuilder

Creates (or retrieves if already registered) a Layout entity.

  • $ref — the key used to reference this layout in page() calls. It is also stored as the Layout's reference.
  • $uiComponent — the suffix of the Vue component name in app/cwa/layouts/. The builder prepends CwaLayout automatically: 'Primary' → stores CwaLayoutPrimary.
  • $uiClassNames — optional array of CSS class strings applied to the layout wrapper. These are the classes the admin can select from the "Style" picker (defined in your Nuxt component via useCwaComponent styles option).

Calling layout() twice with the same $ref returns the same LayoutBuilder — useful for adding groups across separate calls.

page()

$cwa->page(
    string $ref,
    string $uiComponent,
    string $layout,
    ?string $route = null,
    ?string $routeName = null,
    bool $isTemplate = false,
    ?\Closure $configure = null,
    ?array $uiClassNames = null
): PageBuilder

Creates a Page entity.

  • $ref — key unique within this fixture, also stored as the Page's reference
  • $uiComponent — the suffix of the Vue component name in app/cwa/pages/. The builder prepends CwaPage automatically: 'Blog' → stores CwaPageBlog.
  • $layout — the $ref of a layout already registered in this builder
  • route — creates an explicit Route at this path; omit to auto-generate from the page title
  • routeName — a name for getRoute() retrieval after flush()
  • isTemplate: true — marks the page as a template (no route is generated)
  • configure — optional closure called immediately with the PageBuilder, for inline configuration
  • uiClassNames — optional array of CSS class strings for the page wrapper (same "Style" field the admin selects)

The configure closure is equivalent to chaining on the returned builder, but keeps setup co-located with the call — useful when creating multiple pages or when you don't need to reference the builder later:

// Using configure closure — inline, no variable needed
$cwa->page('home', 'PrimaryPageTemplate', layout: 'main', route: '/',
    configure: function (PageBuilder $builder) use ($hero): void {
        $builder->title('Home')
            ->group('hero')->add($hero);
    }
);

// Equivalent chained form
$cwa->page('home', 'PrimaryPageTemplate', layout: 'main', route: '/')
    ->title('Home')
    ->group('hero')->add($hero);

pageData()

$cwa->pageData(
    AbstractPageData $pageData,
    ?string $template = null,
    ?string $route = null,
    ?string $routeName = null,
    ?\Closure $configure = null
): PageDataBuilder

Wraps a PageData record (e.g. a blog article).

  • $template — the $ref of the template page this data record uses
  • route / routeName — same as page()
  • configure — optional closure called immediately with the PageDataBuilder, same inline pattern as page()

component()

$cwa->component(AbstractComponent $component): ComponentBuilder

Registers a component entity with the builder. The component is persisted in Phase 1. Returns a ComponentBuilder for adding child groups.

persist()

$cwa->persist(object $entity): static

Explicitly persist any non-CWA entity (custom relations, settings, etc.). Walks owning-side associations and persists related objects recursively.

getRoute()

$cwa->getRoute(string $routeName): Route

Returns a named Route once routes exist: inside an afterRoutes() callback, or after flush(). Use to retrieve a route and assign it to a NavigationLink component.

A name that this scaffold hasn't declared is looked up in the database, so you can reach a route that is already saved, such as one another scaffold created earlier in the same load. It throws a LogicException (Named route "…" not found) when no route has that name. Looking up saved routes is new in 2.0.0-alpha.6. On alpha.5, only routes this scaffold declared are found.

redirect()

$cwa->redirect(string $path, string $to, ?string $name = null): static

Declares a redirect from $path to the route named $to. It is created during flush(), straight after the page routes, so you don't need a flush() of your own first. See Seeding Redirects.

afterRoutes()

$cwa->afterRoutes(\Closure $callback): static

Registers a callback that runs once during the next flush(), after every route and redirect has been created and before any component position is. It receives the builder, so getRoute() works inside it. Use it for anything that references a route:

$link = new NavigationLink();
$link->label = 'About';
$navGroup->add($link);

$cwa->afterRoutes(function (CwaFixtureBuilder $cwa) use ($link): void {
    $link->route = $cwa->getRoute('about-us');});

The link is still unsaved when the callback runs, and its position is created afterwards with the route already set. That replaces the older pattern of calling flush(), reading the route, then calling flush() again.

flush()

$cwa->flush(): void

Triggers the full persistence sequence (see below). Call it at least once — and call it again any time you register new entities or positions after the first call. All phases are idempotent: already-processed entities, nested closures, and onRoutesCreated callbacks are tracked internally and skipped on subsequent calls. Only work added since the previous flush() is processed, and the underlying EntityManager::flush() is only called when something actually changed.


Sub-builder APIs

LayoutBuilder — from $cwa->layout()

->uiClassNames(string ...$classes): self
->group(
    string $name,
    array $allow = [],
    ?\Closure $configure = null,
    ?string $locationReference = null
): GroupBuilder

uiClassNames() sets the CSS classes on the layout after construction — equivalent to passing $uiClassNames to layout() directly, but useful when chaining or configuring an already-retrieved builder:

$cwa->layout('main', 'Primary')
    ->uiClassNames('bg-stone-900', 'text-white')
    ->group('nav', allow: [NavigationLink::class], locationReference: 'global-nav');

group() creates a ComponentGroup linked to this layout. $allow is an array of component class names to restrict what admins can add. Leave empty to allow all types.

PageBuilder — from $cwa->page()

->title(string $title): self
->metaDescription(string $description): self
->uiClassNames(string ...$classes): self
->liveAt(?\DateTimeImmutable $liveAt): self
->withoutRoute(): self
->group(
    string $name,
    ?\Closure $configure = null,
    ?string $locationReference = null,
    array $allow = []
): GroupBuilder
->nested(\Closure $configure): void
->getRoute(): ?Route    // available after flush()
A page group's allow is its fourth argument, not its second as on LayoutBuilder and ComponentBuilder, so pass it by name: ->group('primary', allow: [HtmlContent::class]). Leave it out to allow every component type.

liveAt() sets the go-live date of the page's route: a future date schedules it, and null creates the route offline. See Scheduled and Offline Routes.

withoutRoute() keeps a page that isn't a template without a route. Without it, a page with no route: gets one generated from its title. It only applies when you don't pass route:, and a template page never gets a route anyway:

$cwa->page('services', 'PrimaryPage', layout: 'main')->withoutRoute();

A routeless page is useful as the parent of pages that do have routes. Give each child an explicit route:, because a child can't have one generated under a parent with no route (see Route Generation Rules).

PageBuilder::withoutRoute() is new in 2.0.0-alpha.6. PageDataBuilder had it already.

nested() receives a CwaFixtureBuilder scoped to this page as parent, for creating child PageData records. It returns void, so it has to be the last call in a chain:

$cwa->page('blog-template', 'BlogPageTemplate', layout: 'main', isTemplate: true)
    ->nested(function (CwaFixtureBuilder $child): void {
        $article = new BlogArticle();
        $article->setTitle('First Post');        $child->pageData($article, template: 'blog-template', route: '/blog/first-post');
    });
title and metaDescription are protected on AbstractPage, so set them with setTitle() / setMetaDescription() — assigning $entity->title directly is a fatal error.

PageDataBuilder — from $cwa->pageData()

->nested(\Closure $configure): self
->onRoutesCreated(\Closure $cb): self
->liveAt(?\DateTimeImmutable $liveAt): self
->withoutRoute(): self
->getRoute(): ?Route

Unlike PageBuilder::nested() (which returns void), PageDataBuilder::nested() returns self, so you can chain onRoutesCreated() after it.

liveAt() works as it does on PageBuilder. withoutRoute() creates the page data with no route at all. It only applies when you don't pass route::

$draft = new BlogArticle();
$draft->setTitle('Coming Soon');
$cwa->pageData($draft, template: 'blog-template')->withoutRoute();

Without it, page data with no route: gets a generated one.

Routeless page data is only created in the same load as its template page or parent. The builder has nothing to look it up by, so it can't tell whether the record already exists. When neither its template page nor its parent is being created in this load, it is skipped and counted as unidentifiable. That happens even on an empty database if the record has no template: and no parent. See Appending to an Existing Site.

onRoutesCreated() fires after all child routes have been created. The closure receives array<PageBuilder> of child page builders, useful for wiring nav links that target child URLs:

$cwa->pageData($section, template: 'section-template')
    ->nested(function (CwaFixtureBuilder $child): void {
        $child->page('article-1', 'Article', layout: 'main', routeName: 'article-1')
            ->title('First Article');
    })
    ->onRoutesCreated(function (array $childBuilders) use ($cwa): void {
        foreach ($childBuilders as $child) {
            $link = new NavigationLink();
            $link->route = $child->getRoute();
            $cwa->component($link);
        }
        $cwa->flush();
    });
onRoutesCreated() only receives child Page builders. Child pageData() records created inside ->nested() are tracked separately and never passed to the callback — capture their PageDataBuilder in a variable and call getRoute() on it after flush() instead.

GroupBuilder — from any ->group() call

->add(AbstractComponent $component, ?int $sort = null): self
->pageDataPosition(string $pageDataClass, string $propertyName, ?int $sort = null): self

add() creates a ComponentPosition pointing to the component. Sort values auto-increment by 10 unless specified.

pageDataPosition() creates a position bound to a field on a specific PageData class — used in template pages where a region renders a component held by the associated data record. pageDataClass must be the fully-qualified class name of an AbstractPageData subclass registered as an API Platform resource:

$cwa->page('blog-template', 'BlogPageTemplate', layout: 'main', isTemplate: true)
    ->group('content')
        ->pageDataPosition(BlogArticle::class, 'headline')  // renders BlogArticle->headline component
        ->pageDataPosition(BlogArticle::class, 'body');     // renders BlogArticle->body component

ComponentBuilder — from $cwa->component()

->uiComponent(string $suffix): self
->uiClassNames(string ...$classes): self
->group(string $name, array $allow = [], ...): GroupBuilder

uiComponent() sets an alternative UI template for this component. The builder computes the full component name from the entity's class name: on a NavigationLink entity, ->uiComponent('YouTube') stores CwaComponentNavigationLinkUiYouTube.

uiClassNames() sets style classes — the same classes the admin can select from the "Style" picker.

group() is for components that contain other components (e.g. a carousel with slide children):

$carousel = new Carousel();
$cwa->component($carousel)
    ->group('slides', allow: [Slide::class])
        ->add(new Slide())
        ->add(new Slide());

// Setting an alt UI and style on a component
$link = new NavigationLink();
$cwa->component($link)
    ->uiComponent('YouTube')
    ->uiClassNames('rounded', 'shadow-lg');

locationReference — Shared Groups

By default a ComponentGroup reference is "{groupName}_{ownerIri}". If locationReference is set, the reference becomes "{groupName}_{locationReference}" — stable across environments and shared across multiple owners.

This is how a single navigation group can serve multiple layouts:

// Both layouts share the same ComponentGroup record
$cwa->layout('primary', 'Primary')
    ->group('nav', allow: [NavigationLink::class], locationReference: 'global-nav');

$cwa->layout('minimal', 'Minimal')
    ->group('nav', allow: [NavigationLink::class], locationReference: 'global-nav');

The Vue <CwaComponentGroup> with the matching locationReference prop renders this shared group.


Flush Phases

Every flush() call runs the full sequence. Each phase is idempotent — it tracks what it has already processed and only does new work:

PhaseWhat happens
1Look up existing layouts, pages, page data and groups (see Appending to an Existing Site), then persist the ones that are missing, and the components. Skips already-persisted entities. Calls EntityManager::flush() only if anything new was added.
nestedEvaluate ->nested() closures. Each closure is tracked by object ID and runs at most once.
3Create explicit routes, and call RouteGenerator::create() for entities that don't yet have a route, in parent-before-child order. Apply liveAt(). Then create the redirect() routes.
3.5Fire onRoutesCreated callbacks, then afterRoutes() callbacks. Each callback fires at most once.
4Create ComponentPosition entities for all registered group builders. Picks up any positions added since the last call.

Because every phase is idempotent, you can call flush() as many times as you like. Anything that references a route belongs in afterRoutes(), which runs between phases 3 and 4 of the same flush().


Complete Example

This example creates a layout with a shared nav, a home page with a hero component, a blog template with two articles, nav links pointing to each page, and a redirect:

<?php

namespace App\DataFixtures;

use App\Entity\BlogArticle;
use App\Entity\Hero;
use App\Entity\NavigationLink;
use Silverback\ApiComponentsBundle\Fixture\AbstractCwaScaffold;
use Silverback\ApiComponentsBundle\Fixture\CwaFixtureBuilder;

class AppFixtures extends AbstractCwaScaffold
{
    public function build(CwaFixtureBuilder $cwa): void
    {
        // Layout with a shared nav group
        $navGroup = $cwa->layout('main', 'Primary')
            ->group('nav', allow: [NavigationLink::class], locationReference: 'global-nav');

        // Home page with a hero component
        $hero = new Hero();
        $hero->headline = 'Welcome';

        // ->group() returns a GroupBuilder, so a second group has to start
        // from the page builder again
        $homePage = $cwa->page('home', 'PrimaryPageTemplate', layout: 'main', route: '/', routeName: 'home')
            ->title('Home');
        $homePage->group('hero')->add($hero);        $homePage->group('content');
        // Blog template — no route (isTemplate: true)
        $cwa->page('blog-template', 'BlogPageTemplate', layout: 'main', isTemplate: true)
            ->group('body')->pageDataPosition(BlogArticle::class, 'body');

        // Two blog articles
        $article1 = new BlogArticle();
        $article1->setTitle('First Post');
        $cwa->pageData($article1, template: 'blog-template', route: '/blog/first-post', routeName: 'article-1');

        $article2 = new BlogArticle();
        $article2->setTitle('Second Post');
        $cwa->pageData($article2, template: 'blog-template', route: '/blog/second-post', routeName: 'article-2');

        // Nav links, added to the shared nav group
        $homeLink = new NavigationLink();
        $homeLink->label = 'Home';

        $blogLink = new NavigationLink();
        $blogLink->label = 'Blog';
        $blogLink->rawPath = '/blog/first-post';

        $navGroup->add($homeLink)->add($blogLink);

        // Runs once the routes exist, before the nav positions are created
        $cwa->afterRoutes(function (CwaFixtureBuilder $cwa) use ($homeLink): void {            $homeLink->route = $cwa->getRoute('home');        });
        // An old URL that now points at the first article
        $cwa->redirect('/blog/hello-world', to: 'article-1');
    }
}

AbstractCwaScaffold::load() calls flush() after build(), so this scaffold needs no flush() of its own.


Route Generation Rules

SituationResult
route: '/path' explicitCreates a Route at that path
routeName: 'name'Also names the route for getRoute('name')
isTemplate: true, no routeNo Route created
No route, has titleRouteGenerator slugifies the title → /my-title
pageData() inside ->nested(), no routeRouteGenerator → /parent-path/slug
page()/pageData() inside ->nested() of a parent that gets no route (e.g. isTemplate: true), no routeflush() throws UnroutedParentException. Pass an explicit route:
No route, no title, top-levelA route is still generated, from the default title — /unnamed-page (then -1, -2… on conflict). Use isTemplate: true if you want no route
page() or pageData() with ->withoutRoute(), no routeNo Route created
route: '/path' whose path or route name another route already hasNo Route created. The page or page data is created without one, and the summary counts the route as skipped (path in use)

Scheduled and Offline Routes

A route the builder creates is live from the moment it is saved. Call liveAt() on the page or page data builder to change that:

$cwa->page('launch', 'PrimaryPage', layout: 'main', route: '/launch')
    ->liveAt(new \DateTimeImmutable('2026-11-01 09:00'));
$cwa->page('draft', 'PrimaryPage', layout: 'main', route: '/draft')
    ->liveAt(null);

A future date schedules the route and null keeps it offline. The rules are in Scheduling and Taking Routes Offline. The date is only applied to a route created in this load. An appended load never changes the liveAt of a route that already exists.


Seeding Redirects

There is no separate redirect entity. A redirect is a Route whose redirect property points at another Route. The API resolves the whole chain when it serialises a route and exposes the final destination as redirectPath; the Nuxt module's route middleware then issues a 308 to it (suppressed while the admin is in edit mode, so redirecting routes stay navigable). See the admin panel Redirects section for the runtime behaviour.

Declare redirects with $cwa->redirect(). The target is a route name: the routeName: of a page or page data, or the name of an earlier redirect.

public function build(CwaFixtureBuilder $cwa): void
{
    $cwa->page('about', 'PrimaryPage', layout: 'main', route: '/about-us', routeName: 'about-us');

    $cwa->redirect('/about', to: 'about-us');}

The builder creates redirects during flush(), straight after the page routes, so they need no flush() of their own. Each one gets createdAt/modifiedAt like any other route.

  • name defaults to the path with its slashes turned into hyphens: /about becomes about, and /blog/old-post becomes blog-old-post. If another route already has that name, the redirect gets the first free suffix: about-1, then about-2. Pass name: to choose it yourself.
  • Chains (/a → /b → /c) work, and the front end resolves to the last route in the chain. Declare the hop nearest the page first and point the next one at its name:
    $cwa->redirect('/about', to: 'about-us', name: 'about-redirect');
    $cwa->redirect('/company', to: 'about-redirect');
    
  • An existing path is left alone. If a route already has the redirect's path, the builder keeps that route and creates nothing, even when the existing route is not a redirect. A second load therefore doesn't duplicate or change it.
to must name a route the builder knows about. A name that no page, page data or earlier redirect in the scaffold declared makes flush() throw a LogicException (Named route "…" not found).A name: you pass is not suffixed. If a route created in the same load already has it, flush() throws a LogicException (The redirect "…" cannot be named "…"), even when the redirect's path already exists. Before api-components-bundle#352 it threw only when the path was free; otherwise the name was pointed at the route already at that path. If a saved route has it, the redirect isn't created and the summary counts it as skipped (name in use).
On 2.0.0-alpha.5, the builder checks a redirect's path but not its name. A name that another route already has, such as a page whose routeName is about when you redirect /about, fails on the route name's unique constraint. Pass a different name:. The suffix and the checks above are new after alpha.5.

Outside a scaffold, for example in your own service, RouteGeneratorInterface::createRedirect(string $fromPath, Route $targetRoute): Route builds the same kind of route for you to persist. It makes the name unique but not the path, and it doesn't look anything up first.


Seeding HTML Content

When seeding components that have HTML body fields (e.g. a rich-text HtmlContent entity), you need structured placeholder HTML rather than a raw lorem ipsum string. The bundle ships HtmlContentPlaceholder as a registered service — inject it directly into your fixture class:

use Silverback\ApiComponentsBundle\Fixture\AbstractCwaScaffold;
use Silverback\ApiComponentsBundle\Fixture\CwaFixtureBuilder;
use Silverback\ApiComponentsBundle\Fixture\Placeholder\HtmlContentPlaceholder;

class AppFixtures extends AbstractCwaScaffold
{
    public function __construct(
        CwaFixtureBuilder $cwa,        private readonly HtmlContentPlaceholder $placeholder,
    ) {
        parent::__construct($cwa);    }

    public function build(CwaFixtureBuilder $cwa): void
    {
        $content = new HtmlContent();
        $content->html = $this->placeholder->generate([
            'paragraphs'      => 4,
            'paragraphLength' => HtmlContentPlaceholder::LENGTH_MEDIUM,
            'includeHeadings' => true,
            'includeLists'    => true,
            'includeQuotes'   => false,
            'includeCode'     => false,
            'includeLinks'    => true,
            'format'          => HtmlContentPlaceholder::FORMAT_HTML,
        ]);

        $cwa->component($content);
    }
}

No services.yaml entry needed — the bundle registers it automatically as silverback.api_components.fixture.html_content_placeholder.

Options

OptionTypeDefaultDescription
paragraphsint3Number of paragraphs to generate
paragraphLengthstring'medium'Sentence density per paragraph
includeHeadingsboolfalseInject <h2> elements between paragraphs
includeListsboolfalseInject <ul>/<ol> elements
includeQuotesboolfalseInject <blockquote> elements
includeCodeboolfalseInject <pre><code> blocks
includeLinksbooltrueInsert <a> tags inside paragraph text
formatstring'html''html' or 'plaintext'

Constants

HtmlContentPlaceholder::LENGTH_SHORT    // 1–2 sentences per paragraph
HtmlContentPlaceholder::LENGTH_MEDIUM   // 3–4 sentences (default)
HtmlContentPlaceholder::LENGTH_LONG     // 5–7 sentences

HtmlContentPlaceholder::FORMAT_HTML      // returns HTML tags
HtmlContentPlaceholder::FORMAT_PLAINTEXT // returns plain text

The data arrays ($paragraphTemplates, $headings, $listItems, $codeSnippets, $quotes, $links) are protected, so you can extend the class and swap in your own copy. The rendering methods are private and cannot be overridden.

You can also call setOptions() to set defaults for all subsequent generate() calls on the same instance:

$this->placeholder->setOptions(['paragraphLength' => HtmlContentPlaceholder::LENGTH_SHORT]);
$shortHtml  = $this->placeholder->generate();
$shortHtml2 = $this->placeholder->generate(); // same defaults apply

Seeding Uploadable Files

Components with an #[UploadableField] property (images, documents, etc.) can be seeded with a real example file straight from a local path. Set a Symfony\Component\HttpFoundation\File\File on the field's transient property and the builder writes it to that field's configured filestore during flush() — no extra builder methods:

use Symfony\Component\HttpFoundation\File\File;

$image = new Image();
$image->file = new File(__DIR__ . '/assets/hero.jpg');$cwa->component($image);

$cwa->flush(); // file is copied to the filestore; `filename` is populated

The file is auto-detected only on entities the bundle recognises as #[Uploadable]; non-uploadable components (or a null file) are left untouched. After flush() the entity's stored filename property is populated, so the resource is valid and renderable.

Each seeded file becomes its own stored object under a unique tokenised name (hero-3f9a2b7c.jpg), so two fixtures pointing at the same source file get independent stored copies — replacing or deleting one never affects the other. The file lands in whatever adapter the field resolves to in the current environment (a local/in-memory store under test, your real store when seeding a prod-like environment).
Image/video: screen recording of doctrine:fixtures:load seeding an image component, then that image rendering in the admin.

Appending to an Existing Site

doctrine:fixtures:load empties the database before it loads. With --append it keeps what is there, and a CwaFixtureBuilder scaffold then creates only what is missing. It never changes content that already exists, so an editor's changes survive:

bin/console doctrine:fixtures:load --append

The builder looks every item up before it creates it. The rules are the same on an empty database, where nothing is found.

ItemLooked up byWhen it exists
Layoutreference (the $ref you pass to layout())Kept as it is
Pagereference (the $ref you pass to page())Kept as it is
Page dataThe path of its route: route: if you gave one, otherwise the path the route generator would produce from its title and parent. It must be page data of the same classKept as it is
Route from route:Path, and the route nameNot created. The summary counts it as skipped (path in use)
RedirectPath, then the name: if you passed oneKept as it is when the path exists. When only the name exists, the redirect is not created and the summary counts it as skipped (name in use)
Component groupReference: {name}_{locationReference}, or {name}_{owner IRI}Kept as it is, and the scaffold's positions for it are not created

What that means in practice:

  • A kept item is not changed. The fields you set on it in the scaffold are ignored, along with anything they point at. Its groups, and the children of its ->nested() closure, are looked up in their own right.
  • A group is all or nothing. A group that an existing page or layout lacks is created with all its components. A group that already exists is kept with the positions it has, so a component you add to it in the scaffold is not added. Add it in the admin instead, or in a new group.
  • Components have no lookup of their own. A component in a new group is created with it, and one in an existing group is not. A component you only register with component(), outside any group, is created on every load.
  • Routeless page data is only created with its template page or parent. See withoutRoute().
  • onRoutesCreated() only runs for page data created in this load. afterRoutes() callbacks always run. Anything they, or persist(), create is not looked up, so it is created again on every load, unless it is linked to scaffold content the load skipped (such as a component in a group that already exists).
Give page data an explicit route: when another record already has its natural path. If the generated path (from the title) belongs to a page, or to page data of another class, the route gets a -1 suffix. The next load looks at the natural path again, finds the other record, and creates the page data a second time, with a -2 route. The route: has to be a path of its own. A route: that is already taken makes the builder create the page data without a route on every load.

The page data lookup asks the route generator for that natural path through RouteGeneratorInterface::generatePath(). An application with its own route generator must implement it. See the upgrade note.

The Summary Line

Every load prints one line per scaffold, at the default verbosity:

  > CWA scaffold: created 1 page, 1 route, 1 group, 1 component; kept 1 page, 1 layout, 2 groups

With more than one scaffold class, each prints its own line.

A load that changes nothing only has a kept part. A skip gives its reason in brackets, for example skipped 1 route (path in use) or skipped 1 page data (unidentifiable). An empty scaffold prints CWA scaffold: nothing to load.

Each kept or skipped item is also logged at notice level, so -v lists them (for example kept page `home` ), along with a line for each existing group whose scaffold positions were not created. Created items are logged at debug, which -vvv shows. The per-item lines come through your application's logger, so they only appear on the console if Monolog has a console handler, as the Symfony recipe sets up.

Image/video: terminal output of doctrine:fixtures:load --append run twice, the first run showing created … and the second only kept ….

More Than One Scaffold

You can split a site across several scaffold classes. doctrine:fixtures:load runs each one as a load of its own, and the builder forgets what the previous scaffold declared. A later scaffold reaches earlier content only through the database, with the same lookups as an append:

  • Declare a shared layout or page again by its reference. It is kept, not created twice.
  • Refer to an earlier route by name. redirect(to:) and getRoute() find a saved route with that name.
  • Add to a shared group in the scaffold that creates it. A group that an earlier scaffold created is kept, so components a later one adds to it are not created.
class BlogScaffold extends AbstractCwaScaffold implements OrderedFixtureInterface
{
    public function getOrder(): int
    {
        return 2; // runs after the scaffold that creates the 'main' layout and the 'home' route
    }

    public function build(CwaFixtureBuilder $cwa): void
    {
        $cwa->layout('main', 'Primary');        $cwa->page('blog', 'PrimaryPage', layout: 'main', route: '/blog');
        $cwa->redirect('/news', to: 'home');    }
}

Use Doctrine's OrderedFixtureInterface or DependentFixtureInterface when a scaffold relies on another one's content, so it runs second.

On 2.0.0-alpha.5, two scaffold classes in one load fail. The builder carries the first scaffold's entities into the second, and saving them fails because they are detached. Keep to one scaffold class there. A new load for each scaffold is new after alpha.5.

Generating Fixtures From an Existing Database

Rather than hand-writing a scaffold, you can capture a populated database as one. The generate-fixtures command reads every layout, page, page data record and route and writes an AbstractCwaScaffold class that, loaded into an empty database, gives back the same site:

bin/console silverback:api-components:generate-fixtures

By default it writes to src/DataFixtures/GeneratedScaffold.php. Use --output (-o) to change the path:

bin/console silverback:api-components:generate-fixtures -o src/DataFixtures/SnapshotFixtures.php

The class is named after the file, so this writes a SnapshotFixtures class. Its namespace is App\DataFixtures unless you pass --namespace. The namespace doesn't follow the directory, so set it whenever you write outside src/DataFixtures:

bin/console silverback:api-components:generate-fixtures \
  -o src/DataFixtures/Snapshots/SiteSnapshot.php --namespace 'App\DataFixtures\Snapshots'

A file name that isn't a valid PHP class name, such as site-snapshot.php, makes the command fail without writing anything, and so does an invalid namespace.

On 2.0.0-alpha.5, the generated class is always App\DataFixtures\GeneratedScaffold, whatever file name you pass, and there is no --namespace. For any other file name, or a directory outside src/DataFixtures, rename the class or change the namespace to match, or Symfony can't load it. Naming the class after the file is new after alpha.5.

The scaffold uses the builder API documented above, so you can commit it as it is or edit it. It carries:

  • Published state and drafts. A component's draft is emitted in an afterRoutes() callback and saved with persist().
  • Uploaded files. Each stored file is copied into an assets/ directory beside the scaffold (src/DataFixtures/assets/ by default), and the scaffold attaches it with the File pattern. The copy drops the stored name's token, so hero-3f9a2b7c.jpg becomes assets/hero.jpg.
  • Groups owned by a component, and groups shared through locationReference, with their allow: lists.
  • Relations, for example a navigation link's route, which are set in an afterRoutes() callback.
  • Field values: dates, enums, inherited fields, and fields that are only reachable through a public setter.
  • Page titles, meta descriptions and uiClassNames.
  • Routes: every route by path and name, scheduled and offline routes through liveAt(), routeless pages and page data through withoutRoute(), and redirects through redirect(). On 2.0.0-alpha.5 a page that isn't a template and has no route comes back with a generated one, because PageBuilder::withoutRoute() is new after alpha.5.

createdAt and modifiedAt are not carried: loading stamps them afresh. Neither is a liveAt in the past, since a route the builder creates is live straight away.

The command prints the scaffold's path, then how many stored files it exported, then anything it could not reproduce, one line each:

Fixture class written to src/DataFixtures/GeneratedScaffold.php
3 stored file(s) exported to src/DataFixtures/assets
2 item(s) could not be reproduced:
  - The collection App\Entity\Gallery::$images.
  - App\Entity\EventCard::$venue refers to /venues/018e…, which is not part of the generated site.

The things it lists:

  • a group used by more than one owner without a locationReference, or a group reference the builder can't express
  • an allowedComponents entry that matches no component class
  • the fallback component of a page data position
  • a to-many relation (a collection) on a component or page data
  • a relation to anything outside the generated site, such as a user
  • a value that can't be written as PHP, or a field with no public property or setter
  • a stored file that can't be read
  • a redirect whose target has no page

Capturing a Site Before a Purge

Because the scaffold reloads as the same site, you can use it to keep a site's content through a database purge, or to copy a site to another environment:

  1. Capture it while the content is still in the database:
    bin/console silverback:api-components:generate-fixtures
    
  2. Review the output. Everything in the could not be reproduced list has to be added back by hand, either in the scaffold or after loading it.
  3. Keep the scaffold and its assets/ directory together. The scaffold reads the files from __DIR__ . '/assets/'.
  4. Reload. doctrine:fixtures:load empties the database and runs every fixture class it finds:
    bin/console doctrine:fixtures:load
    

    To load it into a database that already has content, add --append. It then creates only what is missing (see Appending to an Existing Site), and loading it back into the database it came from creates nothing.
The scaffold holds content, not users. Keep your users fixture, because the purge removes users too. And move the hand-written scaffold that the capture replaces out of the fixtures directory, or it loads as well. The two load together without an error (see More Than One Scaffold), but the old scaffold creates any of its content that isn't in the capture, such as a page an editor deleted.

Working with IRIs

API Platform identifies every resource by its IRI (Internationalized Resource Identifier) — the URL path the API exposes it at, e.g. /_/routes/018e4b… or /_/pages/018e4c…. CwaFixtureBuilder uses IRIs internally for ComponentGroup.location and ComponentGroup.allowedComponents, but you may need them directly in your own fixture code when a custom entity stores an IRI string field pointing to another API Platform resource.

See the API Platform IRI documentation for the full reference on how IRIs are generated and resolved.

Inject ApiPlatform\Metadata\IriConverterInterface into your fixture class alongside the builder:

use ApiPlatform\Metadata\IriConverterInterface;
use Silverback\ApiComponentsBundle\Fixture\AbstractCwaScaffold;
use Silverback\ApiComponentsBundle\Fixture\CwaFixtureBuilder;

class AppFixtures extends AbstractCwaScaffold
{
    public function __construct(
        CwaFixtureBuilder $cwa,        private readonly IriConverterInterface $iriConverter,
    ) {
        parent::__construct($cwa);    }

    public function build(CwaFixtureBuilder $cwa): void
    {
        // ...
    }
}
AbstractCwaScaffold takes CwaFixtureBuilder as its own constructor argument. If you declare a constructor, you must accept the builder and pass it to parent::__construct() — otherwise load() fatals with an uninitialised typed property.

Getting the IRI of a persisted entity

$page = $cwa->page('home', 'PrimaryPageTemplate', layout: 'main', route: '/');
$cwa->flush();

// After flush() the page entity has a database ID and a resolvable IRI
$pageIri = $this->iriConverter->getIriFromResource($page->getPage());
// e.g. "/_/pages/018e4b9a-…"

Each sub-builder exposes its own entity getter — PageBuilder::getPage(), PageDataBuilder::getPageData(), LayoutBuilder::getLayout(), ComponentBuilder::getComponent().

Getting the IRI of a class (collection endpoint)

If you need the collection endpoint IRI for a resource class — for example to populate a custom $targetCollection: string field on a component — pass the class name and a GetCollection operation:

use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\UrlGeneratorInterface;
$collectionIri = $this->iriConverter->getIriFromResource(
    BlogArticle::class,
    UrlGeneratorInterface::ABS_PATH,
    (new GetCollection())->withClass(BlogArticle::class)
);
// e.g. "/page_data/blog_articles" — an AbstractComponent subclass gets "/component/…",
// and only the bundle's own core resources use the "/_/" prefix
ABS_PATH is an API Platform constant — import ApiPlatform\Metadata\UrlGeneratorInterface, not Symfony's routing interface, which has no such constant.

Note: IriConverterInterface::getIriFromResource() requires the entity to already have a persisted ID. Call $cwa->flush() (or $manager->flush() inside an onRoutesCreated callback) before converting an entity that was just created.


Seeding Users

Create users with UserFactory, not by constructing the entity yourself — it hashes the password and applies the role flags:

use Silverback\ApiComponentsBundle\Factory\User\UserFactory;

class UsersFixture extends AbstractCwaScaffold
{
    public function __construct(
        CwaFixtureBuilder $cwa,
        private readonly UserFactory $factory,
    ) {
        parent::__construct($cwa);
    }

    public function build(CwaFixtureBuilder $cwa): void
    {
        $this->factory->create(
            'admin', 'admin', 'hello@example.com',
            inactive: false, superAdmin: true,
            overwrite: true,        );
    }
}
Pass overwrite: true, or a second fixture run fails.UserFactory::create() defaults it to false, and only looks up the existing user when it is true. Otherwise it builds a new user, which fails the #[UniqueEntity] check on username and emailAddress, and create() throws a ValidationFailedException. With overwrite: true the fixture can run any number of times.Bundle versions before #274 ignored that validation and saved a duplicate. The columns have no database unique index, so nothing stopped it, and login then failed with a 500 (NonUniqueResultException). If a database seeded by an older version has duplicate usernames, delete the extra rows.

Tips

  • You don't need to call $manager->persist() or $manager->flush() yourself — the builder handles it
  • Timestamps (createdAt, modifiedAt) are populated automatically
  • Call flush() again any time you register new entities or positions after a previous call — all phases are idempotent and only process new work
  • locationReference is the correct way to share a group between two layouts — don't try to manually link the same ComponentGroup to two owners