Laravel 中的设备检测:用保守的 PHP 启发式规则区分 Mobile、Tablet、Desktop 和 Capacitor
引言
有时候,响应式 CSS 已经足够。有时候,不够。
如果你的 Laravel 应用需要隐藏仅适用于 desktop 的操作、调整 Filament 布局,或者区分原生 Capacitor 容器与普通移动浏览器,那么你就需要一种后端友好的方式来判断当前请求属于哪类设备。
本文会介绍一个实用的 PHP DeviceUtil 实现,它返回一个规范化的设备类型:
desktopmobiletabletcapacitor
这里最重要的设计原则是保守。正如 MDN 在其关于 browser detection using the user agent 的指南中指出的那样,user-agent sniffing 天生脆弱,因此更稳妥的做法是先使用应用自己可控的强信号,再把 UA 启发式作为兜底方案。
什么时候值得在后端检测设备
当后端需要在 JavaScript 运行之前做出决策时,服务端检测就很有价值:
- 渲染不同的导航或布局块
- 关闭在原生容器内没有意义的功能
- 给 HTML 文档打上稳定的设备类型标记
- 为 Blade、Livewire 或 Filament 暴露便捷 helper
对于纯视觉层面的行为,如果 CSS 或客户端能力检测已经能解决,那么后端检测的价值就没有那么高。
TIP
如果你能够控制客户端,优先使用显式信号而不是猜测。自定义 header 或 cookie 通常比解析 user agent 更可靠。
工具类结构
这个工具类暴露了几个职责单一的方法:
DeviceUtil::isMobile();
DeviceUtil::isTablet();
DeviceUtil::isDesktop();
DeviceUtil::isCapacitor();
DeviceUtil::getDeviceType();它还接受一个可选的 Request 对象,这会让测试变得简单很多:
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 没有意义。一个小型静态缓存就足够让实现保持简单:
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 使用更窄的规则集合:
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。它按从强到弱的顺序使用三层信号:
- 自定义
X-Capacitor-Appheader capacitor_app=truecookie- 严格受限的 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;
}这是后端防御性逻辑的一个很好的例子:
- 应用自己可控的信号优先级最高
- 平台启发式规则保持严格
- 常见的第三方浏览器被显式排除
Important
不要假设所有缺少 Version/ 的 iOS 请求都是原生容器。启发式规则必须保持严格,并持续排除已知浏览器签名,否则误判会很快累积。
返回一个规范化的设备类型
与其让应用其他部分自己组合多个布尔判断,不如直接暴露一个统一的返回值:
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 元素上:
<html data-device-type="{{ \App\Utils\DeviceUtil::getDeviceType() }}">这样你就可以在 CSS 或 JavaScript 中消费这个值,而不需要在客户端重新执行检测逻辑。
它同样适用于服务端条件判断:
->visible(fn () => ! DeviceUtil::isCapacitor())在 Filament 面板中,如果某个操作不应该出现在原生 app shell 里,这种写法尤其方便。
测试这些边界情况
当你用真实 world 的 user-agent 样本覆盖测试后,这个工具类会可靠得多:
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,但没有 Tablet 或 Galaxy Tab 这类平板特征标记,就可能出现这种情况。如果你的用户里 Android 平板占比很高,就应该根据你实际支持的设备补充规则,并为这些规则添加测试。
iOS 上的第三方浏览器被误判为 Capacitor
继续收紧排除列表。像 CriOS、FxiOS、EdgiOS、OPR 或 Brave 这样的浏览器签名,应该明确排除在原生 app 类别之外。
测试之间互相影响
记得在测试之间清理静态状态:
beforeEach(function () {
DeviceUtil::clearCache();
});这会重置缓存的 user agent,以及测试套件中使用的 fake 值。
总结
一个好用的设备检测工具类不需要神奇。它需要的是明确、可预测、易于测试。
对 Laravel 应用来说,一个稳健的基础方案是:
- 缓存当前请求的 user agent
- 对原生容器优先使用自定义 header 和 cookie
- 保持 UA 启发式规则足够保守
- 向应用其他部分返回一个统一的设备类型
这种做法并不能完美覆盖所有边界情况,但它能给你一个可以安全演进的工具类,随着新浏览器、平板和 WebView 行为的出现逐步扩展。
