Console Commands
Most commands use the silverback:api-components: prefix. Run them via the Symfony console:
php bin/console silverback:api-components:<command>
In a Docker Compose setup, prefix with the service name:
docker compose exec php php bin/console silverback:api-components:<command>
user:create
Create a user account directly from the command line, without going through the registration API. Useful for creating the first admin on a fresh installation before the front-end is set up.
php bin/console silverback:api-components:user:create
The command prompts interactively for username, email, and password. You can also pass them as positional arguments:
php bin/console silverback:api-components:user:create alice alice@example.com s3cr3t --admin
Options
| Option | Description |
|---|---|
--admin | Create with ROLE_ADMIN |
--super-admin | Create with ROLE_SUPER_ADMIN |
--inactive | Create as disabled (cannot log in until enabled) |
--overwrite | Update the user if they already exist. Without it, a taken username or email address fails validation, and the command prints the violations and exits with an error |
Without --admin or --super-admin, the user is created with ROLE_USER only.
refresh-tokens:expire
Expire all refresh tokens, or all tokens for a specific user. Use this to force re-login after a security incident or account change.
# Expire all tokens for all users
php bin/console silverback:api-components:refresh-tokens:expire
# Expire tokens for one user (by username)
php bin/console silverback:api-components:refresh-tokens:expire alice
# Expire by email address instead of username
php bin/console silverback:api-components:refresh-tokens:expire alice@example.com --field emailAddress
--field accepts only username (the default) or emailAddress; any other value is rejected. The user is looked up through your UserRepositoryInterface, so a custom user repository works. If the value matches one user's username and a different user's email address, the command stops and reports it without expiring any tokens.
scan-orphaned
Scan for orphaned component groups, empty component positions and unused components, and store the report that the admin API returns. It never deletes anything.
php bin/console silverback:api-components:scan-orphaned
It prints the number of orphans of each kind. Add -v to list their IRIs as well:
Component groups: 1
Component positions: 2
Components: 3
It takes no arguments, only the --no-notify option, and always exits with 0. It runs the same scan as POST /_/orphaned_resources/scan, but always during the command, even when you route the scan message to a Messenger worker. That makes it suitable for a cron job or a deploy step that keeps the report fresh.
This command needs bundle 2.0.0-alpha.6 or later.
Emailing when orphans change
When orphaned_resources.notify.recipients is set, the command emails those addresses when its scan differs from the last alert. The recipients, admin page path and subject are in Configuration:
# config/packages/silverback_api_components.yaml
silverback_api_components:
orphaned_resources:
notify:
recipients: '%env(ORPHANED_RESOURCES_NOTIFY_RECIPIENTS)%'
recipients is a list, or one comma-separated string such as ops@example.com,dev@example.com, so a plain %env()% can supply it. With no recipients, nothing is sent and the command prints nothing about it.
A recipient can be a bare address or the Name <address> form, such as My Website <website@example.com>, and null counts as no recipients.
2.0.0-alpha.6, use bare addresses and never null. Two things were refused before 2.0.0-alpha.7:- The
Name <address>form (#361). Written in the config file, it failed the container build. Supplied by an environment variable, it was logged as invalid and nothing was sent, so every run tried again. - A
nullrecipient (#365), such as%env(default::MAILER_EMAIL)%with an empty value. It crashedscan-orphanedwith aTypeErrorbefore the scan ran.
The components-web-app template schedules this command on production, daily at 03:00 London time, with recipients: '%env(string:default::MAILER_EMAIL)%', so the alert goes to the site's own email address, and an empty MAILER_EMAIL sends nothing. string: keeps an empty value from becoming null, which bundles before 2.0.0-alpha.7 couldn't handle.
The result has changed when any of the three lists holds different IRIs from the last alert. Order doesn't matter. With no alert yet, a result counts as a change only if it has orphans. The email gives the count of each kind, the IRIs that are new since the last alert, how many are no longer orphaned, and a Review Orphaned Resources button that opens the admin page.
scan-orphaned sends the email, and it compares with the last alert, not with the stored report. POST /_/orphaned_resources/scan and the refresh after POST /_/orphaned_resources/delete replace the stored report but never email and never move the last alert. So a change an admin has already seen in the admin is still emailed on the next run.The last alert moves forward when an email is sent, and when the result matches it. It stays put when a send fails, so the next run tries again. It also stays put when there are no recipients or the run has --no-notify.Before api-components-bundle#356, the command compared with the stored report, so an admin's scan or delete absorbed the change and nothing was emailed.--notify is on by default. Pass --no-notify to scan and store the report without emailing. The last alert doesn't move, so the next run with notifications on still emails anything that changed.
After the counts, the command prints one of:
The report has changed: a notification was sent.The report has not changed: no notification was sent.The report has changed, but the notification could not be sent. The error has been logged.
The report is saved before the email is sent, and a failed send still exits with 0. A mailer transport failure is logged at error level as a MailerTransportException, as for user emails. An invalid address from the environment variable is logged too, and nothing is sent to anyone.
The button links to admin_page_path (default /_cwa/orphaned) on user.email_links.default_origin. It never uses a request's origin, because the command has none. Without a usable default_origin, the email is sent without the button and a warning is logged.
clean-orphaned
silverback:api-components:clean-orphaned is now an alias of scan-orphaned, so it only scans too.
2.0.0-alpha.5, clean-orphaned deleted orphaned groups and components. It now deletes nothing. A cron job or deploy step that ran it to tidy the database keeps working, but only refreshes the report. To delete orphans, use POST /_/orphaned_resources/delete or send DELETE to each IRI.Orphaned resource report
The report endpoints need ROLE_ADMIN:
| Request | Result |
|---|---|
POST /_/orphaned_resources/scan | No body. Refreshes the report and returns 202. |
GET /_/orphaned_resources | The stored report, sent private, no-store. 404 until the first scan. |
POST /_/orphaned_resources/delete | Deletes orphans; see Deleting orphans. |
The report has a generatedAt time, to the microsecond, and three lists of IRIs:
componentGroups: groups that nothing live owns. A group is live when a page or layout owns it, or a live component does. A group owned only by orphaned components is orphaned.componentPositions: positions in an orphaned group, and every position with neither a component nor a page data property.components: components in no live position and no page data property. A component referenced only by one of your own relations is listed too.
The tree starts at pages, layouts and page data component properties, and anything nothing live reaches is an orphan. That includes a chain orphaned only through its parent, such as a group owned by an orphaned component and the positions and components inside it, and cycles like a component placed in a group it owns. Up to 2.0.0-alpha.5 the report listed only the top of such a chain (api-components-bundle#358).
A draft copy is never listed; it's judged by its published version. A draft whose published version is orphaned has its own groups listed. A component held by a page data property counts as used even when the property is typed as a parent class, such as AbstractComponent. The report always counted it; up to 2.0.0-alpha.5 the old clean-orphaned and the /usage count did not, so that command could delete a component the report left alone.
The report, the command and the delete endpoint all use the same definition of an orphan.
In the Nuxt module's admin, site settings can start a scan and /_cwa/orphaned lists the report with buttons to view and delete each resource; see Orphaned Resources.
The scan runs during the request by default, which is three queries. To queue it instead, install symfony/messenger, route the message to an async transport and run a worker with php bin/console messenger:consume async:
# config/packages/messenger.yaml
framework:
messenger:
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
'Silverback\ApiComponentsBundle\Message\ScanOrphanedResourcesMessage': async
When queued, POST returns before the scan finishes: check generatedAt to see whether the report has been refreshed.
The report is one row of the _acb_orphaned_resource_report table (with the default table_prefix), updated in place by each scan. Every pod and worker reads the same report, and it survives cache clears and deploys. Only the latest report is kept.
2.0.0-alpha.5, the report was kept in Symfony's cache.app pool. Generate and run a migration to create the new table:php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
cache.app isn't carried over, so GET /_/orphaned_resources returns 404 until the next scan. generatedAt now has microseconds, such as 2026-09-26T01:40:07.123456+00:00; update the Nuxt module too (see Orphaned Resources).Deleting orphans
POST /_/orphaned_resources/delete deletes orphans. It needs ROLE_ADMIN and bundle 2.0.0-alpha.6 or later. Send either the IRIs to delete, or all to delete every orphan:
{ "iris": ["/_/component_groups/0199…", "/component/html_contents/0199…"] }
{ "all": true }
iris and "all": true. An empty body, {"all": false}, or both together return 422 and delete nothing. There is no "delete everything when no list is sent", so a truncated body can't empty the site.The endpoint doesn't trust the stored report or your list. It scans again during the request and deletes only IRIs that are orphans in that fresh scan. Everything is deleted in one transaction, cache purges and Mercure updates go out as for any other write, and the stored report is refreshed afterwards.
It returns 200 with what was deleted, grouped by kind, and what was refused:
{
"deleted": {
"componentGroups": ["/_/component_groups/0199…"],
"componentPositions": ["/_/component_positions/0199…"],
"components": ["/component/html_contents/0199…"]
},
"rejected": [
{ "iri": "/component/html_contents/0199…", "reason": "not_orphaned" }
]
}
deletedincludes everything the delete took with it, each IRI once. Deleting an orphaned group, for example, also lists its positions and the components used only there.rejectedlists each requested IRI that wasn't deleted, with a reason. It is always empty for"all": true.not_orphaned: the resource exists but isn't in the fresh scan. Something uses it now, or it is a draft.not_found: the IRI resolves to nothing, because it was already deleted or isn't a resource.
One refused IRI doesn't stop the rest: the others are still deleted. A report can be minutes old, so something may have been reused since.
The delete cascades so that it leaves nothing for the next scan:
- A group takes its positions, and any component in them that isn't used anywhere else.
- A component takes the groups it owns, recursively, unless an owner that isn't being deleted also has them.
- A published component takes its draft too, unless the draft is itself placed or held by page data.
An API DELETE of a group, page, layout or page data cascades the same way from 2.0.0-alpha.6. Before, a component removed with them left its own groups without an owner and its draft behind as a standalone component. The one difference is a DELETE sent to a published component itself: its draft is kept, because the draft holds the pending content and takes over its positions.
purge-rendered-html
Purge every cached page of rendered front-end HTML by its shared cwa-html cache tag. Cached API responses are left alone.
php bin/console silverback:api-components:purge-rendered-html
It takes no arguments or options. Run it after a front-end deploy; POST /_/rendered_html/purge as an admin does the same. If no HTTP cache purger is configured it does nothing and still succeeds. If the purge fails, it prints the reason and exits with 1 (the endpoint returns 502), so a deploy script can rely on it. See Page Caching.
purge-http-cache
Flush every cached response from the HTTP cache: API responses and rendered page HTML together.
php bin/console silverback:api-components:purge-http-cache
It takes no arguments or options. Use it after changing data outside the API, such as a direct database edit or an import. POST /_/http_cache/purge as an admin does the same. It only works with Souin: with any other purger it flushes nothing and still succeeds. If the flush fails, it exits with 1 (the endpoint returns 502). See Flushing the Whole HTTP Cache.
form-cache-purge
Purge the HTTP cache for Form components by setting each form's modified timestamp to the last-modified date of its FormType file.
php bin/console silverback:api-components:form-cache-purge
It takes no arguments or options. Run it after deploying changes to a FormType so cached form views are regenerated rather than served stale.
generate-fixtures
Walk the live database and output an AbstractCwaScaffold class that loads back as the same site. Use it to capture a site before a database purge, to copy it to another environment, or as a starting point after setting up a site through the admin UI.
php bin/console silverback:api-components:generate-fixtures
| Option | Default | Description |
|---|---|---|
--output, -o | src/DataFixtures/GeneratedScaffold.php | The file to write. The class is named after it |
--namespace | App\DataFixtures | The generated class's namespace. It doesn't follow --output, so set it for a file outside src/DataFixtures |
php bin/console silverback:api-components:generate-fixtures --output src/DataFixtures/MyScaffold.php
This writes an App\DataFixtures\MyScaffold class. A file name that isn't a valid class name, or an invalid namespace, fails the command.
--namespace and naming the class after the file are new in 2.0.0-alpha.6. On alpha.5 the class is always App\DataFixtures\GeneratedScaffold, so rename it to match any other file name.The generated class extends AbstractCwaScaffold and reproduces:
- All layouts, pages, and page data records with their routes, including scheduled and offline routes (
liveAt()) and redirects (redirect()) - ComponentGroups, their
allowedComponents(allow:), and groups owned by components or shared throughlocationReference - ComponentPositions — including
pageDataPositioncalls for template pages - Components with their field values, published versions and drafts, and relations such as a navigation link's route
- Uploaded files, copied into an
assets/directory beside the scaffold - Nested closures for parent/child page and page data relationships, and
withoutRoute()for a page or page data with no route
It then lists anything it could not reproduce. Review the file and that list, then run php bin/console doctrine:fixtures:load to reload the captured state, or add --append to create only what is missing. See Data Fixtures → Generating Fixtures From an Existing Database for what it carries and the capture-before-purge workflow.
Maker Commands
These commands use the standard make: prefix from Symfony MakerBundle and are only registered when MakerBundle is installed.
make:api-component
Generate a new AbstractComponent entity with the correct namespace, attributes, and optional behaviour traits wired up automatically.
php bin/console make:api-component
The command prompts interactively for the class name and which behaviours to add. You can also pass everything as arguments:
php bin/console make:api-component HeroBlock --timestamped --publishable
Options
--timestamped— Add#[Timestamped]: generatescreatedAt/modifiedAtcolumns viaTimestampedTrait--publishable— Add#[Publishable]: wires up the draft/publish lifecycle viaPublishableTrait--uploadable— Add#[Uploadable]: includes a$fileproperty andUploadableTraitfor file handling
Example output (with all three flags)
#[Silverback\Timestamped]
#[Silverback\Publishable]
#[Silverback\Uploadable]
#[ApiResource]
#[ORM\Entity]
class HeroBlock extends AbstractComponent
{
use TimestampedTrait;
use PublishableTrait;
use UploadableTrait;
#[Silverback\UploadableField(adapter: 'local')]
public ?File $file = null;
}
After generation, run make:migration and review the generated SQL before executing it — the traits add columns and (for publishable) self-referencing foreign keys.
make:page-data
Generate a new AbstractPageData entity with typed properties pre-wired, plus console output showing the matching nuxt.config snippet and fixture stub.
php bin/console make:page-data
Run with no options, the command prompts for the class name and then for each property: enter a name, accept or change the type (default ?string), and press return on an empty name to stop.
To skip the prompts, pass the properties with --properties, either as one comma-separated list:
php bin/console make:page-data ConferenceData \
--properties headline:?string,body:?string,heroImage:?string
or by repeating the flag once per property:
php bin/console make:page-data ConferenceData \
--properties headline:?string \
--properties body:?string \
--properties heroImage:?string
Properties are defined as name:type pairs, with the ? prefix for nullable types. Leave off :type and the type defaults to ?string.
--properties headline:?string body:?string). Symfony reads everything after the first value as extra command arguments, and the command aborts with "Too many arguments" before it runs. Use a comma or repeat the flag.Example output
#[ApiResource]
#[ORM\Entity]
class ConferenceData extends AbstractPageData
{
#[ORM\Column(nullable: true)]
public ?string $headline;
#[ORM\Column(nullable: true)]
public ?string $body;
#[ORM\Column(nullable: true)]
public ?string $heroImage;
}
After generation the command prints:
- A
nuxt.config.tssnippet to paste undercwa.pageData - A fixture scaffold stub:
$cwa->pageData(new ConferenceData(), template: 'my-template') ->pageDataPosition(ConferenceData::class, 'propertyName')calls for each property, ready to paste into a template group
The nuxt.config.ts snippet gives the data type a display name and maps each property name to a readable label, which the admin shows when you bind a dynamic position to a property:
ConferenceData: {
name: 'Conference Data',
properties: {
headline: 'Headline',
body: 'Body',
heroImage: 'Hero Image',
},
},
Labels are generated from the property names (heroImage becomes Hero Image). Change them to whatever you want editors to see. See nuxt.config for the full pageData options.
properties as an array of names (properties: ['headline', 'body']) and left out name. That shape is wrong: the module expects a map of property name to label, so a pasted array gives you no labels in the admin and a type error in a typed nuxt.config.ts. If you pasted the old snippet, change it to the map form above.If you give no properties at all, the command warns that the entity only has the inherited page data fields and prints properties: {}.
After generation, run make:migration and review the generated SQL before executing it.
make:cwa-scaffold
Generate a starter AbstractCwaScaffold subclass pre-wired with a layout, a home page, and commented-out nav link stubs.
php bin/console make:cwa-scaffold
The command prompts interactively for the class name, layout reference key, and layout UI component name. You can pass everything inline:
php bin/console make:cwa-scaffold AppScaffold --layout-ref main --layout-component Primary
Options
| Option | Default | Description |
|---|---|---|
--layout-ref | main | Reference key passed to $cwa->layout() |
--layout-component | Primary | Layout UI suffix (e.g. Primary → stores CwaLayoutPrimary) |
Example output
class AppScaffold extends AbstractCwaScaffold
{
public function build(CwaFixtureBuilder $cwa): void
{
$navGroup = $cwa->layout('main', 'Primary')
->group('top');
$cwa->page('home', 'PrimaryPage', layout: 'main', route: '/', routeName: 'home-page',
configure: fn (PageBuilder $page) => $page
->title('Home')
->group('primary')
);
// $navGroup->add(...) — nav links go here after routes exist
}
}
After generation the command prints the config/services.yaml snippet to register the class as a Doctrine fixture:
App\DataFixtures\AppScaffold:
tags: [doctrine.fixture.orm]
Then run php bin/console doctrine:fixtures:load to seed the database. See Data Fixtures for the full builder API.
make:rename-component
Generate a Doctrine migration to rename a CWA component type. Use this when you need to rename a PHP entity class that is already stored in the database — it updates the discriminator column (dtype) of the component table and replaces old collection IRIs in the component groups' allowed_components.
php bin/console make:rename-component
The command prompts interactively for the old and new class names, FQCNs, and dtype values (defaulting to lowercase class names):
php bin/console make:rename-component HtmlContent RichText
If your app configures more than one Doctrine Migrations namespace, pass --namespace to choose one. Without it, the command asks, defaulting to the first configured namespace.
When it asks for a collection IRI
The migration rewrites allowedComponents, which stores collection IRIs such as /component/html_contents, so the command needs the collection IRI of both the old and the new component. It asks API Platform for each one from the class, which only works while that class exists and is an API resource. That usually fails in one of two cases:
- You've already renamed the PHP class.
HtmlContentno longer exists, but the database still stores/component/html_contentsinallowedComponentsandhtml_contentindtype. - You run the command before creating the new class, so its IRI can't be resolved yet.
When an IRI can't be resolved, the command says so and asks for it. Enter it exactly as allowedComponents stores it. To find the old one, look at an existing allowed_components value in the component group table, or at the component's collection path in the API docs from before the rename. In a non-interactive run, such as CI, pass --old-iri and --new-iri instead. Without them, the command fails and names the missing option.
# HtmlContent has already been renamed to RichText in the code
php bin/console make:rename-component HtmlContent RichText \
--old-iri=/component/html_contents \
--new-iri=/component/rich_texts
The value must be a path starting with /. API Platform blank nodes (/.well-known/genid/…) are refused, because written into the migration they would match nothing. Check the generated migration's allowed_components values before you run it.
What it does
- Generates a Doctrine migration through Doctrine Migrations itself, as
doctrine:migrations:generatedoes: aVersion<timestamp>class in your configured migrations path (migrations/in the template), so it runs in order with your other migrations - The migration updates the
dtypecolumn of the component table andallowed_componentson the component group table, using the table names from your ORM mapping, including the bundle'stable_prefix(_acb_abstract_componentand_acb_component_groupby default) - After generation, the command prints any
ComponentGrouprecords whoseallowedComponentsarray references the old IRI — these need front-end updates too
Front-end checklist (printed after generation)
- Rename the Vue component file from the old name to the new name
- Update any imports or registrations referencing the old name
- Run
php bin/console doctrine:migrations:migrate
Also update any allowed-components prop in your front-end templates that names the old component or lists its old collection IRI. While a user is signed in, the module syncs that prop to the API, so a stale entry is written back over the migrated list.
make:migration afterwards, or you will get a second, unrelated diff migration. Review the generated SQL in the new Version…php file, then apply it with php bin/console doctrine:migrations:migrate.