Concurrent PHP using FrankenPHP threads with a Go semaphore sliding window, 150x+ speedup with standard blocking PHP code.
FrankenAsync dispatches PHP scripts as internal subrequests to separate FrankenPHP threads — no HTTP overhead, true parallelism. A Go semaphore controls the sliding window — tasks beyond the limit queue and execute as slots free up. Works with ANY blocking PHP code, no rewrites needed.
Note: This is a companion repo for my FrankenPHP conference talks. It's meant as inspiration and a reference implementation, not a production framework. Feel free to explore, fork, and adapt the patterns for your own projects.
For common questions, see the FAQ.
- PHP 150x Faster, Still Legacy-Friendly — ConFoo 2026 (slides)
- php[tek] 2026 — Chicago, May 19-21
PHP / Script::async() Go / FrankenAsync Go / FrankenPHP
┌────────────────────► ┌─────────────────────┐ ┌─────────────────┐
│ │ │ │ │
│ task 1 ───────────►│ ╔═══╗ ╔═══╗ │ │ thread 1 ● │
│ │ ║ 1 ║ ║ 2 ║ ───┼───►│ thread 2 ● │
│ task 2 ───────────►│ ╚═══╝ ╚═══╝ │ │ thread 3 ● │
│ │ │ │ thread 4 ● │
PHP │ task 3 ───────────►│ running (W) │ │ │
────────── │ │ │ └────────┬────────┘
index.php │ ... │ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │ │
│ │ ╎ 3 ╎ 4 ╎ ... ╎ │ │
│ task N ───────────►│ │ │
│ │ queued (N-W) │ │
│ │ │ │
│ └─────────────────────┘ │
│ │
│◄───────────────── Future::awaitAll() ◄─────────────────┘
│ collect results
- PHP calls
Script::async()for each task - Go task manager queues tasks through a semaphore (limits concurrent PHP threads)
- FrankenPHP executes each task on a separate thread
- PHP calls
Future::awaitAll()to collect all results
The entire concurrency model builds on just two primitives — Script::async() and Future. From there, higher-level patterns are plain PHP generators that compose on top. No coroutine runtime, no event loop, no framework — just generators and threads.
Script::async() + Future
│
▼
┌──────┬──────────┬──────────┐
│ │ │ │
race retry parallel throttle
Your existing PHP scripts — blocking DB queries, API calls, file I/O — stay exactly as they are. You add a thin orchestration layer on top:
// product.php, reviews.php, stock.php — existing scripts, unchanged
function productPage(int $id): \Generator {
[$product, $reviews, $stock] = yield from parallel([
fn() => (new Script('product.php'))->async(['id' => $id]),
fn() => (new Script('reviews.php'))->async(['id' => $id]),
fn() => (new Script('stock.php'))->async(['id' => $id]),
], concurrency: 3);
yield compact('product', 'reviews', 'stock');
}
// cart.php, stripe.php, paypal.php — existing scripts, unchanged
function checkout(int $cartId): \Generator {
$cart = yield from retry(3,
fn() => (new Script('cart.php'))->async(['id' => $cartId]));
$payment = yield from race([
(new Script('stripe.php'))->async($cart),
(new Script('paypal.php'))->async($cart),
], "10s");
yield $payment;
}Mix, nest, and chain patterns freely — retry inside a race, throttle with parallel batches — using standard PHP control flow. The scripts are legacy. The orchestration is new. You don't rewrite your PHP — you compose it. The more code paths you can compose into parallel execution, the faster your application becomes — without changing a single line of existing code.
FrankenAsync currently requires a fork of FrankenPHP that adds APIs not yet available in upstream FrankenPHP — hopefully that changes! The Frankenphp\Script and Frankenphp\Async\Future PHP classes are implemented as a C extension that calls back into Go to reach the per-request task manager — upstream FrankenPHP doesn't expose the thread plumbing to make that possible.
FrankenPHP already provides frankenphp.RegisterExtension(ptr) to register C zend_module_entry extensions — FrankenAsync uses this to register the Script and Future PHP classes.
The fork adds:
| API | Language | Purpose |
|---|---|---|
frankenphp.Thread(index) |
Go | Retrieves a PHP thread by index, returning its *http.Request — which carries the request context where the task manager is stored |
frankenphp_thread_index() |
C | Returns the current thread's index from C code, so PHP extension methods can call Thread(index) to get back into Go |
The call chain:
PHP: (new Script('task.php'))->async(['id' => 1])
→ C: PHP_METHOD(Script, async) // phpext.c
→ C: frankenphp_thread_index() // gets current thread index
→ Go: go_execute_script_async(index,...) // phpext.go (CGO export)
→ Go: frankenphp.Thread(index) // retrieves the request context
→ Go: asynctask.FromContext(ctx) // gets the task manager
→ Go: manager.Async(runnable) // executes on a new FrankenPHP thread
The fork is referenced via a replace directive in go.mod:
replace github.com/dunglas/frankenphp v1.11.3 => ../frankenphp
docker build -t frankenasync .
docker run -p 8081:8081 frankenasyncThe multi-stage Dockerfile handles everything — PHP build, FrankenPHP fork, and the host binary. Open http://localhost:8081 to see the demos.
The PHP build stage uses static-php-cli which can download pre-built libraries from GitHub instead of compiling from source. This requires a GitHub token to avoid API rate limits:
GITHUB_TOKEN=$(gh auth token) docker build \
--secret id=github_token,env=GITHUB_TOKEN \
-t frankenasync .Without the token the build still works — it just compiles all libraries from source, which takes longer.
- Go 1.26+
- The FrankenPHP fork cloned as a sibling directory (
../frankenphp)
make php # Build PHP 8.3 (ZTS, embed) via static-php-cli (one-time)
make env # Generate env.yaml with CGO flags from the PHP build
make build # Build the binary (dist/frankenasync)
make run # Build + start the server on :8081
make bench # Build + run automated test suiteThe PHP build is cached in build/.php/ — subsequent runs skip the build if libphp.a exists. To rebuild PHP from scratch:
make php-clean # Remove cached downloads and build artifacts
make php # Rebuild
make env # Regenerate env.yamlIf you prefer to use your own PHP build, create an env.yaml manually:
HOME: "/Users/you"
GOPATH: "/Users/you/go"
GOFLAGS: "-tags=nowatcher"
CGO_ENABLED: "1"
CGO_CFLAGS: "-I/path/to/php/include ..."
CGO_CPPFLAGS: "-I/path/to/php/include ..."
CGO_LDFLAGS: "-L/path/to/php/lib -lphp ..."The CGO flags must point to your PHP build's include headers and libraries. PHP must be built with ZTS (--enable-zts) and embed (--enable-embed).
Install the EnvFile plugin, then in your Run Configuration enable EnvFile and add env.yaml to load the CGO flags automatically.
| Variable | Default | Description |
|---|---|---|
FRANKENASYNC_PORT |
8081 |
HTTP listen port |
FRANKENASYNC_THREADS |
4 x CPU |
FrankenPHP thread pool size |
FRANKENASYNC_WORKERS |
threads - 2 |
Max concurrent subrequests (capped at threads - 2) |
| Parameter | Default | Description |
|---|---|---|
n |
100 |
Total number of tasks (comment fetches) |
local |
1 |
1 = simulated I/O (usleep), 0 = real HTTP via local Go API |
Examples:
?n=100— 100 tasks with simulated I/O (default)?n=500— 500 tasks, Go semaphore sliding window?n=100&local=0— 100 tasks with real HTTP calls to local Go API
use Frankenphp\Script;
use Frankenphp\Async\Future;
// Fire async subrequests
$task1 = (new Script('api/slow.php'))->async(['id' => 1]);
$task2 = (new Script('api/fast.php'))->async(['id' => 2]);
// Wait for all to complete
$results = Future::awaitAll([$task1, $task2], "5s");
// Sync execution
$result = (new Script('api/hello.php'))->execute();
// Deferred (starts on first await)
$task = (new Script('api/lazy.php'))->defer();
$result = $task->await("5s");$task->await("5s"); // Wait for completion
$task->cancel(); // Cancel the task
$task->getStatus(); // Status enum
$task->getDuration(); // Execution time in ms
$task->getError(); // Error message if failed
Future::awaitAll($tasks, "30s"); // Wait for all
Future::awaitAny($tasks, "30s"); // Wait for firstComposable generators on top of Script::async() and Future — no coroutines, no event loop (source):
use function Frankenphp\Async\{race, retry, parallel, throttle};
// Race: first wins, losers get cancelled
$result = yield from race([
(new Script('primary.php'))->async(),
(new Script('fallback.php'))->async(),
], "5s");
// Retry with exponential backoff
$result = yield from retry(3, fn() => (new Script('flaky.php'))->async(), "1s", 2.0);
// Parallel with sliding window concurrency limit
$results = yield from parallel($callables, concurrency: 5);
// Throttle — stream results in batches (generator)
foreach (throttle($allIds, 'task.php', batch: 50) as $result) {
// process each result as batches complete
}Concurrency is controlled through:
- PHP thread pool (
FRANKENASYNC_THREADS, default4 x CPU) — fixed pool of FrankenPHP threads - Worker semaphore (
FRANKENASYNC_WORKERS, defaultthreads - 2) — limits concurrent Go goroutines
Tasks exceeding the semaphore limit queue up and execute as slots become available (sliding window).
frankenasync/
|-- main.go # HTTP server, FrankenPHP init, request handling
|-- asynctask/ # Go task manager (async, defer, await, cancel)
| |-- manager.go # Task lifecycle, semaphore, goroutine pool
| |-- manager_option.go # Configuration options
| +-- context.go # Request context helpers
|-- phpext/ # C + Go PHP extension
| |-- phpext.go # Go exports (script exec, task await, etc.)
| |-- phpext.c # PHP class registration (Script, Future)
| |-- phpext.h # PHP class declarations + arginfos
| |-- phpext_cgo.h # CGO bridge header
| |-- util.c # Exception helpers
| +-- util.h # Exception declarations
|-- examples/ # PHP demo pages
| |-- index.php # Main demo (thread dispatch)
| |-- lib/
| | +-- async.php # Structured concurrency helpers (race, retry, throttle)
| +-- include/
| +-- task.php # Single blocking task
|-- build/
| +-- php/
| +-- Makefile # PHP build via static-php-cli (ZTS + embed)
|-- bench.sh # Automated test suite
|-- env.yaml # IDE environment variables (generated by `make env`)
+-- Makefile # Build targets
Code is MIT — see LICENSE.md. The talk material is licensed under CC BY 4.0 — free to share and adapt with attribution.
