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.

Maintainers

Package info

github.com/1978io/scry

pkg:composer/1978io/scry

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-08-05 13:27 UTC

This package is auto-updated.

Last update: 2026-08-05 13:57:35 UTC


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:

  1. Is this a real browser? — the anomaly flag
  2. Which browser family? — Chrome, Firefox, Safari, Edge, …
  3. Which OS? — Windows, macOS, iOS, Android, Linux, …
  4. 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.phpBROWSERS, 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.