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.
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.