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', 'CwaLayoutPrimary');
        $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

The Builder API

layout()

$cwa->layout(string $ref, string $uiComponent): LayoutBuilder

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

  • $ref — a local key used to reference this layout in page() calls. Not stored in the database.
  • $uiComponent — the uiComponent field value (e.g. 'CwaLayoutPrimary'). Maps to app/cwa/layouts/primary.vue in the Nuxt app.

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
): PageBuilder

Creates a Page entity.

  • $ref — local key unique within this fixture
  • $uiComponent — maps to your page template Vue file
  • $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

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 after flush(). Throws LogicException if called before routes have been created. Use to retrieve a route and assign it to a NavigationLink component.

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()

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

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
->group(string $name, ...): GroupBuilder
->nested(\Closure $configure): void
->getRoute(): ?Route    // available after flush()

nested() receives a CwaFixtureBuilder scoped to this page as parent, for creating child PageData records:

$cwa->page('blog-template', 'BlogPageTemplate', layout: 'main', isTemplate: true)
    ->nested(function (CwaFixtureBuilder $child): void {
        $article = new BlogArticle();
        $article->title = 'First Post';
        $child->pageData($article, template: 'blog-template', route: '/blog/first-post');
    });

PageDataBuilder — from $cwa->pageData()

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

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->pageData(new Article(), template: 'article-template', routeName: 'article-1');
    })
    ->onRoutesCreated(function (array $childBuilders) use ($cwa): void {
        foreach ($childBuilders as $child) {
            $link = new NavigationLink();
            $link->route = $child->getRoute();
            $cwa->component($link);
        }
        $cwa->flush();
    });

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()

->group(string $name, array $allow = [], ...): GroupBuilder

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());

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', 'CwaLayoutPrimary')
    ->group('nav', allow: [NavigationLink::class], locationReference: 'global-nav');

$cwa->layout('minimal', 'CwaLayoutMinimal')
    ->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
1Persist layouts, pages, pageData, components; create ComponentGroups. 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.
3Call RouteGenerator::create() for entities that don't yet have a route, in parent-before-child order.
3.5Fire onRoutesCreated 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 — for example once to create routes, then again after wiring nav links that reference those routes.


Complete Example

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

<?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', 'CwaLayoutPrimary')
            ->group('nav', allow: [NavigationLink::class], locationReference: 'global-nav');

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

        $cwa->page('home', 'PrimaryPageTemplate', layout: 'main', route: '/', routeName: 'home')
            ->title('Home')
            ->group('hero')->add($hero)
            ->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->title = 'First Post';
        $cwa->pageData($article1, template: 'blog-template', route: '/blog/first-post', routeName: 'article-1');

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

        // Phase 1–3: persist everything and create routes
        $cwa->flush();

        // Now use named routes to build nav links
        $homeLink = new NavigationLink();
        $homeLink->label = 'Home';
        $homeLink->route = $cwa->getRoute('home');

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

        $cwa->component($homeLink);
        $cwa->component($blogLink);

        // Add to the shared nav group
        $navGroup->add($homeLink)->add($blogLink);

        // Phase 4: create the nav ComponentPositions
        $cwa->flush();
    }
}

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
No route, no title, top-levelNo Route (draft)

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(private readonly HtmlContentPlaceholder $placeholder)
    {
    }

    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 class uses protected properties and methods so you can extend it to swap in your own paragraph templates, headings, or link phrases.

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

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(private readonly IriConverterInterface $iriConverter)
    {
    }

    public function build(CwaFixtureBuilder $cwa): void
    {
        // ...
    }
}

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->getEntity());
// e.g. "/_/pages/018e4b9a-…"

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 Symfony\Component\Routing\Generator\UrlGeneratorInterface;

$collectionIri = $this->iriConverter->getIriFromResource(
    BlogArticle::class,
    UrlGeneratorInterface::ABS_PATH,
    (new GetCollection())->withClass(BlogArticle::class)
);
// e.g. "/_/blog_articles"

Practical example — redirect entity

$redirect = new Redirect();
$redirect->from = '/old-path';
$redirect->toIri = $this->iriConverter->getIriFromResource($targetPage);
$cwa->persist($redirect);
$cwa->flush();

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.


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