piwik/device-detector
Device Detector is a universal library that parses User Agents and Browser Client Hints to detect devices, browsers, operating systems, brands, and models. It identifies desktop, tablet, mobile, TV, car, and console devices plus clients like feed readers and media players.
Install the package — there is no Laravel service provider, it is a plain framework-agnostic PHP library:
composer require matomo/device-detector
A minimal, working call inside a controller or middleware:
use DeviceDetector\ClientHints;
use DeviceDetector\DeviceDetector;
$dd = new DeviceDetector(
$request->userAgent() ?? '',
ClientHints::factory($request->server->all()) // optional but recommended
);
$dd->parse();
if ($dd->isBot()) {
$bot = $dd->getBot(); // ['name' => 'Googlebot', 'category' => 'Search bot', ...]
} else {
$dd->getClient(); // ['type' => 'browser', 'name' => 'Chrome', 'version' => '126.0', 'engine' => 'Blink']
$dd->getOs(); // ['name' => 'Android', 'version' => '14', 'platform' => '']
$dd->getDeviceName(); // int constant, e.g. 1 (smartphone)
$dd->getBrandName(); // 'Samsung'
$dd->getModel(); // 'Galaxy S24'
}
Where to look first:
DeviceDetector\DeviceDetector class itself — the getters and the is*() helpers (isSmartphone(), isTablet(), isTV(), isBrowser(), isMobileApp(), …) are the whole public surface.DeviceDetector\ClientHints — how client hints are read from server vars.regexes/*.yml in the vendor directory — everything is regex-driven YAML; this is also what gets cached.DeviceDetector\Parser\ — you can use individual parsers (Bot, OperatingSystem, Client\Browser, Device\AbstractDeviceParser) standalone when you only need one answer.The highest-value first use case in a Laravel app is a bot filter: parse the UA once early (middleware or a form request), drop/flag known crawlers, and skip the expensive parts of your stack (analytics writes, search indexing, throttling counters). The second is persisting device_type / os / client alongside logins and page views so you can segment later without re-parsing history.
Bind a factory, never the detector itself. DeviceDetector holds per-user-agent state; putting it in the container as a singleton will leak the first request's result into every later request (especially under Octane). Bind a small factory that returns a freshly configured instance:
// app/Support/DeviceDetection/DeviceDetectorFactory.php
final class DeviceDetectorFactory
{
public function __construct(private CacheInterface $cache) {}
public function make(string $userAgent, array $server): DeviceDetector
{
$dd = new DeviceDetector($userAgent, ClientHints::factory($server));
$dd->setCache($this->cache);
$dd->setYamlParser(new \DeviceDetector\Yaml\Symfony()); // if symfony/yaml is installed
return $dd;
}
}
Register it in a service provider and set the global version-truncation policy there once:
public function register(): void
{
AbstractDeviceParser::setVersionTruncation(AbstractDeviceParser::VERSION_TRUNCATION_NONE);
$this->app->singleton(DeviceDetectorFactory::class, fn () => new DeviceDetectorFactory(
new LaravelStoreCache('redis') // see Gotchas for why not always LaravelCache
));
// scoped: one DeviceContext per request/queue job, reset automatically under Octane
$this->app->scoped(DeviceContext::class, fn ($app) => new DeviceContext(
$app->make(DeviceDetectorFactory::class),
$app['request']
));
}
Wrap parsing in a lazy, request-scoped context object. This keeps parsing out of requests that never need it (health checks, asset routes, webhooks):
final class DeviceContext
{
private ?DeviceDetector $dd = null;
public function __construct(private DeviceDetectorFactory $factory, private Request $request) {}
public function detector(): DeviceDetector
{
return $this->dd ??= tap(
$this->factory->make($this->request->userAgent() ?? '', $this->request->server->all()),
fn (DeviceDetector $d) => $d->parse()
);
}
public function isBot(): bool { return $this->detector()->isBot(); }
public function type(): string { return AbstractDeviceParser::getDeviceName($this->detector()->getDeviceName()); }
}
Cache the parsed result per user agent, not just the regexes. The built-in cache layer mainly accelerates loading the YAML regex sets; if you are on the hot path, memoize your normalized array by UA + hints fingerprint:
$fingerprint = md5($ua.'|'.($server['HTTP_SEC_CH_UA'] ?? '').'|'.($server['HTTP_SEC_CH_UA_PLATFORM'] ?? ''));
$result = Cache::store('redis')->remember("dd:v6:$fingerprint", now()->addDays(30), function () use ($ua, $server) {
$dd = $this->factory->make($ua, $server);
$dd->parse();
return [ /* normalized scalars only — do not cache the object */ ];
});
Send the Accept-CH header or client hints will simply never arrive. A global middleware is the right place:
$response->headers->set('Accept-CH', implode(', ', [
'Sec-CH-UA', 'Sec-CH-UA-Mobile', 'Sec-CH-UA-Platform', 'Sec-CH-UA-Platform-Version',
'Sec-CH-UA-Model', 'Sec-CH-UA-Full-Version-List', 'Sec-CH-UA-Arch',
]));
$response->headers->set('Vary', 'Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform', false);
Add Critical-CH with the same list if you need platform version on the very first request. Client hints are only sent on secure origins, so this is a no-op locally over HTTP.
Persist a denormalized snapshot next to the raw UA string. Store device_type, device_brand, device_model, os_name, os_version, client_name, client_version, is_bot, bot_category as plain indexed columns. Normalize the integer constant to a string once:
AbstractDeviceParser::getDeviceName($dd->getDeviceName()); // 1 => 'smartphone'
OperatingSystem::getOsFamily($dd->getOs('name')); // 'Android' => 'Android'
Browser::getBrowserFamily($dd->getClient('name')); // 'Chrome Mobile' => 'Chrome'
The family helpers are what you actually want for reporting — otherwise your "Chrome" segment fragments into dozens of near-duplicate client names.
Use the standalone BotParser for cheap gating. If all you need is "is this a crawler?", skip the full detector (it loads far fewer regex files):
$bot = new \DeviceDetector\Parser\Bot();
$bot->setUserAgent($request->userAgent() ?? '');
$bot->discardDetails(); // returns bool instead of an array; faster
if ($bot->parse()) { abort(403); }
Typical homes for that: public form submissions, registration, outbound-link redirects, sitemap/robots endpoints, and analytics ingestion.
Queue pattern: capture the UA string and the handful of Sec-CH-* server values at request time, push a job, and parse inside the worker. Never re-read $_SERVER in a queued job, and never parse at query time in a report — store the snapshot.
Testing: freeze user agents as data providers and assert on the normalized array, not on the raw detector. Remember $_SERVER is empty in tests, so construct ClientHints from an explicit array:
$dd = new DeviceDetector($ua, ClientHints::factory(['HTTP_SEC_CH_UA_MOBILE' => '?1']));
parse() must be called explicitly. Don't rely on getters triggering detection; call parse() once, immediately after construction.X.Y only. Call AbstractDeviceParser::setVersionTruncation(...) before any parsing (a service provider register() is fine), and be aware it persists across tests in the same process.discardBotInformation() and skipBotDetection() change semantics. The first makes getBot() return true instead of an array; the second makes crawlers look like ordinary devices. Only one of those is what you want — pick deliberately.isBot() must be checked before getClient(); a UA containing "Chrome" can still be a crawler. Never infer bot-ness from a missing/odd client name.'', not null, and treat getClient() === null / getOs() === null as "unknown" rather than an error. Client hints can still identify the platform on modern Chrome even with a stripped UA.Accept-CH (and HTTPS), ClientHints::factory() yields an empty object and Android models / Windows 11 / macOS versions degrade. Also note ClientHints::factory() expects a $_SERVER-shaped array — $request->server->all() works, $request->headers->all() does not.DeviceDetector\Cache\LaravelCache uses your default cache store. If the default is file, you get lock contention and slow reads. Either point the default store at Redis/APCu or implement the small DeviceDetector\Cache\CacheInterface against a named store (Cache::store('redis')) — check the installed interface's exact method signatures before you write it.dd:v6:...) and clear the detector's cache namespace on deploy.DeviceDetector instance or reuse it across requests. It is stateful. Under Octane, scoped bindings are correct; singletons are a silent data-corruption bug.mustangostang/spyc, or — cleaner in a Laravel app — composer require symfony/yaml and call $dd->setYamlParser(new \DeviceDetector\Yaml\Symfony()).getBrandName() / getModel() are frequently empty. Model detection is best-effort and heavily dependent on client hints (Sec-CH-UA-Model). Never build required logic on model being present; always have an "Unknown" bucket.composer update matomo/device-detector (or Dependabot PR) is the single biggest quality lever.$dd->getBot(), getClient(), getOs(), getDeviceName(), getBrandName(), getModel() — plus the raw UA and hints, and keep that dump in your log context when you record a misdetection, so you can file it upstream with a reproducible UA.How can I help you explore Laravel packages today?