sinergi/browser-detector
Lightweight PHP library for detecting browser, operating system, device, and language from user agent strings. Uses targeted regex rules instead of a bloated UA database, with simple detectors and optional Laravel service provider and facade integration.
Install it as a plain Composer dependency — there is no first-party Laravel service provider to publish, so wiring is up to you:
composer require sinergi/browser-detector
Pin ^6.1.4 (or run composer update sinergi/browser-detector on an existing install): that release is the one that teaches the parser about Chromium Edge. On 6.1.3 and earlier an Edge UA can be misclassified, so any stored browser name/version from those versions is not directly comparable with what you'll get now.
Everything you need lives in one namespace with six small classes. Read these first, in this order:
| Class | What it answers |
|---|---|
Sinergi\BrowserDetector\Browser |
name, version, isChrome(), isFirefox(), isSafari(), isIE(), isOpera(), isRobot(); Edge surfaces as getName() === Browser::EDGE |
Sinergi\BrowserDetector\Os |
isWindows(), isMacintosh(), isLinux(), isAndroid(), isIos() |
Sinergi\BrowserDetector\Device |
isMobile(), isTablet(), plus getName() |
Sinergi\BrowserDetector\Language |
two-letter language from the request |
Sinergi\BrowserDetector\AcceptLanguage |
parsed Accept-Language header, ordered by q-value |
Sinergi\BrowserDetector\UserAgent |
the raw UA string wrapper |
Every detector accepts an optional user-agent — either a raw string or a UserAgent instance — and falls back to $_SERVER['HTTP_USER_AGENT'] when you pass nothing. Always pass the UA explicitly in Laravel; don't rely on the superglobal.
The first use case worth building is a tiny request-scoped service so views, middleware, and logs all agree:
// app/Providers/DetectionServiceProvider.php
public function register(): void
{
$this->app->scoped(Browser::class, fn () => new Browser(request()->userAgent()));
$this->app->scoped(Os::class, fn () => new Os(request()->userAgent()));
$this->app->scoped(Device::class, fn () => new Device(request()->userAgent()));
}
$browser = app(Browser::class);
$browser->getName(); // Sinergi\BrowserDetector\Browser::CHROME — or ::EDGE on Chromium Edge
$browser->getVersion(); // "120.0.6099.109"
$browser->isChrome(); // true
Before you build anything on top of it, run one real Edge UA through it (Edg/ on desktop/Android, EdgiOS/ on iOS) and confirm what getName() returns in your installed version — that single check tells you whether the 6.1.4 fix is live.
Middleware that tags the request once. Parse the UA a single time per request and stash a plain array or DTO on the request; every downstream consumer reads that instead of re-instantiating detectors.
class DetectClient
{
public function handle(Request $request, Closure $next): Response
{
$ua = (string) $request->userAgent();
$userAgent = new UserAgent($ua);
$browser = new Browser($userAgent);
$os = new Os($userAgent);
$device = new Device($userAgent);
$request->attributes->set('client', [
'browser' => $browser->getName(),
'browser_version' => $browser->getVersion(),
'os' => $os->getName(),
'os_version' => $os->getVersion(),
'mobile' => $device->isMobile(),
'tablet' => $device->isTablet(),
]);
return $next($request);
}
}
Progressive-enhancement Blade helpers. Wrap the booleans in directives so templates stay readable:
Blade::if('browser', fn (string $name) => app(Browser::class)->getName() === $name);
Blade::if('mobile', fn () => app(Device::class)->isMobile());
@browser(\Sinergi\BrowserDetector\Browser::SAFARI)
<p>Add to Home Screen for the best experience.</p>
@endbrowser
Any directive that branches on a specific browser is a place the Edge change can bite you: a Chromium Edge user is no longer "Chrome" for @browser(...) purposes, so an @browser(Browser::CHROME) block silently stops rendering for them after the upgrade. Prefer capability/intent names (@modernBrowser, @supportsInstallPrompt) over brand names in templates.
Gate an unsupported-browser page. Compare with version_compare() and guard against UNKNOWN first — never assume getVersion() is numeric:
$browser = app(Browser::class);
if ($browser->isIE()
&& $browser->getName() !== Browser::UNKNOWN
&& version_compare((string) $browser->getVersion(), '11', '<')) {
abort(426, 'Please upgrade your browser.');
}
Locale negotiation with AcceptLanguage. Feed it the header explicitly and walk the ordered list against your supported locales, falling back to config('app.fallback_locale'):
$header = $request->header('Accept-Language', '');
$preferred = (new AcceptLanguage($header))->getLanguage(); // ordered by q-value
$locale = collect($preferred)
->map(fn ($l) => substr($l, 0, 2))
->first(fn ($l) => in_array($l, config('app.supported_locales'), true))
?? config('app.fallback_locale');
Analytics and audit logging. Persist browser, browser_version, os, and mobile alongside sessions or login events — but truncate the version to the major number. Storing full versions produces thousands of distinct values and destroys the usefulness of any group-by. Note that a Chromium Edge UA reports the Edge version token (e.g. 120.0.2210.91), not the underlying Chromium build, so Edge and Chrome major numbers drift apart even on the same engine.
Deterministic tests. Constructors take strings, so UA parsing is trivially unit-testable. Use data providers of real UA strings and assert names/versions; never mutate $_SERVER globally in a test suite. Add explicit cases for the shapes you care about most — Windows Edge Chromium, macOS Edge Chromium, Android Edge, iOS Edge — and assert both the name and that isChrome() does not come back true. Those four asserts are your regression net for the 6.1.4 behaviour and for any future UA change.
^6.1.4), treat every result as best-effort, and reach for matomo/device-detector or whichbrowser/php if accuracy matters more than the small footprint.Browser::EDGE instead of falling in with Chrome or coming back UNKNOWN. Anything you keyed off isChrome() — feature flags, CSS classes, bug workarounds, analytics funnels — needs to be re-checked against Edge traffic, and historical reports will show a step change on the upgrade date.Edg/, iOS Edge sends EdgiOS/, and legacy EdgeHTML sends Edge/. Verify each shape you actually receive rather than assuming one fix covers all three — iOS in particular is a common UNKNOWN.Sec-CH-UA headers are invisible to this library. Even with Edge fixed, this remains the main long-term failure mode: the classic UA is being deliberately degraded.Os::OSX / desktop Safari. If tablet-vs-desktop matters, supplement with a feature/JS hint or accept the noise.CriOS, FxiOS, EdgiOS). Verify isChrome()/isFirefox() against a real iOS UA before you build feature gates on them — the underlying engine is always WebKit.UNKNOWN. Queue workers, console commands, health checks, and many tests have no HTTP_USER_AGENT; the fallback to $_SERVER may also emit an undefined-index notice. Always pass the string.singleton() bindings capturing request()->userAgent() will leak the first request's UA into every subsequent request. Use scoped() (as above) or resolve manually inside the middleware.getName() can be UNKNOWN; versions can come back empty or with odd suffixes. Always run version_compare() on a sanitized string, not on raw output.is* checks. A Chrome UA contains Safari; an Edge UA contains Chrome. The library already orders its internal matching correctly — reimplementing that logic in your own regex is exactly where the next Edge-style regression comes from.new Browser() inside a Blade loop is wasteful. The scoped bindings above solve this, and the result can also be cached on the request object.Language vs AcceptLanguage: Language gives you the single top preference, AcceptLanguage gives the ordered list. Use the latter for negotiation, keeping an explicit user-chosen locale in session as the winner over any header-derived guess.Browser (isRobot()) and is regex-based — useful for suppressing analytics pings, not for blocking traffic.How can I help you explore Laravel packages today?