Device Detection in Laravel: Mobile, Tablet, Desktop, and Capacitor with Conservative PHP Heuristics
Introduction
Sometimes responsive CSS is enough. Sometimes it is not.
If your Laravel app needs to hide a desktop-only action, adapt a Filament layout, or distinguish a native Capacitor shell from a regular mobile browser, you need a backend-friendly way to classify the current request.
This article walks through a practical DeviceUtil implementation in PHP that returns one canonical device type:
desktopmobiletabletcapacitor
The key design choice is to stay conservative. As MDN notes in its guide to browser detection using the user agent, user-agent sniffing is fragile, so the safest strategy is to use stronger application signals first and keep UA heuristics restrictive.
When Backend Device Detection Is Worth It
Server-side detection is useful when your backend needs to make a decision before JavaScript runs:
- rendering different navigation or layout blocks
- disabling features that do not make sense inside a native shell
- tagging the HTML document with a stable device type
- exposing convenience helpers to Blade, Livewire, or Filament
It is less useful for purely visual behavior that CSS and client-side feature detection can handle.
TIP
If you control the client, prefer explicit signals over guessing. A custom header or cookie is usually more reliable than a parsed user-agent string.
The Utility Structure
The utility exposes small methods with a single responsibility:
DeviceUtil::isMobile();
DeviceUtil::isTablet();
DeviceUtil::isDesktop();
DeviceUtil::isCapacitor();
DeviceUtil::getDeviceType();It also accepts an optional Request object, which makes testing much easier:
public static function isMobile(?Request $request = null): bool
public static function isTablet(?Request $request = null): bool
public static function isDesktop(?Request $request = null): bool
public static function isCapacitor(?Request $request = null): bool
public static function getDeviceType(?Request $request = null): stringCaching the User-Agent Per Request
If multiple components ask for the device type during the same request, repeatedly reading and parsing the same header is unnecessary. A small static cache keeps the implementation simple:
private static ?string $userAgent = null;
private static function getUserAgent(?Request $request = null): string
{
if ($request !== null) {
return $request->header('User-Agent') ?? '';
}
if (self::$userAgent === null) {
$req = RequestFacade::getFacadeRoot();
self::$userAgent = $req->header('User-Agent') ?? '';
}
return self::$userAgent;
}This pattern gives you two useful properties:
- explicit
Requestinstances bypass the cache, which keeps tests isolated - normal application flow only reads the facade-backed header once
Detecting Mobile and Tablet Requests
The utility uses regular expressions for broad mobile detection and a narrower set for tablets:
public static function isMobile(?Request $request = null): bool
{
$userAgent = self::getUserAgent($request);
if (! $userAgent) {
return false;
}
$mobilePatterns = [
'/iPhone|iPod|iPad|Android|webOS|BlackBerry|Windows Phone|Opera Mini|IEMobile|Mobile/i',
'/\\b(?:a(?:ndroid|vantgo)|b(?:lackberry|olt|o?ost)|cricket|docomo|hiptop|i(?:emobile|p[ao]d)|kitkat|m(?:ini|obi)|palm|(?:i|smart|windows )phone|symbian|up\\.(?:browser|link)|tablet(?: browser| pc)|(?:hp-|rim |sony )tablet|w(?:ebos|indows ce|os))/i',
];
foreach ($mobilePatterns as $pattern) {
if (preg_match($pattern, $userAgent)) {
return true;
}
}
return false;
}
public static function isTablet(?Request $request = null): bool
{
$userAgent = self::getUserAgent($request);
if (! $userAgent) {
return false;
}
$tabletPatterns = [
'/iPad|Android.*Tablet|Tablet.*Android|Kindle|Silk|Galaxy Tab/i',
];
foreach ($tabletPatterns as $pattern) {
if (preg_match($pattern, $userAgent)) {
return true;
}
}
return false;
}There is an intentional tradeoff here: some tablets will still look like generic mobile devices unless they expose a recognizable tablet marker. That is better than aggressively labeling desktop or phone traffic incorrectly.
Making Capacitor Detection More Reliable
The most interesting part of this utility is that it does not rely on the user agent first. It uses three layers, ordered from strongest signal to weakest:
- a custom
X-Capacitor-Appheader - a
capacitor_app=truecookie - restrictive WebView heuristics
public static function isCapacitor(?Request $request = null): bool
{
$req = $request ?? RequestFacade::getFacadeRoot();
if ($req->header('X-Capacitor-App') === 'true') {
return true;
}
if ($req->cookie('capacitor_app') === 'true') {
return true;
}
$userAgent = self::getUserAgent($request);
if (! $userAgent) {
return false;
}
if (str_contains($userAgent, 'Android')) {
if (str_contains($userAgent, 'wv') && str_contains($userAgent, 'Version/')) {
return true;
}
}
if (str_contains($userAgent, 'iPhone') || str_contains($userAgent, 'iPad')) {
if (str_contains($userAgent, 'Capacitor') || str_contains($userAgent, 'Cordova')) {
return true;
}
$hasSafari = str_contains($userAgent, 'Safari/');
$hasVersion = str_contains($userAgent, 'Version/');
$hasMobile = str_contains($userAgent, 'Mobile/');
$hasChrome = str_contains($userAgent, 'CriOS');
$hasFirefox = str_contains($userAgent, 'FxiOS');
$hasEdge = str_contains($userAgent, 'EdgiOS');
if ($hasSafari && $hasMobile && ! $hasVersion && ! $hasChrome && ! $hasFirefox && ! $hasEdge) {
foreach (['Vivaldi', 'OPR', 'Opera', 'DuckDuckGo', 'Brave'] as $browser) {
if (str_contains($userAgent, $browser)) {
return false;
}
}
return true;
}
}
return false;
}This is a good example of defensive backend logic:
- application-controlled signals win
- platform heuristics are narrow
- common alternative browsers are explicitly excluded
Important
Do not assume every request without Version/ on iOS is a native shell. Restrict the heuristic and keep excluding known browser signatures, otherwise false positives will pile up quickly.
Returning One Canonical Device Type
Instead of forcing the rest of your application to combine multiple boolean checks, expose a single canonical value:
public static function getDeviceType(?Request $request = null): string
{
if (self::isCapacitor($request)) {
return 'capacitor';
} elseif (self::isTablet($request)) {
return 'tablet';
} elseif (self::isMobile($request)) {
return 'mobile';
}
return 'desktop';
}The priority matters:
capacitoroverrides browser-style categoriestabletoverridesmobile- everything else falls back to
desktop
That removes ambiguity for view logic and template conditionals.
Using It in Laravel Views
One useful pattern is to expose the result directly in the root HTML element:
<html data-device-type="{{ \App\Utils\DeviceUtil::getDeviceType() }}">You can then consume that value in CSS or JavaScript without re-running detection logic on the client.
It also works well for server-side conditionals:
->visible(fn () => ! DeviceUtil::isCapacitor())This is especially handy in Filament panels when a specific action should not appear inside the native app shell.
Testing the Edge Cases
The utility becomes much more trustworthy once you cover real-world user-agent samples in tests:
it('detects Chrome on Windows as desktop', function () {
$request = createRequestWithHeaders(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' .
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
);
expect(DeviceUtil::isDesktop($request))->toBeTrue();
expect(DeviceUtil::getDeviceType($request))->toBe('desktop');
});
it('detects iPhone Safari as mobile', function () {
$request = createRequestWithHeaders(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) ' .
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1'
);
expect(DeviceUtil::isMobile($request))->toBeTrue();
expect(DeviceUtil::isCapacitor($request))->toBeFalse();
});
it('detects Capacitor via custom header', function () {
$request = createRequestWithHeaders('Mozilla/5.0 (...)', 'true', null);
expect(DeviceUtil::isCapacitor($request))->toBeTrue();
expect(DeviceUtil::getDeviceType($request))->toBe('capacitor');
});A few cases are worth testing explicitly:
- desktop browsers on Windows and macOS
- iPhone Safari versus iPhone WebView
- Android Chrome versus Android WebView
- tablets that also match mobile patterns
- null, empty, and bot user agents
Troubleshooting
Android tablets are being classified as mobile
That can happen if the user agent contains Android but not a tablet-specific marker like Tablet or Galaxy Tab. If your audience depends heavily on Android tablets, add patterns based on the exact devices you support and back them with tests.
Alternative iOS browsers are being flagged as Capacitor
Tighten the exclusion list. Browser signatures such as CriOS, FxiOS, EdgiOS, OPR, or Brave should remain outside the native-app bucket.
Tests affect one another
Clear static state between tests:
beforeEach(function () {
DeviceUtil::clearCache();
});This resets the cached user agent and any fake values used by your test suite.
Conclusion
A useful device utility does not need to be magical. It needs to be explicit, predictable, and easy to test.
For Laravel applications, a strong baseline is:
- cache the current request's user agent
- prefer custom headers and cookies for native shells
- keep UA heuristics conservative
- return one canonical device type for the rest of the app
That approach will not classify every edge case perfectly, but it will give you a utility you can safely evolve as new browsers, tablets, and WebView behaviors appear.
