Skip to content

Commit 9e23105

Browse files
authored
move distribution PWA tips to doc (#2255)
1 parent fab8096 commit 9e23105

File tree

1 file changed

+168
-23
lines changed

1 file changed

+168
-23
lines changed

symfony/caddy.md

Lines changed: 168 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,181 @@
44
[the Caddy web server](https://caddyserver.com). The build contains the
55
[Mercure](../core/mercure.md) and the [Vulcain](https://vulcain.rocks) Caddy modules.
66

7-
Caddy is positioned in front of the web API and of the Progressive Web App. It routes requests to
8-
either service depending on the value of the `Accept` HTTP header or the extension of the requested
9-
file.
7+
Caddy is positioned in front of the web API and of the Progressive Web App (PWA). It routes requests
8+
to either service depending on the value of the `Accept` HTTP header or the path of the request.
109

1110
Using the same domain to serve the API and the PWA
1211
[improves performance by preventing unnecessary CORS preflight requests and encourages embracing the REST principles](https://dunglas.fr/2022/01/preventing-cors-preflight-requests-using-content-negotiation/).
1312

14-
By default, requests having an `Accept` request header containing the `text/html` media type are
15-
routed to the Next.js application, except for some paths known to be resources served by the API
16-
(e.g. the Swagger UI documentation, static files provided by bundles...). Other requests are routed
17-
to the API.
13+
## Why `route {}` Is Required
1814

19-
Sometimes, you may want to let the PHP application generate HTML responses. For instance, when you
20-
create your own Symfony controllers serving HTML pages, or when using bundles such as EasyAdmin or
21-
SonataAdmin.
15+
Caddy processes directives in a
16+
[predefined global order](https://caddyserver.com/docs/caddyfile/directives#directive-order), not in
17+
the order they appear in the Caddyfile. In that order, `rewrite` runs **before** `reverse_proxy`.
18+
Without explicit ordering, a browser request to `/` would match the `@phpRoute` rewrite condition
19+
and be rewritten to `index.php` before Caddy ever evaluated whether the request should be proxied to
20+
Next.js.
2221

23-
To do so, you have to tweak the rules used to route the requests. Open
24-
`api-platform/api/frankenphp/Caddyfile` and modify the expression. You can use
25-
[any CEL (Common Expression Language) expression](https://caddyserver.com/docs/caddyfile/matchers#expression)
26-
supported by Caddy.
22+
Wrapping the directives in a `route {}` block enforces **strict first-match-wins evaluation in file
23+
order**. The first directive that matches a request wins, and Caddy stops evaluating the rest. This
24+
is what makes the `@pwa` proxy check run before the PHP rewrite:
25+
26+
```caddy
27+
route {
28+
# 1. Check @pwa first — proxy to Next.js if matched
29+
reverse_proxy @pwa http://{$PWA_UPSTREAM}
30+
31+
# 2. Only if @pwa did not match, rewrite to index.php
32+
@phpRoute { not path /.well-known/mercure*; not file {path} }
33+
rewrite @phpRoute index.php
34+
35+
# 3. Run PHP for index.php
36+
@frontController path index.php
37+
php @frontController
38+
39+
# 4. Serve remaining static files
40+
file_server { hide *.php }
41+
}
42+
```
2743

28-
For instance, if you want to route all requests to a path starting with `/admin` to the API, modify
29-
the existing expression like this:
44+
## The `@pwa` Matcher
3045

31-
```patch
32-
# Matches requests for HTML documents, for static files and for Next.js files,
33-
# except for known API paths and paths with extensions handled by API Platform
46+
The `@pwa` named matcher is a
47+
[CEL (Common Expression Language) expression](https://caddyserver.com/docs/caddyfile/matchers#expression)
48+
that decides which requests are forwarded to the Next.js application:
49+
50+
```caddy
3451
@pwa expression `(
35-
{header.Accept}.matches("\\btext/html\\b")
36-
- && !{path}.matches("(?i)(?:^/docs|^/graphql|^/bundles/|^/_profiler|^/_wdt|\\.(?:json|html$|csv$|ya?ml$|xml$))")
37-
+ && !{path}.matches("(?i)(?:^/admin|^/docs|^/graphql|^/bundles/|^/_profiler|^/_wdt|\\.(?:json|html$|csv$|ya?ml$|xml$))")
38-
)`
52+
header({'Accept': '*text/html*'})
53+
&& !path(
54+
'/docs*', '/graphql*', '/bundles*', '/contexts*', '/_profiler*', '/_wdt*',
55+
'*.json*', '*.html', '*.csv', '*.yml', '*.yaml', '*.xml'
56+
)
57+
)
58+
|| path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*')
59+
|| query({'_rsc': '*'})`
60+
```
61+
62+
The expression has three independent clauses joined by `||`. A request matches `@pwa` if **any**
63+
clause is true.
64+
65+
### Clause 1: HTML requests that are not API paths
66+
67+
A browser navigating to any URL sends `Accept: text/html, */*`. This clause forwards those requests
68+
to Next.js unless the path is known to be served by the API or carries an extension that API
69+
Platform handles through [content negotiation](../core/content-negotiation.md).
70+
71+
Paths excluded from Next.js (handled by PHP instead):
72+
73+
| Pattern | Reason |
74+
| -------------------------------------------------------- | --------------------------------------------------- |
75+
| `/docs*` | Swagger UI and OpenAPI documentation |
76+
| `/graphql*` | GraphQL endpoint |
77+
| `/bundles*` | Symfony bundle assets published by `assets:install` |
78+
| `/contexts*` | JSON-LD context documents |
79+
| `/_profiler*`, `/_wdt*` | Symfony Web Debug Toolbar and Profiler |
80+
| `*.json*`, `*.html`, `*.csv`, `*.yml`, `*.yaml`, `*.xml` | Content-negotiated formats served by the API |
81+
82+
### Clause 2: Next.js static assets and well-known files
83+
84+
```caddy
85+
path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*')
86+
```
87+
88+
These paths are forwarded to Next.js unconditionally, regardless of the `Accept` header. `/_next/*`
89+
and `/__next/*` are the internal asset paths used by the Next.js runtime for JavaScript chunks, CSS,
90+
images, and hot module replacement updates in development.
91+
92+
### Clause 3: React Server Components requests
93+
94+
```caddy
95+
query({'_rsc': '*'})
96+
```
97+
98+
Next.js uses the `_rsc` query parameter internally for
99+
[React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components)
100+
data fetching. These requests do not carry `text/html` in their `Accept` header, so they would miss
101+
clause 1 without this dedicated check.
102+
103+
## The `Link` Header
104+
105+
```caddy
106+
header ?Link `</docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation", </.well-known/mercure>; rel="mercure"`
107+
```
108+
109+
This directive is placed at the **site block level**, outside the `route {}` block, so it applies to
110+
every response regardless of whether it came from PHP or Next.js. The `?` prefix means the header is
111+
only set when not already present in the response — a PHP response that sets its own `Link` header
112+
is not overwritten.
113+
114+
Setting this at the Caddy level serves two purposes:
115+
116+
1. **API discoverability**: every response advertises the Hydra API documentation URL, allowing
117+
clients to auto-discover the API.
118+
2. **Mercure subscription**: every response advertises the Mercure hub URL, so clients can subscribe
119+
to real-time updates without any application code.
120+
121+
The Next.js application does not need to set these headers itself — they arrive on every response
122+
automatically.
123+
124+
## The `PWA_UPSTREAM` Environment Variable
125+
126+
```caddy
127+
reverse_proxy @pwa http://{$PWA_UPSTREAM}
128+
```
129+
130+
`PWA_UPSTREAM` is resolved at runtime from the container environment. In `compose.yaml` it is set to
131+
`pwa:3000`, where `pwa` is the Docker Compose service name and `3000` is the default port of the
132+
Next.js server.
133+
134+
When the `pwa` service is not running (for example in an API-only project), Caddy returns a
135+
`502 Bad Gateway` for any request matching `@pwa`. To run without a Next.js frontend, comment out
136+
that line in the Caddyfile:
137+
138+
```caddy
139+
route {
140+
# Comment the following line if you don't want Next.js to catch requests for HTML documents.
141+
# In this case, they will be handled by the PHP app.
142+
# reverse_proxy @pwa http://{$PWA_UPSTREAM}
143+
144+
@phpRoute { not path /.well-known/mercure*; not file {path} }
145+
rewrite @phpRoute index.php
146+
@frontController path index.php
147+
php @frontController
148+
file_server { hide *.php }
149+
}
150+
```
151+
152+
## Adjusting the Routing Rules
153+
154+
### Routing an admin path to PHP
155+
156+
If you use EasyAdmin, SonataAdmin, or a custom Symfony controller that serves HTML pages, add the
157+
path prefix to the exclusion list inside clause 1 so those requests bypass Next.js:
158+
159+
```caddy
160+
@pwa expression `(
161+
header({'Accept': '*text/html*'})
162+
&& !path(
163+
'/admin*',
164+
'/docs*', '/graphql*', '/bundles*', '/contexts*', '/_profiler*', '/_wdt*',
165+
'*.json*', '*.html', '*.csv', '*.yml', '*.yaml', '*.xml'
166+
)
167+
)
168+
|| path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*')
169+
|| query({'_rsc': '*'})`
170+
```
171+
172+
You can use [any CEL expression](https://caddyserver.com/docs/caddyfile/matchers#expression)
173+
supported by Caddy.
174+
175+
### Adding a custom API prefix
176+
177+
If your API is mounted under a prefix such as `/api`, add it to the exclusion list:
178+
179+
```caddy
180+
&& !path(
181+
'/api*',
182+
'/docs*', '/graphql*', ...
183+
)
39184
```

0 commit comments

Comments
 (0)