Skip to content

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:

  • desktop
  • mobile
  • tablet
  • capacitor

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:

php
DeviceUtil::isMobile();
DeviceUtil::isTablet();
DeviceUtil::isDesktop();
DeviceUtil::isCapacitor();
DeviceUtil::getDeviceType();

También acepta un objeto Request opcional, lo que facilita bastante las pruebas:

php
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): string

Cacheando 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:

php
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 Request evitan 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:

php
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:

  1. un header personalizado X-Capacitor-App
  2. una cookie capacitor_app=true
  3. heurísticas restrictivas de WebView
php
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:

php
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:

  • capacitor sobrescribe las categorías estilo navegador
  • tablet sobrescribe mobile
  • 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:

blade
<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:

php
->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:

php
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.

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:

php
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.