When you scaffold a project with the API Platform CLI, the Symfony application is built
on top of symfony-docker, which ships
the Caddy web server running FrankenPHP. The
build contains the Mercure and the Vulcain Caddy
modules.
The Caddyfile lives at api/frankenphp/Caddyfile.
By default the API and the Progressive Web App (PWA) are served separately:
https://localhost.--with-pwa, the Next.js application runs standalone with pnpm dev
on http://localhost:3000. It calls the API cross-origin using the NEXT_PUBLIC_API_ENTRYPOINT
value written to pwa/.env.local, and the CLI installs and configures
nelmio/cors-bundle on the API so those
cross-origin requests are allowed.This keeps the two applications independent and requires no Caddy configuration. If you prefer to serve both on the same domain through Caddy — which improves performance by preventing unnecessary CORS preflight requests and encourages embracing the REST principles — see Serving the API and the PWA on the Same Domain below.
Out of the box, api/frankenphp/Caddyfile routes every request to the PHP application. The relevant
part of the site block looks like this:
{$SERVER_NAME:localhost} {
root /app/public
encode zstd br gzip
mercure {
# ...Mercure hub configuration...
}
vulcain
# Extra directives injected by the CLI (see "The Link Header" below)
{$CADDY_SERVER_EXTRA_DIRECTIVES}
@phpRoute {
not path /.well-known/mercure*
not file {path}
}
rewrite @phpRoute index.php
@frontController path index.php
php @frontController {
worker {
file ./public/index.php
}
}
file_server {
hide *.php
}
}Any request that is not an existing static file and is not a Mercure subscription is rewritten to
index.php and handled by Symfony through the FrankenPHP worker.
Link HeaderThe CLI adds a Hydra + Mercure Link header to every response. Rather than editing the Caddyfile
directly, it injects the directive through the CADDY_SERVER_EXTRA_DIRECTIVES environment variable
in api/compose.yaml, inside a recipe block:
###> api-platform/api-platform ###
CADDY_SERVER_EXTRA_DIRECTIVES:
'header ?Link `</docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation",
</.well-known/mercure>; rel="mercure"`'
###< api-platform/api-platform ###The ? prefix means the header is only set when not already present in the response — a PHP
response that sets its own Link header is not overwritten.
Setting it at the Caddy level serves two purposes:
If you want Caddy to serve both the API and the Next.js application on a single domain, you need to forward HTML requests to the PWA and keep API requests on PHP. This is not configured by the CLI; the steps below add it on top of a scaffolded project.
Caddy runs inside the php container, so it must be able to reach the Next.js server. Either:
pwa listening on port 3000 (then the upstream is
pwa:3000), orpnpm dev on the host and target it with host.docker.internal:3000.Declare the upstream as an environment variable for the php service in api/compose.yaml:
services:
php:
environment:
PWA_UPSTREAM: pwa:3000route {} blockCaddy processes directives in a
predefined global order, not in
the order they appear in the Caddyfile. In that order, rewrite runs before reverse_proxy.
Without explicit ordering, a browser request to / would match the @phpRoute rewrite condition
and be rewritten to index.php before Caddy ever evaluated whether the request should be proxied to
Next.js.
Wrapping the directives in a route {} block enforces strict first-match-wins evaluation in file
order. The first directive that matches a request wins, and Caddy stops evaluating the rest. This
makes the @pwa proxy check run before the PHP rewrite. Replace the @phpRoute … file_server
section of the site block with:
route {
# 1. Check @pwa first — proxy to Next.js if matched
reverse_proxy @pwa http://{$PWA_UPSTREAM}
# 2. Only if @pwa did not match, rewrite to index.php
@phpRoute { not path /.well-known/mercure*; not file {path} }
rewrite @phpRoute index.php
# 3. Run PHP for index.php
@frontController path index.php
php @frontController {
worker {
file ./public/index.php
}
}
# 4. Serve remaining static files
file_server { hide *.php }
}@pwa matcherAdd a @pwa named matcher — a
CEL (Common Expression Language) expression
that decides which requests are forwarded to the Next.js application:
@pwa expression `(
header({'Accept': '*text/html*'})
&& !path(
'/docs*', '/graphql*', '/bundles*', '/contexts*', '/_profiler*', '/_wdt*',
'*.json*', '*.html', '*.csv', '*.yml', '*.yaml', '*.xml'
)
)
|| path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*')
|| query({'_rsc': '*'})`The expression has three independent clauses joined by ||. A request matches @pwa if any
clause is true.
A browser navigating to any URL sends Accept: text/html, */*. This clause forwards those requests
to Next.js unless the path is known to be served by the API or carries an extension that API
Platform handles through content negotiation.
Paths excluded from Next.js (handled by PHP instead):
| Pattern | Reason |
|---|---|
/docs* | Swagger UI and OpenAPI documentation |
/graphql* | GraphQL endpoint |
/bundles* | Symfony bundle assets published by assets:install |
/contexts* | JSON-LD context documents |
/_profiler*, /_wdt* | Symfony Web Debug Toolbar and Profiler |
*.json*, *.html, *.csv, *.yml, *.yaml, *.xml | Content-negotiated formats served by the API |
path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*')These paths are forwarded to Next.js unconditionally, regardless of the Accept header. /_next/*
and /__next/* are the internal asset paths used by the Next.js runtime for JavaScript chunks, CSS,
images, and hot module replacement updates in development.
query({'_rsc': '*'})Next.js uses the _rsc query parameter internally for
React Server Components
data fetching. These requests do not carry text/html in their Accept header, so they would miss
clause 1 without this dedicated check.
When the PWA upstream is unreachable, Caddy returns a 502 Bad Gateway for any request matching
@pwa. To temporarily fall back to PHP-rendered HTML, comment out the reverse_proxy @pwa line
inside the route {} block.
The rules below assume you have enabled single-domain serving and therefore have a @pwa matcher to
tweak.
If you use EasyAdmin, SonataAdmin, or a custom Symfony controller that serves HTML pages, add the path prefix to the exclusion list inside clause 1 so those requests bypass Next.js:
@pwa expression `(
header({'Accept': '*text/html*'})
&& !path(
'/admin*',
'/docs*', '/graphql*', '/bundles*', '/contexts*', '/_profiler*', '/_wdt*',
'*.json*', '*.html', '*.csv', '*.yml', '*.yaml', '*.xml'
)
)
|| path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*')
|| query({'_rsc': '*'})`You can use any CEL expression supported by Caddy.
If your API is mounted under a prefix such as /api, add it to the exclusion list:
&& !path(
'/api*',
'/docs*', '/graphql*', ...
)You can also help us improve the documentation of this page.
Using an AI coding agent? See the documentation index for LLMs at /docs/llms.txt.
Made with love by
Les-Tilleuls.coop can help you design and develop your APIs and web projects, and train your teams in API Platform, Symfony, Next.js, Kubernetes and a wide range of other technologies.
Learn more