The CWA is in heavy development
The CWA is still in alpha and not ready for production - some code and implementations are likely to change. If you would like to try out the CWA, please enjoy what we have provided and feel free to provide feedback, or get involved on GitHub.
DraftApi

Configuration Reference

Complete reference for all silverback_api_components bundle configuration options.

Full YAML structure with every option, its type, and default value.

Leave out any required field and the container fails to compile, with an error that names the missing key. The required fields are:

  • website_name
  • user, with user.class_name
  • publishable, with publishable.permission
  • refresh_token, with handler_id, cookie_name, ttl and database_user_provider
  • refresh_token.options.class, but only when handler_id is the Doctrine storage handler (silverback.api_components.refresh_token.storage.doctrine). A custom handler doesn't need it.

Everything else has a default. That includes all of user.email_verification, which defaults to off: users start unverified, no verification email is sent, and unverified users can still sign in. Anything stricter needs a redirect target for its emails, so you have to opt in. If you turn on verify_on_register or verify_on_change, you must also set email.default_redirect_path or email.redirect_path_query, or the container won't compile.

user.email_links compiles without a value, but then every email that carries a link (password reset, verification, welcome, account enabled) is refused. Set default_origin or allowed_origins. See Links in Emails.

Full Configuration

silverback_api_components:

    # Required. Used in email subjects and templates.
    website_name: My CWA App

    # Table prefix for all bundle-managed tables. Default: '_acb_'
    table_prefix: '_acb_'

    # Key under which runtime metadata is nested in API responses. Default: '_metadata'
    metadata_key: '_metadata'

    # ─── User ────────────────────────────────────────────────────────────────
    user:
        # Required. Fully-qualified class name of your User entity.
        class_name: App\Entity\User

        email_verification:
            enabled: true
            # Whether new users start verified (false = must verify email)
            default_value: false
            # Send a verification email when a new user registers
            verify_on_register: true
            # Re-send verification when the user changes their email
            verify_on_change: true
            # Block login for unverified email addresses
            deny_unverified_login: true
            email:
                # Query param name carrying the redirect path (optional)
                redirect_path_query: ~
                # Path with {{ username }} and {{ token }} placeholders
                default_redirect_path: /verify-email/{{ username }}/{{ token }}
                subject: Please verify your email
            # Minimum seconds between verification emails (429 until then). Default: 300
            repeat_ttl_seconds: 300

        new_email_confirmation:
            email:
                redirect_path_query: ~
                default_redirect_path: /confirm-new-email/{{ username }}/{{ new_email }}/{{ token }}
                subject: Please confirm your new email address
            # Token validity in seconds. Default: 86400 (24 hours)
            request_timeout_seconds: 86400
            # Minimum seconds between confirmation emails (429 until then). Default: 300
            repeat_ttl_seconds: 300

        password_reset:
            email:
                redirect_path_query: ~
                default_redirect_path: /reset-password/{{ username }}/{{ token }}
                subject: Your password reset request
            # Minimum seconds between password reset emails only (429 until then).
            # Default: 86400 (24 hours)
            repeat_ttl_seconds: 86400
            # Reset token validity in seconds. Default: 3600
            request_timeout_seconds: 3600

        # Origin that links in user emails point to. Set one of these, or every
        # email carrying a link is refused. See Users & Security.
        email_links:
            # scheme://host[:port]. Default: null
            default_origin: 'https://www.example.com'
            # Regexes, anchored by the bundle, that the request's Origin/Referer
            # may match to be used instead. Default: []
            allowed_origins: []

        emails:
            welcome:
                enabled: true
                subject: 'Welcome to {{ website_name }}'
            user_enabled:
                enabled: true
                subject: 'Your account has been enabled'
            username_changed:
                enabled: true
                subject: 'Your username has been updated'
            password_changed:
                enabled: true
                subject: 'Your password has been changed'

    # ─── Publishable ─────────────────────────────────────────────────────────
    publishable:
        # Symfony expression: who can read/write draft resources and publish
        permission: "is_granted('ROLE_ADMIN')"

    # ─── HTTP Cache Safety ───────────────────────────────────────────────────
    http_cache:
        # Resource classes whose GET responses are marked `private, no-store`
        # for authenticated users, so a shared cache never stores an admin's
        # draft view. Any Publishable resource is treated this way automatically,
        # in addition to this list. Default: [Route, ResourceManifest, ComponentPosition]
        personalised_resource_classes:
            - 'Silverback\ApiComponentsBundle\Entity\Core\Route'
            - 'Silverback\ApiComponentsBundle\ApiResource\ResourceManifest'
            - 'Silverback\ApiComponentsBundle\Entity\Core\ComponentPosition'
        # Resource classes whose anonymous GET responses have `s-maxage` and
        # `max-age` capped at the soonest future route go-live date.
        # Default: [Route, RoutableInterface, ResourceManifest]
        scheduled_expiry_resource_classes:
            - 'Silverback\ApiComponentsBundle\Entity\Core\Route'
            - 'Silverback\ApiComponentsBundle\Entity\Core\RoutableInterface'
            - 'Silverback\ApiComponentsBundle\ApiResource\ResourceManifest'
        # Resource classes whose writes also purge the `cwa-html` surrogate key,
        # dropping every cached page. List only leaf resources.
        # Default: [SiteConfigParameter]
        purge_rendered_html_classes:
            - 'Silverback\ApiComponentsBundle\Entity\Core\SiteConfigParameter'

    # ─── Built-in Components ─────────────────────────────────────────────────
    enabled_components:
        form: true        # The Form component (Symfony FormType → JSON)
        collection: true  # The Collection component (resource list proxy)

    # ─── Refresh Tokens ──────────────────────────────────────────────────────
    # Every key in this block except `options` is required — there are no defaults.
    refresh_token:
        # Service ID of the refresh token storage handler
        handler_id: silverback.api_components.refresh_token.storage.doctrine
        options:
            # Your RefreshToken entity class (see the template app's src/Entity/RefreshToken.php)
            class: App\Entity\RefreshToken
        # Must match the cookie name in lexik_jwt_authentication.set_cookies
        # and token_extractors.cookie.name. The template app uses `api_component`.
        cookie_name: api_components
        # Token lifetime in seconds (the template uses 604800 = 1 week)
        ttl: 604800
        # The user provider alias used in security.yaml
        database_user_provider: database

    # ─── Mercure ─────────────────────────────────────────────────────────────
    mercure:
        # Name of the Mercure hub (if multiple hubs; null = default hub)
        hub_name: ~
        cookie:
            # SameSite attribute for the Mercure cookie. Default: 'strict'
            samesite: '%env(JWT_COOKIE_SAMESITE)%'
        # Scope subscriber JWT tokens to resources the current user can access.
        # Recommended in production. Default: false (all topics subscribed).
        secure_subscriptions: false

    # ─── Route Security ──────────────────────────────────────────────────────
    # URL pattern → Symfony security expression. Evaluated per-route.
    route_security:
        - { route: '/user-area*', security: "is_granted('ROLE_USER')" }
        - { route: '/admin*', security: "is_granted('ROLE_ADMIN')" }

    # Expression for who can read pages, page data and components that no live
    # Route reaches, and who can create pages and page data. Default: null (no gate)
    routable_security: "is_granted('ROLE_ADMIN')"

    # ─── Orphaned Resources ──────────────────────────────────────────────────
    # Emails sent by `scan-orphaned` when the orphans differ from the last
    # stored report. See Console Commands. Needs bundle 2.0.0-alpha.6 or later.
    orphaned_resources:
        notify:
            # A list, or one comma-separated string so an env var can supply it.
            # Default: [] (no emails)
            recipients: '%env(ORPHANED_RESOURCES_NOTIFY_RECIPIENTS)%'
            # Front-end path of the admin page, linked on
            # user.email_links.default_origin. Default: '/_cwa/orphaned'
            admin_page_path: '/_cwa/orphaned'
            # Default: 'Orphaned resources changed on {{ website_name }}'
            subject: 'Orphaned resources changed on {{ website_name }}'

There is no enabled flag for the orphaned resources email: no recipients means no email. Literal addresses are checked when the container compiles; addresses from an environment variable are checked when an email is sent. See scan-orphaned for when it sends.

Mercure Secure Subscriptions

By default the subscriber JWT token issued at login includes every resource topic — Mercure will push updates for any resource the front-end subscribes to, regardless of the current user's access level. This is fine for public content but leaks real-time updates for resources that have server-side security expressions.

Set mercure.secure_subscriptions: true to scope the token: the bundle evaluates each API resource's security expression at token-generation time and omits topics the current user cannot access:

silverback_api_components:
    mercure:
        secure_subscriptions: true

Caveats:

  • Security expressions that reference object (item-level security, e.g. object.owner == user) cannot be evaluated at token time because there is no concrete entity instance. Those resource topics are always included — you cannot scope to specific items, only to the class/role level.
  • The Mercure authorization cookie is written at login and re-issued whenever the JWT is refreshed. A role change mid-session therefore doesn't take effect immediately — the scope widens or narrows at the next refresh. Sign out and back in if you need it applied straight away.

Enable in production for any application with role-gated resources. Leave false (the default) for fully public sites.

HTTP Cache Safety

Some resources look different depending on who's asking. When an admin is signed in, GET /_/routes/{path}, the resource manifest, and any draft of a Publishable component return the draft view — the same URL a signed-out visitor uses to get the published view (the API decides from the auth cookie, not the URL). If a shared cache stored the admin's response, it could later serve that draft to the public.

The bundle prevents this at the source. On a GET/HEAD for an authenticated user, when the resource is in the affected set, a kernel.response listener overrides API Platform's default public caching:

Cache-Control: private, no-store

It also strips the long shared s-maxage from that response. Anonymous requests, and any resource not in the affected set (a Layout, say), are untouched and stay publicly cacheable — so the only variant a shared cache ever holds is the published one.

The affected set is the configured personalised_resource_classes (default Route, ResourceManifest, ComponentPosition) plus every Publishable resource, matched dynamically — you don't list your Publishable components. Subclasses of a listed class match too. Add your own resource classes if they vary by session:

silverback_api_components:
    http_cache:
        personalised_resource_classes:
            - 'Silverback\ApiComponentsBundle\Entity\Core\Route'
            - 'Silverback\ApiComponentsBundle\ApiResource\ResourceManifest'
            - 'Silverback\ApiComponentsBundle\Entity\Core\ComponentPosition'
            - 'App\Entity\MyPersonalisedComponent'
This is deliberately done withoutVary: Cookie. Varying on the cookie would collapse the cache hit-rate for anonymous visitors (cookies churn), and it isn't needed — the unsafe responses are marked non-cacheable outright rather than cached under a cookie key. The cacheable anonymous variant carries no cookie to vary on.

This header is the API-side contract a service worker relies on to cache CWA responses safely — a no-store/private response is the signal to not store it. See Progressive Web App & Offline on the front-end for the full picture.

Cache Tags and Purging

With API Platform's HTTP cache invalidation turned on, every cacheable response is tagged with the IRIs of the resources it contains. With the template's Souin purger, the tags go in a Surrogate-Key header. When a resource is written, the bundle sends its IRI to the shared cache as a purge, so every cached response that used the resource is dropped.

Two IRIs are never tagged, because nothing can purge them: JSON-LD blank nodes (/.well-known/genid/…) and /_/resource_metadatas.

A failed purge doesn't fail the write. If the cache refuses the purge or can't be reached, the write still gets its normal response and Mercure still publishes. The failure is logged at error level, with the tags, as The HTTP cache purge after a write failed, so these tags may still be cached. Those responses can stay stale until they expire or you fix the cache and purge again, so alert on that log.

Tags are IRIs generated by Symfony's router, so a route import prefix such as the template's /_api is included whether the write comes over HTTP or from the CLI (fixtures, console commands, messenger workers). A Route write, for example, purges /_api/_/routes and /_api/_/routes//my-route.

Serving the API from a sub-directory? Set framework.router.default_uri to a URL that includes that path. A request's base path is only known over HTTP, so without it every write made from the CLI purges unprefixed tags and misses the cache. The template doesn't need this: /_api is a route prefix, not a base path.

Resource manifests are tagged per page

A resource manifest (GET /_/resource_manifest/{id}) is the exception. Its body is only a list of IRIs, so it changes only when the page's structure changes: which component sits in which group, which layout a page uses, or which page a route publishes. Instead of the IRI of every resource it lists, a manifest carries one grouping key per rendering depth:

Surrogate-Key: manifest:/_/pages/8f3c…

A nested page carries one key for each ancestor depth as well as its own. A page-data manifest also carries the key of the template page it renders through. A manifest requested by route path and the same manifest requested by ID carry identical keys, so one purge drops both.

A write to any of these purges the manifests that contain it: Route, Page, any AbstractPageData, Layout, ComponentGroup and ComponentPosition. The bundle works upward from the written resource to the pages that contain it, so adding a component to a group in a shared layout purges the manifest of every page using that layout. A change inside a template page purges the manifest of every page data that uses it.

Editing a component's content no longer purges any manifest. The manifest lists the component's IRI, and that IRI hasn't changed. The component's own IRI is still purged, so the component's response, and any cached page tagged with it, still refresh straight away. Manifests now stay cached much longer than before, so don't rely on a component save to refresh one.

One exception: a component placed through a pageDataProperty still purges the manifests of the page data that references it, because the bundle treats that write as a change to the page data itself.

The key count no longer grows with the size of the page. Before, a 44-resource manifest produced about 2.7 KB of Surrogate-Key header, which is more than Souin's 1500-byte batch and large enough to need a bigger proxy buffer. A manifest now carries a handful of keys whatever the page size.

Scheduled Publication and Cache Lifetime

A scheduled publication changes what a response should contain at a set time, with no write to trigger a purge. The bundle handles this by shortening the cache lifetime instead, on anonymous, successful GET responses that already have an s-maxage or max-age.

Expires caps the cache lifetime. A component with a future publishedAt sends an Expires header set to that date, including on anonymous requests, so the front end can see a change coming without seeing the draft. Shared caches give s-maxage priority over Expires, so the bundle now also lowers s-maxage and max-age to the time left until Expires whenever that's sooner. This applies to any response that sets Expires, not only publishable components.

Route go-live dates cap the classes in scheduled_expiry_resource_classes. For these responses, s-maxage and max-age are capped at the soonest future liveAt across all routes, whether or not the response mentions that route. That's deliberate: a route going live changes pages that never mention it (a navigation menu, for example), and no cache tag can express that. The default list covers routes, pages and page data (RoutableInterface) and the resource manifest, which is where a page render starts:

silverback_api_components:
    http_cache:
        scheduled_expiry_resource_classes:
            - 'Silverback\ApiComponentsBundle\Entity\Core\Route'
            - 'Silverback\ApiComponentsBundle\Entity\Core\RoutableInterface'
            - 'Silverback\ApiComponentsBundle\ApiResource\ResourceManifest'

Subclasses and implementations of a listed class match too.

A collection response carries no Expires. A collection that contains a resource with a pending publication is only capped if its class is in scheduled_expiry_resource_classes.

Because every scheduled change now shortens the responses it affects, the front end can take a page's lifetime from the shortest s-maxage among the API responses it used, without reading publication dates it can't see anonymously. That's what the Nuxt module does — see Page Caching. It makes a long shared_max_age safe.

Purging Every Rendered Page

The Nuxt module tags each cached page with the IRIs of the resources it rendered, so a write to any of them drops the affected pages. Some resources change every page without being one of those resources. Site config is the main case: changing siteName would leave every cached page showing the old name until it expired.

A write to a class listed in purge_rendered_html_classes therefore also purges the surrogate key cwa-html, which the module adds to every page it caches. The default list is SiteConfigParameter, and subclasses match:

silverback_api_components:
    http_cache:
        purge_rendered_html_classes:
            - 'Silverback\ApiComponentsBundle\Entity\Core\SiteConfigParameter'
This drops every cached page at once, on purpose. All the traffic then reaches Nuxt SSR together. That's fine for a rare admin action, which is why the list should stay short. List only leaf resources: ones that aren't reachable as a Doctrine association of another resource. Otherwise unrelated writes will drop the whole page cache.

The purge is sent once per write request. Saving several site config parameters is several requests and several purges, which is harmless because the key is already gone after the first.

cwa-html is a contract shared by the bundle and the Nuxt module. Both sides must use the same name. A mismatch fails silently: the purge matches nothing and pages stay stale until they expire.
Upgrading:POST /_/site_config_parameters now needs the same permission as updating or deleting a parameter, publishable.permission (usually ROLE_ADMIN). Before, the POST operation had no security of its own, so an application that creates site config parameters as a non-admin user will now get a 403.

Flushing the Whole HTTP Cache

Tag purges only reach resources the API has seen change. After a direct database edit, a data fix or an import, flush every cached response instead:

php bin/console silverback:api-components:purge-http-cache

or, as an admin, POST /_/http_cache/purge with no body. It returns 204. Both drop every cached response, API responses and rendered page HTML together. There is no option to flush only the API responses. The Nuxt module's site settings has a Purge all cached data button that sends the same request; see Purging all cached data.

The flush is sent to Souin's flush endpoint, <invalidation url>/flush, for each URL in api_platform.http_cache.invalidation.urls.

SituationCommandEndpoint
Flushedexits 0204
The purger is not Souin, or no invalidation URL is setexits 0, flushes nothing, and says so501
The cache refused or couldn't be reachedexits 1 with the error502
To clear pages after a front-end deploy, keep using purge-rendered-html (Purging Every Rendered Page). It drops only the pages, so the API responses stay cached. A full flush sends every request back to PHP and Nuxt until the cache refills.

Environment Variables

VariablePurpose
DATABASE_URLDoctrine connection string
JWT_SECRET_KEYPath to private key PEM file
JWT_PUBLIC_KEYPath to public key PEM file
JWT_PASSPHRASEPrivate key passphrase (.env.local only)
JWT_COOKIE_SAMESITECookie SameSite attribute (strict, lax, none)
MERCURE_URLInternal API → Mercure hub URL
MERCURE_PUBLIC_URLBrowser → Mercure hub URL
MERCURE_JWT_SECRETShared secret for Mercure publisher JWT
MAILER_DSNSymfony Mailer transport
APP_SECRETSymfony application secret

Services You Can Override

All ~320 bundle services have stable string IDs in the form silverback.api_components.*. Each service's FQCN is also registered as an alias, so you can decorate using either form in config/services.yaml:

# config/services.yaml
services:
    App\YourCustomFilesystemProvider:
        decorates: Silverback\ApiComponentsBundle\Flysystem\FilesystemProvider

    # or equivalently using the stable string ID:
    App\YourCustomFilesystemProvider:
        decorates: silverback.api_components.flysystem.filesystem_provider

Commonly overridden services:

Service IDPurpose
Silverback\ApiComponentsBundle\Flysystem\FilesystemProviderCustom storage adapter resolution
Silverback\ApiComponentsBundle\Security\UserCheckerCustom account checks on login
silverback.security.jwt_managerJWT creation/parsing (decorates lexik_jwt_authentication.jwt_manager)

The bundle does not own the login success/failure handlers — those are Lexik's (lexik_jwt_authentication.handler.authentication_success and …_failure) and are wired on the login firewall in security.yaml rather than decorated. See Users & Security.

The string IDs follow the class namespace: Silverback\ApiComponentsBundle\Foo\BarBaz → silverback.api_components.foo.bar_baz. Inspect vendor/components-web-app/api-components-bundle/src/Resources/config/services.php for the full list.