phalanx-php / aegis
Expression-based async coordination for PHP 8.4+
Requires
- php: ^8.4
- react/async: ^4.3
- react/event-loop: ^1.5
- react/promise: ^3.2
Requires (Dev)
- phalanx-php/archon: ^0.5
- phalanx-php/hydra: ^0.5
- phalanx-php/stoa: ^0.5
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
- react/promise-timer: ^1.11
- rector/rector: ^2.0
- slevomat/coding-standard: ^8.15
- squizlabs/php_codesniffer: ^3.10
Suggests
- phalanx-php/archon: CLI application framework
- phalanx-php/hydra: Worker process parallelism
- phalanx-php/stoa: HTTP server framework
- phalanx-php/styx: Reactive stream primitives (Emitter, ScopedStream, Channel)
- react/http: Required for HttpRunner
- spatie/backtrace: Enhanced stack traces
- symfony/dotenv: For .env file support
- symfony/runtime: For symfony/runtime integration
This package is auto-updated.
Last update: 2026-04-29 14:53:22 UTC
README
Phalanx Aegis
Async PHP without the async.
Three HTTP calls in parallel. Cancellation that actually fires. Memory that stays flat across millions of rows. Stack traces that name your work instead of Closure@handler.php:47.
Phalanx is built on ReactPHP and AMPHP. It separates what you want to happen from how it runs. You write named computations. The scope handles the fibers, the event loop, cancellation, and cleanup.
PHP 8.4 -- fibers, property hooks, asymmetric visibility, lazy proxies -- is the foundation, not an afterthought.
Proof
<?php [$app, $scope] = Application::starting() ->providers(new AppBundle()) ->compile() ->boot(); [$customer, $inventory, $quote] = $scope->concurrent([ new FetchCustomer($id), new ValidateInventory($items), new PriceQuote($items), ]);
Run it with tracing:
PHALANX_TRACE=1 php server.php
0ms STRT compiling
4ms STRT startup
6ms CON> concurrent(3)
7ms EXEC FetchCustomer
7ms EXEC ValidateInventory
8ms EXEC PriceQuote
12ms DONE PriceQuote +4.1ms
16ms DONE FetchCustomer +9.2ms
18ms DONE ValidateInventory +10.4ms
19ms CON< concurrent(3) joined +12.8ms
3 svc 4.0MB peak 0 gc 39.8ms total
Three tasks. One process. Bounded memory. Visible execution.
The deadlocks you can't see at any single call site
Async PHP is mostly fine until you compose it. Two services that are correct in isolation can deadlock when one calls the other -- and the call site that triggers it looks completely innocuous. These are the ones that bite real codebases.
The classes that look fine on their own
<?php final class OrderRepository { public function __construct( private ConnectionPool $pool, private AuditService $audit, ) {} public function createOrder(array $data): Order { $conn = $this->pool->acquire(); $conn->beginTransaction(); $order = $conn->insert('orders', $data); $this->audit->log('order.created', $order->id); // looks innocent $conn->commit(); $this->pool->release($conn); return $order; } } final class AuditService { public function __construct(private ConnectionPool $pool) {} public function log(string $event, int $entityId): void { $conn = $this->pool->acquire(); // same pool $conn->insert('audit_log', ['event' => $event, 'entity_id' => $entityId]); $this->pool->release($conn); } }
Pool size 10. Ten concurrent fibers each hold a connection in createOrder and call audit->log, which tries to acquire a second from the same pool. Nothing is available, nothing will be released until each fiber gets the second connection it's waiting for. The server hangs with no error. Unit tests passed. The author of AuditService had no idea it was being called inside an existing acquisition.
What goes wrong, and what Phalanx does about it
| What goes wrong | Why it bites | What Phalanx gives you |
|---|---|---|
| Nested pool acquire across services exhausts a shared pool | Each class is correct alone; the deadlock only appears when concurrency >= pool size | Pass the connection through the scope; bound concurrency with $scope->map($items, $fn, limit: N) so you can't outrun your pool |
| Awaiting external I/O while inside an open DB transaction holds row locks across the suspension | The row lock is in the database; PHP can't see it; sync PHP-FPM never had this problem | $scope->await() is the only suspension primitive in app code -- it's grep-able; the scope-level deadline kills the request before the lock starves others |
Two parallel fibers compute cache A (which needs B) and cache B (which needs A); both hold one lock and wait for the other |
The lock is hidden inside the cache library | $scope->singleflight($key, $task) collapses concurrent duplicates behind one execution; resolve dependencies before you enter the cache callback |
| A bug in a transitive dependency swallows an exception; a deferred is never resolved; the fiber suspends forever; memory grows | Every awaiting fiber is one more leaked stack | $scope->await() races every promise against the scope's CancellationToken; a scope-level deadline guarantees every fiber eventually resumes, even with an exception |
| Non-fiber-safe C extension (gRPC, some DB drivers) holds a pthread mutex across a fiber suspension; the next fiber blocks the entire process | The event loop is dead, not just one fiber | $scope->inWorker($task) runs the task in a child process so the extension can't poison the event loop |
| Pool isn't FIFO; faster fibers always win the race; slower fibers timeout indefinitely | The pool is "healthy" but the application isn't | Bound concurrency at the call site with map(..., limit: N) instead of relying on pool fairness; $scope->settle() lets you see which tasks failed |
The point isn't that Phalanx prevents every footgun. It's that the patterns that cause these problems -- raw await() scattered across services, untracked transactions, untimed promises, ad-hoc pool acquisition -- aren't the path of least resistance. The scope is.
Install
composer require phalanx/aegis
Note
Requires PHP 8.4 or later.
Mental model
Application -> Scope -> Tasks
The Application owns long-lived services and the trace. A Scope is the unit of work -- a request, a command, a worker turn -- and carries cancellation, disposal, and access to services. Tasks are the things that actually run; the scope orchestrates them.
<?php [$app, $scope] = Application::starting() ->providers(new AppBundle()) ->compile() ->boot(); $result = $scope->execute(new ProcessOrder($id)); $scope->dispose(); // scoped services and disposal hooks fire in reverse order $app->shutdown(); // singleton shutdown hooks fire
Concurrency primitives
This is what the scope gives you. Every method takes Scopeable | Executable tasks -- a closure-backed Task::of(...) or a named class.
| Method | Behavior | Returns |
|---|---|---|
concurrent($tasks) |
Run all concurrently, wait for all, throw on first failure | Array of results |
settle($tasks) |
Run all, collect outcomes including failures | SettlementBag |
race($tasks) |
First to settle wins (success or failure) | Single result |
any($tasks) |
First success wins (failures ignored unless all fail) | Single result |
map($items, $fn, limit: 10) |
Bounded concurrent map over a collection | Array of results |
series($tasks) |
Sequential | Array of results |
waterfall($tasks) |
Sequential, each receives the previous result | Final result |
timeout($seconds, $task) |
Run with a deadline | Result, or throws |
retry($task, $policy) |
Run with retry policy | Result, or throws after exhaustion |
singleflight($key, $task) |
De-duplicate concurrent calls behind a key | Shared result |
inWorker($task) |
Run in a child process (parallel, not concurrent) | Result |
<?php // Bounded concurrent fetch -- 10,000 items, 10 fibers in flight at any moment. $results = $scope->map( items: $orderIds, fn: static fn(int $id): Executable => new FetchOrder($id), limit: 10, ); // Fallback pattern -- first cache hit wins, network is the backstop. $value = $scope->any([ new FetchFromRedis($key), new FetchFromS3($key), new FetchFromOrigin($key), ]); // Partial-failure tolerant fan-out. $bag = $scope->settle([ 'primary' => new SyncToPrimary($order), 'analytics' => new EmitAnalytics($order), 'webhook' => new NotifyWebhook($order), ]); if ($bag->allOk) { return $bag->values; } logger()->warning('partial sync', ['failed' => $bag->errKeys]); return $bag->extract(['primary' => null, 'analytics' => [], 'webhook' => false]);
Concurrent != Parallel. concurrent(), map(), race(), any() interleave fibers in a single process. inWorker() runs in a child process. Same API, different runtime -- the call site decides.
Tasks
For trivial one-offs, a static closure is fine:
<?php $user = $scope->execute( Task::of(static fn(Scope $s) => $s->service(UserRepo::class)->find($id)) );
Task::of() enforces static at runtime via reflection. Non-static closures capture $this and create reference cycles that leak in long-running processes.
For anything with meaning beyond a single call site -- anything you want to log, test, queue, or read in a stack trace -- use a named invokable:
<?php final readonly class FetchUser implements Scopeable { public function __construct(private int $id) {} public function __invoke(Scope $scope): User { return $scope->service(UserRepo::class)->find($this->id); } } $user = $scope->execute(new FetchUser(42));
What you get back:
- Traceable. Stack traces show
FetchUser::__invoke, notClosure@handler.php:47. - Testable. Construct with mocks, invoke, assert. No framework required.
- Serializable. Constructor args are data -- queue it, ship it to a worker, replay it.
- Inspectable. The class name is the identity. Logs, metrics, traces all carry it.
Scopeable tasks receive Scope (service resolution only). Executable tasks receive ExecutionScope (full concurrency primitives). Pick the narrowest interface that covers what the task actually does.
Behavior via interfaces
Tasks declare cross-cutting behavior through PHP 8.4 property hooks. The scope reads the interface, the pipeline applies automatically.
<?php final class DatabaseQuery implements Scopeable, Retryable, HasTimeout, Traceable { public RetryPolicy $retryPolicy { get => RetryPolicy::exponential(attempts: 3) ->retryingOn(ConnectionException::class); } public float $timeout { get => 5.0; } public string $traceName { get => "db.query[{$this->table}]"; } public function __construct( private string $table, private string $sql, ) {} public function __invoke(Scope $scope): array { return $scope->service(Database::class)->query($this->sql); } }
timeout wraps retry wraps trace wraps your work. The pipeline is composed once and reused on every dispatch.
| Interface | Property | Adds |
|---|---|---|
Retryable |
RetryPolicy $retryPolicy { get; } |
Automatic retry with policy |
HasTimeout |
float $timeout { get; } |
Per-task deadline |
HasPriority |
int $priority { get; } |
Priority queue ordering |
UsesPool |
UnitEnum $pool { get; } |
Pool-aware scheduling |
Traceable |
string $traceName { get; } |
Custom trace label |
SelfDescribed |
string $description { get; } |
Human-readable description |
Tagged |
list<string> $tags { get; } |
Classification labels |
Cancellation, deadlines, retry
Every scope carries a CancellationToken. $scope->await() races every promise against it -- so cancellation is not advisory, it lands.
<?php // Hard deadline for the entire scope. $scope = $app->createScope(CancellationToken::timeout(30.0)); // Per-task deadline. $result = $scope->timeout(5.0, new SlowApiCall($id)); // Retry with exponential backoff and jitter. $result = $scope->retry( new FetchFromApi($url), RetryPolicy::exponential(attempts: 3)->retryingOn(NetworkException::class), ); // Cooperative cancellation in long loops. final class ProcessChunks implements Executable { public function __invoke(ExecutionScope $scope): int { $count = 0; foreach ($this->chunks as $chunk) { $scope->throwIfCancelled(); $count += $this->process($chunk); } return $count; } }
RetryPolicy::exponential(), linear(), and fixed() are first-class. Jitter is built in. CancelledException is never retried.
Lazy sequences
LazySequence is a generator-driven pipeline. Values flow one at a time, operators are lazy, terminals trigger execution. Memory stays flat regardless of dataset size -- process a million-row cursor without holding a million rows.
<?php use Phalanx\Task\LazySequence; $summary = LazySequence::from(static function (ExecutionScope $scope) { foreach ($scope->service(OrderRepo::class)->cursor() as $order) { yield $order; } }) ->filter(static fn(Order $o) => $o->total > 100_00) ->map(static fn(Order $o) => new OrderSummary($o)) ->take(50) ->toArray(); $top50 = $scope->execute($summary);
map, filter, take, chunk, mapConcurrent($fn, concurrency: 10), mapParallel($fn, concurrency: 4). Terminals: toArray, reduce, first, consume. Cancellation is checked between every yield.
Services
Register only things with state or lifecycle -- repositories, clients, pools, loggers. Pure helpers and value objects belong in new, not in a container.
<?php use Phalanx\Service\ServiceBundle; use Phalanx\Service\Services; final class AppBundle implements ServiceBundle { public function services(Services $services, array $context): void { $services->singleton(DatabasePool::class) ->factory(static fn() => new DatabasePool($context['db_url'])) ->onStartup(static fn(DatabasePool $pool) => $pool->warmUp(5)) ->onShutdown(static fn(DatabasePool $pool) => $pool->drain()); $services->scoped(RequestLogger::class) ->lazy() ->onDispose(static fn(RequestLogger $log) => $log->flush()); } }
| Lifetime | Lifecycle |
|---|---|
singleton() |
One instance per application; onStartup / onShutdown |
scoped() |
One instance per scope; onDispose fires in reverse order at scope end |
eager() |
Singleton, instantiated at boot rather than first access |
lazy() |
Defer creation until first property access -- PHP 8.4 lazy ghosts |
All configuration flows through array $context from Symfony Runtime. getenv() is forbidden in service bundles and application code; the explicit context flow is what makes the system testable and fiber-safe.
Scope hierarchy
The scope is decomposed into capability interfaces. Type-hint the narrowest one that covers what the dependency actually needs.
Scope service(), attribute(), withAttribute(), trace()
Suspendable await(PromiseInterface): mixed
Cancellable isCancelled, throwIfCancelled(), cancellation()
Disposable onDispose(), dispose()
TaskScope Scope + Suspendable + Cancellable + Disposable
execute(), executeFresh()
TaskExecutor concurrent(), race(), any(), map(), series(), waterfall(),
settle(), timeout(), retry(), delay(), defer(),
singleflight(), inWorker()
ExecutionScope TaskScope + TaskExecutor + StreamContext
| Interface | Use when |
|---|---|
Scope |
You only need service resolution (loaders, simple middleware) |
Suspendable |
A service only needs await() (e.g. RedisClient, TwilioRest) |
TaskScope |
You compose tasks and need cancellation/disposal |
ExecutionScope |
You orchestrate concurrent operations |
<?php // A service declares exactly what it needs -- nothing more. final readonly class RedisClient { public function __construct( private Client $inner, private Suspendable $scope, ) {} public function get(string $key): mixed { return $this->scope->await($this->inner->__call('get', [$key])); } }
Domain packages extend ExecutionScope with typed properties for their context:
| Scope | Package | Adds |
|---|---|---|
RequestScope |
phalanx/stoa |
$request, $params, $query, $body |
CommandScope |
phalanx/archon |
$args, $options, $commandName |
WsScope |
phalanx/hermes |
$connection, $request |
All fiber suspension goes through $scope->await(). Raw React\Async\await() is used only inside ExecutionLifecycleScope internals and stream/transport infrastructure.
Tracing
Visibility is a feature. Set PHALANX_TRACE=1 and every dispatch, suspend, concurrent group, service init, and disposal lands in a structured trace with timing, depth, and memory samples.
PHALANX_TRACE=1 php server.php
Programmatic access:
<?php $entries = $scope->trace()->entries(); // list<TraceEntry> $json = $scope->trace()->toArray(); // serialized
The footer line -- N svc XMB peak N gc Xms total -- is the at-a-glance health check: how many services were created, peak resident memory, how many GC runs fired, total wall time.
Deterministic cleanup
Disposal hooks run in reverse registration order when a scope ends:
<?php $scope = $app->createScope(); $scope->onDispose(static fn() => $connection->close()); $scope->onDispose(static fn() => $tempFile->unlink()); // ...your work... $scope->dispose(); // tempFile first, then connection
Scopes derived via $scope->withAttribute(...) are independent. In long-lived sessions, dispose() derived scopes after each unit of work -- undisposed derived scopes leak their cleanup callbacks.
The Phalanx ecosystem
Aegis is the core. The rest of the framework is built on the same scope model.
| Package | Purpose |
|---|---|
| phalanx/aegis | Scopes, tasks, services, cancellation, retry, tracing |
| phalanx/stoa | HTTP server, routes, middleware |
| phalanx/archon | CLI commands, console runner |
| phalanx/styx | Reactive streams, channels, backpressure |
| phalanx/athena | AI agent runtime, tool dispatch, streaming |
| phalanx/theatron | Async terminal UI |
| phalanx/hermes | WebSocket server and client |
| phalanx/hydra | Worker process parallelism |
| phalanx/eidolon | Route contracts, OpenAPI generation |
| phalanx/skopos | Dev server orchestrator |
| phalanx/postgres | Async PostgreSQL with pooling |
| phalanx/argos | Network utilities |
| phalanx/grammata | Async filesystem with bounded concurrency |
| phalanx/enigma | SSH client |
Support the project
Phalanx is built in the open. If the work here is useful to you, the most direct way to help is to star the repo -- it makes the project easier for other PHP developers to find, and it keeps the work visible. Issues, discussion, and pull requests are welcome.
License
MIT. See Contributing to get involved.