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', '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
The Builder API
layout()
$cwa->layout(string $ref, string $uiComponent, ?array $uiClassNames = null): 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— the suffix of the Vue component name inapp/cwa/layouts/. The builder prependsCwaLayoutautomatically:'Primary'→ storesCwaLayoutPrimary.$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 viauseCwaComponentstylesoption).
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— local key unique within this fixture$uiComponent— the suffix of the Vue component name inapp/cwa/pages/. The builder prependsCwaPageautomatically:'Blog'→ storesCwaPageBlog.$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 configurationuiClassNames— 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$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()
->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
->group(string $name, ?\Closure $configure = null, ?string $locationReference = null): GroupBuilder
->nested(\Closure $configure): void
->getRoute(): ?Route // available after flush()
LayoutBuilder and ComponentBuilder, a page group takes noallow argument — page-level groups always accept every component type. Passing allow: is an Unknown named parameter error.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
->getRoute(): ?Route
Unlike PageBuilder::nested() (which returns void), PageDataBuilder::nested() returns self, so you can chain onRoutesCreated() after it.
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:
| 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', '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');
// 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 | A route is still generated, from the default title — /unnamed-page (then -1, -2… on conflict). Use isTemplate: true if you want no route |
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.
Use RouteGeneratorInterface::createRedirect() rather than building the Route by hand — it resolves route-name conflicts and stamps createdAt/modifiedAt for you:
use Silverback\ApiComponentsBundle\Fixture\AbstractCwaScaffold;
use Silverback\ApiComponentsBundle\Fixture\CwaFixtureBuilder;
use Silverback\ApiComponentsBundle\Helper\Route\RouteGeneratorInterface;
class AppFixtures extends AbstractCwaScaffold
{
public function __construct(
CwaFixtureBuilder $cwa,
private readonly RouteGeneratorInterface $routeGenerator,
) {
parent::__construct($cwa);
}
public function build(CwaFixtureBuilder $cwa): void
{
$cwa->page('about', 'PrimaryPage', layout: 'main', route: '/about-us', routeName: 'about');
// Routes only exist after flush()
$cwa->flush();
$redirect = $this->routeGenerator->createRedirect('/about', $cwa->getRoute('about')); $cwa->persist($redirect);
$cwa->flush();
}
}
getRoute() throws a LogicException if you call it before flush() — the named route doesn't exist yet. Call flush(), create your redirects, then flush() again; every phase is idempotent so the second call only processes the new work.Redirects can be chained (/a → /b → /c) and the front end resolves to the last route in the chain. Create each hop the same way, passing the previous redirect as the target.
from path must not already be in use by another route. createRedirect() de-duplicates the route name but not the path, so pointing a redirect at a path an existing route already occupies fails on the database unique constraint.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
| 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 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.
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).doctrine:fixtures:load seeding an image component, then that image rendering in the admin.Generating Fixtures From an Existing Database
Rather than hand-writing a scaffold, you can snapshot a populated database back into a fixture class. The generate-fixtures command walks every Layout, Page, PageData, ComponentGroup, ComponentPosition, and Component — including uiComponent, uiClassNames, own properties, and nested closures — and emits a ready-to-run AbstractCwaScaffold file:
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 generated class uses the same suffix-only builder API documented above, so you can commit it as-is or edit it as a starting point.
File pattern above after generating.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 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