Search by

sorge-it / cdp-browser-installer

sorge-it

Installs a pinned, SHA-256-checked chrome-headless-shell from Chrome for Testing as a Composer dev dependency: the same headless Chrome for every developer and CI run, without Node. Built for agentic engineering.

v1.0.1 2026-11-02 16:41 UTC

This package is auto-updated.

Last update: 2026-11-02 17:58:23 UTC


README

Pinned headless Chrome™ for PHP browser tests, installed by Composer. No Node, no npm.

Tests Chrome for Testing Works with Latest Version Total Downloads License PHP Version PHPStan

sorge-it/cdp-browser-installer installs chrome-headless-shell from Chrome for Testing as a Composer dev dependency, on Linux and macOS, on x64 and arm64. A pin file next to composer.lock names the version and the SHA-256 of each archive. install checks the SHA-256 before each unpack, also for an archive that a CI cache restored. Every developer and every CI run gets the same headless browser. It is the PHP counterpart of npx @puppeteer/browsers install.

Built for coding agents and CI. An agent writes the browser test, and CI installs the browser on each run. No human looks at each download. You review the pin file once, in the pull request, like a change to composer.lock. After that, each install checks the archive against it. A command that refuses ends with a non-zero exit code and a message that names the cause and the next step: 75 for a download that a retry can fix, 65 for a wrong SHA-256, 1 for every other cause. The laptop and CI read the same pin file, so a test does not fail in CI only because the browser differs.

Quick start

composer require --dev sorge-it/cdp-browser-installer
vendor/bin/cdp-browser pin 156.0.8078.80   # writes the pin file .chrome-version; commit it
vendor/bin/cdp-browser install             # in each checkout, container and CI run
vendor/bin/cdp-browser path                # prints the path of the browser

Take the version from the Chrome for Testing dashboard. pin downloads the archive for each platform and writes the version and each SHA-256. If the system lacks curl, unzip or a library of the browser, run vendor/bin/cdp-browser deps as root first. In the text demo on macOS arm64, the first install took 9.4 s with the download, the second 0.0 s.

A test tool that drives Chrome through the Chrome DevTools Protocol (CDP) starts the binary that path prints, or calls the PHP API. Works with names the tools that a CI job proves with this browser. The package contains no Pest or PHPUnit plugin, and it installs no ChromeDriver. A project with Node can use npx @puppeteer/browsers install or npx playwright install instead.

Platforms. Linux with glibc and macOS, on x64 and arm64. On Windows, Composer refuses the package. On Alpine and other musl systems, use the system's chromium; its version is Alpine's, not the pin file's (see Alpine). install and pin refuse to run as root without --allow-root (see Commands).

Trust. Security lists seven rules and the code that implements each. Limits of the security model lists what the installer does not check. composer test requires PHPStan level max and 100 % type coverage; a CI job requires 100 % line coverage. Each week, the workflow chrome-for-testing checks each pinned SHA-256 against Chrome for Testing (see The pin file). The CI job works-with runs each tool under Works with.

install checks the pinned archive and installs the browser, then refuses an archive with one byte added

install reads the version and the SHA-256 from the pin file, downloads the archive and checks it before unpacking. The GIF skips the wait for the download. The stamp is a file beside the browser with the SHA-256 of its archive. The demo removes the browser and its stamp, as if a CI cache had restored only the archive. It adds one byte to the archive, and install deletes the archive without unpacking it. The GIF demo runs in Docker on Debian 13, on linux-arm64.

Run the browser

vendor/bin/cdp-browser path prints the path of the browser. Start it from that path, not through a symbolic link: on macOS, Chrome looks for its data files next to the path it was started from.

"$(vendor/bin/cdp-browser path)" --dump-dom 'data:text/html,<p>ok</p>'

On Ubuntu 23.10 and later, and in most containers, Chrome's sandbox finds no user namespace and Chrome stops with No usable sandbox!. Start it with --no-sandbox there; the installer does not decide that for you.

The pin file

{
    "version": "156.0.8078.80",
    "sha256": {
        "linux64": "547dcd374e02961bf998f6046a267644f8502225c36bb78ca307dab32a0b3eb4",
        "linux-arm64": "5bb6a00a4285c13303d3f72c5afdb70f144ea7a93eed462365a52b69631c9607",
        "mac-x64": "535d417d9034cbd11e7dacd1a71aa3d6e1b525e6d5dcd2671eb2532f01481228",
        "mac-arm64": "ea615baa2e9caf0d02d1898a6562b5f8fa9c35239707dae0f592178df5b986ac"
    }
}

Chrome for Testing publishes no SHA-256 and no signature: its JSON endpoints list only a platform and a URL for each download. So pin computes the SHA-256 from its own download, after it has compared the MD5 that the storage sends in x-goog-hash. Commit the file, and review each change to it in the pull request, like a change to composer.lock: the pin is the point where you trust Google.

The workflow chrome-for-testing checks the pin file of this repository. It runs each Monday, on demand, and on each pull request that changes .chrome-version or the workflow. It has two jobs:

  • Chrome for Testing · SHA-256 runs pin again for the pinned version and compares each SHA-256 with the pin file. It fails when pin fails or a SHA-256 differs.
  • Chrome for Testing · Stable fails when Chrome for Testing has a newer Stable than the pin.

Its badge at the top of this file shows the last result on main. A failed SHA-256 job means that Chrome for Testing serves other bytes for the pinned version, or that a download failed; the log names the cause. Other bytes stop each install of that version with exit code 65. A failed Stable job means that a newer version exists, or that the job could not read the list of versions. CONTRIBUTING.md gives the next step for each job. The workflow checks only the pin file of this repository. To update the pin file of your project, run pin.

GitHub Actions

Cache the archives under a key from the hash of the pin file. The file holds the version and each SHA-256, so a new pin gives a new key:

env:
  CDP_BROWSER_PATH: ${{ github.workspace }}/.cache/cdp-browser

steps:
  - uses: actions/cache@v6
    with:
      path: .cache/cdp-browser/*/*/*.zip
      key: cdp-browser-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.chrome-version') }}
  - run: sudo "$(command -v php)" vendor/bin/cdp-browser deps   # on macOS: "Nothing to do"
  - run: vendor/bin/cdp-browser install

To pin the action to a commit SHA, as this repository's workflow does: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0.

Use from PHP

use SorgeIt\CdpBrowserInstaller\DownloadException;
use SorgeIt\CdpBrowserInstaller\Installer;
use SorgeIt\CdpBrowserInstaller\Pin;
use SorgeIt\CdpBrowserInstaller\Platform;

// The environment decides: CDP_BROWSER_BINARY, CDP_BROWSER_PATH, XDG_CACHE_HOME, HOME,
// CDP_BROWSER_TIMEOUT, CDP_BROWSER_LOCK_TIMEOUT.
$browser = static fn (): string => Installer::fromEnvironment(getenv())->browser(Pin::FILE, Platform::current());

try {
    $binary = $browser();
} catch (DownloadException) {
    // A retry can help, as after a network error or a 5xx status.
    $binary = $browser();
}

browser() checks and returns the system's browser, or returns the path of the pinned one and installs it first if it is missing. A ChecksumException and every other InstallerException reach the caller (see Exceptions).

Without the environment, pass each value to the constructors:

use SorgeIt\CdpBrowserInstaller\CommandLine;

$installer = new Installer(new CommandLine(timeout: 300), '/srv/cache/cdp-browser', lockTimeout: 60);
$binary = $installer->install(Pin::read('.chrome-version'), Platform::current());

API lists the classes and members that SemVer covers.

Works with

The CI job works-with runs each tool in this section with the browser of the pin file of this repository. For each tool, it installs the newest release, loads a data: page through the tool and reads the text of one element. It also checks that the browser reports the version of the pin file. It runs with PHP 8.5 on Linux x64, Linux arm64 and macOS arm64, not on macOS x64. It runs each Monday, on demand, after each merge, and on each pull request that changes the pin file, the installer or the job.

Its badge at the top of this file shows the last result on main. Green means that each tool read the page on each of the three platforms. Each run lists in its summary the version of each tool, the browser and the platform. Red means that a step failed, and the name of the step tells which: each tool has a step with its name. The steps before them fail when a tool is not at its newest release, when composer audit finds an advisory, or when deps or install stops. When a new release of a tool fails, require the version of the last green run in your project. A tool that the job does not run is not in this section. The code of the job is in works-with/. A test of composer test keeps three parts of the examples below equal to the scripts of the job: the PHP lines that set up the tool, the finally block that stops the browser, and the command line.

chrome-php/chrome

chrome-php/chrome starts the browser from the path that browser() returns. It needs ext-sockets. The example turns off the sandbox on Linux, as the job does: on Ubuntu 24.04, AppArmor blocks the user namespaces of the sandbox (see Run the browser).

use HeadlessChromium\BrowserFactory;
use SorgeIt\CdpBrowserInstaller\Installer;
use SorgeIt\CdpBrowserInstaller\Pin;
use SorgeIt\CdpBrowserInstaller\Platform;

$binary = Installer::fromEnvironment(getenv())->browser(Pin::FILE, Platform::current());
$browser = new BrowserFactory($binary)->createBrowser(['noSandbox' => PHP_OS_FAMILY === 'Linux']);

try {
    $page = $browser->createPage();
    // Drive the page.
} finally {
    $browser->close();
}

dmore/chrome-mink-driver

dmore/chrome-mink-driver connects to a browser that already runs; dmore/behat-chrome-extension uses this driver. The driver needs ext-curl and ext-mbstring. Start the browser with a debugging port and a profile directory. On Linux, add --no-sandbox, as the job does: on Ubuntu 24.04, AppArmor blocks the user namespaces of the sandbox (see Run the browser). The browser writes the port to DevToolsActivePort in the profile once it listens; wait for that file before the driver connects. Stop the browser after the tests.

"$(vendor/bin/cdp-browser path)" --headless --remote-debugging-port=9222 --user-data-dir="$PWD/.chrome-profile" &
use Behat\Mink\Session;
use DMore\ChromeDriver\ChromeDriver;

$session = new Session(new ChromeDriver('http://127.0.0.1:9222', null, 'http://localhost'));
$session->start();

try {
    // Drive the page.
} finally {
    $session->stop();
}

Comparison with Puppeteer, Playwright, Laravel Dusk and Symfony Panther

Chrome for Testing's README names @puppeteer/browsers as "the easiest way" to download it. That package, Puppeteer and Playwright run on Node. The PHP packages for browser tests install a driver, use a browser that is already installed, or rely on Playwright or Puppeteer from npm. As of 2026-11-02, from the source code of each version:

Package Version Made for Installs Node Pins a version Checks a SHA-256 Locks a parallel install Windows
sorge-it/cdp-browser-installer this README installing a pinned, SHA-256-checked chrome-headless-shell as a Composer dev dependency chrome-headless-shell from Chrome for Testing, for Linux and macOS on x64 and arm64 no yes, in the pin file yes, before each unpack yes, for each version and platform no: Composer refuses it, as it requires ext-posix
@puppeteer/browsers 3.2.6, 2026-10-22 downloading and launching browsers chrome-headless-shell and other browsers, by build or by alias 22.12 or later in the command line, not in a file: the caller names a build or an alias when the caller of the API passes expectedHash; the CLI passes none no yes: win32 and win64
puppeteer 25.16.0, 2026-10-22 controlling headless Chrome over the DevTools Protocol chrome and chrome-headless-shell, through @puppeteer/browsers, on npm install 22.12 or later yes, one version for each release no no yes, through @puppeteer/browsers
playwright-core 1.64.1, 2026-10-14 automating web browsers chromium-headless-shell, from its own mirror cdn.playwright.dev 20 or later yes, in browsers.json no yes yes: win64
pestphp/pest-plugin-browser 5.2.0, 2026-10-29 testing browser interactions in Pest no browser; its message asks for npm install playwright && npx playwright install yes, for Playwright from npm — — — not stated; its CI runs on Ubuntu only
dbrekelmans/bdi 1.4.1, 2025-11-03 installing browser drivers ChromeDriver and GeckoDriver no no: the latest, or the one that matches the browser it finds no no yes: drivers for Windows
laravel/dusk 8.7.2, 2026-10-07 end-to-end tests and browser automation for Laravel ChromeDriver no no: the stable one, an argument, or with --detect the one that matches the installed Chrome no no yes: ChromeDriver for win32
symfony/panther 2.4.0, 2026-01-08 browser tests and web scraping in PHP and Symfony no browser; it names bdi for the drivers no — — — yes: a CI job on windows-latest
chrome-php/chrome 1.16.1, 2026-07-06 controlling headless Chrome from PHP no browser; it finds a Chrome by CHROME_PATH or a system path no — — — yes: it reads the path of Chrome from the registry
spatie/browsershot 5.4.2, 2026-10-26 images and PDFs of web pages, through headless Chrome no browser; it uses the npm package puppeteer 22 or later — — — yes: it starts Node in its own way on Windows
Sources of the comparison

"Made for" follows the description of each package on npm or Packagist. The sources for the other columns:

Requirements

  • PHP 8.4 or later, with posix and proc_open(). No ext-zip, no Node.
  • curl, plus Info-ZIP UnZip 6.00 and ZipInfo 3.00, which both come with the unzip package. safe-unzip checks the version of both tools before it reads an archive. macOS ships curl and unzip; on Debian and Ubuntu, deps installs them.
  • Linux with glibc, or macOS, on x64 or arm64. On musl, as on Alpine, the installer uses the system's chromium instead (see Alpine). Composer refuses to install the package on Windows: the package requires ext-posix, and PHP has no ext-posix on Windows. On other platforms, the installer stops with an InstallerException.

Ubuntu 24.04 ships PHP 8.3; there, install PHP 8.4, for example from ppa:ondrej/php.

The rest of this file is reference: How it works · Security · Limits of the security model · Commands · Environment variables · Where the browser goes · Alpine · API · Exceptions · Tests · Development

How it works

Native tools do the work; PHP decides and checks. curl downloads, zipinfo lists and unzip unpacks. Each starts from an argument list that you can read in Download and sorge-it/safe-unzip. Composer unpacks a package the same way: its ZipDownloader tries the unzip binary first and falls back to ZipArchive only when unzip is missing, because ZipArchive loses the Unix modes. A browser without its executable bit does not start.

Composer trusts unzip. This installer adds checks before and after it:

Step Native tool PHP checks
Download curl -q --proto =https --tlsv1.2 --max-filesize …, without --location the status is 200; a failed download leaves no archive; pin compares the MD5 from x-goog-hash
Archive — the SHA-256 of the pin file, before each unpack
Before unpacking zipinfo -h, zipinfo -v each line against a closed grammar, then safe-unzip's "standard ZIP" profile: methods, hosts, flags, extra fields, names, types, counts, offsets, and fixed header fields read at the offsets of the listing
Check pass unzip -p each entry decompresses into a pipe and has the size and the CRC-32 of the listing; nothing is written
Unpacking unzip -q the written tree holds exactly the files of the listing, with their sizes and CRC-32, and no link; safe-unzip sets each mode without write access for group and others
Into place rm -rf of the temporary directories a lock per version and platform, the old browser aside, the new one in, one rename each

The installer unpacks with SorgeIt\SafeUnzip\ZipArchive from sorge-it/safe-unzip: the reading interface of PHP's \ZipArchive, on top of unzip and zipinfo. It needs no ext-zip, keeps the Unix modes, checks before and after unzip writes, and throws a subclass of UnzipException instead of returning false. Its README lists each check and each exception. It knows nothing about Chrome. The installer wraps each such exception in an InstallerException, as its previous exception (see Exceptions).

Security

The package protects the download: the archive from the one known source, with the pinned SHA-256. The host is responsible for the rest: whoever can write to the cache, PATH or PHP's environment decides what runs.

Each rule names the code that implements it. A test in tests/ proves each rule except rule 7.

  1. Pinned. One version, one SHA-256 for each platform, in a file in your repository (Pin). pin first compares each download with the MD5 in x-goog-hash (PinCommand).
  2. One source. Downloads come only from https://storage.googleapis.com/chrome-for-testing-public/ (Download::SOURCE). curl -q reads no .curlrc. --proto =https --tlsv1.2 allows only HTTPS with TLS 1.2 or later, and TLS verification stays on. --max-filesize stops at 500 MiB. Any status other than 200 stops the install; without --location, this includes a redirect. A failed download, including one stopped by a timeout, leaves no archive: install downloads into a .part-* file of the run and deletes it. If it cannot, as in a directory that turned read-only, the file stays until an install that unpacks removes it, 120 seconds after its last write (rule 5). pin removes its own directory with each download in it.
  3. Checked before unpacked. A SHA-256 that differs from the pin file stops the install and deletes the archive (Installer::verify). The check runs once before each unpack: on a new download, and on an archive that a CI cache restored. The install checks and unpacks a file of its own: it downloads into a .part-* file, or renames the restored archive to one, and moves the file back into the cache after the unpack. A browser whose binary and stamp match the pin file counts as installed, and its archive is not read again.
  4. No entry outside the target. safe-unzip parses zipinfo -v with a closed grammar and checks the archive against its "standard ZIP" profile before unzip writes a byte. The install stops at any of these: a line that the grammar does not know, a method other than store and deflate, a host other than Unix, FAT and NTFS, an encrypted entry, a symbolic link or device, a file with the name of another entry's directory, a name with a character outside A-Z a-z 0-9 . _ - / or a . or .. segment, two entries with the same name, a local header that differs from its central header, or overlapping entries. It also stops at more than 10,000 entries, counted by zipinfo -h before the listing, and at more than 2 GiB unpacked (safe-unzip's default Limits). Before unzip writes, a check pass compares the size and the CRC-32 of each entry with the listing, so no zip bomb reaches the disk. After unzip, the temporary directory must hold exactly the files of the listing, with their sizes and CRC-32, and no link. Every tool runs with LC_ALL=C, and without the variables UNZIP, UNZIPOPT, ZIPINFO and ZIPINFOOPT, which would add options from the environment.
  5. One install at a time. A lock file per version and platform makes a second install wait for the first one, and then find the browser installed. An install that finds the browser installed takes no lock and leaves the files of a stopped run in place; the next run that unpacks removes them. install replaces a browser of the same version when the binary or its stamp is missing, or when the stamp holds the SHA-256 of another archive. Then the old tree moves aside and the new one moves in, with one rename each, and the old tree is removed (Installer). A browser that still runs from the old tree keeps only the files it has open. Where the lock does not exclude a second process, as on a Docker Desktop bind mount on macOS, each install downloads its own file, checks it and unpacks it. Another archive that a second run puts at the path of the cache meanwhile does not change what the first run unpacks. An install that finds the browser installed after it has unpacked keeps that browser and removes its own tree.
  6. Root only for the package manager. Only deps needs root. It installs a fixed package list from the system's signed sources (SystemLibraries). It finds apk or apt-get in PATH, passes the caller's environment plus DEBIAN_FRONTEND=noninteractive, and runs it without a timeout, so that no timeout leaves dpkg half configured; Ctrl-C or SIGKILL still can. The commands install and pin stop as root without --allow-root (Cli::refuseRoot); path and install with CDP_BROWSER_BINARY write nothing; path and the PHP API do not check the user.
  7. No shell. Every process starts from an argument list (CommandLine); no test scans the code for a shell call. The installer looks a tool up in the PATH that the process gets, where a relative or empty entry counts from the working directory; without a PATH, it looks in /usr/bin and /bin. safe-unzip looks for zipinfo and unzip in the absolute entries only. A timeout or a signal stops the install like a failing exit code.

Limits of the security model

  • Trust on first use. pin trusts the bytes that Google serves at that moment. The SHA-256 detects every later change, but not a wrong first download. Google Cloud Storage sends an MD5 and a CRC32C of each archive in the x-goog-hash header. pin compares the MD5 with its download and writes no pin file when they differ. For a platform without an MD5, it prints one line that says so, and still writes the pin file. The MD5 and the archive come from the same server: the MD5 detects a broken download, not a changed source.
  • The lock on a shared file system. On a Docker Desktop bind mount on macOS, flock() did not exclude a second process (measured on 2026-10-30). Rule 5 keeps the browser intact there, but each install downloads the archive. NFS and bind mounts on a Linux host are not measured.
  • No later check of an installed browser. The SHA-256 covers the archive before the unpack, not the files after it. A changed file of an installed browser stays undetected while its binary and stamp match the pin file. To check it again, remove <cache>/<version>/ and run install.
  • Rosetta. Platform::current() reads php_uname('m'). An x64 build of PHP that runs through Rosetta 2 on Apple silicon reports x86_64 and installs mac-x64, which then also runs through Rosetta. Not measured; use an arm64 build of PHP.
  • Older distributions. deps uses the package names from after the 64-bit time transition, such as libasound2t64. They exist on Debian 13 and on Ubuntu 24.04 and later; CI checks the list on minimal images of Debian 13 and Ubuntu 26.04. On an older release, apt stops at the first unknown name, and so does deps. There, install the libraries that ldd chrome-headless-shell lists yourself.
  • Two timeouts. The lock timeout (600 seconds) covers the whole install of the other run: a download and each run of zipinfo and unzip, each of which can take up to the tool timeout (900 seconds). On a slow connection, a second install can stop at the lock while the first one is still running. There, set CDP_BROWSER_LOCK_TIMEOUT above the duration of one install.
  • Old versions stay. A new pin installs into a new directory <cache>/<version>/. No command removes the directory of an old version; remove it yourself.

Commands

Command Does Runs as
pin <version> Checks that it can write the pin file and that the real path of its temporary directory has no refused character, then downloads the archive for each platform, compares its MD5 with the one the storage sends, unpacks it once with the checks of install, and writes the version and each SHA-256 to the pin file a normal user; root with --allow-root
install Installs the pinned version for this platform, or checks the browser in CDP_BROWSER_BINARY, and prints the path in its message; a script reads the path from path a normal user; root with --allow-root, or with CDP_BROWSER_BINARY
path Prints the path of the browser; it writes nothing any user, including root
deps Installs curl, unzip and the browser's libraries through apt, or Alpine's chromium through apk; on macOS it does nothing. For the systems, see Older distributions root
Option Commands Does
--pin-file <file> pin, install, path the pin file; the default is .chrome-version in the working directory
--allow-root pin, install lets the command run as root
--version the application prints the version that Composer installed, or why it knows none
-v each command on an error: Symfony's format, with the class, file, line, trace and usage

A command that stops prints the message of its exception to the error output, unchanged, and ends with the EXIT_CODE of its class; 75 and 65 come from sysexits.h:

Exit code Class A script can
75 (EX_TEMPFAIL) DownloadException run the command again
65 (EX_DATAERR) ChecksumException stop: the bytes are not the pinned ones
1 InstallerException stop: a human fixes the cause in the message

Any other exception, such as a bug, is shown in Symfony's format and ends with exit code 1. install prints Download <url> before each download, as pin does.

install and pin stop when they run as root: a browser or a pin file owned by root breaks the next run as a normal user, who cannot replace it. In a container where only root runs, as in a Dockerfile RUN, --allow-root accepts that risk:

RUN vendor/bin/cdp-browser deps && vendor/bin/cdp-browser install --allow-root

A later run as another user, such as the tests, finds the browser installed and needs no write access to the cache.

No environment variable sets this option; add it to the command line. Only the commands install and pin refuse root: path writes nothing, and Installer::install() from PHP, as in a test tool, runs as any user, including root. With CDP_BROWSER_BINARY, install only checks the system's browser and writes nothing, so it runs as root without --allow-root.

Environment variables

Variable Default Does
CDP_BROWSER_BINARY none an absolute path to the system's browser, such as /usr/bin/chromium; install then downloads nothing and needs no cache
CDP_BROWSER_PATH $XDG_CACHE_HOME/cdp-browser if XDG_CACHE_HOME is absolute, else ~/.cache/cdp-browser the cache directory (see below)
CDP_BROWSER_TIMEOUT 900 the timeout in seconds for each tool that install, path and pin start: one curl download, each run of zipinfo and unzip, and --version of CDP_BROWSER_BINARY. deps has no timeout
CDP_BROWSER_LOCK_TIMEOUT 600 how many seconds an install waits for another install in the same version directory, <cache>/<version>/<platform>/. install and path read it together with the cache; with CDP_BROWSER_BINARY set, and in pin and deps, it is not read

Both timeouts take a whole number of seconds above 0, in digits only, without a sign, a leading zero or a space: 900, not 15m, 1e3, +900, 900 or 0900. 0 is refused: it would mean no limit for a tool but no wait at the lock, two opposite things for the same value. Any other value stops the command that reads it with an InstallerException. An empty variable counts as not set, as with CDP_BROWSER_PATH. curl also stops on its own when the speed stays below 1 KiB/s for 60 seconds.

Where the browser goes

install puts the browser under <cache>/<version>/<platform>/, next to the archive it came from:

  1. CDP_BROWSER_PATH, if set (a relative path is resolved against the working directory),
  2. else $XDG_CACHE_HOME/cdp-browser, if XDG_CACHE_HOME is absolute; as the XDG Base Directory Specification asks, a relative value, also one that starts with ~, is ignored,
  3. else ~/.cache/cdp-browser.

. and .. are resolved textually, without following symbolic links: link/../cache is cache in the working directory. The root directory as the cache, and a CDP_BROWSER_PATH or HOME that starts with ~, stop the install: only a shell expands ~; a .env file or a Dockerfile does not. A cache path with *, ?, [, ], \ or a line break stops it before the download, also where a symbolic link in the path leads to such a name: unzip reads the first five as a pattern, and zipinfo prints a line break as two lines, so safe-unzip would refuse the real path of the archive (see How it works). pin refuses these characters in the real path of its temporary directory, from TMPDIR or sys_temp_dir, before the first download. The constructor of Installer checks its cache directory the same way as install, and takes no relative path. With CDP_BROWSER_BINARY, the installer needs no cache directory; a relative path in CDP_BROWSER_BINARY stops the command.

In a container, set CDP_BROWSER_PATH to a directory in the project, so that the next container finds the browser of the previous one. To start the browser, see Run the browser.

Alpine and other musl systems

Chrome for Testing is built against glibc and does not start on musl, not even with gcompat. Measured on 2026-10-31 on Alpine 3.24.3, aarch64, with musl 1.2.6, gcompat 1.1.0 and the libraries of the chromium package: chrome-headless-shell 155.0.8059.39 for linux-arm64 stops with exit code 127 and "unsupported relocation type 1032", and glibc symbols such as __memcpy_chk are missing. Playwright does not support Alpine; Puppeteer, Symfony Panther, chrome-php and Browsershot use Alpine's chromium and an environment variable with its path. This installer does the same:

vendor/bin/cdp-browser deps                                  # as root: apk add chromium nss freetype harfbuzz ttf-freefont
CDP_BROWSER_BINARY=/usr/bin/chromium vendor/bin/cdp-browser install

Without CDP_BROWSER_BINARY, install detects musl (getconf, then the loader in the memory maps of the PHP process, then the loader file) and stops before it downloads; its message names the two commands above. path stops with the same message. The version of chromium is Alpine's, not the pin file's: on the same day, Alpine 3.24 shipped chromium 155.0.8059.84. On a musl system without apk, deps stops; install Chromium yourself there and set CDP_BROWSER_BINARY to its path.

path then prints the path of that chromium. It is the full Chromium, not the headless shell. Without a display, start it with --headless:

/usr/bin/chromium --headless --no-sandbox --dump-dom 'data:text/html,<p>ok</p>'

API

These classes and members are the API; SemVer covers them. Only a new major release breaks them, and the CHANGELOG marks each break as Breaking; Composer's ^1.0 takes no such release:

namespace SorgeIt\CdpBrowserInstaller;

final readonly class Installer
{
    public const string OWN_BROWSER = 'CDP_BROWSER_BINARY';
    public const string TIMEOUT = 'CDP_BROWSER_TIMEOUT';
    public const string LOCK_TIMEOUT = 'CDP_BROWSER_LOCK_TIMEOUT';

    public function __construct(CommandLine $commandLine, ?string $cache, ?string $ownBrowser = null, ?bool $musl = null, int $lockTimeout = 600);
    /** @param array<string, string> $environment */
    public static function fromEnvironment(array $environment, CommandLine $commandLine = new CommandLine, ?bool $musl = null): self;
    /** @param array<string, string> $environment */
    public static function cache(array $environment): string;
    /** @param (\Closure(string): void)|null $onDownload gets the URL before each download */
    public function browser(string $pinFile, Platform $platform, ?\Closure $onDownload = null): string;
    /** @param (\Closure(string): void)|null $onDownload gets the URL before each download */
    public function install(Pin $pin, Platform $platform, ?\Closure $onDownload = null): string;
    public function binary(Pin $pin, Platform $platform): string;
    public function isInstalled(Pin $pin, Platform $platform): bool;
    public function ownBrowser(): ?string;
}

final readonly class CommandLine
{
    /** @param array<string, string> $environment added to the environment of PHP for each tool */
    public function __construct(array $environment = [], int $timeout = 900);
}

final readonly class Pin
{
    public const string FILE = '.chrome-version';
    public string $version;

    /** @param array<string, string> $hashes the SHA-256 for each value of Platform */
    public function __construct(string $version, array $hashes);
    public static function checkVersion(string $version): void;
    public static function read(string $file): self;
    public function write(string $file): void;
    public function hash(Platform $platform): string;
}

enum Platform: string
{
    case LinuxX64 = 'linux64';
    case LinuxArm64 = 'linux-arm64';
    case MacX64 = 'mac-x64';
    case MacArm64 = 'mac-arm64';

    public const string BINARY = 'chrome-headless-shell';

    public static function detect(string $family, string $machine): self;
    public static function current(): self;
    public function directory(): string;
    public function binary(): string;
}

class InstallerException extends \RuntimeException { public const int EXIT_CODE = 1; }
final class DownloadException extends InstallerException { public const int EXIT_CODE = 75; }
final class ChecksumException extends InstallerException { public const int EXIT_CODE = 65; }

$musl = null detects musl as fromEnvironment() does; true or false sets it. The code of each exception is its EXIT_CODE.

Timeouts are in seconds: 0 means no limit for a tool, and no wait at the lock. A value below 0 throws an InstallerException. fromEnvironment() takes the timeout of $commandLine and 600 seconds for the lock, unless the environment variables set other values.

Each tool gets PHP's environment plus the variables in $environment: curl, rm, getconf, the package manager, CDP_BROWSER_BINARY, and zipinfo and unzip through safe-unzip.

The rest is @internal and can change in any release: the command classes of bin/cdp-browser (Console\*), the steps of an install (Download, Unpack, Libc, SystemLibraries, Environment, Files), every method of CommandLine, Installer::onMusl(), and the constructors of the three exceptions. The API covers what a test tool calls: find, install and check a browser, and tell a failed download from a wrong SHA-256. How the installer starts a tool or unpacks an archive is not part of it. CommandLine is part of the API only for its constructor, the one place where a caller sets the environment and the timeout of the tools.

A new subclass of InstallerException comes only in a minor release, and the CHANGELOG names it.

Exceptions

Every exception that the API throws is an InstallerException (a RuntimeException) or one of its two subclasses. Its message names the path or the value, and the next step. Callers catch it; only the package throws it. Where safe-unzip, Symfony or PHP threw first, getPrevious() returns that exception, one level deep: for the tool timeout (CDP_BROWSER_TIMEOUT), it is Symfony's ProcessTimedOutException. An exit code has no exception behind it, including curl's exit code 28 at its own limits; then getPrevious() is null.

Class When What the caller can do
DownloadException curl stops with an exit code, a signal or the tool timeout, or the source answers with a status other than 200 or 404; no archive is left, only a .part-* file that the run could not delete (see rule 2 under Security) a retry can help after a network error or a 5xx status; the message names the status or the exit code. Only a 3xx status names the redirect
ChecksumException the SHA-256 of the archive differs from the pin file; nothing is unpacked. In pin: the MD5 of a download differs from the one in x-goog-hash; no pin file is written after a new download, a retry does not help: the bytes are not the pinned ones. If the run deleted an archive from the cache, a retry downloads it again. The message names what happened to the archive. In pin, a retry downloads again; the message names the platform and both MD5 values
InstallerException every other cause: a value of the environment, an option or an argument; the user; the pin file; a version that the source does not have for the platform (status 404), such as a wrong version for pin; the platform or musl; CDP_BROWSER_BINARY; a missing tool, also curl; a tool other than curl that stops; safe-unzip refuses the archive or finds it broken, or the archive holds no binary of the platform; the file system; the lock of another install; path before install a human fixes the cause that the message names

install unpacks only after the SHA-256 has matched the pin file, and pin computes the SHA-256 from the same bytes. So a refusal by safe-unzip is an InstallerException, not a ChecksumException. safe-unzip's RefusedException, available through getPrevious(), has its own $reason: the rule of the profile.

A failed removal in a finally does not replace the first error: the first error names the cause, and the next install removes what the run left.

PHP's TypeError and ValueError are not the package's exceptions: they mark a call that violates the signature, such as a path with a NUL byte. Each step of the API suppresses the warning of a failed call of the file system and throws its own exception. So an error handler that turns PHP warnings into an ErrorException, as Laravel's does, still gets the package's exception. The tests run each such step under one; for chmod() and unlink() in the write of a stamp or a pin file, which a test cannot make fail, they put failing functions in their place.

Tests

composer test           # pint, rector, PHPStan level max, type coverage 100 %, every suite
composer test:coverage  # line coverage 100 %, with pcov; a CI job

The suite runs on Symfony 8: Pest 5 requires symfony/process 8.1. composer.json also allows Symfony 7.4.8 or later. The CI job symfony-7 installs the package at the lowest versions, without its development packages: Symfony 7.4.8. It runs deps, install, path, the browser's --version, and the refusal of an archive with one byte added.

Development

Report a bug or request a feature with a pull request, not an issue; a pull request with a failing test is enough. How to run the checks and record the demo: CONTRIBUTING.md. Report a vulnerability privately through GitHub; see SECURITY.md.

License

MIT, see LICENSE. Chrome for Testing has its own license; the archive contains it as LICENSE.headless_shell.

About the author

Stefan Sorge · PHP expert, builder, agentic engineer. 25 years of PHP and tech for 40+ startups. I build tools that humans and AI agents read and act on the same way.

Also by me:

GitHub · LinkedIn

Trademarks

Chrome™ and Chromium™ are trademarks of Google LLC. This project names them only to identify these browsers:

  • Chrome for Testing, the browser that it installs,
  • the system's Chromium, which it uses on musl,
  • the browsers in the comparison.

This project is not affiliated with, endorsed by or sponsored by Google.