Skip to content

Instantly share code, notes, and snippets.

@nrctkno
Last active October 21, 2025 15:55
Show Gist options
  • Select an option

  • Save nrctkno/081f62d28e48e455728ddbbf98051caf to your computer and use it in GitHub Desktop.

Select an option

Save nrctkno/081f62d28e48e455728ddbbf98051caf to your computer and use it in GitHub Desktop.
UserAgentDetails.php, a small library to extract client details from User-Agent header
<?php
/**
* Class to parse User-Agent header lines into detailed components.
* It covers most of the common User-Agent formats used by various clients.
*/
class UserAgentDetails {
private function __construct(
private readonly string $headerLine,
private readonly ?string $appName,
private readonly ?string $appVersion,
private readonly ?string $deviceInfo,
private readonly ?string $osInfo,
private readonly ?string $libraryName,
private readonly ?string $libraryVersion
) {
}
public function headerLine(): string {
return $this->headerLine;
}
public function appName(): ?string {
return $this->appName;
}
public function appVersion(): ?string {
return $this->appVersion;
}
public function deviceInfo(): ?string {
return $this->deviceInfo;
}
public function osInfo(): ?string {
return $this->osInfo;
}
public function normalizedOsInfo(): ?string {
return in_array($this->osInfo, ['iPadOS', 'iPad', 'iOS', 'iPhone'])
? 'iOS'
: $this->osInfo;
}
public function libraryName(): ?string {
return $this->libraryName;
}
public function libraryVersion(): ?string {
return $this->libraryVersion;
}
/**
* Extracts detailed User-Agent information from the request.
* @param string $header The User-Agent header line string.
*/
public static function fromHeaderLine(string $ua): self
{
$ua = trim($ua);
// Return early if header is empty
if (empty($ua)) {
return new self($ua, null, null, null, null, null, null);
}
$dummy = '';
$app = '';
$device = '';
$library = '';
match (1) {
//e.g. Mozilla/5.0 (Linux; Android 11.0; Build/RTM9.241015.168) AppleWebKit/537.36 ...
preg_match(
'/^([^\s()]+)\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)\s*(.+)$/',
$ua,
$m
) => [$dummy, $app, $device, $library] = $m,
//e.g. AppleCoreMedia/1.0.0.22G100 (iPhone; U; CPU OS 18_6_2 like Mac OS X; ru_ru)
//e.g. consumer/3.11.0/1813 (Android; samsung SM-A155M; android 14)
preg_match(
'/^([^\s()]+)\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)$/',
$ua,
$m
) => [$dummy, $app, $device] = $m,
//e.g. datadog/1.0 api synthetics
preg_match(
'/^([^\s()]+)\s*(.+)?$/',
$ua,
$m
) => [$dummy, $app] = $m,
//e.g. RevenueCat
preg_match(
'/^([^\s()]+)$/',
$ua,
$m
) => [$dummy, $app] = $m,
// No matches
default => [$dummy, $app, $device, $library] = [null, null, null, null],
};
[$osInfo, $deviceInfo] = match (1) {
// e.g. iPhone; U; {device}, {lang} ... (legacy iOS UA convention)
preg_match(
'/^(iPhone|iPad|iPod); [UDE]; ([^;]+); .+$/',
$device,
$m
) => [$m[1], $m[2]],
//e.g. com.zumba.strong.syncgo; build:2; iOS 18.6.2 (custom UA)
preg_match(
'/^([^;]+); build:[^;]+; (iOS .+)$/',
$device,
$m
) => ['iOS', $m[1]],
// e.g. Linux; U; Android {version}; {device} ... (legacy Android UA convention)
preg_match(
'/^Linux; [UDE]; Android ([^;]+); (.+)$/',
$device,
$m
) => ['Android', $m[2]],
//other
default => explode('; ', $device . '; ')
};
[$appName, $appVersion] = explode('/', $app . '/');
[$libraryName, $libraryVersion] = explode('/', $library . '/');
return new self(
$ua,
$appName !== '' ? $appName : null,
$appVersion !== '' ? $appVersion : null,
$deviceInfo !== '' ? $deviceInfo : null,
$osInfo !== '' ? $osInfo : null,
$libraryName !== '' ? $libraryName : null,
$libraryVersion !== '' ? $libraryVersion : null
);
}
}
<?php
use PHPUnit\Framework\TestCase;
use UserAgentDetails;
class UserAgentDetailsTest extends TestCase {
/**
* @dataProvider detailsProvider
*/
public function testDetails($appName, $appVersion, $deviceInfo, $osInfo, $libraryName, $libraryVersion, $normalizedOsInfo) {
$userAgent = $this->dataName();
$details = UserAgentDetails::fromHeaderLine($userAgent);
$this->assertEquals($appName, $details->appName(), 'appName');
$this->assertEquals($appVersion, $details->appVersion(), 'appVersion');
$this->assertEquals($deviceInfo, $details->deviceInfo(), 'deviceInfo');
$this->assertEquals($osInfo, $details->osInfo(), 'osInfo');
$this->assertEquals($libraryName, $details->libraryName(), 'libraryName');
$this->assertEquals($libraryVersion, $details->libraryVersion(), 'libraryVersion');
$this->assertEquals($normalizedOsInfo, $details->normalizedOsInfo(), 'normalizedOsInfo');
}
public function detailsProvider() {
return [
'' => [null, null, null, null, null, null, null],
'datadog/1.0 api synthetics' => ['datadog', '1.0', null, null, null, null, null],
'datadog/1.1v666 api synthetics (it was all a dream)' => ['datadog', '1.1v666', null, null, null, null, null],
'Faraday v2.9.2' => ['Faraday', null, null, null, null, null, null],
'okhttp/4.12.0' => ['okhttp', '4.12.0', null, null, null, null, null],
'RevenueCat' => ['RevenueCat', null, null, null, null, null, null],
'Ruby' => ['Ruby', null, null, null, null, null, null],
'Stripe/1.0 (+https://stripe.com/docs/webhooks)' => ['Stripe', '1.0', null, '+https://stripe.com/docs/webhooks', null, null, '+https://stripe.com/docs/webhooks'],
'axios/1.10.0' => ['axios', '1.10.0', null, null, null, null, null],
'Android/1.2.16-hot1' => ['Android', '1.2.16-hot1', null, null, null, null, null],
'Android/1.5.4' => ['Android', '1.5.4', null, null, null, null, null],
'Mozilla/5.0 (Linux; Android 11.0; Build/RTM9.241015.168) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.0 Safari/537.36 CrKey/1.56.500000 DeviceType/AndroidTV' => ['Mozilla', '5.0', 'Android 11.0', 'Linux', 'AppleWebKit', '537.36 (KHTML, like Gecko) Chrome', 'Linux'],
'AirPlay/2.0 (App/63.146.0) MFi_AirPlay_Device' => ['AirPlay', '2.0', null, 'App/63.146.0', 'MFi_AirPlay_Device', null, 'App/63.146.0'],
'Apache-HttpClient/4.5.14 (Java/21.0.8)' => ['Apache-HttpClient', '4.5.14', null, 'Java/21.0.8', null, null, 'Java/21.0.8'],
'AppleCoreMedia/1.0.0.19F77 (iPhone; U; CPU OS 15_5 like Mac OS X; ja_jp)' => ['AppleCoreMedia', '1.0.0.19F77', 'CPU OS 15_5 like Mac OS X', 'iPhone', null, null, 'iOS'],
'AppleCoreMedia/1.0.0.22G100 (iPhone; U; CPU OS 18_6_2 like Mac OS X; ru_ru)' => ['AppleCoreMedia', '1.0.0.22G100', 'CPU OS 18_6_2 like Mac OS X', 'iPhone', null, null, 'iOS'],
'AppleCoreMedia/1.0.0.23A355 (iPhone; U; CPU OS 26_0_1 like Mac OS X; fr_fr)' => ['AppleCoreMedia', '1.0.0.23A355', 'CPU OS 26_0_1 like Mac OS X', 'iPhone', null, null, 'iOS'],
'Dalvik/2.1.0 (Linux; U; Android 16; SM-S938U Build/BP2A.250605.031.A3)' => ['Dalvik', '2.1.0', 'SM-S938U Build/BP2A.250605.031.A3', 'Android', null, null, 'Android'],
];
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment