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
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 inpage()calls. It is also stored as the Layout'sreference.$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— key unique within this fixture, also stored as the Page'sreference$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 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()
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.
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:
| Phase | What happens |
|---|---|
| 1 | Look 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. |
| nested | Evaluate ->nested() closures. Each closure is tracked by object ID and runs at most once. |
| 3 | Create 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.5 | Fire onRoutesCreated callbacks, then afterRoutes() 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. 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
| 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 |
page()/pageData() inside ->nested() of a parent that gets no route (e.g. isTemplate: true), no route | flush() throws UnroutedParentException. Pass an explicit route: |
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 |
page() or pageData() with ->withoutRoute(), no route | No Route created |
route: '/path' whose path or route name another route already has | No 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.
namedefaults to the path with its slashes turned into hyphens:/aboutbecomesabout, and/blog/old-postbecomesblog-old-post. If another route already has that name, the redirect gets the first free suffix:about-1, thenabout-2. Passname: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).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
| 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.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.
| Item | Looked up by | When it exists |
|---|---|---|
| Layout | reference (the $ref you pass to layout()) | Kept as it is |
| Page | reference (the $ref you pass to page()) | Kept as it is |
| Page data | The 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 class | Kept as it is |
Route from route: | Path, and the route name | Not created. The summary counts it as skipped (path in use) |
| Redirect | Path, then the name: if you passed one | Kept 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 group | Reference: {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, orpersist(), 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).
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.
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:)andgetRoute()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.
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.
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 withpersist(). - 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 theFilepattern. The copy drops the stored name's token, sohero-3f9a2b7c.jpgbecomesassets/hero.jpg. - Groups owned by a component, and groups shared through
locationReference, with theirallow: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 throughwithoutRoute(), and redirects throughredirect(). On 2.0.0-alpha.5 a page that isn't a template and has no route comes back with a generated one, becausePageBuilder::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
allowedComponentsentry 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:
- Capture it while the content is still in the database:
bin/console silverback:api-components:generate-fixtures - Review the output. Everything in the
could not be reproducedlist has to be added back by hand, either in the scaffold or after loading it. - Keep the scaffold and its
assets/directory together. The scaffold reads the files from__DIR__ . '/assets/'. - Reload.
doctrine:fixtures:loadempties 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.
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.
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, );
}
}
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 locationReferenceis the correct way to share a group between two layouts — don't try to manually link the sameComponentGroupto two owners