zenstruck/mobile-bundle
Detects mobile browsers in Symfony and automatically switches to mobile-optimized templates, with device detection, view switching, and a simple integration for serving mobile users a tailored experience.
Read this first: this is an archived Symfony 2 bundle (last release December 2013), not a Laravel package. There is no service provider, no facade, no config/mobile.php. You cannot composer require it into a Laravel app — its composer.json pins symfony/symfony ~2.x. The only two legitimate reasons to touch it are (a) you inherited a Symfony 2 application that already depends on it, or (b) you want to copy its ~200 lines of request-listener logic into a Laravel middleware.
Minimal steps for a legacy Symfony 2 app:
composer.json: add "zenstruck/mobile-bundle": "dev-master" (the 2013 tag is the last one; expect PHP 5.4-era code).app/AppKernel.php:
new Zenstruck\Bundle\MobileBundle\ZenstruckMobileBundle(),
app/config/routing.yml).app/config/config.yml. Confirm the exact key by opening DependencyInjection/Configuration.php in the bundle — it will be zenstruck_mobile: with a small tree (enabled flag, detector/service override, and likely a redirect or template-suffix option).Where to look first, in this order:
DependencyInjection/Configuration.php — the entire public API surface is whatever is in this tree.EventListener/ or EventSubscriber/ directory — this is the actual behavior. It almost certainly hooks kernel.request and sets $request->setRequestFormat('mobile') or issues a redirect.Resources/config/services.xml — tells you the service IDs you can inject or override (zenstruck_mobile.detector or similar).First use case to get working: serve a mobile template variant without changing any controller. With Symfony's format-based view guessing, once the request format is mobile, AcmeBundle:Page:index.html.twig resolves to index.mobile.twig. Drop that file next to the desktop one, hit the page with a mobile UA, and confirm you get the mobile file.
curl -H "User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X)" \
http://legacy-app.local/
If the redirect/format behavior doesn't fire, the bundle is probably disabled by default or the listener priority is being beaten by another kernel.request listener.
Format switching (the least invasive pattern). Let the listener set the request format to mobile, and rely on Symfony's <name>.<format>.<engine> template resolution. Your controllers stay untouched. This is the pattern to prefer because it's reversible — remove the bundle and the app falls back to desktop templates with no code changes.
Redirect to a mobile host or path prefix. If the bundle exposes a redirect option, the flow is: detect UA → issue RedirectResponse to m.example.com or /m/.... Use this when the mobile site is a genuinely separate front controller, not just different templates. Guard it with an exclusion list so the mobile host doesn't redirect to itself.
Opt-out override that survives. A "View full site" link is table stakes. The reliable implementation:
// in a small controller action, not in the bundle
$request->getSession()->set('mobile_opt_out', true);
// redirect back to the desktop URL
and then in your own kernel.request listener registered after the bundle's (lower priority number runs later) — or simply by decorating the bundle's detector service — force the format back to html when the session flag is set. Don't rely on the bundle to know about opt-out; it predates that concern.
Decorating the detector instead of forking. If the bundle defines a MobileDetectorInterface, override the service in config.yml:
parameters:
zenstruck_mobile.detector.class: Acme\Mobile\MyDetector
Wrap the original and add your rules (tablet handling, bot exclusion, opt-out cookie). This is the extension point that keeps the archived code untouched and your logic testable.
Caching integration. Anywhere the detected variant influences the response, you must emit Vary: User-Agent. Without it, Symfony's HTTP cache, Varnish, or Cloudflare will serve mobile HTML to desktop users and vice versa. If you're on FOSHttpCache, add the User-Agent header to the hash policy.
Functional testing. In Symfony 2 tests, this is just a request header:
$client->request('GET', '/', [], [], [
'HTTP_USER_AGENT' => 'Mozilla/5.0 (Android 4.4; Mobile)',
]);
$this->assertTrue($client->getResponse()->headers->has('Vary'));
Write one test per UA class (iPhone, iPad, Android phone, Android tablet, desktop, Googlebot) and assert both the format/render and the Vary header. UA detection is the part that silently rots.
Porting the idea to Laravel. This is what a Laravel developer actually does with this package. A DetectMobile middleware plus a view namespace:
// app/Http/Middleware/DetectMobile.php
public function handle($request, Closure $next)
{
$mobile = $this->detector->isMobile($request->userAgent())
&& ! $request->session()->get('mobile_opt_out');
$request->attributes->set('is_mobile', $mobile);
View::addNamespace('mobile', resource_path('views/mobile'));
$response = $next($request);
$response->headers->set('Vary', 'User-Agent', false);
return $response;
}
Then resolve mobile::pages.index vs pages.index in a view composer or a View::exists() check. That is the entire value proposition of the bundle, and it's about 40 lines.
Archived means the dependency tree is hostile. The bundle's composer.json references symfony/symfony ~2.2 or similar. On any modern PHP (8.x) the code will fatal on things like each(), dynamic properties, or Twig_Extension base classes. If you must run it, pin the whole app to PHP 5.6/7.0 and treat the container as frozen. Do not attempt to "upgrade" the bundle in place — port the listener.
No composer require in Laravel, and no bridge. There is no zenstruck/mobile-bundle-for-Laravel, no laravel/legacy-mobile shim, and zero dependents on Packagist. Any tutorial implying otherwise is wrong. The porting path above is the realistic one.
UA sniffing is unreliable by construction. The bundle predates Client Hints (Sec-CH-UA-Mobile), so it likely delegates to a UA regex library of that era. iPads report as Macintosh on iPadOS 13+; Chrome on Android can request the desktop site and lie about its UA. Don't use the detector for anything security- or pricing-relevant — only for presentation.
Redirect loops are the #1 production failure. If the detector doesn't exclude the mobile host/path, m.example.com detects a mobile UA, redirects to m.example.com, forever. Any redirect configuration needs an explicit "am I already on the mobile endpoint" check, and the mobile endpoint must never run the redirect listener.
Vary: User-Agent destroys cache hit rate. It's necessary but it fragments your cache by UA string unless you normalize to mobile/desktop first. The correct order: detect → normalize to a short key → set Vary: User-Agent → let the cache key on the normalized value. If you only do the Vary header and not the normalization, expect a near-zero hit ratio.
SEO: emit the canonical pair. Desktop page links <link rel="alternate" media="only screen and (max-width: 640px)" href="...m...">; mobile page links <link rel="canonical" href="...desktop...">. This is not optional and the bundle will not do it for you — it has no concept of SEO metadata.
Debugging checklist for Symfony 2. php app/console container:debug zenstruck to confirm the service is registered; php app/console debug:event-dispatcher kernel.request to see whether the listener is attached and at what priority; php app/console router:debug if a redirect route is missing. If the format isn't switching, check whether a later kernel.request listener (security, locale) is resetting it.
Extension points to prefer over forks. In order of safety: (1) override the detector service class via a parameter, (2) decorate the detector service, (3) add your own listener at a lower priority to post-process the format. Never edit vendor/ — the bundle is unmaintained, so your patch will be lost on the next composer update and there will be no upstream to send it to.
Treat the config tree as the docs. There is no maintained documentation site for a 13-star, decade-old bundle. DependencyInjection/Configuration.php plus the bundle's README (frozen at 2013) is the complete reference. Budget an hour to read the source before wiring anything.
How can I help you explore Laravel packages today?