DEV Community

Cover image for Box: Shipping a PHP CLI as a Single PHAR Binary
Gabriel Anhaia
Gabriel Anhaia

Posted on

Box: Shipping a PHP CLI as a Single PHAR Binary


You wrote a small CLI. It reads a config, hits a database, prints a
report. It works on your machine. Now a colleague on another team wants
to run it, and the instructions start with "clone the repo, run
composer install, make sure you're on PHP 8.4, oh and set this env
var." Three Slack messages later they give up and ask you to run it for
them.

The tools you already trust don't ask that. PHPStan, PHP-CS-Fixer,
Composer itself, Infection: you download one file and run it. That file
is a PHAR, and the standard way to build one in 2026 is
Box.

Turning a domain CLI into a single downloadable binary comes down to
four things: compilation, compression, the requirement checker, and
signing. No composer install on the user's side.

What a PHAR actually is

A PHAR (PHP Archive) is a bundle of PHP files plus a stub, wrapped in
one file the runtime can execute directly. Think of it as a jar for
PHP. The stub is a small bootstrap that runs first, registers the
archive's autoloader, and hands control to your entry point.

You can run one with php mytool.phar, or make it executable and run
./mytool if the stub starts with a shebang. Everything your CLI needs
(your code, your Composer dependencies, the autoloader) lives inside
that one file.

The catch that stops most people: creating a PHAR requires the
phar.readonly ini setting to be off. Reading one never does. Box
handles the build side for you by restarting PHP with the right flag, so
you don't edit php.ini by hand.

Install Box and point it at your entry file

Install Box as a dev dependency of the CLI project:

composer require --dev humbug/box
Enter fullscreen mode Exit fullscreen mode

Your CLI needs one entry file. A typical Symfony Console binary looks
like this:

#!/usr/bin/env php
<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use App\Command\ReportCommand;
use Symfony\Component\Console\Application;

$app = new Application('report', '1.0.0');
$app->add(new ReportCommand());
$app->run();
Enter fullscreen mode Exit fullscreen mode

Nothing here is PHAR-specific. The vendor/autoload.php require works
the same inside the archive as it does on disk, because Box rewrites
the path resolution when it compiles.

The box.json config

Box reads a box.json (or box.json.dist) at the project root. Start
minimal:

{
    "main": "bin/report",
    "output": "build/report.phar",
    "compression": "GZ",
    "directories": ["src"],
    "finder": [
        {
            "name": "*.php",
            "exclude": ["tests", "docs"],
            "in": "vendor"
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

main is your entry file. output is where the PHAR lands. The
finder block controls which vendor files get pulled in. That last
part matters more than it looks.

Before you compile, install dependencies without dev packages, so the
archive doesn't ship PHPUnit and Box themselves:

composer install --no-dev --classmap-authoritative
Enter fullscreen mode Exit fullscreen mode

--classmap-authoritative builds a single class map instead of the
PSR-4 directory scan, which is both faster at runtime and cleaner to
bundle. Then:

vendor/bin/box compile
Enter fullscreen mode Exit fullscreen mode

You now have build/report.phar. Run it:

php build/report.phar report:generate --month=2026-06
Enter fullscreen mode Exit fullscreen mode

Compression: real size, real trade-off

Set "compression": "GZ" and Box compresses each file inside the
archive with zlib. On a CLI with a few Symfony components bundled, that
turns a multi-megabyte PHAR into a fraction of the size. BZ2
compresses harder and runs slower. NONE is the default.

The trade-off is a runtime dependency. A GZ-compressed PHAR needs
ext-zlib on the machine that runs it; BZ2 needs ext-bz2. Zlib is
present on almost every PHP install, so GZ is the safe default. BZ2 is
worth it only when download size dominates and you control the target
environment.

Check what you shipped:

vendor/bin/box info build/report.phar
Enter fullscreen mode Exit fullscreen mode

That prints the archive's PHP version requirement, the compression
mode, the file count, and the total size. Run it after every build
change so a size regression doesn't sneak in.

The requirement checker: fail with a sentence, not a stack trace

Here's the failure you want to avoid. A user on PHP 8.1 downloads your
PHAR, which uses property hooks from PHP 8.4. They run it and get a
parse error pointing at a file inside the archive they've never seen.
They file a bug that isn't a bug.

Box solves this with the requirement checker. Turn it on:

{
    "check-requirements": true
}
Enter fullscreen mode Exit fullscreen mode

Box reads the platform constraints from your composer.json and
composer.lock: the PHP version, every ext-* you require. It embeds a
small checker that runs before your code. If the runtime doesn't
match, the user sees a readable message:

The application requires PHP 8.4 or greater but PHP 8.1.2
is installed.
Enter fullscreen mode Exit fullscreen mode

For this to work, declare your real requirements in composer.json
instead of leaving them implicit:

{
    "require": {
        "php": ">=8.4",
        "ext-pdo": "*",
        "ext-json": "*"
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the PHAR checks for PDO and JSON before touching a database, and
tells the user exactly what's missing. That one config flag turns a
cryptic crash into a support ticket you never receive.

Signing: proving the file wasn't tampered with

Every PHAR carries a signature. By default Box uses SHA-512: a hash of
the archive contents stored in the manifest. If a byte changes, the
runtime refuses to execute it. That protects against corruption but not
against a determined attacker, who could recompute the hash.

For real distribution integrity, sign with an OpenSSL private key.
Generate a key pair once:

openssl genrsa -out report.key 4096
openssl rsa -in report.key -pubout -out report.pub
Enter fullscreen mode Exit fullscreen mode

Point Box at the private key:

{
    "algorithm": "OPENSSL",
    "key": "report.key",
    "key-pass": true
}
Enter fullscreen mode Exit fullscreen mode

"key-pass": true makes Box prompt for the passphrase at compile time
instead of storing it in the repo. When you compile, Box writes
report.phar.pubkey next to the PHAR. Ship both files. The runtime uses
the public key to verify the signature, and any tampering breaks
verification. Keep report.key out of the repository and out of the
release artifacts.

Wiring it into a release

Put the build behind a script so every release is reproducible:

#!/usr/bin/env bash
set -euo pipefail

composer install --no-dev --classmap-authoritative
vendor/bin/box compile
vendor/bin/box info build/report.phar
Enter fullscreen mode Exit fullscreen mode

Two things worth adding once the basics work. First, validate the config
in CI with box validate, so a broken box.json fails the pipeline
early. Second, if your CLI bundles dependencies that other tools might
also load, look at PHP-Scoper,
which prefixes your vendored namespaces so a globally installed PHAR
never collides with a project's own copy of the same library. Box
integrates with it directly.

The result is what you wanted at the start. A user downloads one file,
maybe verifies it against the public key, and runs it. No clone, no
composer install, no PHP-version debugging over Slack. The tool
travels as a single artifact, the way a good CLI should.

Keeping the packaging at the edge

Compilation is a delivery concern, not a domain one. Your commands,
your report logic, your database access shouldn't know or care that
they're going to end up inside a PHAR. When the entry file is a thin
wrapper and the real work lives in classes that depend on interfaces
rather than a runtime, packaging becomes a step you bolt on at the
outer boundary. That separation between the domain and how it ships is
exactly the line Decoupled PHP draws.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)