Skip to content

Laravel 中的设备检测:用保守的 PHP 启发式规则区分 Mobile、Tablet、Desktop 和 Capacitor

引言

有时候,响应式 CSS 已经足够。有时候,不够。

如果你的 Laravel 应用需要隐藏仅适用于 desktop 的操作、调整 Filament 布局,或者区分原生 Capacitor 容器与普通移动浏览器,那么你就需要一种后端友好的方式来判断当前请求属于哪类设备。

本文会介绍一个实用的 PHP DeviceUtil 实现,它返回一个规范化的设备类型:

  • desktop
  • mobile
  • tablet
  • capacitor

这里最重要的设计原则是保守。正如 MDN 在其关于 browser detection using the user agent 的指南中指出的那样,user-agent sniffing 天生脆弱,因此更稳妥的做法是先使用应用自己可控的强信号,再把 UA 启发式作为兜底方案。

什么时候值得在后端检测设备

当后端需要在 JavaScript 运行之前做出决策时,服务端检测就很有价值:

  • 渲染不同的导航或布局块
  • 关闭在原生容器内没有意义的功能
  • 给 HTML 文档打上稳定的设备类型标记
  • 为 Blade、Livewire 或 Filament 暴露便捷 helper

对于纯视觉层面的行为,如果 CSS 或客户端能力检测已经能解决,那么后端检测的价值就没有那么高。

TIP

如果你能够控制客户端,优先使用显式信号而不是猜测。自定义 header 或 cookie 通常比解析 user agent 更可靠。

工具类结构

这个工具类暴露了几个职责单一的方法:

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

它还接受一个可选的 Request 对象,这会让测试变得简单很多:

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

按请求缓存 User-Agent

如果同一个请求中的多个组件都要查询设备类型,那么反复读取并解析同一个 header 没有意义。一个小型静态缓存就足够让实现保持简单:

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;
}

这种模式有两个好处:

  • 显式传入的 Request 会绕过缓存,便于测试彼此隔离
  • 正常应用流程只会读取一次 facade 背后的 header

检测 Mobile 与 Tablet 请求

这个工具类对 mobile 使用较宽泛的正则规则,而对 tablet 使用更窄的规则集合:

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;
}

这里存在一个有意为之的权衡:有些平板如果没有暴露明确的 tablet 标记,仍然会被视为普通 mobile 设备。但这比激进地把流量错误地标记成 desktop 或 phone 更安全。

让 Capacitor 检测更可靠

这个工具类最有意思的地方在于,它并不是先看 user agent。它按从强到弱的顺序使用三层信号:

  1. 自定义 X-Capacitor-App header
  2. capacitor_app=true cookie
  3. 严格受限的 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;
}

这是后端防御性逻辑的一个很好的例子:

  • 应用自己可控的信号优先级最高
  • 平台启发式规则保持严格
  • 常见的第三方浏览器被显式排除

Important

不要假设所有缺少 Version/ 的 iOS 请求都是原生容器。启发式规则必须保持严格,并持续排除已知浏览器签名,否则误判会很快累积。

返回一个规范化的设备类型

与其让应用其他部分自己组合多个布尔判断,不如直接暴露一个统一的返回值:

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';
}

优先级非常重要:

  • capacitor 会覆盖浏览器风格的类别
  • tablet 会覆盖 mobile
  • 其他情况统一回退到 desktop

这样可以让视图逻辑和模板条件判断避免歧义。

在 Laravel 视图中使用

一个很实用的模式,是直接把结果写到根 HTML 元素上:

blade
<html data-device-type="{{ \App\Utils\DeviceUtil::getDeviceType() }}">

这样你就可以在 CSS 或 JavaScript 中消费这个值,而不需要在客户端重新执行检测逻辑。

它同样适用于服务端条件判断:

php
->visible(fn () => ! DeviceUtil::isCapacitor())

在 Filament 面板中,如果某个操作不应该出现在原生 app shell 里,这种写法尤其方便。

测试这些边界情况

当你用真实 world 的 user-agent 样本覆盖测试后,这个工具类会可靠得多:

php
it('将 Windows 上的 Chrome 识别为 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('将 iPhone Safari 识别为 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('通过自定义 header 识别 Capacitor', function () {
    $request = createRequestWithHeaders('Mozilla/5.0 (...)', 'true', null);

    expect(DeviceUtil::isCapacitor($request))->toBeTrue();
    expect(DeviceUtil::getDeviceType($request))->toBe('capacitor');
});

以下几类情况值得明确测试:

  • Windows 和 macOS 上的 desktop 浏览器
  • iPhone Safari 与 iPhone WebView
  • Android Chrome 与 Android WebView
  • 同时匹配 mobile 模式的平板
  • 空值、空字符串以及 bot user agent

Troubleshooting

Android 平板被识别成 mobile

如果 user agent 包含 Android,但没有 TabletGalaxy Tab 这类平板特征标记,就可能出现这种情况。如果你的用户里 Android 平板占比很高,就应该根据你实际支持的设备补充规则,并为这些规则添加测试。

iOS 上的第三方浏览器被误判为 Capacitor

继续收紧排除列表。像 CriOSFxiOSEdgiOSOPRBrave 这样的浏览器签名,应该明确排除在原生 app 类别之外。

测试之间互相影响

记得在测试之间清理静态状态:

php
beforeEach(function () {
    DeviceUtil::clearCache();
});

这会重置缓存的 user agent,以及测试套件中使用的 fake 值。

总结

一个好用的设备检测工具类不需要神奇。它需要的是明确、可预测、易于测试。

对 Laravel 应用来说,一个稳健的基础方案是:

  • 缓存当前请求的 user agent
  • 对原生容器优先使用自定义 header 和 cookie
  • 保持 UA 启发式规则足够保守
  • 向应用其他部分返回一个统一的设备类型

这种做法并不能完美覆盖所有边界情况,但它能给你一个可以安全演进的工具类,随着新浏览器、平板和 WebView 行为的出现逐步扩展。