Reading the User-Agent header in PHP to decide what a Drupal page looks like is the wrong instinct, and the reason is not purity — it is that Drupal's anonymous page cache keys on the URL alone, so the first visitor's device decides what every later visitor sees.
It is an understandable instinct. You have a menu that should behave differently on a phone, someone suggests detecting the device on the server and rendering the right one, and it sounds tidier than shipping both and hiding one. It is tidier. It is also a cache poisoning bug with extra steps.
Everything below was read from Drupal core 11.4.6 on disk, and from a theme I maintain, rather than recalled.
Core does not look at the User-Agent header at all
I searched core/lib and core/modules for HTTP_USER_AGENT, getUserAgent, Sec-CH-UA and Accept-CH, excluding test directories. The search returns exactly one line, and it is not a rendering decision: FunctionalTestSetupTrait.php line 656 sets $_SERVER['HTTP_USER_AGENT'] to the string 'Drupal command line' so the test bootstrap can identify itself.
That is the whole of it. No block, no formatter, no theme negotiator and no cache layer in core asks what browser you are using. When a framework with this much machinery deliberately declines to use a signal that is sitting in every request, it is worth asking why before adding it back.
The cache is the reason, not the sniffing
The anonymous page cache keys on the URL and nothing else
Look at core/modules/page_cache/src/StackMiddleware/PageCache.php. Its getCacheId() method, at lines 363 to 378, builds the key from two parts: the scheme and host concatenated with the request URI, and the request format. Both get() and set() call it. There is no cache context in that key, and no header.
Follow that through. An anonymous phone user requests /, your code sniffs the User-Agent and renders the phone variant, and the middleware stores that response under a key that says nothing about phones. The next anonymous visitor arrives on a 27-inch monitor, the key matches, and they are served the phone page. It will look intermittent, it will not reproduce while you are logged in, and it will be blamed on the CDN.
Vary: Cookie is the only variation Drupal declares
FinishResponseSubscriber.php is where outgoing cache headers are set. For a cacheable response, line 273 calls setVary('Cookie', FALSE) unless a Vary header is already present or the omit_vary_cookie setting is on. For a response that is not cacheable, line 233 removes the Vary header outright.
So Drupal tells shared caches that a response depends on the cookie, and on nothing else. Every proxy and CDN in front of your site is therefore entitled to reuse one stored copy of a URL for all devices. If your markup varies by device and your headers do not say so, the edge is not misbehaving — it is doing exactly what you told it.
What to reach for instead, in order
The useful reframing is this: stop asking what device is this and start asking where does this decision have to be made. Anything the browser can decide for itself costs you nothing in cache variations, because the response is identical for everyone.
| Technique | Decided by | Survives the anonymous page cache | How it goes wrong |
|---|---|---|---|
| User-Agent parsing in PHP | your code, at render time | No | one visitor's device decides the stored page for everyone |
| CSS media query | the browser | Yes | it measures the window, never the container the block sits in |
| Feature test in JavaScript | the browser | Yes | needs a real fallback, not a redirect |
| Responsive image style | the browser, from srcset | Yes | a careless sizes value fetches the wrong file |
| Header rule at the CDN | the edge, before Drupal boots | Not applicable | you must declare the Vary and pay for it in hit rate |
Three of those five are free. That is the argument in one line.
A worked example from a theme I maintain: Solo is a large multi-region theme, and it has no server-side device detection anywhere in its PHP, its .theme file or its Twig templates — I grepped for it and the result is empty. The only navigator.userAgent reference in the entire theme is in js/debug/solo-menu-diagnostic.js line 56, a diagnostic script that reports the string when you are chasing a bug. It is used for describing a problem, never for building a page. Where the layout genuinely depends on how wide a container is rather than how wide the window is, the answer is to measure the container — small screen sizes in a Drupal theme goes through that properly.
And if you are writing the JavaScript that does the measuring, scope it to the element rather than the document; scoped versus global selection is the difference between a behaviour that survives an AJAX rebuild and one that does not. When the decision turns out to be architectural rather than a stylesheet fix, that is what a Drupal developer is for.
The one case where the server is still the right place
There is one, and it has a shape you can recognise: the decision is not about presentation, and it must happen before the page is built. Blocking an abusive client, routing a "download the app" link to the right store, or recording the client string for analytics are all legitimate server-side uses of the header. None of them changes the HTML that gets cached.
If you truly must vary the markup, Drupal gives you the tool and makes you pay for it honestly. HeadersCacheContext, in core/lib/Drupal/Core/Cache/Context/, is registered as cache_context.headers in core.services.yml and supports a calculated form — headers:User-Agent varies a render array by that header's exact value.
Two things to accept before you use it. First, every distinct User-Agent string is its own cache entry, and there are more of them than you think; this is a render-cache variation count that grows with your traffic, not with your design. Second, it does not fix the page cache, because that layer never looks at contexts — you must either bypass it for those routes or put a CDN in front that varies on the same header. If the edge is doing that work, how long edge TTLs and purging fit together is the companion piece.
Common questions
What about client hints instead of the User-Agent string?
Client hints are the modern, negotiated version of the same idea, and they are still a request header that your response now depends on. Everything above about cache keys and Vary applies unchanged. They are cleaner to parse; they do not make device-dependent markup cheaper to cache.
We only need to detect iOS for one small thing. Is that fine?
If the one small thing is behaviour rather than markup, do it in JavaScript and nothing is cached wrongly. If it changes the HTML, the size of the change is irrelevant — one wrong element in a cached page is still a wrong cached page.
Is a separate mobile theme ever the answer?
Rarely now, and never for layout alone. A theme negotiator that switches on a request header has the same cache problem as sniffing in a preprocess hook, plus two sets of templates to keep in step. Responsive markup is one template and one cache entry.
How do I prove this is happening on my site?
Request the same URL twice as an anonymous client with two different User-Agent strings and compare the bodies byte for byte. If the second response matches the first when it should not, you have it. Do it against the live edge as well as the origin; the two layers fail at different points.
Where to go next
Search your codebase for HTTP_USER_AGENT and for userAgent in PHP and Twig. If the count is zero, you are in good shape. If it is not, the question to answer for each hit is whether it changes markup that gets cached — and if you would rather have someone else answer that, Drupal consulting covers exactly this kind of review, or just tell me what you found.