Data Fixtures
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 inpage()calls. Not stored in the database.$uiComponent— theuiComponentfield value (e.g.'CwaLayoutPrimary'). Maps toapp/cwa/layouts/primary.vuein 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$refof a layout already registered in this builderroute— creates an explicit Route at this path; omit to auto-generate from the page titlerouteName— a name forgetRoute()retrieval afterflush()isTemplate: true— marks the page as a template (no route is generated)configure— optional closure called immediately with thePageBuilder, 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$refof the template page this data record usesroute/routeName— same aspage()configure— optional closure called immediately with thePageDataBuilder, same inline pattern aspage()
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:
| Phase | What happens |
|---|---|
| 1 | Persist layouts, pages, pageData, components; create ComponentGroups. Skips already-persisted entities. Calls EntityManager::flush() only if anything new was added. |
| nested | Evaluate ->nested() closures. Each closure is tracked by object ID and runs at most once. |
| 3 | Call RouteGenerator::create() for entities that don't yet have a route, in parent-before-child order. |
| 3.5 | Fire onRoutesCreated callbacks. Each callback fires at most once. |
| 4 | Create 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
| Situation | Result |
|---|---|
route: '/path' explicit | Creates a Route at that path |
routeName: 'name' | Also names the route for getRoute('name') |
isTemplate: true, no route | No Route created |
No route, has title | RouteGenerator slugifies the title → /my-title |
pageData() inside ->nested(), no route | RouteGenerator → /parent-path/slug |
No route, no title, top-level | No 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
| Option | Type | Default | Description |
|---|---|---|---|
paragraphs | int | 3 | Number of paragraphs to generate |
paragraphLength | string | 'medium' | Sentence density per paragraph |
includeHeadings | bool | false | Inject <h2> elements between paragraphs |
includeLists | bool | false | Inject <ul>/<ol> elements |
includeQuotes | bool | false | Inject <blockquote> elements |
includeCode | bool | false | Inject <pre><code> blocks |
includeLinks | bool | true | Insert <a> tags inside paragraph text |
format | string | '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 anonRoutesCreatedcallback) 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 locationReferenceis the correct way to share a group between two layouts — don't try to manually link the sameComponentGroupto two owners