bits.1978.io

Scry

Coarse-but-reliable User-Agent detection for PHP. One file, zero dependencies. Scry answers four questions — is this a real browser, which browser, which OS, desktop or mobile — and refuses to pretend it can answer more.

  • PHP 8.0+
  • single file
  • zero deps
  • MIT
  • v0.1.0

Install

Composer

composer require 1978io/scry

Autoloaded, versioned, updatable. The usual.

Or drop the file in

require __DIR__ . '/lib/Scry.php';

src/Scry.php is the entire library. No autoloader, no vendor directory, no build step. PHP 8.0+.

Live demo

What Scry makes of your request

Rendered server-side, just now, by the copy of Scry.php in this repo. Open this page in another browser — or from your phone — and the answer changes.

Bot

Not a normal browser — bot.
kind() bot
browser() Other
os() Other
device() unknown
isBrowser() false
isMobile() false
isBot() true
confidence() high

raw() — your User-Agent, verbatim Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)

That table is the whole API. It came from:

use Scry\Scry;

$d = Scry::detect();      // reads the current request: User-Agent + Sec-CH-UA* headers
echo $d->label();         // Bot

Documentation

Usage

use Scry\Scry;

$d = Scry::detect();   // reads the current request: User-Agent + Sec-CH-UA* headers

if (!$d->isBrowser()) {
    // curl, a script, a headless browser, a crawler…
}

echo $d->label();      // "Chrome on Windows"

Detect an arbitrary string — a stored UA from your logs, say:

$d = Scry::detect($uaString);
$d = Scry::detect($uaString, $clientHints);   // hints optional, assoc array

The full surface

$d->kind();        // 'browser' | 'bot' | 'tool' | 'unknown'
$d->isBrowser();   // bool — the anomaly flag
$d->isBot();       // bool — a crawler specifically
$d->browser();     // 'Chrome' | 'Firefox' | 'Safari' | … | 'Other'
$d->os();          // 'Windows' | 'macOS' | 'iOS' | 'Android' | … | 'Other'
$d->device();      // 'desktop' | 'tablet' | 'mobile' | 'unknown'
$d->isMobile();    // bool — mobile or tablet
$d->label();       // 'Chrome on Windows' — for display
$d->confidence();  // 'high' | 'low'
$d->raw();         // the original User-Agent string, always
$d->toArray();     // ['kind','browser','os','device','bot','label','confidence','raw']

One static entry point. The returned object is immutable, and detect() never throws — an empty or nonsensical User-Agent comes back as unknown / Other with confidence() of 'low', never as a guess.

Example: a request log line

$d = Scry::detect();

log_line([
    'ip'      => $_SERVER['REMOTE_ADDR'] ?? '-',
    'client'  => $d->label(),          // "Safari on iOS"
    'kind'    => $d->kind(),           // "browser"
    'device'  => $d->device(),         // "mobile"
    'ua'      => $d->raw(),            // keep the truth alongside the label
]);

What it detects, and what it does not

It does:

  • separate real browsers from curl, wget, python-requests, Go-http-client, okhttp, Postman, headless Chrome, PhantomJS and generic crawlers
  • name the browser family and OS in a form you would actually show a human
  • classify desktop / tablet / mobile
  • prefer Client Hints (Sec-CH-UA, Sec-CH-UA-Platform, Sec-CH-UA-Mobile) over the UA string when the browser sends them, and ignore the GREASE entries browsers inject into the brand list to stop servers hard-coding it

It does not:

  • report versions (browser, OS or engine)
  • report device make or model, screen size, or vendor
  • identify which crawler is calling — a crawler is reported as a crawler
  • maintain a bot catalogue, so it will not catch every obscure bot on earth

The last point is deliberate. A UA-based bot list is always out of date and anything determined to hide will just send a Chrome UA. Treat isBrowser() as a strong, cheap signal — not a security control.


Honesty notes

The friendly labels are best effort. The anomaly flag is the durable part. UA strings are self-reported and frozen by vendors; browser() and os() are convenience, and raw() is always there so you can re-parse later with something else. Nothing is thrown away.

The iPad trap. Since iPadOS 13, Safari on an iPad requests desktop sites by default and sends a User-Agent byte-identical to Safari on a Mac. Scry reports it as macOS / desktop, because that is genuinely all the string says. It does not guess "tablet" — a guess that would also mislabel every real Mac. iPads that send the mobile-mode UA (containing iPad) are correctly reported as iPadOS / tablet. If you truly need to tell them apart, that decision belongs on the client side (touch points, screen size) and should be treated as a separate signal.

confidence() describes the classification, not the visitor. 'high' means something matched positively; 'low' means Scry fell back to Other / unknown rather than invent an answer.


How it works

Everything lives in ordered data tables at the top of src/Scry.php — BROWSERS, OS, DEVICES, BOTS_AND_TOOLS — each row a [label, pattern] pair. Detection walks the tables and the first match wins, so the tables run specific to general. The ordering carries real meaning and is commented in place: Edge before Chrome (Edge's UA contains Chrome/), Chrome before Safari (Chrome's UA ends in Safari/), tablets before mobile (an Android tablet is Android without the Mobile token).

Adding a browser is a one-line addition to a table, in the right place.


Tests

The test suite is plain PHP with no dev dependencies:

php tests/run.php

The corpus in tests/run.php is the living spec — real User-Agent strings with the exact result expected for each, including the iPad trap, empty and garbage input, bots and tools, and Client-Hints cases. If you disagree with a result, open an issue against the corpus; that is the conversation worth having.


Contributing

Bug reports and additional UA corpus cases are very welcome. Pull requests that add versions, device models or a bot catalogue will be politely declined — that is not a gap in Scry, it is the design.


Links

Disagree with a verdict? The test corpus is the living spec — open an issue against it.