Last active
October 21, 2025 15:55
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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 | |
| ); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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