crazywhalecc/static-php-cli
Static PHP CLI builds standalone, statically linked PHP binaries with selected extensions for Linux, macOS, and Windows, making it easy to package and distribute PHP applications without runtime dependencies.
static-php-cli (command name spc) is a build orchestrator: it downloads PHP + extension sources, compiles them statically, and emits a self-contained php binary (CLI, FPM, embed, or a micro.sfx runtime for PHARs). You use it whenever "the target machine has no PHP and no package manager" is a requirement.
Minimal setup inside the project that needs the artifact:
composer require --dev crazywhalecc/static-php-cli
./vendor/bin/spc doctor # verify compiler, autoconf, bison, re2c, pkg-config, etc.
./vendor/bin/spc list # what extensions/features this spc version knows about
Then the three-phase build (downloads are cached and reusable, builds are not):
./vendor/bin/spc download --all
./vendor/bin/spc build "bcmath,curl,openssl,mbstring,phar" --build-cli
# artifact: buildroot/bin/php
Verify immediately with the artifact itself, not with your system PHP:
./buildroot/bin/php -m
./buildroot/bin/php -r 'echo PHP_VERSION, PHP_EOL;'
First real use case: ship a CLI script/phar to a hardened host or scratch container. Build --build-micro instead of --build-cli, then combine:
./vendor/bin/spc build "phar,mbstring,openssl,curl" --build-micro
./vendor/bin/spc micro:combine app.phar -M buildroot/bin/micro.sfx -O app
./app --help
Where to look first when something misbehaves: spc <command> --help, then buildroot/ (binaries, logs, per-extension build dirs) and downloads/ (tarballs). The config/ directory in the package defines each extension's source, dependencies, and configure flags — that is the authoritative answer to "can this extension be built statically?". The registry grows with every minor/patch release (2.8.5 added deepclone and decimal), so spc list is only meaningful for the version you have locked.
Project-local build script. Treat the build as a repeatable task, not a one-off. Wire it into composer.json so everyone builds the same extension set:
"scripts": {
"build:cli": "spc download --all && spc build \"phar,mbstring,openssl,curl,pcntl\" --build-cli",
"build:app": "spc build \"phar,mbstring,openssl\" --build-micro && spc micro:combine build/app.phar -M buildroot/bin/micro.sfx -O build/app"
}
Keep the extension list in one place (a shell script or a Makefile target) — extension order and set drift is the number-one source of "works on my machine" build failures.
PHAR → single binary pipeline. The common day-to-day workflow for CLI tools (Laravel Zero, Symfony Console, custom scripts):
composer install --no-dev --classmap-authoritativephp -d phar.readonly=0, or your tool's own app:build)spc build ... --build-microspc micro:combine app.phar -M buildroot/bin/micro.sfx -O dist/appShip dist/app; the recipient needs nothing installed. This is the highest-leverage pattern — one artifact per platform, no runtime dependency management.
CI with aggressive caching. Downloads dominate cold builds. Cache downloads/ (and, if your CI tolerates it, buildroot/) keyed on the spc version + PHP version + extension list:
- uses: actions/cache@v4
with:
path: downloads
key: spc-${{ hashFiles('composer.lock') }}-${{ hashFiles('build/extensions.txt') }}
- run: ./vendor/bin/spc download --all
- run: ./vendor/bin/spc build "$(cat build/extensions.txt)" --build-cli
Build a matrix (PHP 8.2/8.3/8.4, x86_64/aarch64) and upload each binary as a release artifact. The spc version is a build input: pin it in composer.lock and rebuild when you bump it.
Multi-stage Docker. Two viable shapes:
debian:bookworm (with the toolchain), copy buildroot/bin/php into debian:bookworm-slim or ubuntu:.... This works because the runtime has a compatible glibc. Linux Mint hosts are now recognised as Ubuntu/Debian variants by spc's OS detection, so doctor gives you the right package hints there too.ldd output and the target libc before committing to FROM scratch.FPM for lean containers. --build-fpm gives you a static php-fpm you can drop into a minimal image without apt install php*. Pair with the extension set your framework actually needs (for Laravel: pdo_mysql/pdo_pgsql, mbstring, openssl, ctype, fileinfo, tokenizer, xml, curl, redis/sqlite3 as applicable) and verify with a real request, not just php -m.
Custom/private extensions. Use spc craft to scaffold a config for an extension not in the registry, then reference it like any built-in. This is the extension point for in-house C extensions or forks that PECL won't cover statically.
spc doctor is not optional; missing bison, re2c, autoconf, pkg-config, or dev headers produce confusing per-extension failures deep into the build. Run it first, every time. On rolling distros, keep spc current: 2.8.5 carries fixes for ICU on Windows, Swoole, krb5, PostgreSQL header/library copying on Windows, and xlswriter against modern Clang with C23 defaults.download --all plus build is multi-GB and tens of minutes. Cache downloads/ in CI; never rebuild from scratch on every commit.curl needs openssl (or another TLS backend) built first; intl needs ICU; pdo_mysql needs mysqlnd. If a build fails, read the extension's entry in config/ for its declared depends, and try --with-suggested-libs to let spc link system libraries for extensions that want them.dl() is a no-op, extension_dir is meaningless, and composer packages that require a missing extension fail at runtime, not build time. Diff php -m against your composer.json ext-* requirements in CI.micro.sfx is a different SAPI. php_sapi_name() returns micro; a system php.ini is not read, so set required directives in your bootstrap (ini_set, date.timezone, memory limits) or embed them at combine time. $_SERVER, argv, and getcwd() behave like CLI but are not identical — smoke-test argument parsing and config discovery in the combined binary, not just under buildroot/bin/php.__DIR__/__FILE__ resolve to phar://... paths. Use Phar::running() or your framework's base-path helper for filesystem access, and remember that writing next to the binary requires resolving the real executable path, not a phar path.--no-strip keeps symbols so gdb/valgrind are usable on a crashing extension. Re-enable stripping for release artifacts (size matters when shipping a single binary).--debug for verbose compiler output; the per-extension build directory under buildroot/ retains the generated configure invocation and logs. Reproducing that command by hand is usually faster than re-running the whole pipeline.spc version, PHP version, and extension set are all build inputs; unpinned builds silently change behavior between releases — and even patch releases shift which extensions exist and how they are patched. Pin spc in composer.lock and record the PHP version in your build script or CI matrix; rebuild deliberately when you bump it.buildroot/bin/php (or the combined binary) before publishing — this catches missing extensions, ini differences, and SAPI quirks that php -m will not.How can I help you explore Laravel packages today?