Skip to content

Commit e37099b

Browse files
committed
move distribution PWA tips to doc
1 parent fab8096 commit e37099b

1 file changed

Lines changed: 159 additions & 23 deletions

File tree

symfony/caddy.md

Lines changed: 159 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,172 @@
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).
8+
It routes requests 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 [predefined global order](https://caddyserver.com/docs/caddyfile/directives#directive-order),
16+
not in the order they appear in the Caddyfile. In that order, `rewrite` runs **before** `reverse_proxy`. Without
17+
explicit ordering, a browser request to `/` would match the `@phpRoute` rewrite condition and be rewritten to
18+
`index.php` before Caddy ever evaluated whether the request should be proxied to Next.js.
2219

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.
20+
Wrapping the directives in a `route {}` block enforces **strict first-match-wins evaluation in file order**. The first
21+
directive that matches a request wins, and Caddy stops evaluating the rest. This is what makes the `@pwa` proxy check
22+
run before the PHP rewrite:
2723

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

0 commit comments

Comments
 (0)