phalanx / core
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/console: ^0.5
- phalanx/http: ^0.5
- phalanx/parallel: ^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/console: CLI application framework
- phalanx/http: HTTP server framework
- phalanx/parallel: Worker process parallelism
- phalanx/stream: 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
README
Phalanx Core - Async PHP
PHP 8.4 changed what's possible. Phalanx is an attempt to make it accessible.
In my view, PHP's async foundations — built over years by the ReactPHP and AMPHP communities — represent some of the most underutilized work in the language's ecosystem. Fibers, property hooks, lazy objects, and asymmetric visibility have quietly made PHP a different language since 8.0. Phalanx is an attempt to bring all of this to bear through a unified coordination layer, using the most powerful tools available in the modern PHP ecosystem — particularly as AI workloads raise the bar on throughput and reactivity.
The framework coordinates async PHP through a centralized scope hierarchy — a single scheduler that manages concurrency, cancellation, and resource cleanup no matter how deeply nested the execution becomes. Developers write named computations. The scope handles the machinery. Every API decision is weighed against the balance between first-class DX and call-site explicitness — a tradeoff influenced by ideas from SICP, Rust's ownership model, .NET's task system, Clojure's value semantics, and Go's structured concurrency.
The design has been iterated on since late 2024, with intensive refinement through mid-2025 and resumed development earlier this year. Every abstraction has been questioned, discarded, and rebuilt — often more than once. Several proof-of-concept applications continue to evolve from their MVP baseline — a multi-agent AI terminal (aisentinel-cli), a subnet scanner, a Twilio control center, a Docker CLI, and a debug dashboard. Some of these will be released as they stabilize, and there are many ideas for what comes next that haven't been started yet.
The project is currently stabilizing through active iteration. Design decisions in one area sometimes require foundational refactoring in others, and some corners of the codebase will fall out of sync with others. This is expected.
Contributions are welcome from those who feel inspired to do so. See the contributing guide for how to get involved.
Table of Contents
- Installation
- Quick Start
- How It Works
- The Task System
- Concurrency Primitives
- Lazy Sequences
- Route Groups
- Command Groups
- Services
- Cancellation & Retry
- Tracing
- Deterministic Cleanup
- Examples
Installation
composer require phalanx/core
Requires PHP 8.4+.
Quick Start
<?php [$app, $scope] = Application::starting() ->providers(new AppBundle()) ->compile() ->boot(); $result = $scope->execute(Task::of(static fn(ExecutionScope $s) => $s->service(OrderService::class)->process(42) )); $scope->dispose(); $app->shutdown();
Note: Service classes like OrderService, UserRepo, DatabasePool in these examples are illustrative. Phalanx Core provides the coordination primitives—your application brings the domain logic.
How It Works
Phalanx's model: Application -> Scope -> Tasks.
Application::starting($context)
-> compile() // Validate service graph, create app
-> startup() // Run startup hooks, enable shutdown handlers
-> createScope() // Create ExecutionScope
-> execute(Task) // Run typed tasks
-> dispose() // Cleanup scope resources
-> shutdown() // Cleanup app resources
Every task implements Scopeable or Executable—single-method interfaces:
<?php // Tasks needing only service resolution interface Scopeable { public function __invoke(Scope $scope): mixed; } // Tasks needing execution primitives (concurrency, cancellation) interface Executable { public function __invoke(ExecutionScope $scope): mixed; }
Scope Hierarchy
Phalanx decomposes scope into granular capability interfaces:
Scope service(), attribute(), trace()
Suspendable await(PromiseInterface): mixed
Cancellable isCancelled, throwIfCancelled(), cancellation()
Disposable onDispose(), dispose()
TaskScope extends Scope + Suspendable + Cancellable + Disposable
execute(), executeFresh()
TaskExecutor concurrent(), race(), any(), map(), settle()
timeout(), retry(), delay(), defer(), singleflight(), inWorker()
ExecutionScope extends TaskScope + TaskExecutor + StreamContext
| Interface | Use when... |
|---|---|
Scope |
You only need service resolution (file loaders, middleware) |
Suspendable |
A service needs await() (RedisClient, TwilioRest) |
TaskScope |
You compose tasks and need cancellation/disposal (handlers, middleware chains) |
ExecutionScope |
You orchestrate concurrent operations (scanners, pipelines, deployment tasks) |
All fiber suspension goes through $scope->await(). Raw React\Async\await() is only used inside ExecutionLifecycleScope internals.
<?php // Services type-hint what they actually need final class RedisClient { public function __construct( private readonly Client $inner, private readonly Suspendable $scope, // only needs await() ) {} public function get(string $key): mixed { return $this->scope->await($this->inner->__call('get', [$key])); } }
Domain scopes extend ExecutionScope with typed properties:
| Scope | Package | Adds |
|---|---|---|
CommandScope |
phalanx-console | $args, $options, $commandName |
RequestScope |
phalanx-http | $request, $params, $query, $body |
WsScope |
phalanx-ws-server | $connection, $request |
The Task System
Two Ways to Define Tasks
Quick tasks for one-offs:
<?php $task = Task::of(static fn(ExecutionScope $s) => $s->service(UserRepo::class)->find($id)); $user = $scope->execute($task);
Invokable classes for everything else:
<?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));
The invokable approach gives you:
- Traceable: Stack traces show
FetchUser::__invoke, notClosure@handler.php:47 - Testable: Mock the scope, invoke the task, assert the result
- Serializable: Constructor args are data—queue jobs, distribute across workers
- Inspectable: The class name is the identity; constructor args are the inputs
Behavior via Interfaces
Tasks declare behavior through PHP 8.4 property hooks:
<?php final class DatabaseQuery implements Scopeable, Retryable, HasTimeout { public RetryPolicy $retryPolicy { get => RetryPolicy::exponential(3); } public float $timeout { get => 5.0; } public function __invoke(Scope $scope): array { return $scope->service(Database::class)->query($this->sql); } }
The behavior pipeline applies automatically: timeout wraps retry wraps trace wraps work.
| Interface | Property | Purpose |
|---|---|---|
Retryable |
RetryPolicy $retryPolicy { get; } |
Automatic retry with policy |
HasTimeout |
float $timeout { get; } |
Automatic timeout in seconds |
HasPriority |
int $priority { get; } |
Priority queue ordering |
UsesPool |
UnitEnum $pool { get; } |
Pool-aware scheduling |
Traceable |
string $traceName { get; } |
Custom trace label |
Self-Description
Two opt-in interfaces let tasks and components carry human-readable metadata. The execution layer does not read these — they are consumed by tooling that introspects the component graph at registration time.
| Interface | Property | Purpose |
|---|---|---|
SelfDescribed |
string $description { get; } |
Human-readable description of what the component does |
Tagged |
list<string> $tags { get; } |
Classification labels for grouping and filtering |
Consumers: OpenAPI generation in phalanx-http uses $description to populate operation summaries. phalanx-ai reads both when registering agent tools. CLI inspection commands use them to build help output.
<?php final class SummarizeDocument implements Executable, SelfDescribed, Tagged { public string $description { get => 'Fetches a document by ID and returns an AI-generated summary.'; } /** @return list<string> */ public array $tags { get => ['ai', 'documents', 'read']; } public function __construct(private int $documentId) {} public function __invoke(ExecutionScope $scope): string { return $scope->service(Summarizer::class)->summarize($this->documentId); } }
Concurrency Primitives
| Method | Behavior | Returns |
|---|---|---|
concurrent($tasks) |
Run all concurrently, wait for all | Array of results |
race($tasks) |
First to settle (success or failure) | Single result |
any($tasks) |
First success (ignores failures) | Single result |
map($items, $fn, $limit) |
Bounded concurrency over collection | Array of results |
settle($tasks) |
Run all, collect outcomes including failures | SettlementBag |
timeout($seconds, $task) |
Run with deadline | Result or throws |
series($tasks) |
Sequential execution | Array of results |
waterfall($tasks) |
Sequential, passing result forward | Final result |
<?php // Concurrent fetch [$customer, $inventory] = $scope->concurrent([ new FetchCustomer($customerId), new ValidateInventory($items), ]); // First successful response wins (fallback pattern) $data = $scope->any([ new FetchFromPrimary($key), new FetchFromFallback($key), ]); // 10,000 items. 10 concurrent fibers. $results = $scope->map($items, fn($item) => new ProcessItem($item), limit: 10);
Lazy Sequences
LazySequence processes large datasets through generator-based pipelines. Values flow one at a time—memory stays flat regardless of dataset size.
<?php use Phalanx\Task\LazySequence; $seq = LazySequence::from(static function (ExecutionScope $scope) { foreach ($scope->service(OrderRepo::class)->cursor() as $order) { yield $order; } }); $totals = $seq ->filter(fn(Order $o) => $o->total > 100_00) ->map(fn(Order $o) => new OrderSummary($o)) ->take(50) ->toArray(); $result = $scope->execute($totals);
Operators (map, filter, take, chunk) are lazy—nothing runs until a terminal (toArray, reduce, first, consume) triggers execution. Two mapping modes handle different workloads:
| Method | Execution Model |
|---|---|
mapConcurrent($fn, $concurrency) |
Fibers in the current process |
mapParallel($fn, $concurrency) |
Worker processes via IPC |
Route Groups
Typed collections of HTTP routes with RouteGroup. Route handlers receive RequestScope—a scope decorator with typed route parameters, query strings, and request body access:
<?php // routes/api.php use Phalanx\Http\RouteGroup; return RouteGroup::of([ 'GET /users' => ListUsers::class, 'GET /users/{id}' => ShowUser::class, 'POST /users' => CreateUser::class, ]);
Loading Routes
<?php use Phalanx\Http\Runner; $runner = Runner::from($app, requestTimeout: 30.0) ->withRoutes(__DIR__ . '/routes'); $runner->run('0.0.0.0:8080');
Composing Route Groups
<?php use Phalanx\Http\RouteGroup; $api = $publicRoutes ->merge($adminRoutes) ->mount('/admin', $adminRoutes) ->wrap(AuthMiddleware::class);
Command Groups
Typed collections of CLI commands with CommandGroup. Command handlers receive CommandScope—a scope decorator with typed arguments and options:
<?php // commands/db.php use Phalanx\Console\CommandConfig; use Phalanx\Console\CommandGroup; use Phalanx\Console\Opt; return CommandGroup::of([ 'migrate' => [RunMigrations::class, new CommandConfig( description: 'Run database migrations', )], 'db:seed' => [SeedDatabase::class, new CommandConfig( description: 'Seed the database', options: [Opt::flag('fresh', 'f', 'Truncate tables first')], )], ]);
Running Commands
<?php use Phalanx\Console\ConsoleRunner; $runner = ConsoleRunner::withCommands($app, __DIR__ . '/commands'); exit($runner->run($argv));
Services
<?php use Phalanx\Service\ServiceBundle; use Phalanx\Service\Services; class AppBundle implements ServiceBundle { public function services(Services $services, array $context): void { $services->singleton(DatabasePool::class) ->factory(fn() => new DatabasePool($context['db_url'])) ->onStartup(fn($pool) => $pool->warmUp(5)) ->onShutdown(fn($pool) => $pool->drain()); $services->scoped(RequestLogger::class) ->lazy() ->onDispose(fn($log) => $log->flush()); } }
| Method | Lifecycle |
|---|---|
singleton() |
One instance per application |
scoped() |
One instance per scope, disposed with scope |
lazy() |
Defer creation until first access (PHP 8.4 lazy ghosts) |
Cancellation & Retry
<?php use Phalanx\Concurrency\CancellationToken; use Phalanx\Concurrency\RetryPolicy; // Timeout for entire scope $scope = $app->createScope(CancellationToken::timeout(30.0)); // Task-level timeout $result = $scope->timeout(5.0, new SlowApiCall($id)); // Retry with exponential backoff $result = $scope->retry( new FetchFromApi($url), RetryPolicy::exponential(attempts: 3) ); // Check cancellation within tasks (use Executable when you need ExecutionScope) final class LongRunningTask implements Executable { public function __invoke(ExecutionScope $scope): mixed { foreach ($this->chunks as $chunk) { $scope->throwIfCancelled(); $this->process($chunk); } return $this->result; } }
Tracing
PHALANX_TRACE=1 php server.php
0ms STRT compiling
4ms STRT startup
6ms CON> concurrent(2)
7ms EXEC FetchCustomer
8ms DONE FetchCustomer +0.61ms
19ms CON< concurrent(2) joined +12.8ms
0 svc 4.0MB peak 0 gc 39.8ms total
Authentication
Phalanx provides core auth primitives that transport packages (phalanx/http, phalanx/ws-server) build on.
Guard Interface
Implement Guard to extract identity from a request:
<?php use Phalanx\Auth\AuthContext; use Phalanx\Auth\Guard; use Psr\Http\Message\ServerRequestInterface; final class BearerTokenGuard implements Guard { public function resolve(ServerRequestInterface $request): ?AuthContext { $header = $request->getHeaderLine('Authorization'); if (!str_starts_with($header, 'Bearer ')) { return null; } $user = $this->validateToken(substr($header, 7)); return $user !== null ? AuthContext::authenticated($user, substr($header, 7)) : null; } }
Identity Interface
Your user model implements Identity:
<?php use Phalanx\Auth\Identity; final class AppUser implements Identity { public string|int $id { get => $this->userId; } public function __construct(private readonly int $userId) {} }
AuthContext
The resolved auth state, carrying identity, token, and abilities:
<?php use Phalanx\Auth\AuthContext; $auth = AuthContext::authenticated($user, $token, ['admin', 'write']); $auth->isAuthenticated; // true $auth->identity->id; // user ID $auth->can('admin'); // true $auth->token(); // the raw token string $guest = AuthContext::guest(); $guest->isAuthenticated; // false
Authenticate Middleware
Use the built-in Authenticate middleware with any Guard:
<?php use Phalanx\Http\Auth\Authenticate; $routes = RouteGroup::of([...])->wrap(new Authenticate(new BearerTokenGuard()));
Deterministic Cleanup
<?php $scope = $app->createScope(); $scope->onDispose(fn() => $connection->close()); // Your task code... $scope->dispose(); // Cleanup fires in reverse order
Examples
Docker CLI
A practical CLI app demonstrating CommandScope with typed arguments and options, ServiceBundle wiring, and invokable task classes.
php examples/docker-cli/docker-cli.php ps -a php examples/docker-cli/docker-cli.php images php examples/docker-cli/docker-cli.php logs nginx