Loïc Faugeron Technical Blog

Turn your PHP app into a standalone binary 25/03/2026

Edit (2026-03-26): added Distribution / Upgrade section.


TL;DR:

  1. Prepare app for prod environment: no dev deps, autoloading optimisation (classmap authoritative), making .env.local.php from .env, no debug symfony cache, etc
  2. Compile the PHAR with Box
  3. Concatenate micro.sfx + dtk.phar into a self-contained binary with static-php-cli

I've been building DTK, a PHP CLI tool that automates the repetitive ceremony around the developer workflow: open ticket, create branch, open PR, merge, deploy - all wired together so you don't have to context-switch between your terminal, your Kanban board, and GitHub.

It's a Symfony Console app, it runs fine with php dtk, but distributing it to teammates means they need PHP installed at the right version, with the right extensions, plus Composer. That's friction I'd rather not ask anyone to deal with.

Turns out, PHP can produce a single self-contained binary, no PHP required on the target machine. I learned this from a talk by Jean-François Lépine at Forum PHP 2025, PHP without PHP: Make Standalone Binaries from Your Code, which is in French, but here's an English recap of it.

Surprisingly easy to set up. Here's how.

The two ingredients

Two tools do all the work.

Box packages a PHP project into a .phar archive. A .phar is a self-contained PHP archive: it includes all your source files and vendor dependencies, and PHP can execute it directly.

PHP Micro SFX, part of the static-php-cli (SPC) project, is a minimal static PHP binary with no external dependencies. It reads whatever binary data is appended to it and executes it as a .phar.

Combine the two:

micro.sfx + app.phar = standalone binary

One file. No PHP needed on the target machine. Drop it, run it.

Step 1: package the app as a PHAR

Box reads a box.json config file and produces the archive. Here's the one for DTK:

{
    "$schema": "https://box-project.github.io/box/schema.json",
    "main": "dtk",
    "output": "build/dtk.phar",
    "compression": "GZ",
    "check-requirements": false,
    "directories": [
        "config",
        "src",
        "var/cache/prod",
        "vendor"
    ],
    "files": [
        ".env.local.php"
    ]
}

A few things worth noting:

Step 2: get the micro binaries

static-php-cli prebuilds micro SFX files for all major platforms and PHP versions, so you don't need to compile anything yourself.

In the DTK Dockerfile, I download them all at image build time:

RUN for PLATFORM in linux-x86_64 linux-aarch64 macos-x86_64 macos-aarch64; do \
        curl -fsSL \
            -o /tmp/micro.tar.gz \
            "https://dl.static-php.dev/static-php-cli/common/php-${PHP_VERSION}-micro-${PLATFORM}.tar.gz" \
        && tar xzf /tmp/micro.tar.gz -C /usr/local/lib/ \
        && mv /usr/local/lib/micro.sfx "/usr/local/lib/micro-${PLATFORM}.sfx" \
        && rm /tmp/micro.tar.gz; \
    done

Windows has a separate download (a zip, not a tarball), but same idea.

The micro SFX from static-php-cli includes a whole bunch of extensions, so if you need some that are missing, or if you want the bare minimum, you'd need to compile your own micro using SPC, that's more involved, but SPC has a doctor --auto-fix command to help with the build environment setup.

Step 3: assemble the binaries

With the PHAR built and the micro SFX files in place, combining them is a cat 😼:

cat micro-linux-x86_64.sfx dtk.phar > dtk-linux-x86_64
chmod +x dtk-linux-x86_64

That's it. The resulting file is a valid ELF binary (or Mach-O on macOS, PE on Windows) that carries its own PHP interpreter alongside the application code.

Here's the full build script I use for DTK (bin/mk-dtk-bin.sh) that does all of it:

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

# Restore dev dependencies once finished
trap 'composer install --optimize-autoloader --quiet' EXIT

echo '  // Installing prod dependencies...'
composer install --no-dev --classmap-authoritative --quiet

echo '  // Compiling environment variables...'
php bin/mk-dtk-bin/dump-env-prod.php

echo '  // Warming up Symfony cache...'
APP_ENV=prod APP_DEBUG=0 php bin/console cache:warmup --quiet

echo '  // Building PHAR...'
mkdir -p build
php -d phar.readonly=0 /usr/local/bin/box compile

echo '  // Assembling binaries...'
for _PLATFORM in linux-x86_64 linux-aarch64 macos-x86_64 macos-aarch64 windows-x86_64; do
    case "${_PLATFORM}" in
        windows-*) _EXT='.exe' ; _CHMOD=false ;;
        *)         _EXT=''     ; _CHMOD=true  ;;
    esac

    cat "/usr/local/lib/micro-${_PLATFORM}.sfx" build/dtk.phar > "build/dtk-${_PLATFORM}${_EXT}"
    ${_CHMOD} && chmod +x "build/dtk-${_PLATFORM}${_EXT}"
done

echo '  // Generating checksums...'
sha256sum \
    build/dtk-linux-x86_64 \
    build/dtk-linux-aarch64 \
    build/dtk-macos-x86_64 \
    build/dtk-macos-aarch64 \
    build/dtk-windows-x86_64.exe \
    > build/checksums.txt

echo '  [OK] Binaries built'

A few things the script does before building the PHAR:

The trap at the top restores dev dependencies when the script exits, so the local dev environment is left intact after a build.

What comes out

Running make app-bin in the Docker container produces:

build/dtk.phar
build/dtk-linux-x86_64
build/dtk-linux-aarch64
build/dtk-macos-x86_64
build/dtk-macos-aarch64
build/dtk-windows-x86_64.exe
build/checksums.txt

Five binaries, one per platform, from a single command, without leaving Docker. Each one runs without PHP on the target machine.

Distribution / Upgrade

The binaries attach naturally to a GitHub release, alongside a checksums.txt generated by sha256sum. Users can download the right binary for their platform and verify the checksum.

That could cover the first install, but what about upgrades?

Note: In the PHP world, the "obvious" idea could be a self-update command: check the GitHub Releases API for a newer version, download the matching binary, replace the current executable. Tools like php-cs-fixer do this. It works, but it means implementing platform detection and handling the running-binary-can't-overwrite-itself problem on Windows. A reasonable amount of work for a modest gain.

There's an easier way, and it's what most CLI tools already rely on: a package manager (e.g. homebrew).

A Homebrew tap gets you the same result with none of that complexity, and it's the mechanism users already know and trust for CLI tools (e.g. fd, eza, tmux, etc).

A tap is just a GitHub repository (named homebrew-<something>) with a Ruby formula that points at the release assets:

class Dtk < Formula
  desc "Kanban, Git and Deployment, in one coherent flow"
  homepage "https://github.com/ssc-php/dtk"
  license "MIT"
  version "0.1.0"

  on_macos do
    on_intel do
      url "https://github.com/ssc-php/dtk/releases/download/v#{version}/dtk-macos-x86_64"
      sha256 "3e44b1c8583b9f5cc140acbc43726de81fb8a151c0afc43e1152729c9a06213b"
    end

    on_arm do
      url "https://github.com/ssc-php/dtk/releases/download/v#{version}/dtk-macos-aarch64"
      sha256 "957953df74861df6cb8f975c4864f4495277c63cdd8b73fa20f9884dd2538ca0"
    end
  end

  on_linux do
    on_intel do
      url "https://github.com/ssc-php/dtk/releases/download/v#{version}/dtk-linux-x86_64"
      sha256 "4870846397532db7e513b39cabfd3b6fba426fbb3232190480ffc2368a51da27"
    end

    on_arm do
      url "https://github.com/ssc-php/dtk/releases/download/v#{version}/dtk-linux-aarch64"
      sha256 "e4674f606d96fcbf2fe80505d26a4f260ef27f488b5d0e56e5193f54a9ebd263"
    end
  end

  def install
    bin.install Dir["dtk-*"].first => "dtk"
  end

  test do
    assert_match version.to_s, shell_output("#{bin}/dtk --version")
  end
end

Users install once:

brew tap ssc-php/dtk
brew install dtk

And upgrade the same way as any other tool:

brew upgrade dtk

The formula file lives in the tap repo. On each release, update the version field and the sha256 hashes, commit, push. The release script does this automatically.

Constraints worth knowing

This is real PHP, the same interpreter, the same extensions, the same behaviour. A few things to be aware of:

FFI is not available. Foreign Function Interface calls (PHP calling C libraries directly) don't work in static builds. For a CLI tool this is unlikely to matter.

Binary size. A minimal PHP binary with no extensions is around 3 MB. DTK, which only uses standard extensions, comes out much smaller than a full PHP install. Not Go-binary small, but perfectly acceptable for a CLI tool distributed via GitHub Releases.

Startup time. There's a small overhead compared to running php dtk directly: PHAR extraction adds a few milliseconds, and the static build uses musl libc rather than glibc, which is slightly slower. For a developer tool where the user is waiting hundreds of milliseconds anyway, this doesn't matter.

Not for web apps. This is for CLI / TUI / scripts, for web PHP apps, use FrankenPHP instead. It's a production-proven PHP app server built on top of static-php-cli that handles all the complexity, and it ships as a standalone binary too.

A surprisingly short path

The whole thing (Box config, Dockerfile setup, build script) took an afternoon. Most of that time was reading the static-php-cli docs and figuring out the Docker layering. The actual concatenation step (cat micro.sfx app.phar > binary) was the part that surprised me most: something that powerful should not be that simple :D !

If you're building a PHP CLI tool meant to be distributed to people who shouldn't need to care about PHP, this is the approach. It works, it's well-supported (FrankenPHP, Laravel Herd, and NativePHP all use static-php-cli under the hood), and the tooling is solid.

If you want to see a real-world example of a PHP project that compiles its own micro binaries (including the SPC setup), look at Castor. Castor is a task runner / script launcher for PHP (think Make or Taskfile, but in PHP) and it ships prebuilt binaries for all platforms. Its build setup is a good reference for when you outgrow the prebuilt micro SFX files and need to compile your own with a custom extension set.

The full DTK source used in this article is available at github.com/ssc-php/dtk/tree/v0.1.0. The Homebrew tap code added in the edit is in commit 129e26a. The Homebrew tap itself is at github.com/ssc-php/homebrew-dtk/commit/daa6aa1.