1978io / scry
Zero-dependency, single-file PHP library that turns a User-Agent (plus Client Hints) into coarse-but-reliable facts: real browser or not, browser family, OS and device class.
Requires
- php: >=8.0
README
Coarse-but-reliable User-Agent detection for PHP. One file, zero dependencies.
Scry answers four questions about a request and refuses to pretend it can answer more:
- Is this a real browser? — the anomaly flag
- Which browser family? — Chrome, Firefox, Safari, Edge, …
- Which OS? — Windows, macOS, iOS, Android, Linux, …
- Desktop, tablet or mobile?
That is the whole library. No version numbers, no device make and model, no crawler catalogue. Those things are useful, but keeping them accurate is a permanent maintenance job — which is exactly why the good libraries that do it are big. If you need that precision, use matomo/device-detector; it is excellent and Scry is not trying to replace it.
Scry is for the common case: you want to log something readable, split desktop from
mobile, or notice that a "visitor" is actually curl. It is one file you can read in a
sitting.
Install
Composer:
composer require 1978io/scry
Or just drop the file in. src/Scry.php is the entire library — copy it into your
project and require it. No autoloader, no vendor directory, no build step.
require __DIR__ . '/lib/Scry.php';
Requires PHP 8.0+.
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.
License
MIT — see LICENSE. Copyright © 2026 James Robinson / 1978.io.
A 1978.io project.