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

Bundle Setup

Installing and configuring the Silverback API Components Bundle in a Symfony application.

The API Components Bundle is the Symfony backend of CWA. It wires together API Platform, Doctrine ORM, LexikJWTAuthenticationBundle, Mercure, and a suite of content management abstractions so you spend your time building your app rather than infrastructure.

Prerequisites

  • PHP 8.5+
  • Symfony 7.4+ or 8.1+
  • Doctrine ORM 3.7+ (with DBAL 4.4+)
  • API Platform 4.4+ or 5.x
  • A database server that supports recursive CTEs (see below)

Database servers

The bundle resolves a route's inherited go-live date with a recursive CTE (WITH RECURSIVE), so the database server must support one:

ServerMinimumRecommended
PostgreSQL8.412+
MySQL8.08.0+
MariaDB10.210.6+
SQLite3.8.33.8.3+

The Recommended column is Doctrine DBAL 4's own floor: below it DBAL still connects but raises a deprecation, and DBAL 5 drops support. The CWA template runs PostgreSQL 16.

MySQL 5.7 and MariaDB 10.0/10.1 cannot run CWA. They have no CTE support, and neither Composer nor the container can check this: the application boots normally, then every anonymous request to GET /_/routes, GET /_/pages or a page-data collection returns a 500. The sitemap is built from GET /_/routes, so it fails too.

Installation

composer require "components-web-app/api-components-bundle:^2.0@alpha"
Include the version constraint. CWA 2 is released as alphas. Without ^2.0@alpha, a project with the usual minimum-stability: stable gets 1.1.11, the last 1.x release, which these docs don't describe. To follow unreleased changes, require 2.x-dev: main is aliased to it.

Each release's changes are in the bundle's CHANGELOG.md, from 2.0.0-alpha.2 on, and the same notes are on its GitHub releases. Changes not yet released are listed under Unreleased. Read them before you upgrade, because breaking changes have their own heading.

The bundle requires Mercure and installs it with itself. From 2.0.0-alpha.8, the bundle requires symfony/mercure 0.8 and symfony/mercure-bundle 0.5, and speaks only the Mercure 1.0 protocol, so the hub must be Mercure 1.0 too. See Mercure Hub for the configuration. Up to 2.0.0-alpha.7, the bundle accepted symfony/mercure 0.7.1 or later and symfony/mercure-bundle 0.4.3 or later, and spoke the 0.x protocol.

Coming from symfony/mercure 0.7? Check your own hub decorators. In symfony/mercure 0.8 (symfony/mercure-bundle 0.5), HubInterface gains getProtocolVersion() and getCookieName(), and getUrl() moves to RemoteHubInterface. A class that decorates mercure.hub.default and doesn't have these methods breaks cache:clear with a fatal error. Implement RemoteHubInterface and forward the new methods to the inner hub. The template's api/src/Mercure/SkipAwareMercureHub.php shows how, and so does the bundle's own PublishableAwareHub.

The bundle works with API Platform 4.4 and 5. Two differences are visible to clients. First, on API Platform 5, a request body with the wrong type for a property that has validation constraints returns a 422 with violations, where 4.4 returns a 400. Second, a unique-constraint violation from the database returns a 422 on API Platform 5 and a 500 on 4.4 (see Error Status Codes). On both versions, GET /me returns @context: /contexts/User.

Configure a default Mercure hub. The bundle decorates the mercure.hub.default service, so config/packages/mercure.yaml must define a hub named default (see Mercure Hub), using the MERCURE_* variables under Environment Variables. Without it, the container fails to compile with non-existent service "mercure.hub.default".
The Composer package is components-web-app/api-components-bundle, but the PHP namespace is Silverback\ApiComponentsBundle\ — the mismatch is historical, not a typo.
The bundle works whether API Platform's use_symfony_listeners is on or off, and leaves it at API Platform's default (off). If your own code listens on API Platform's kernel events (EventPriorities::PRE_WRITE and the rest), set use_symfony_listeners: true in your api_platform.yaml, because those events only run with it on.The template uses API Platform's default too, since fc0afb7. A project that copied use_symfony_listeners: true from an older template can remove it, unless its own code uses EventPriorities or listens on kernel.view.
Upgrading to 2.0.0-alpha.8: up to 2.0.0-alpha.7 the bundle forced use_symfony_listeners: true, and setting it to false broke requests such as GET /_api/me with a 500. From 2.0.0-alpha.8 it no longer turns it on (#369), so an application that relied on it must now set it itself. The same change renamed or removed several of the bundle's listener classes and services, and upload and download no longer use controllers. If you decorate or call any of them, check the Breaking list under 2.0.0-alpha.8 in the changelog above.

The bundle has no Flex recipe in symfony/recipes-contrib (earlier submissions were never merged), so Flex runs no recipe for it. Create these files yourself; the CWA template app (components-web-app) has a working copy of each under api/:

  • config/bundles.php — register Silverback\ApiComponentsBundle\SilverbackApiComponentsBundle::class => ['all' => true]
  • src/Entity/User.php — your user entity extending AbstractUser
  • src/Entity/RefreshToken.php — the refresh token entity
  • config/packages/silverback_api_components.yaml — the bundle configuration
  • config/routes/silverback_api_components.yaml — imports @SilverbackApiComponentsBundle/Resources/config/routing/all.php, with the same prefix as API Platform in config/routes/api_platform.yaml (/api on a fresh Flex install, /_api in the template)
  • config/packages/security.yaml — the security configuration (see Users & Security)

Generate JWT Keys

The bundle uses cookie-based JWT tokens for authentication. Generate a key pair once per environment:

mkdir -p config/jwt
openssl genpkey -out config/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa_keygen_bits:4096
openssl pkey -in config/jwt/private.pem -out config/jwt/public.pem -pubout

Add the passphrase to .env.local — never commit this file:

JWT_PASSPHRASE=your_secure_passphrase

Add the key paths to .env:

JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem

Core Configuration

Open config/packages/silverback_api_components.yaml. This is the configuration the CWA template app ships with:

silverback_api_components:
    website_name: My CWA App
    user:
        class_name: App\Entity\User
        email_links:
            default_origin: '%app.email_link_default_origin%'  # the site's public origin
        email_verification:
            default_value: false
            verify_on_register: true
            verify_on_change: true
            deny_unverified_login: true
            email:
                redirect_path_query: null
                default_redirect_path: /verify-email/{{ username }}/{{ token }}
        password_reset:
            email:
                redirect_path_query: null
                default_redirect_path: /reset-password/{{ username }}/{{ token }}
        new_email_confirmation:
            email:
                redirect_path_query: null
                default_redirect_path: /confirm-new-email/{{ username }}/{{ new_email }}/{{ token }}
    publishable:
        permission: "is_granted('ROLE_ADMIN')"
    refresh_token:
        handler_id: silverback.api_components.refresh_token.storage.doctrine
        options:
            class: App\Entity\RefreshToken
        cookie_name: api_component  # must match Lexik's cookie name
        ttl: 604800  # 1 week in seconds
        database_user_provider: database
    route_security:
        - { route: "/user-area*", security: "is_granted('ROLE_USER')" }
    routable_security: "is_granted('ROLE_ADMIN')"
    mercure:
        cookie:
            samesite: '%env(JWT_COOKIE_SAMESITE)%'
user.email_verification is off if you leave it out: users start unverified, no verification email is sent, and unverified users can still sign in. The template turns it on. If you set 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. The redirect paths are front-end routes, so keep them in step with the pages your Nuxt app actually serves. See Configuration Reference for which keys are required.
Set user.email_links.default_origin to the origin your front end is served from, such as https://www.example.com. Without it, password reset and verification emails are refused. The template builds %app.email_link_default_origin% in config/services.php from EMAIL_LINK_DEFAULT_ORIGIN, falling back to https://<BROWSER_SERVER_NAME>. See Links in Emails.

Mercure Hub

From 2.0.0-alpha.8, the bundle speaks only the Mercure 1.0 protocol. The hub that receives the API's updates and serves them to browsers must be a Mercure 1.0 hub. In the template, that hub is the Mercure module inside FrankenPHP, and FrankenPHP 1.13 and later ship Mercure 1.0. From 2.0.0-alpha.4, the Nuxt module subscribes with the 1.0 protocol as well, so a 0.x hub rejects its subscription and live updates stop. Neither the bundle nor the module can switch back to 0.x.

The hub must be Mercure 1.0. That means FrankenPHP 1.13 or later, or a standalone Mercure hub at 1.0 or later. There is no setting in the bundle or the module that brings back the 0.x protocol.

mercure.yaml

symfony/mercure-bundle still defaults to the 0.x protocol, so set protocol_version: '1.0' on the hub. Mercure 1.0 tokens are OAuth access tokens, so add the iss, sub and client_id claims:

config/packages/mercure.yaml
mercure:
    hubs:
        default:
            url: '%env(MERCURE_URL)%'
            public_url: '%env(MERCURE_PUBLIC_URL)%'
            protocol_version: '1.0'            jwt:
                secret: '%env(MERCURE_JWT_SECRET)%'
                publish: '*'
                claims:                    iss: 'https://www.example.com'                    sub: 'api'                    client_id: 'api'

With protocol_version: '1.0' and any of the three claims missing, the container won't compile. Without protocol_version: '1.0', the bundle fails when it builds the Mercure cookie, at sign-in and on /me. iss can be any stable identifier, such as your site's URL, as long as the hub's issuer block uses the same value. aud is set for you: it defaults to public_url.

Hub Caddyfile

A Mercure 1.0 hub verifies tokens per issuer. The keys move from the top-level publisher_jwt and subscriber_jwt directives into an issuer block, whose name must equal the iss claim:

api/frankenphp/Caddyfile
mercure {
    transport bolt {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
    issuer https://www.example.com {
        publisher {
            jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
        }
        subscriber {
            jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
        }
    }
    resource_identifier {$MERCURE_PUBLIC_URL}
    anonymous
    subscriptions
    cors_origins {$MERCURE_CORS_ORIGIN:*}
}

resource_identifier is the audience the hub expects in a token's aud. The bundle's tokens carry the public hub URL. The API publishes through the internal MERCURE_URL, though, and without resource_identifier the hub expects the URL each request came in on. Pin resource_identifier to the public hub URL, or every publish from the API gets a 401.

Don't set protocol_version_compatibility. That directive keeps a 1.0 hub accepting 0.x clients, it only works in a binary built with the deprecated_topic and deprecated_claim tags, and it turns off the 1.0 token checks. In a modern-mode hub, publisher_jwt and subscriber_jwt are a configuration error. Unknown directives are errors too, so a 0.x demo or ui in MERCURE_EXTRA_DIRECTIVES stops the php container from starting. Use debugger, and only in development.

In the template

The template runs Mercure 1.0 in modern mode, with bundle 2.0.0-alpha.8 and @cwa/nuxt 2.0.0-alpha.4 (1a9f7a5, on main after template 2.0.0-alpha.3). Both sides use the fixed issuer cwa:

api/config/packages/mercure.yaml
mercure:
    hubs:
        default:
            protocol_version: '1.0'
            # url, public_url, jwt.secret, publish and algorithm as above
            jwt:
                claims:
                    iss: 'cwa' # must equal the Caddyfile's issuer                     sub: 'cwa-api'
                    client_id: 'cwa-api'
api/frankenphp/Caddyfile
issuer cwa { # [!code focus]
    publisher {
        jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
    }
    subscriber {
        jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
    }
}
resource_identifier {$MERCURE_PUBLIC_URL:https://localhost/.well-known/mercure} # [!code focus]

resource_identifier follows MERCURE_PUBLIC_URL, the tokens' aud, because the API publishes through the internal http://php.local/… or in-cluster URL. Don't remove it: every publish would get a 401.

The issuer is fixed on both sides, because the hub trusts only its own API, so there is nothing to configure. If you change it, change iss in mercure.yaml and the Caddyfile's issuer together: if they differ, the hub rejects every token with a 401.

Between the template's Mercure 1.0 switch (1a9f7a5) and b3b8622, both sides read a MERCURE_JWT_ISSUER variable. Caddy reads only the process environment, while Symfony also reads .env, so setting it could reach one side and break every token. If your project has env(MERCURE_JWT_ISSUER) in mercure.yaml or {$MERCURE_JWT_ISSUER:cwa} in the Caddyfile, replace both with the fixed cwa.

Before this change the template ran the hub in compatibility mode, with protocol_version_compatibility 8 and the deprecated_topic and deprecated_claim build tags. That mode is gone, along with the tags.

The bundle sets a cookie that lists the topics a visitor may receive privately. It sets it at sign-in, on a JWT refresh and on /me, including the 401 a signed-out visitor gets there. The cookie name comes from the hub: with Mercure 1.0 it is __Secure-mercure_access_token, which replaces mercureAuthorization. Browsers only accept a __Secure- cookie over HTTPS. If your hub's public_url is plain http://, symfony/mercure throws when it builds the cookie. In that case, set the same prefix-less name on both sides, for local development only:

config/packages/mercure.yaml
mercure:
    hubs:
        default:
            cookie_name: 'mercure_access_token'
api/frankenphp/Caddyfile
mercure {
    cookie_name mercure_access_token
}

The template's development stack serves the hub at https://localhost/.well-known/mercure, so it needs neither.

The topics in the cookie are URL Patterns: https://www.example.com/_api/_/component_groups/:id\? for published resources, plus …/:id\?draft=1 for users who may see drafts. They're also returned as _metadata.mercureSubscribeTopics on the user from GET /me. Each pattern ends in an explicit query part: a pattern without one would also match ?draft=1 and send draft updates to everyone with the cookie.

Upgrading to bundle 2.0.0-alpha.8: the bundle speaks only Mercure 1.0 (#379), and @cwa/nuxt2.0.0-alpha.4 subscribes with the 1.0 protocol (match=* instead of topic=*, and last_event_id on reconnect, cwa-nuxt-module#364). Upgrade the hub, the bundle and the module together:
  1. Run FrankenPHP 1.13 or later (or a Mercure 1.0 hub).
  2. Require symfony/mercure ^0.8 and symfony/mercure-bundle ^0.5.
  3. In mercure.yaml, set protocol_version: '1.0' and the iss, sub and client_id claims.
  4. In the Caddyfile, move the keys into an issuer block named after iss, set resource_identifier to the public hub URL, and remove protocol_version_compatibility (and the deprecated_topic and deprecated_claim build tags, if you added them).
  5. On a plain-HTTP hub URL, set a prefix-less cookie_name in both mercure.yaml and the Caddyfile.
Anything of your own that read the mercureAuthorization cookie, or mercureSubscribeTopics as URI Templates (…/{id}{._format}), must change too. Signed-in users get the new cookie at their next sign-in or JWT refresh.

Database Setup

The bundle adds tables for layouts, pages, routes, component groups, component positions, site config parameters, uploaded-file info (FileInfo), refresh tokens, and your user and component entities.

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate

Review the generated migration before running it — the initial migration is sizeable.

Environment Variables

Set these in .env (public) and .env.local (secrets):

# Database
DATABASE_URL="postgresql://user:pass@localhost:5432/app?serverVersion=16&charset=utf8"

# JWT auth
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=your_passphrase

# Mercure (real-time updates)
# MERCURE_URL is the internal publish URL — it must be reachable from the PHP container
MERCURE_URL=http://php.local/.well-known/mercure
# MERCURE_PUBLIC_URL is the subscribe URL the browser connects to
MERCURE_PUBLIC_URL=https://yourdomain.com/.well-known/mercure
MERCURE_JWT_SECRET=your_mercure_secret

# Email
MAILER_DSN=smtp://localhost:1025

Verifying the Install

Start your Symfony server and visit the API documentation UI — in the CWA template that is /_api/docs, since API Platform is mounted under the /_api prefix set in config/routes/api_platform.yaml. You should see every available resource listed.

The API entrypoint is the API root itself (/_api/), which returns the IRI of every resource collection. The Nuxt module fetches that entrypoint and discovers the Hydra documentation URL from its Link header.

Create your first admin user — pass --admin so the account has ROLE_ADMIN access:

bin/console silverback:api-components:user:create --admin

Follow the prompts to set username, email, and password. Without the flag the account is created with ROLE_USER only and cannot access the admin panel. Then load any fixtures you've defined:

bin/console doctrine:fixtures:load

What Gets Auto-Registered

You don't need to register these — the bundle provides them out of the box:

ResourceEndpoint prefixPurpose
Layout/_/layoutsOuter page shell (header/footer)
Page/_/pagesIndividual pages with component groups
Route/_/routesURL → page/page data mapping
ComponentGroup/_/component_groupsNamed regions within a layout or page
ComponentPosition/_/component_positionsOrdered slot assignments
Collection/component/collectionsProxy to paginated resource lists
Form/component/formsSymfony form types via API

Core bundle resources are served under the /_/ prefix, components under /component/, and page data under /page_data/. These sit below whatever routing prefix your app mounts API Platform on — /_api in the CWA template, so the full path to layouts is /_api/_/layouts.

Your custom components are registered when you create entity classes extending AbstractComponent.