Detección de Dispositivos en Laravel: Mobile, Tablet, Desktop y Capacitor con Heurísticas Conservadoras en PHP
Introducción
A veces el responsive CSS es suficiente. A veces no.
Si tu app Laravel necesita ocultar una acción exclusiva de desktop, adaptar un layout de Filament o distinguir un contenedor nativo de Capacitor de un navegador móvil normal, necesitas una forma backend-friendly de clasificar el request actual.
Este artículo recorre una implementación práctica de DeviceUtil en PHP que devuelve un tipo de dispositivo canónico:
desktopmobiletabletcapacitor
La decisión principal de diseño es ser conservadores. Como señala MDN en su guía sobre detección de navegadores usando el user agent, el user-agent sniffing es frágil, así que la estrategia más segura es usar primero señales más fuertes de la aplicación y dejar las heurísticas de UA como último recurso.
Cuándo Vale la Pena Detectar el Dispositivo en el Backend
La detección del lado del servidor es útil cuando tu backend necesita decidir algo antes de que corra JavaScript:
- renderizar bloques de navegación o layout diferentes
- desactivar funciones que no tienen sentido dentro del contenedor nativo
- marcar el documento HTML con un tipo de dispositivo estable
- exponer helpers de conveniencia para Blade, Livewire o Filament
Es menos útil para comportamientos puramente visuales que CSS y la detección de capacidades del cliente pueden resolver.
TIP
Si controlas el cliente, prefiere señales explícitas en lugar de adivinar. Un header o una cookie personalizada suele ser más confiable que parsear el user agent.
Estructura de la Utilidad
La utilidad expone métodos pequeños con una sola responsabilidad:
DeviceUtil::isMobile();
DeviceUtil::isTablet();
DeviceUtil::isDesktop();
DeviceUtil::isCapacitor();
DeviceUtil::getDeviceType();También acepta un objeto Request opcional, lo que facilita bastante las pruebas:
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): stringCacheando el User-Agent por Request
Si varios componentes consultan el tipo de dispositivo durante el mismo request, leer y parsear el mismo header una y otra vez no aporta nada. Un pequeño caché estático mantiene la implementación 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;
}Este patrón da dos propiedades útiles:
- las instancias explícitas de
Requestevitan el caché, lo que mantiene las pruebas aisladas - el flujo normal de la aplicación solo lee una vez el header respaldado por la facade
Detectando Requests Mobile y Tablet
La utilidad usa expresiones regulares para detección amplia de mobile y un conjunto más estrecho para 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;
}Aquí hay un tradeoff intencional: algunas tablets seguirán viéndose como dispositivos móviles genéricos si no exponen un marcador reconocible de tablet. Eso es mejor que etiquetar tráfico de desktop o teléfono de forma agresiva e incorrecta.
Haciendo la Detección de Capacitor Más Confiable
La parte más interesante de esta utilidad es que no depende primero del user agent. Usa tres capas, ordenadas de la señal más fuerte a la más débil:
- un header personalizado
X-Capacitor-App - una cookie
capacitor_app=true - heurísticas restrictivas de WebView
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;
}Este es un buen ejemplo de lógica defensiva en backend:
- las señales controladas por la aplicación ganan
- las heurísticas de plataforma son estrechas
- los navegadores alternativos comunes se excluyen de forma explícita
Importante
No asumas que cualquier request de iOS sin Version/ es un contenedor nativo. Restringe la heurística y sigue excluyendo firmas de navegadores conocidos, o los falsos positivos se acumularán rápido.
Devolviendo un Solo Tipo de Dispositivo Canónico
En lugar de obligar al resto de tu aplicación a combinar múltiples verificaciones booleanas, expón un único valor canónico:
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';
}La prioridad importa:
capacitorsobrescribe las categorías estilo navegadortabletsobrescribemobile- todo lo demás cae en
desktop
Eso elimina ambigüedad en la lógica de vistas y en los condicionales de templates.
Usándolo en Vistas de Laravel
Un patrón útil es exponer el resultado directamente en el elemento raíz de HTML:
<html data-device-type="{{ \App\Utils\DeviceUtil::getDeviceType() }}">Luego puedes consumir ese valor desde CSS o JavaScript sin volver a ejecutar la lógica de detección en el cliente.
También funciona bien para condicionales del lado del servidor:
->visible(fn () => ! DeviceUtil::isCapacitor())Esto es especialmente útil en paneles Filament cuando una acción específica no debería aparecer dentro del contenedor nativo.
Probando los Casos Límite
La utilidad se vuelve mucho más confiable cuando cubres muestras reales de user agents en pruebas:
it('detecta Chrome en Windows como 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('detecta iPhone Safari como 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('detecta Capacitor mediante header personalizado', function () {
$request = createRequestWithHeaders('Mozilla/5.0 (...)', 'true', null);
expect(DeviceUtil::isCapacitor($request))->toBeTrue();
expect(DeviceUtil::getDeviceType($request))->toBe('capacitor');
});Hay algunos casos que conviene probar de forma explícita:
- navegadores desktop en Windows y macOS
- iPhone Safari versus iPhone WebView
- Android Chrome versus Android WebView
- tablets que también coinciden con patrones mobile
- user agents nulos, vacíos y bots
Troubleshooting
Las tablets Android se clasifican como mobile
Puede pasar si el user agent contiene Android pero no un marcador específico de tablet como Tablet o Galaxy Tab. Si tu audiencia depende mucho de tablets Android, agrega patrones basados en los dispositivos que realmente soportas y respáldalos con pruebas.
Navegadores alternativos de iOS se están marcando como Capacitor
Endurece la lista de exclusión. Firmas de navegador como CriOS, FxiOS, EdgiOS, OPR o Brave deberían permanecer fuera del bucket de app nativa.
Las pruebas se contaminan entre sí
Limpia el estado estático entre pruebas:
beforeEach(function () {
DeviceUtil::clearCache();
});Eso reinicia el user agent cacheado y cualquier valor fake usado por la suite de pruebas.
Conclusión
Una utilidad de detección de dispositivos no necesita ser mágica. Necesita ser explícita, predecible y fácil de probar.
Para aplicaciones Laravel, una base sólida es:
- cachear el user agent del request actual
- preferir headers y cookies personalizados para contenedores nativos
- mantener conservadoras las heurísticas de UA
- devolver un solo tipo de dispositivo canónico para el resto de la app
Ese enfoque no clasificará perfectamente todos los edge cases, pero sí te dará una utilidad que puedes evolucionar con seguridad a medida que aparezcan nuevos navegadores, tablets y comportamientos de WebView.
