CLI
Single binary, all commands. Built with clap (Rust).
ephpm <command> [subcommand] [flags]Core Commands
ephpm serve
Start the PHP application server. This is the primary command — what runs in production.
# Start with config file (default: ./ephpm.toml)
ephpm serve
# Explicit config path
ephpm serve --config /etc/ephpm/ephpm.toml
# Override listen address
ephpm serve --listen 0.0.0.0:443
# Embed admin UI on this node (dev convenience)
ephpm serve --admin
# Foreground with log level
ephpm serve --log-level debug
# Specific PHP worker count (overrides config)
ephpm serve --workers 16
# Daemonize (background, writes PID file)
ephpm serve --daemon --pid-file /var/run/ephpm.pid
# Test mode — embedded SQLite, no external DB needed (see Development & Testing)
ephpm serve --test
ephpm serve --test --db-memory # in-memory SQLite (fastest, data lost on exit)
ephpm serve --test --db-temp # temp file SQLite (cleaned up on exit)What it starts:
- HTTP server (
:443by default,:80for HTTP→HTTPS redirect) - PHP thread pool (ZTS,
spawn_blocking) - DB proxy (if configured) — or embedded SQLite in
--testmode - KV store (if configured)
- OTLP receiver (
:4317gRPC,:4318HTTP — if configured) - Gossip listener (
:7946— if clustering configured) - Node API (
:9090— always) - Admin UI (
:8080— only with--admin)
Graceful shutdown: SIGTERM or SIGINT → drains in-flight requests, closes DB connections, leaves cluster gracefully, writes KV snapshot (if persistence enabled). On Windows, Ctrl+C and Ctrl+Break trigger graceful shutdown via SetConsoleCtrlHandler.
Graceful reload: SIGHUP → reloads ephpm.toml, restarts PHP workers (rolling — no dropped requests), updates DB pool sizes, refreshes TLS config. Does NOT restart the Rust process. On Windows (which has no SIGHUP), use ephpm reload which connects to the running instance’s Node API to trigger the reload.
ephpm admin
Start the admin UI as a standalone instance. Connects to one or more serving nodes via their Node API.
# Connect to specific nodes
ephpm admin --nodes 10.0.1.1:9090,10.0.1.2:9090,10.0.1.3:9090
# With config file (nodes listed in [admin] section)
ephpm admin --config /etc/ephpm/admin.toml
# Custom listen address
ephpm admin --listen 0.0.0.0:8080
# With Node API auth
ephpm admin --nodes 10.0.1.1:9090 --secret your-shared-secretWhat it starts:
- Admin web UI (
:8080by default) - Node connector (polls/streams Node API from each configured node)
Does NOT start: PHP workers, DB proxy, KV store, HTTP server, OTLP receiver. Zero PHP-related resource usage.
ephpm stop
Signal a running ePHPm instance to shut down gracefully.
# Stop via PID file
ephpm stop --pid-file /var/run/ephpm.pid
# Stop via signal to process
ephpm stop --pid 12345Sends SIGTERM. The running instance drains requests and exits cleanly.
ephpm reload
Signal a running instance to reload configuration without downtime.
ephpm reload --pid-file /var/run/ephpm.pidSends SIGHUP. The running instance reloads ephpm.toml and performs a rolling restart of PHP workers.
ephpm php
Run PHP CLI commands using the embedded PHP runtime. All standard PHP CLI flags are supported — args pass straight through to PHP’s own argument parser.
# Version
ephpm php -v
# Run code inline
ephpm php -r "echo phpversion();"
# Execute a file
ephpm php script.php
ephpm php -f script.php
# PHP info
ephpm php -i
# Loaded modules
ephpm php -m
# Syntax check (lint)
ephpm php -l src/Controller.php
# INI configuration
ephpm php --ini
# Source highlighting / stripping
ephpm php -s script.php
ephpm php -w script.php
# Reflection
ephpm php --rf array_map
ephpm php --rc DateTime
ephpm php --re json
ephpm php --ri opcache
# Pass -d INI overrides
ephpm php -d memory_limit=256M -r "echo ini_get('memory_limit');"
# Exit codes propagate correctly
ephpm php -r "exit(42);"; echo $? # → 42Implementation notes:
- The
ephpm phpsubcommand has clap’sdisable_help_flag = trueso-hpasses through to PHP instead of being intercepted by clap. - Backed by
ephpm_cli_main()incrates/ephpm-php/ephpm_wrapper.c, which uses PHP’s ownphp_getoptwith a copy of the CLI SAPI’s option table. - The
cli_options[]table inephpm_wrapper.cis a manual copy of PHP’sOPTIONSarray fromsapi/cli/php_cli.c. When upgrading PHP versions, diff that array against ours and sync any new flags. - Output goes directly to stdout/stderr (not buffered through the HTTP SAPI).
Configuration Commands
ephpm init
Scaffold a new ephpm.toml with sensible defaults and commented documentation.
# Interactive — asks about DB, clustering, etc.
ephpm init
# Generate minimal config
ephpm init --minimal
# Generate full config with all options documented
ephpm init --full
# Specify output path
ephpm init --output /etc/ephpm/ephpm.tomlGenerates something like:
# ePHPm Configuration
# Docs: https://ephpm.dev/docs/config
[server]
listen = "0.0.0.0:443"
http_redirect = true # redirect :80 → :443
workers = 0 # 0 = auto (num_cpus)
worker_max_requests = 0 # 0 = unlimited (restart after N requests for leak protection)
[php]
root = "./public"
entry = "index.php" # for worker mode
[tls]
acme_email = "" # required for auto TLS
# domains = ["example.com"] # optional, auto-detected from requests
# [db.sqlite]
# path = "./data/app.db" # file path, or ":memory:" for in-memory
# journal_mode = "wal" # WAL mode for better read concurrency
# create = true # auto-create DB file if missing
# [db.mysql]
# url = "mysql://user:pass@db:3306/myapp"
# max_connections = 50
# [db.postgres]
# url = "postgres://user:pass@db:5432/myapp"
# max_connections = 30
# [cluster]
# enabled = false
# bind = "0.0.0.0:7946"
# join = ["10.0.1.2:7946"]
[node_api]
listen = "0.0.0.0:9090"
# secret = "" # set this in productionephpm validate
Check configuration for errors without starting the server.
ephpm validate
ephpm validate --config /etc/ephpm/ephpm.tomlValidates:
- TOML syntax
- Required fields present
- DB URLs parseable
- Port conflicts (HTTP, DB proxy, Node API, OTLP, gossip — all on different ports)
- PHP root directory exists
- TLS cert paths valid (if manual certs)
- Cluster seed nodes resolvable
$ ephpm validate
✓ Config loaded from ./ephpm.toml
✓ PHP root ./public exists
✓ DB MySQL URL valid
✓ No port conflicts
✓ Node API secret set
✗ TLS: acme_email is empty — auto TLS will not workephpm config
Show the effective running configuration (with defaults applied, secrets redacted).
# Show effective config as TOML
ephpm config
# Show specific section
ephpm config server
ephpm config db
# Query from a running instance's Node API
ephpm config --node 10.0.1.1:9090Extension Management
The Extension Problem
PHP is built around extensions — gd for images, redis for caching, imagick for thumbnails, intl for i18n. The PHP ecosystem assumes you can pecl install whatever you need.
ePHPm embeds PHP as a statically linked library (libphp.a). Extensions are compiled directly into the binary. Unlike a standard PHP installation where you drop a .so file into a directory, ePHPm’s extensions are fixed at build time. This is what allows fully static binaries with zero runtime dependencies on every platform.
How competitors handle this:
- RoadRunner / Swoole — sidestep the problem entirely. They use a standard PHP installation on the system, so extensions work the normal way (
pecl install,apt install php-redis, etc.). The tradeoff: they require a full PHP installation on the target machine. - FrankenPHP — has the same problem. Their solution: ship a “mostly static” binary linked against glibc so that
.soextensions can be loaded at runtime. This forces a glibc dependency and breaksFROM scratch/ Alpine containers.
ePHPm’s approach: All extensions are statically compiled into the binary. No runtime loading, no glibc dependency, no .so files to manage. Instead, we solve the “I need extension X” problem with:
- Pre-built suite binaries — download a binary with extensions for your framework
- Custom builder — rebuild the binary with exactly the extensions you need via a container
This keeps the single-binary, zero-dependency model intact on every platform.
How It Works
ePHPm publishes multiple binary variants per platform, each with a different set of statically compiled extensions:
Production suites:
| Suite | Extensions | Binary size (approx) | Use case |
|---|---|---|---|
| core | ~15 exts (json, pcre, mbstring, openssl, curl, xml, zip, zlib, session, fileinfo, filter, dom, phar, tokenizer, sodium) | ~30 MB | Minimal base, add what you need via custom build |
| wordpress | core + mysqli, gd, exif, iconv, simplexml, xmlreader, pdo_sqlite, sqlite3 (~25 exts) | ~40 MB | WordPress and similar CMS apps |
| laravel | core + pdo_mysql, pdo_pgsql, pdo_sqlite, sqlite3, redis, gd, intl, bcmath, iconv (~30 exts) | ~70 MB | Laravel, Symfony, and modern PHP frameworks |
| full | Everything static-php-cli supports (~100+ exts) | ~150 MB | Kitchen sink — when you don’t want to think about it |
Development suites:
Each production suite has a corresponding -dev variant that adds debugging and profiling tools:
| Suite | Adds on top of production suite | Use case |
|---|---|---|
| wordpress-dev | xdebug, pcov, spx | Local WordPress development with step debugging and coverage |
| laravel-dev | xdebug, pcov, spx | Local Laravel/Symfony development |
| full-dev | xdebug, pcov, spx, excimer | Development with all extensions |
Dev suites include Zend extensions (xdebug, pcov) that are normally impossible to statically compile. ePHPm’s builder patches these into PHP’s source tree before building, the same way PHP’s own opcache (also a Zend extension) is built statically. See Zend Extensions for details.
Dev suites should never be used in production — xdebug adds significant overhead to every request, and pcov instruments code paths. The separation is intentional.
Users pick the suite that fits or use the custom builder.
Release Naming
# Production suites
ephpm-0.1.0-php8.4-core-linux-x86_64
ephpm-0.1.0-php8.4-wordpress-linux-x86_64
ephpm-0.1.0-php8.4-laravel-linux-x86_64
ephpm-0.1.0-php8.4-full-linux-x86_64
# Development suites
ephpm-0.1.0-php8.4-wordpress-dev-linux-x86_64
ephpm-0.1.0-php8.4-laravel-dev-linux-x86_64
ephpm-0.1.0-php8.4-full-dev-linux-x86_64
# Other platforms
ephpm-0.1.0-php8.4-wordpress-macos-aarch64
ephpm-0.1.0-php8.4-wordpress-dev-macos-aarch64
ephpm-0.1.0-php8.4-laravel-windows-x86_64.exe
ephpm-0.1.0-php8.4-laravel-dev-windows-x86_64.exe
# ... etc for each PHP version × suite × platformFully Static on Every Platform
Because all extensions are compiled in at build time, there is no need for dlopen() or LoadLibrary(). This means:
- Linux: Fully static musl binaries. Zero runtime dependencies. Works on Alpine,
FROM scratch, any distro. - macOS: Statically linked against libphp. Only system
libSystem.dylibrequired (always present — Apple mandates it). - Windows: Statically linked against
php8embed.libwith static CRT (/MT). No DLL dependencies beyond Windows system libraries.
No glibc requirement. No extension directory. No .so/.dll file management. One binary = complete deployment.
ephpm ext build
Build a new ePHPm binary with a custom extension set. Uses a container with static-php-cli and the Rust toolchain to rebuild both libphp.a (with your extensions) and the final ephpm binary.
# Add extensions to the default suite
ephpm ext build --add redis,imagick,intl
# Start from a specific suite and add more
ephpm ext build --suite laravel --add mongodb,grpc
# Build a dev variant (adds xdebug, pcov, spx automatically)
ephpm ext build --suite wordpress --dev
# Add xdebug to a custom build (builder detects it's a Zend extension)
ephpm ext build --suite wordpress --add redis,xdebug
# Build from an explicit extension list (no suite base)
ephpm ext build --extensions "json,pcre,mbstring,openssl,curl,redis,pdo_mysql"
# Pin PECL extension versions
ephpm ext build --suite wordpress --add "redis@6.0.2,apcu@5.1.24"
# Specify output path (default: ./ephpm or ./ephpm.exe)
ephpm ext build --suite laravel --output ./bin/ephpm
# Use Docker instead of Podman
CONTAINER_ENGINE=docker ephpm ext build --suite wordpressWhat happens:
$ ephpm ext build --suite wordpress --add redis,intl
■ Reading current binary metadata...
PHP 8.4.2, x86_64-linux, ePHPm v0.1.0
■ Pulling builder image...
ghcr.io/ephpm/builder:0.1.0-php8.4 (cached)
■ Building libphp.a with extensions...
Suite: wordpress (25 extensions)
Adding: redis, intl
Total: 27 extensions
spc download --with-php=8.4 --for-extensions="bcmath,curl,...,redis,intl"
spc build "bcmath,curl,...,redis,intl" --build-embed
■ Building ephpm binary...
cargo build --release
■ Validating...
Binary size: 72 MB
PHP version: 8.4.2 ✓
Extensions: 27 ✓ (redis ✓, intl ✓)
■ Output → ./ephpm
Verify with: ./ephpm ext listBuilder images are published by the ePHPm project:
ghcr.io/ephpm/builder:0.1.0-php8.4
ghcr.io/ephpm/builder:0.1.0-php8.3Each image contains: static-php-cli, Rust toolchain, ePHPm source (at the matching version tag), and all system library sources needed to build extensions from source (libpng, freetype, ICU, ImageMagick, etc.). The build runs entirely inside the container — no compiler toolchain needed on the host machine.
Build time: Expect 5-15 minutes depending on the extension set. ICU (for intl) and ImageMagick (for imagick) are the slowest to compile. Results can be cached — rebuilding with the same extension set reuses the static-php-cli build cache.
ephpm ext list
Show all extensions compiled into the current binary.
ephpm ext list$ ephpm ext list
Suite: wordpress + custom
EXTENSION VERSION STATUS
bcmath 8.4.2 built-in
curl 8.4.2 built-in
dom 20031129 built-in
exif 8.4.2 built-in
fileinfo 8.4.2 built-in
filter 8.4.2 built-in
gd 8.4.2 built-in
iconv 8.4.2 built-in
intl 8.4.2 custom
json 8.4.2 built-in
mbstring 8.4.2 built-in
mysqli 8.4.2 built-in
openssl 8.4.2 built-in
pcre 8.4.2 built-in
pdo_sqlite 8.4.2 built-in
redis 6.1.0 custom
session 8.4.2 built-in
simplexml 8.4.2 built-in
sodium 8.4.2 built-in
sqlite3 8.4.2 built-in
xml 8.4.2 built-in
xmlreader 8.4.2 built-in
zip 8.4.2 built-in
zlib 8.4.2 built-in
24 suite + 2 custom = 26 extensionsbuilt-in = part of the suite. custom = added via --add during ephpm ext build.
ephpm ext search
Search for extensions available in static-php-cli (the ~139 that can be statically compiled).
ephpm ext search redis
ephpm ext search image$ ephpm ext search image
NAME VERSION DESCRIPTION IN SUITE
imagick 3.7.0 ImageMagick bindings full
gmagick 2.0.6 GraphicsMagick bindings —
gd 8.4.2 GD image library wordpress, laravel, fullIN SUITE shows which pre-built suite binaries already include the extension, so users know if they need a custom build or can just download a different suite.
ephpm ext info
Show details about a specific extension in the current binary.
ephpm ext info redis$ ephpm ext info redis
Name: redis
Version: 6.1.0
Type: custom (added via ephpm ext build)
PHP API: 20240924
PECL: https://pecl.php.net/package/redis
Deps: igbinary (also compiled in)# Extension not in the current binary
ephpm ext info intl$ ephpm ext info intl
ext-intl is NOT in this binary.
Available in suites: laravel, full
Or add it: ephpm ext build --add intlZend Extensions (xdebug, pcov, spx)
Zend extensions hook into PHP’s engine at a deeper level than standard extensions — they intercept opcode execution, instrument function calls, and modify the compiler. PHP’s external extension build system (phpize) only supports compiling them as shared .so files, which conflicts with ePHPm’s fully-static model.
However, this is a build system limitation, not a technical one. PHP’s own opcache is a Zend extension and it compiles statically — because it lives inside the PHP source tree. The key insight: if you patch a Zend extension’s source into ext/ before building PHP, configure treats it like opcache and compiles it statically.
ePHPm’s approach: The builder patches Zend extension sources into the PHP source tree during the build. This lets xdebug, pcov, and other Zend extensions be compiled statically into the binary just like any other extension.
Why dev suites exist: Zend extensions like xdebug add overhead to every PHP opcode execution. pcov instruments every code path. These should never run in production. Rather than requiring users to remember which extensions are safe, ePHPm separates them into -dev suite variants:
# Development — xdebug, pcov, and spx compiled in
ephpm-0.1.0-php8.4-laravel-dev-linux-x86_64
# Production — same extensions minus dev tools
ephpm-0.1.0-php8.4-laravel-linux-x86_64Included dev Zend extensions:
| Extension | Purpose | Controlled via |
|---|---|---|
| xdebug | Step debugging, stack traces, profiling | xdebug.mode INI setting (off by default — zero overhead until enabled) |
| pcov | Code coverage (faster than xdebug’s coverage mode) | pcov.enabled INI setting |
| spx | Simple Profiling eXtension — web UI for profiling | spx.http_enabled INI setting |
Even in dev suites, these extensions are disabled by default via their INI settings. They only activate when explicitly configured:
# ephpm.toml — enable xdebug for step debugging
[php]
ini_overrides = [
["xdebug.mode", "debug"],
["xdebug.client_host", "host.docker.internal"],
["xdebug.start_with_request", "yes"],
]# Or via environment variable
XDEBUG_MODE=debug ephpm serve --testCustom dev builds: The builder also supports adding Zend extensions:
# Add xdebug to a custom build
ephpm ext build --suite wordpress --add redis,xdebug
# The builder detects xdebug is a Zend extension and patches it into ext/Zend extensions that cannot be statically compiled:
Some commercial Zend extensions (ionCube Loader, Zend Guard) are distributed as pre-compiled .so binaries with no source code available. These cannot be patched into the source tree. This is a hard limitation — there is no workaround without access to the source.
Development & Testing
Embedded SQLite
ePHPm includes pdo_sqlite and sqlite3 as built-in extensions (compiled into the binary). Combined with --test mode, this means a PHP application can run with zero external dependencies — no MySQL server, no Docker database container, no configuration.
Why this matters: The #1 barrier to “just try it” with any PHP application server is the database. Every other tool requires you to set up MySQL/PostgreSQL before you can see your app run. ePHPm removes that barrier.
Performance characteristics:
| SQLite (embedded) | MySQL (network) | |
|---|---|---|
| Read latency | ~1-5μs (in-process, no network) | ~200-500μs (TCP round-trip) |
| Write concurrency | Limited (WAL mode helps, but file-locked) | High |
| Setup time | 0ms (open a file) | Seconds (start server, create DB, configure) |
| Good for | Dev, testing, small/read-heavy apps | Production, write-heavy workloads |
SQLite is not a replacement for MySQL/PostgreSQL in production. It’s a development and testing tool that eliminates infrastructure dependencies.
ephpm serve --test
Test mode configures ePHPm for local development and testing with zero external dependencies.
# Default: SQLite file at ./data/ephpm-test.db (persists between runs)
ephpm serve --test
# In-memory SQLite — fastest, data lost when process exits
ephpm serve --test --db-memory
# Temp file SQLite — data cleaned up when process exits
ephpm serve --test --db-temp
# Combine with other flags
ephpm serve --test --listen :8080 --log-level debugWhat --test changes:
| Setting | Normal mode | Test mode |
|---|---|---|
| Database | [db.mysql] or [db.postgres] from config | Embedded SQLite :memory: (automatic) |
| Listen address | :443 | :8080 (no TLS) |
| TLS | ACME / manual certs | Disabled |
| Workers | Auto (num_cpus) | 1 (NTS, simpler debugging) |
| Log level | info | debug |
PHP display_errors | Off | On |
PHP error_reporting | E_ALL & ~E_NOTICE | E_ALL |
Test mode is explicit — it never activates implicitly. You must pass --test. This prevents accidentally running a production server with SQLite.
ephpm serve --test with Frameworks
Laravel:
# Laravel works out of the box — ePHPm sets DB_CONNECTION=sqlite automatically
cd my-laravel-app
ephpm serve --test --php-root ./public
# Run migrations against the embedded SQLite
ephpm php run artisan migrateePHPm injects DB_CONNECTION=sqlite and DB_DATABASE into the PHP environment in test mode. Laravel picks these up automatically via its .env fallback chain.
Symfony:
# Symfony uses DATABASE_URL — ePHPm sets it to the SQLite path
cd my-symfony-app
ephpm serve --test --php-root ./public
# Run migrations
ephpm php run bin/console doctrine:migrations:migrateWordPress:
WordPress officially supports MySQL only, but the WordPress Performance Team maintains wp-sqlite-db — a drop-in SQLite driver used by WordPress Playground. Setting this up with ePHPm is covered in the WordPress tutorial (see docs/tutorials/), not baked into the CLI.
ephpm test
Run the application’s test suite with an embedded SQLite database. This is a convenience wrapper that starts ePHPm in the background, runs your tests, and tears everything down.
# Run PHPUnit with an in-memory database (data fresh every run)
ephpm test -- vendor/bin/phpunit
# Run Pest
ephpm test -- vendor/bin/pest
# Run a specific test file
ephpm test -- vendor/bin/phpunit tests/Feature/CheckoutTest.php
# Pass flags to ePHPm
ephpm test --listen :9999 -- vendor/bin/phpunit
# Run with a persistent test database (useful for debugging failed tests)
ephpm test --db-persist ./test.db -- vendor/bin/phpunitWhat ephpm test does:
1. Start ePHPm in background
- --test --db-memory (default)
- Random available port (or --listen)
- Injects APP_URL=http://localhost:<port> into test env
2. Wait for server ready (health check)
3. Run your test command
- Passes through all args after --
- Inherits stdout/stderr
4. Capture exit code from test runner
5. Stop ePHPm, cleanup temp DB
6. Exit with the test runner's exit codeCI pipeline — before vs after:
Before (GitHub Actions with MySQL service):
services:
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: testdb
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
steps:
- uses: actions/checkout@v4
- run: cp .env.ci .env
- run: php artisan migrate
- run: php artisan serve &
- run: sleep 3
- run: vendor/bin/phpunitAfter:
steps:
- uses: actions/checkout@v4
- run: ephpm test -- vendor/bin/phpunitNo database service, no config copying, no sleep hacks, no background process management. One command.
Database Configuration
SQLite (development & testing)
For development setups that persist the database between runs (not --test mode), configure SQLite explicitly in ephpm.toml:
[db.sqlite]
path = "./data/app.db" # path to SQLite database file
journal_mode = "wal" # "wal" (default, best concurrency) or "delete" (traditional)
create = true # auto-create DB file and parent dirs if missing
busy_timeout = 5000 # ms to wait on locked DB before returning SQLITE_BUSY
cache_size = -64000 # negative = KB (64MB), positive = pagesIn-memory mode: Set path = ":memory:" for a fully in-process database with zero disk I/O. Fastest option for tests and throwaway environments. Data is lost when the process exits.
[db.sqlite]
path = ":memory:" # no disk, no file — pure in-process
cache_size = -128000 # 128MB (generous for in-memory)This is what ephpm serve --test --db-memory uses under the hood.
MySQL (production)
[db.mysql]
url = "mysql://user:pass@db-primary:3306/myapp"
min_connections = 5
max_connections = 50
idle_timeout = "300s"
inject_env = true # auto-set DB_HOST, DB_PORT, etc. for PHP
[db.mysql.replicas]
urls = [
"mysql://user:pass@db-replica-1:3306/myapp",
"mysql://user:pass@db-replica-2:3306/myapp",
]
read_write_split = true # SELECTs go to replicasPostgreSQL (production)
[db.postgres]
url = "postgres://user:pass@pg-primary:5432/myapp"
min_connections = 5
max_connections = 30
inject_env = true # auto-set DATABASE_URL, DB_HOST, etc. for PHP
[db.postgres.replicas]
urls = [
"postgres://user:pass@pg-replica-1:5432/myapp",
]
read_write_split = truePostgreSQL support uses the same DB proxy architecture as MySQL — connection pooling, query digest, slow query detection, read/write splitting. The proxy speaks the PostgreSQL wire protocol to PHP and to the real database.
Why no embedded PostgreSQL?
SQLite replaces MySQL/PostgreSQL for development because it runs in-process with zero setup. There is no equivalent embedded PostgreSQL — PostgreSQL is a client-server database by design and has no library/in-process mode.
For apps that depend on PostgreSQL-specific features (JSONB columns, arrays, ON CONFLICT, window functions with PostgreSQL syntax), SQLite won’t be a drop-in replacement. In those cases, point [db.postgres] at a local or containerized PostgreSQL instance:
# Start a dev PostgreSQL (one-time setup)
podman run -d --name pg-dev -p 5432:5432 \
-e POSTGRES_PASSWORD=dev postgres:17
# ephpm.toml
# [db.postgres]
# url = "postgres://postgres:dev@localhost:5432/myapp"When to use what:
| Scenario | Database config | External infra needed? |
|---|---|---|
Quick dev / ephpm serve --test | SQLite :memory: (automatic) | No |
| Dev with persistent data | [db.sqlite] with file path | No |
| Dev needing MySQL-specific features | [db.mysql] → local container | MySQL container |
| Dev needing PostgreSQL-specific features | [db.postgres] → local container | PostgreSQL container |
| Production (MySQL) | [db.mysql] with replicas | MySQL server(s) |
| Production (PostgreSQL) | [db.postgres] with replicas | PostgreSQL server(s) |
Database configs are mutually exclusive. Only one of [db.sqlite], [db.mysql], or [db.postgres] can be active. ephpm validate reports an error if multiple are present. Use environment variables to switch between environments:
# Development — zero setup
EPHPM_DB_SQLITE_PATH=":memory:" ephpm serve
# Staging — PostgreSQL
EPHPM_DB_POSTGRES_URL=postgres://user:pass@pg:5432/myapp ephpm serve
# Production — MySQL with replicas
ephpm serve --config /etc/ephpm/production.tomlInspection Commands
These connect to the Node API of a running instance. Useful for debugging, monitoring, and scripting.
ephpm status
Quick overview of a running node.
ephpm status
ephpm status --node 10.0.1.1:9090$ ephpm status
ePHPm v0.1.0 (PHP 8.4.2 ZTS)
Uptime: 3d 14h 22m
Workers: 12/16 busy, 4 idle, 0 queued
HTTP: 1,247 req/s (p99: 12ms)
DB Pool: 38/50 active connections
KV Store: 124MB used, 89,421 keys, 98.7% hit rate
Cluster: 3 nodes healthy
TLS: 4 certs managed, next renewal in 23d (this node is renewal leader)ephpm workers
PHP thread pool details.
# List workers
ephpm workers
ephpm workers --node 10.0.1.1:9090
# Restart all workers (rolling, no dropped requests)
ephpm workers restart
# Restart specific worker
ephpm workers restart --id 3$ ephpm workers
ID STATUS REQUESTS MEMORY UPTIME LAST REQUEST
0 busy 14,231 32MB 3d 14h 12ms ago
1 idle 13,887 28MB 3d 14h 340ms ago
2 busy 14,102 35MB 3d 14h 2ms ago
3 busy 14,450 31MB 3d 14h 8ms ago
...
16 workers | 12 busy | 4 idle | 0 queued | 0 crashedephpm db
DB proxy inspection and management. Connects to the Node API of a running instance.
# ── Pool Status ──
ephpm db status
ephpm db status --node 10.0.1.1:9090
# ── Query Digests ──
# Top query digests (by total time, default)
ephpm db digests
ephpm db digests --sort count # by execution count
ephpm db digests --sort avg-time # by average execution time
ephpm db digests --sort max-time # by worst single execution
ephpm db digests --sort total-time # by cumulative time (default)
ephpm db digests --limit 20
# Filter digests
ephpm db digests --min-count 100 # only queries executed 100+ times
ephpm db digests --min-avg "10ms" # only queries averaging >10ms
ephpm db digests --type read # only SELECT queries
ephpm db digests --type write # only INSERT/UPDATE/DELETE
# Show a specific digest's detail
ephpm db digest 0xa3f2b1c4
# Reset digest stats (clears all counters)
ephpm db digests reset
# ── Slow Queries ──
ephpm db slow
ephpm db slow --since 1h # last hour
ephpm db slow --since "2026-03-27 08:00" # since specific time
ephpm db slow --with-explain # include EXPLAIN output
ephpm db slow --min-duration "500ms" # only queries > 500ms
ephpm db slow --limit 50
# ── Pool Management ──
# Live connection pool details
ephpm db pool
ephpm db pool --backend mysql # filter by backend type
ephpm db pool --backend postgres
# Drain connections (for maintenance — new queries wait for fresh connections)
ephpm db pool drain
ephpm db pool drain --backend mysql
# ── Interactive Query (development only) ──
# Run a query through the proxy (for testing/debugging)
ephpm db query "SELECT COUNT(*) FROM users"
ephpm db query "SHOW PROCESSLIST"
ephpm db query "EXPLAIN SELECT * FROM orders WHERE status = 'pending'"$ ephpm db status
MySQL:
Primary: db-primary:3306 (connected)
Replicas: 2 healthy, 0 unhealthy
Pool: 38/50 active, 12 idle, 0 waiting
Lag: replica-1: 12ms, replica-2: 45ms
Postgres:
Primary: pg-primary:5432 (connected)
Replicas: 1 healthy
Pool: 8/30 active, 22 idle, 0 waiting
$ ephpm db digests --limit 5
DIGEST QUERY COUNT AVG MAX TOTAL TYPE
0xa3f2b1c4 SELECT * FROM users WHERE id = ? 45,231 2.1ms 89ms 95.0s read
0xb1c4d9e7 INSERT INTO orders (user_id, ...) VALUES (?) 12,089 5.3ms 210ms 64.1s write
0xd9e7f2a3 SELECT * FROM products WHERE category = ? 8,445 45.2ms 1.2s 381.8s read
0xf2a3b1c4 UPDATE users SET last_login = ? WHERE id = ? 6,721 1.8ms 45ms 12.1s write
0x1234abcd SELECT COUNT(*) FROM orders WHERE status = ? 3,211 12.4ms 340ms 39.8s read
$ ephpm db digest 0xd9e7f2a3
Digest: 0xd9e7f2a3
Query: SELECT * FROM products WHERE category = ? ORDER BY created_at DESC LIMIT ?
Type: read (routed to replica)
Count: 8,445
Avg time: 45.2ms
Min time: 3.1ms
Max time: 1,204ms
Total time: 381.8s
Rows sent: avg 24.3, max 50
First seen: 2026-03-25 14:22:01
Last seen: 2026-03-27 09:15:33
$ ephpm db slow --since 1h --with-explain --limit 3
── 2026-03-27 09:12:44 ─────────────────────────────────────────
Duration: 1,204ms
Digest: 0xd9e7f2a3
Query: SELECT * FROM products WHERE category = 'electronics' ORDER BY created_at DESC LIMIT 50
Backend: db-replica-1:3306
EXPLAIN:
id type table key rows Extra
1 range products idx_category 48,291 Using index condition; Using filesort
→ Missing index on (category, created_at). Suggest: CREATE INDEX idx_cat_date ON products(category, created_at DESC)
── 2026-03-27 09:08:12 ─────────────────────────────────────────
Duration: 892ms
...
$ ephpm db pool
BACKEND TYPE STATUS ACTIVE IDLE TOTAL MAX WAIT TIMEOUTS
db-primary:3306 mysql/rw healthy 22 8 30 50 0 0
db-replica-1:3306 mysql/ro healthy 10 5 15 50 0 0
db-replica-2:3306 mysql/ro healthy 6 9 15 50 0 0
pg-primary:5432 postgres healthy 5 15 20 30 0 0
Total: 43 active, 37 idle, 80 connections, 0 waitingephpm kv
Inspect and debug the KV store on a running server. Connects directly to the embedded KV server (RESP2 protocol).
Current implementation: String-based cache with TTL and counters. For clustering and advanced features (hashes, sets, transactions), see Planned below.
Shared Flags
ephpm kv [OPTIONS] <COMMAND>
Options:
--host <HOST> KV server host [default: 127.0.0.1]
--port <PORT> KV server port [default: 6379]Commands
# Connection test
ephpm kv ping
# Get a value
ephpm kv get mykey
ephpm kv get session:abc123
# Set a value
ephpm kv set mykey "hello world"
ephpm kv set session:data "{\"user\":42}" --ttl 3600 # with TTL in seconds
# Delete keys
ephpm kv del mykey
ephpm kv del key1 key2 key3 # multiple keys
# List keys matching a pattern (wildcard *)
ephpm kv keys "*"
ephpm kv keys "session:*"
ephpm kv keys "cache:*"
# Increment/decrement counters
ephpm kv incr page:views:/blog
ephpm kv incr counter
ephpm kv incr counter --by 10 # increment by N
# Show TTL information
ephpm kv ttl mykey
ephpm kv ttl session:abc123Examples
# Start the server with KV enabled
cargo xtask release
./target/release/ephpm serve
# In another terminal, test the commands:
$ ./target/debug/ephpm kv ping
PONG
$ ./target/debug/ephpm kv set greeting "hello world"
OK
$ ./target/debug/ephpm kv get greeting
hello world
$ ./target/debug/ephpm kv set counter 0
OK
$ ./target/debug/ephpm kv incr counter
(integer) 1
$ ./target/debug/ephpm kv incr counter --by 5
(integer) 6
$ ./target/debug/ephpm kv set temp value --ttl 60
OK
$ ./target/debug/ephpm kv ttl temp
expires in 59s (59986ms)
$ ./target/debug/ephpm kv keys "*"
1) greeting
2) counter
3) temp
$ ./target/debug/ephpm kv del counter temp
(integer) 2
$ ./target/debug/ephpm kv get missing
(nil)Planned KV Features
Future releases will add:
- Hashes, Lists, Sets — multi-type data structures (requires Store refactoring to an enum-based Entry type)
- Transactions —
MULTI/EXEC/WATCHfor atomic operations (requires per-connection state) - Scan — cursor-based iteration (SCAN, HSCAN, etc.) for large keyspaces without blocking
- Node API integration — Query KV stats from a running instance’s Node API (
--nodeflag) - Clustering — gossip-based key distribution, replication, ownership metadata
- Bulk operations — export/import, flush with pattern matching, key type breakdown
- Advanced patterns — RENAME, SCAN, PEXPIRE, GETEX, GETEX with replication info
See examples/README-KV.md for details on the embedded SAPI functions (ephpm_kv_get, ephpm_kv_set, etc.) which provide zero-copy direct access when called from PHP.
ephpm cluster
Cluster management.
# ── Status ──
ephpm cluster status
ephpm cluster status --node 10.0.1.1:9090
# ── Membership ──
# Force a node to leave (for maintenance — triggers key rebalancing)
ephpm cluster leave --node 10.0.1.3:7946
ephpm cluster leave --node 10.0.1.3:7946 --yes # skip confirmation
# ── Hash Ring ──
# Show hash ring layout
ephpm cluster ring
ephpm cluster ring --verbose # show all vnodes
# ── Replication ──
# Show replication status per key range
ephpm cluster replication
ephpm cluster replication --behind # only show ranges with lag
# ── Gossip ──
# Show gossip protocol state
ephpm cluster gossip$ ephpm cluster status
Cluster: ephpm-cluster (3 nodes)
State: healthy
Secret: configured ✓
mTLS: auto-generated certs, fingerprint pinning
NODE ROLE STATUS UPTIME LOAD VERSION
10.0.1.1:7946 member healthy 3d 14h 45% 0.1.0
10.0.1.2:7946 member healthy 3d 14h 38% 0.1.0
10.0.1.3:7946 leader healthy 3d 14h 42% 0.1.0
ACME leader: 10.0.1.3:7946 (heartbeat 12s ago)
Certs managed: 2 (example.com, www.example.com)
Next renewal: 23d
$ ephpm cluster ring
RANGE OWNER REPLICA 1 REPLICA 2
0x0000-0x0A3F 10.0.1.1:7946 10.0.1.2:7946 10.0.1.3:7946
0x0A40-0x1B2C 10.0.1.3:7946 10.0.1.1:7946 10.0.1.2:7946
0x1B2D-0x2E47 10.0.1.2:7946 10.0.1.3:7946 10.0.1.1:7946
...
450 vnodes (150 per node), 3x replication
$ ephpm cluster gossip
NODE GEN LAST SEEN RTT STATE
10.0.1.1:7946 127 0.2ms ago 0.8ms alive
10.0.1.2:7946 124 1.1ms ago 1.2ms alive
10.0.1.3:7946 131 0.4ms ago 0.6ms alive
Protocol: SWIM (chitchat)
Gossip interval: 200ms
Failure detection: 5 missed heartbeats (1s)ephpm traces
View recent traces from the ring buffer.
# List recent traces
ephpm traces
ephpm traces --limit 50
# Filter by slow requests
ephpm traces --min-duration 500ms
# Filter by status code
ephpm traces --status 500
# Show trace detail
ephpm traces show <trace-id>
# Live tail
ephpm traces tail$ ephpm traces --min-duration 500ms --limit 5
TRACE ID METHOD PATH STATUS DURATION DB QUERIES KV OPS
a1b2c3d4e5f6 GET /api/products 200 892ms 12 3
f6e5d4c3b2a1 POST /checkout 200 1,204ms 28 7
...
$ ephpm traces show a1b2c3d4e5f6
[HTTP GET /api/products 892ms]
├─ [PHP: App\Http\Controllers\ProductController@index 845ms]
│ ├─ [DB: SELECT * FROM products WHERE category = ? 312ms] ← SLOW
│ ├─ [DB: SELECT * FROM categories WHERE id IN (?, ?, ?) 8ms]
│ ├─ [KV: GET cache:products:featured 0.2ms] HIT
│ ├─ [DB: SELECT COUNT(*) FROM reviews WHERE product_id IN (...) 445ms] ← SLOW
│ └─ [KV: SET cache:products:listing 0.4ms]
└─ [Response: 200 OK, 12.4KB]Diagnostic Commands
ephpm version
$ ephpm version
ephpm 0.1.0 (rustc 1.83.0, PHP 8.4.2 ZTS)
Built: 2026-03-15T10:30:00Z
Commit: a1b2c3d
Target: x86_64-unknown-linux-musl
Suite: wordpress + custom (redis, intl)
Extensions: 26 (use `ephpm ext list` for full list)Shows the embedded PHP version, build target, suite, and extension count. Fully static on all platforms.
ephpm php
Interact with the embedded PHP interpreter directly.
# PHP version info (like php -v)
ephpm php version
# PHP info (like php -i, but from the embedded SAPI)
ephpm php info
# List compiled extensions (same as ephpm ext list)
ephpm php extensions
# Run a PHP file with the embedded interpreter
ephpm php run script.php
# Evaluate PHP code
ephpm php eval "echo phpversion();"
# Interactive REPL (if feasible)
ephpm php replThis is useful for verifying the embedded PHP works, checking which extensions are available, and debugging PHP issues without starting the full server.
ephpm doctor
Run diagnostics to verify the system is ready. Includes application extension scanning — doctor analyzes the PHP application in the document root to detect which extensions it needs and warns about any that are missing.
$ ephpm doctor
Checking ePHPm environment...
✓ PHP 8.4.2 ZTS embedded and functional
✓ OPcache enabled
✓ Suite: wordpress (25 extensions compiled in)
✓ Config ./ephpm.toml valid
✓ PHP root ./public/index.php exists
✓ Port 443 available
✓ Port 9090 available
✓ Container engine: podman 5.3.1 (for ephpm ext build)
✓ DB connection: mysql://...@db:3306/myapp — connected (5ms)
✓ DB user has PROCESS privilege (required for auto-EXPLAIN)
✗ Cluster: seed node 10.0.1.2:7946 unreachable
✓ TLS: ACME account registered with Let's Encrypt
✓ TLS: cert renewal leader is node-a (healthy, heartbeat 12s ago)
✓ TLS: 4 certs replicated across 3 nodes
✓ DNS: example.com resolves to this server (93.184.216.34)
✓ Memory: 16GB available, recommended min 512MB per worker × 16 workers = 8GB
Scanning application for extension requirements...
✓ ext-json — required by composer.json ✓ (compiled in)
✓ ext-mbstring — required by composer.json ✓ (compiled in)
✓ ext-curl — required by composer.json ✓ (compiled in)
✓ ext-redis — required by composer.json ✓ (compiled in)
✗ ext-intl — required by composer.json ✗ MISSING
✓ ext-gd — detected: imagecreatefromjpeg() in src/ImageService.php:42 ✓ (compiled in)
⚠ ext-memcached — detected: new Memcached() in src/Cache/MemcachedStore.php:18 — not in binary (optional?)
2 issues found:
✗ ext-intl required by composer.json — rebuild: ephpm ext build --add intl
✗ Cluster seed node 10.0.1.2:7946 unreachable — check firewall or node status
1 warning:
⚠ ext-memcached detected in source but not in composer.json — may be optional or dead codeHow application scanning works:
Doctor uses a layered detection strategy, from most to least authoritative:
composer.json/composer.lock(highest confidence) — Parses therequireandrequire-devsections forext-*entries. This is the authoritative source because the developer explicitly declared these dependencies. Also recursively checkscomposer.lockfor transitiveext-*requirements from packages.PHP source scanning (medium confidence) — Scans
.phpfiles in the document root for function calls, class instantiations, and constants that map to specific extensions. PHP’s function-to-extension mapping is well-defined — every function in the PHP docs belongs to exactly one extension. Examples:new \Redis(),$redis->connect()→ext-redisnew \Imagick()→ext-imagickcurl_init(),curl_exec()→ext-curlmb_strlen(),mb_detect_encoding()→ext-mbstring\IntlDateFormatter,\NumberFormatter→ext-intlsodium_crypto_secretbox()→ext-sodiumyaml_parse()→ext-yaml
WordPress detection (framework-specific) — If the document root contains
wp-config.phporwp-includes/, doctor knows the WordPress core requirements (mysqli, json, mbstring, xml, curl, openssl, gd, zip, etc.) and checks for them directly. Also scans active plugin/theme directories for their own declared requirements.
Confidence levels in output:
required by composer.json— definite requirement, will fail at runtime without itdetected: function() in file:line— found in source code, very likely needed⚠ detected in source but not in composer.json— may be optional, dead code, or behind a feature flag
Skipping the scan:
# Skip application scanning (faster, just checks config + infra)
ephpm doctor --no-scan
# Only run the application scan
ephpm doctor --scan-onlyCommand Summary
ephpm serve Start the PHP application server
ephpm serve --test Start in test mode (embedded SQLite, no external DB)
ephpm admin Start the admin UI (standalone)
ephpm stop Graceful shutdown of a running instance
ephpm reload Reload config + rolling worker restart
ephpm init Scaffold ephpm.toml
ephpm validate Check config for errors
ephpm config Show effective configuration
ephpm ext build Rebuild binary with custom extensions via container
ephpm ext list List extensions compiled into the current binary
ephpm ext search Search available extensions (static-php-cli supported)
ephpm ext info Show details about a specific extension
ephpm test Run tests with embedded SQLite (start, test, teardown)
ephpm status Quick overview of a running node
ephpm workers PHP thread pool details + restart
ephpm db status DB proxy pool status (active/idle connections, lag)
ephpm db digests Top query digests (sort by count/time, filter by type)
ephpm db digest ID Detail for a specific query digest
ephpm db slow Slow query log with optional EXPLAIN output
ephpm db pool Connection pool details per backend
ephpm db pool drain Drain connections for maintenance
ephpm db query SQL Run a query through the proxy (dev/debug only)
ephpm kv stats KV store memory, keys, hit rate, evictions
ephpm kv get KEY Get a key (value, TTL, owner node, size)
ephpm kv set K V Set a key with optional --ttl
ephpm kv del KEY Delete a key (or --pattern for glob match)
ephpm kv keys PAT Scan keys by pattern (cursor-based, safe)
ephpm kv count PAT Count keys matching a pattern
ephpm kv types Key type breakdown (string/hash counts + memory)
ephpm kv owner KEY Show which node owns a key + its replicas
ephpm kv hgetall K Show all fields of a hash key
ephpm kv flush Flush keys (all or by --pattern, requires confirm)
ephpm kv export PAT Export keys to JSON or Redis format
ephpm kv import Import keys from JSON
ephpm kv cluster Cluster membership, per-node key distribution
ephpm cluster status Cluster health, nodes, ACME leader, mTLS status
ephpm cluster ring Hash ring layout (owners + replicas per range)
ephpm cluster leave Force a node to leave (triggers rebalancing)
ephpm cluster replication Replication status per key range
ephpm cluster gossip Gossip protocol state (generations, RTT, liveness)
ephpm traces View/filter/tail distributed traces
ephpm version Version, build info, embedded PHP version
ephpm php Interact with embedded PHP (version, info, eval, run)
ephpm doctor Run system diagnosticsDesign Principles
Zero to running in one command. A developer should go from
git cloneto a working app withephpm serve --test. No database server, no config files, no infrastructure. The embedded SQLite and sensible defaults make this possible. The config file is for production tuning, not getting started.Development-first, production-ready. ePHPm ships binaries for Linux, macOS, and Windows. Developers run it natively on their machine — no Docker required for local dev. The same binary that runs on a developer’s laptop runs in production (different config, same tool).
Inspection commands connect to the Node API. They don’t read internal state directly — they’re HTTP clients to
:9090. This means they work locally (ephpm status) or remotely (ephpm status --node 10.0.1.1:9090).Machine-readable output. All inspection commands support
--jsonfor scripting and automation:ephpm workers --json | jq '.[] | select(.status == "busy")' ephpm db digests --json --sort total-time --limit 10No interactive prompts in production commands.
ephpm serve,ephpm admin,ephpm stop,ephpm reloadnever prompt. Onlyephpm initis interactive (and has--minimal/--fullfor non-interactive use).Consistent
--nodeflag. Any inspection command can target a remote node:ephpm status --node 10.0.1.1:9090 ephpm workers --node 10.0.1.1:9090 ephpm db digests --node 10.0.1.1:9090Without
--node, commands connect tolocalhost:9090(assumes local instance).Exit codes matter.
0= success,1= error,2= validation failure.ephpm validate,ephpm doctor, andephpm testuse this for CI/CD gating:ephpm validate && ephpm serve ephpm test -- vendor/bin/phpunit # exits with test runner's exit code