Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Device Detector Laravel Package

matomo/device-detector

Universal device detection library that parses User Agents and Browser Client Hints to identify devices (desktop, tablet, mobile, TV, console), browsers and other clients, operating systems, brands, and models.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and confirm you actually need it. composer require matomo/device-detector has no Laravel service provider, no config file, and no facade — it's a plain library. That's fine: it means you wire it once and forget it. First look at DeviceDetector\DeviceDetector, DeviceDetector\ClientHints, and DeviceDetector\Cache\CacheInterface; those are the only three things you'll touch day-to-day.

The 30-second smoke test (tinker or a one-off route) — note that parse() is mandatory before any getter:

use DeviceDetector\DeviceDetector;

$dd = new DeviceDetector(request()->userAgent() ?? '');
$dd->parse();

dump($dd->isBot(), $dd->getDeviceName(), $dd->getOs('name'), $dd->getClient('name'));

Where the real value lands for a Laravel app: the very first useful case is almost always request classification, not pixel-perfect model detection. Typical first ticket: "serve the mobile layout / redirect to the mobile app deep link / stop counting bot traffic in our funnel." All three are a single parse() call plus isSmartphone(), isTablet(), or isBot().

Wire it once in a service provider. Version truncation is global static state, so set it at boot, not per request:

namespace App\Providers;

use DeviceDetector\Cache\CacheInterface;
use DeviceDetector\Cache\PSR16Bridge;
use DeviceDetector\Parser\Device\AbstractDeviceParser;
use Illuminate\Support\ServiceProvider;

class DeviceDetectorServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Default truncates to X.Y — turn it off if you gate features on exact versions
        AbstractDeviceParser::setVersionTruncation(AbstractDeviceParser::VERSION_TRUNCATION_NONE);

        // Laravel's cache repository already implements PSR-16, so the bridge is enough.
        $this->app->singleton(CacheInterface::class, fn () => new PSR16Bridge(
            cache()->store(config('services.device_detector.store', 'redis'))
        ));
    }
}

The package also ships DeviceDetector\Cache\LaravelCache, which uses Laravel's cache out of the box. Prefer the PSR16Bridge version above because you control which store it hits — and you almost certainly want Redis/APCu rather than file or array.

Implementation Patterns

Resolve once per request, share everywhere. Bind a scoped detector so a request parses a UA exactly once, even if five places ask for it:

// App\Support\DeviceDetectorFactory
public function __construct(private CacheInterface $cache) {}

public function forRequest(Request $request): DeviceDetector
{
    $dd = new DeviceDetector(
        (string) $request->userAgent(),
        ClientHints::factory($request->server->all()) // Symfony server bag == $_SERVER shape
    );
    $dd->setCache($this->cache);
    $dd->parse();

    return $dd;
}

// In the provider's register()
$this->app->scoped(DeviceDetector::class, fn ($app) => $app
    ->make(DeviceDetectorFactory::class)
    ->forRequest($app['request']));

Now app(DeviceDetector::class) works in controllers, jobs, listeners, and Blade. scoped() (not singleton()) matters if you run Octane — the binding is flushed between requests, which is exactly what you want.

Middleware + request attributes is the cleanest way to avoid container noise in views:

public function handle($request, Closure $next)
{
    $request->attributes->set('device', app(DeviceDetector::class));

    return $next($request);
}

Then add a Blade conditional once in a service provider:

Blade::if('mobile', fn () => request()->attributes->get('device')?->isSmartphone() ?? false);
Blade::if('desktop', fn () => request()->attributes->get('device')?->getDeviceName() === 'desktop');

Persist the classification, not the UA only. In analytics/audit tables, store both the raw UA (truncated) and the derived columns — derived columns are what you'll actually group by:

$table->string('user_agent', 512)->nullable();
$table->string('device_type', 32)->nullable()->index();
$table->string('os_name', 64)->nullable();
$table->string('os_version', 32)->nullable();
$table->string('client_name', 64)->nullable();
$table->string('client_version', 32)->nullable();
$table->string('brand', 64)->nullable();
$table->string('model', 128)->nullable();
$table->boolean('is_bot')->default(false)->index();
$table->string('bot_name', 128)->nullable();

Because you stored the UA, you can backfill after a package upgrade with a queued job instead of losing history — a very real reason to keep the raw string.

Bot gating before anything else. Bots short-circuit meaning: don't read client/OS/device info as if it were a human:

$dd = app(DeviceDetector::class);

if ($dd->isBot()) {
    $bot = $dd->getBot();          // ['name' => ..., 'category' => ..., ...]
    Log::channel('bots')->info($bot['name'] ?? 'unknown', ['ua' => request()->userAgent()]);

    return $next($request);        // or abort(403) for scraping endpoints
}

If you only need the boolean, call $dd->discardBotInformation() before parse() — it skips building the detail array and is measurably cheaper on high-traffic paths.

Group with the family helpers rather than inventing your own string matching. These are the difference between a dashboard with 400 "browsers" and one with 6:

use DeviceDetector\Parser\Client\Browser;
use DeviceDetector\Parser\OperatingSystem;

Browser::getBrowserFamily($dd->getClient('name'));   // "Chrome", "Firefox", "Safari"...
OperatingSystem::getOsFamily($dd->getOs('name'));    // "Android", "iOS", "Windows"...

Use narrow parsers when you only need one answer. A rate-limiter or a form-spam guard that only cares about bots shouldn't pay for full device parsing:

use DeviceDetector\Parser\Bot as BotParser;

$botParser = new BotParser();
$botParser->setUserAgent((string) $request->userAgent());
$botParser->discardDetails();

if ($botParser->parse() !== null) {
    // bot — bail out cheaply
}

Same trick with DeviceDetector\Parser\Client\Browser when you only need browser family for a "your browser is unsupported" banner.

Feature gating by device type in a policy or helper rather than scattered in views:

use DeviceDetector\Parser\Device\AbstractDeviceParser as Device;

public function isHandheld(DeviceDetector $dd): bool
{
    return in_array($dd->getDevice(), [
        Device::DEVICE_TYPE_SMARTPHONE,
        Device::DEVICE_TYPE_PHABLET,
        Device::DEVICE_TYPE_TABLET,
        Device::DEVICE_TYPE_FEATURE_PHONE,
    ], true);
}

Testing — Laravel's test client sends its own default user agent if you don't override it, so be explicit, and remember that client hints are absent in $this->get() calls:

$this->withHeader('User-Agent', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1')
     ->get('/pricing')
     ->assertSee('Download the app');

For unit tests of your own logic, don't hit the real detector — bind a stub or a pre-parsed fixture into the container so tests stay fast and don't depend on package regex data.

Gotchas and Tips

parse() is not implied. Forget it and every getter silently returns null. No exception, no warning — just an empty dashboard. If device data "isn't working," check this first.

The default cache is an in-process array. Without setCache(), every request re-parses the YAML regex files from disk, and that is the single biggest performance mistake people make with this package. On a busy Laravel app, wire a persistent cache in the service provider and verify it works (redis-cli keys '*device*'). Avoid the array and file stores for this. Avoid the database store too — you don't want cache queries on every request.

Flush the cache after composer update. The regex data lives in the package's YAML files; the cache stores the parsed forms under stable keys. A package bump plus a stale cache can leave you on old detection rules. Add cache flushing (or a key namespace bump) to your deploy script when this package changes.

Client hints are the future and are HTTPS-only. Chrome/Android UA reduction means getModel() and granular versions are increasingly unavailable from the UA string alone. Hints only arrive if your server asks for them, which means an Accept-CH response header — usually a middleware:

$response = $next($request);
$response->headers->set('Accept-CH', 'Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform-Version, Sec-CH-UA-Model');
$response->headers->set('Vary', 'Sec-CH-UA, Sec-CH-UA-Platform, Sec-CH-UA-Model');

return $response;

Reality check: hints vanish behind proxies/CDNs that strip unknown headers, and Sec-CH-UA-Model is mostly only sent on Android. Treat model detection as best-effort, never as a hard dependency.

UA is attacker-controlled. Truncate before you store it (Str::limit($ua, 512, '')) and never use device detection for authorization, entitlement, or fraud decisions. isSmartphone() is a hint for presentation, not a security boundary.

Global static version truncation is sticky. AbstractDeviceParser::setVersionTruncation() affects every consumer in the process, including other packages that use device-detector. Set it once at boot; don't toggle it inside a controller "just for this endpoint." Client versions are governed by the client parser base class, so if you need exact browser versions, set that one too.

Getters return null for unknown UAs. getModel() is empty for nearly all desktops and getBrandName() is frequently null. Always null-coalesce: $dd->getOs('name') ?? 'Unknown'. Don't feed raw getter output into a NOT NULL column without a default.

getBrand() and getBrandName() are different. The former returns a short code ('AP'), the latter a display name ('Apple'). Store the name for reporting; store the code if you're matching against the Matomo icon packs.

Gate on isBot() first, always. Bot UAs often still resolve to something for client/OS, which makes downstream charts look bizarre. And skip detection entirely for obvious cases (health checks, curl, your own uptime monitor) before you even construct the object.

Octane and long-lived queue workers. The regexes cached in the detector instance are fine, but never bind DeviceDetector as a singleton() — that pins the first request's UA for the life of the worker. Use scoped(). Same discipline applies in queue jobs: construct with the UA you stored, not with request().

Pin and upgrade deliberately. The regex database is the product. There's no artisan command and no data migration — new device support arrives with new releases. A monthly composer update matomo/device-detector plus a cache flush is the whole maintenance story, and it's worth a calendar reminder: frozen UA strings mean last year's regex set is quietly wrong about this year's phones.

One parse() per request, one place. If you catch yourself constructing DeviceDetector inside a Blade partial or an Eloquent accessor, stop — you'll parse the same UA dozens of times per request and wonder why pages got slower. Resolve it once via the container and pass it around.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
marcelgwerder/laravel-api-handler
firebrikdotcom/conveyor-laravel-broadcaster
kanata-php/conveyor-laravel-broadcaster
alish/short-message
sonrac/lumen-league-oauth2
pushery/sanctum-hardening-for-laravel
evanschleret/laravel-uuidable
yigit-serin/laravel-openvidu
adachsoft/command-executor-lib
tagixo/document-builder
tagixo/page-builder
tagixo/mail-builder
tagixo/form-builder
primix/primix
tagixo/core
alesima/laravel-azure-service-bus
tomshaw/shopcart
likyly/laravel
darthsoup/shoppingcart
tdt/core