phalanx-php / stoa
HTTP server framework for Phalanx
Requires
- php: ^8.4
- nikic/fast-route: ^2.0
- phalanx-php/aegis: ^0.5
- react/datagram: ^1.10
- react/http: ^1.10
Suggests
- phalanx-php/hydra: For worker process parallelism in request handlers
- phalanx-php/styx: For streaming responses
- symfony/runtime: For Symfony Runtime integration via PhalanxHttpRunner
This package is not auto-updated.
Last update: 2026-04-28 08:42:03 UTC
README
Phalanx Stoa
Part of the Phalanx async PHP framework.
Async HTTP server built on ReactPHP with scope-driven request handling. Every route handler receives an ExecutionScope with full access to concurrent task execution, service injection, and cancellation -- write concurrent data-fetching code that reads like sequential PHP.
Table of Contents
- Installation
- Quick Start
- Defining Routes
- Route Groups
- Route Parameters
- Route Contracts
- Input Validation
- Concurrent Request Handling
- Middleware
- Mounting Sub-Groups
- Loading Routes from Files
- Server-Sent Events
- UDP Listeners
- Authentication
- WebSocket Integration
- ToResponse Interface
- Response Wrappers
- Request Validators
- OpenAPI Generation
Installation
composer require phalanx/stoa
Note
Requires PHP 8.4 or later.
Quick Start
<?php use Phalanx\Application; use Phalanx\Stoa\RouteGroup; use Phalanx\Stoa\Runner; use Phalanx\Scope; use Phalanx\Task\Scopeable; use React\Http\Message\Response; final class HealthCheck implements Scopeable { public function __invoke(Scope $scope): mixed { return Response::plaintext('Hello, Phalanx!'); } } $app = Application::starting()->compile(); $routes = RouteGroup::of([ 'GET /hello' => HealthCheck::class, ]); Runner::from($app) ->withRoutes($routes) ->run('0.0.0.0:8080');
$ curl http://localhost:8080/hello
Hello, Phalanx!
For anything beyond a one-liner, use an invokable class implementing Scopeable or Executable instead of an inline closure. Named handlers are traceable, testable, and carry their own identity through the system.
Defining Routes
Routes are class-strings registered in a RouteGroup. The HandlerResolver constructs the handler at dispatch time with dependencies injected from the service container, then calls __invoke with a RequestScope (which extends ExecutionScope). Constructor injection makes dependencies explicit at the class level and keeps __invoke bodies focused on the work:
<?php use Phalanx\Stoa\RequestScope; use Phalanx\Task\Scopeable; use React\Http\Message\Response; final class ShowUser implements Scopeable { public function __construct( private readonly UserRepository $users, ) {} public function __invoke(RequestScope $scope): mixed { $user = $this->users->find($scope->params->get('id')); return Response::json($user); } }
<?php use Phalanx\Stoa\RouteGroup; $routes = RouteGroup::of([ 'GET /users/{id}' => ShowUser::class, ]);
Route keys use the METHOD /path format. Multiple methods can be comma-separated:
<?php $routes = RouteGroup::of([ 'GET /users' => ListUsers::class, 'POST /users' => CreateUser::class, 'GET,HEAD /health' => HealthCheck::class, ]);
Route Groups
RouteGroup collects routes into a dispatch table backed by FastRoute. Build from an array using "METHOD /path" => HandlerClass::class keys:
<?php use Phalanx\Stoa\RouteGroup; $routes = RouteGroup::of([ 'GET /users' => ListUsers::class, 'POST /users' => CreateUser::class, 'GET /users/{id}' => ShowUser::class, ]); // Merge groups $all = $apiRoutes->merge($adminRoutes);
Route Parameters
Path parameters use {name} syntax. Named pattern aliases constrain parameters to specific formats. The default set includes int, uuid, slug, year, month, day, date, and any. Add your own with withPatterns():
<?php use Phalanx\Stoa\RouteGroup; $routes = RouteGroup::of([ 'GET /users/{id:int}' => ShowUser::class, 'GET /posts/{slug:slug}' => ShowPost::class, 'GET /orgs/{orgId:uuid}/projects' => ListProjects::class, ])->withPatterns([ 'int' => '\d+', 'uuid' => '[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}', 'slug' => '[a-z0-9-]+', ]);
The built-in patterns are pre-registered -- the withPatterns() call above is only needed when adding custom ones. Access parameters through $scope->params:
<?php use Phalanx\Stoa\RequestScope; use Phalanx\Task\Scopeable; use React\Http\Message\Response; final class ShowUser implements Scopeable { public function __construct( private readonly UserRepository $users, ) {} public function __invoke(RequestScope $scope): mixed { $id = $scope->params->get('id'); $user = $this->users->find($id); return Response::json($user); } }
The RequestScope exposes $request, $params, $query, $body, and $config through typed property hooks. Convenience methods -- $scope->method(), $scope->path(), $scope->header(), $scope->isJson(), $scope->bearerToken() -- wrap common PSR-7 access patterns.
Route Contracts
The __invoke signature of a handler is the complete contract for a route. Beyond the scope parameter, any additional typed class parameter is automatically hydrated from request data before dispatch -- no manual parsing, no $scope->body->get(...) boilerplate.
InputHydrator reflects on the handler's __invoke at first dispatch and caches the result. It skips parameters typed as scope interfaces (Scope, ExecutionScope, RequestScope) and targets the first remaining class-typed parameter. The source for hydration follows HTTP convention:
POST,PUT,PATCH-- hydrated from the request bodyGET,DELETE, and all other methods -- hydrated from query string parameters
Handlers with no extra typed parameter work exactly as before -- the scope is passed as the sole argument.
POST handler with a typed input DTO
<?php use Phalanx\Stoa\RequestScope; use Phalanx\Stoa\Response\Created; use Phalanx\Task\Executable; final readonly class CreateTaskInput { public function __construct( public string $title, public ?string $description = null, public int $priority = 0, ) {} } final class CreateTask implements Executable { public function __construct( private readonly TaskRepository $tasks, ) {} public function __invoke(RequestScope $scope, CreateTaskInput $input): Created { $task = $this->tasks->create( title: $input->title, description: $input->description, priority: $input->priority, ); return new Created($task); } }
<?php use Phalanx\Stoa\RouteGroup; $routes = RouteGroup::of([ 'POST /tasks' => CreateTask::class, ]);
The JSON body keys map to constructor parameter names. Missing required fields produce a 422 before the handler is called.
GET handler with a typed query DTO
<?php use Phalanx\Stoa\RequestScope; use Phalanx\Task\Scopeable; use React\Http\Message\Response; final readonly class ListTasksQuery { public function __construct( public int $page = 1, public int $perPage = 25, public ?string $status = null, ) {} } final class ListTasks implements Scopeable { public function __construct( private readonly TaskRepository $tasks, ) {} public function __invoke(RequestScope $scope, ListTasksQuery $query): mixed { $result = $this->tasks->paginate( page: $query->page, perPage: $query->perPage, status: $query->status, ); return Response::json($result); } }
Query string values are coerced to constructor parameter types -- ?page=2&perPage=50 arrives as int $page = 2, int $perPage = 50.
Handler with no typed input
<?php use Phalanx\Task\Scopeable; use React\Http\Message\Response; final class HealthCheck implements Scopeable { public function __invoke(): mixed { return Response::json(['status' => 'ok']); } }
No parameters -- the handler needs nothing from the request. Hydration is a no-op and the invoker calls the handler with no arguments.
Input Validation
Type coercion
InputHydrator coerces raw string data to the declared constructor parameter type:
| Target type | Coercion |
|---|---|
string |
(string) $value |
int |
(int) $value (fails if non-numeric) |
float |
(float) $value (fails if non-numeric) |
bool |
filter_var($value, FILTER_VALIDATE_BOOLEAN) |
array |
passed through if already an array, error otherwise |
| backed enum | EnumClass::from($value) with allowed-values error on failure |
Coercion errors are collected across all fields before throwing. The handler never executes when coercion fails.
The Validatable interface
After successful construction, if the DTO implements Validatable, its validate() method is called. Return an empty array to pass, or a field => messages map to fail:
<?php use Phalanx\Stoa\Contract\Validatable; final readonly class CreateTaskInput implements Validatable { public function __construct( public string $title, public int $priority = 0, ) {} public function validate(): array { $errors = []; if (strlen($this->title) < 3) { $errors['title'][] = 'Must be at least 3 characters'; } if ($this->priority < 0 || $this->priority > 10) { $errors['priority'][] = 'Must be between 0 and 10'; } return $errors; } }
validate() runs after construction, so it has access to the fully typed, coerced values. It is the right place for cross-field rules or domain constraints that go beyond type correctness.
422 responses
Both coercion failures and Validatable failures throw ValidationException. The runner catches this and returns a 422 with a JSON body:
{
"error": "Validation failed (2 error(s))",
"errors": {
"title": ["Must be at least 3 characters"],
"priority": ["Must be between 0 and 10"]
}
}
ValidationException can also be thrown manually from handler code when domain validation fails after the DTO is already hydrated:
<?php use Phalanx\Stoa\ValidationException; throw ValidationException::single('email', 'Already in use'); throw ValidationException::fromErrors(['email' => ['Already in use'], 'name' => ['Taken']]);
Static Closures in Long-Running Processes
Phalanx runs as a long-lived process. PHP's cycle collector runs infrequently relative to event loop tick rate, which means reference cycles can accumulate unbounded between collections. One common source: non-static closures inside class methods.
A closure declared without static implicitly captures $this:
<?php // Non-static: captures $this. If $this also holds a reference back to the // closure (e.g. via a timer or promise callback), the cycle may persist // until GC runs -- or indefinitely if the collector is never triggered. $timer = Loop::addPeriodicTimer(1.0, fn() => $this->poll());
Declare closures static to prevent the implicit capture. When you need object state, extract it into a local variable first -- this copies the reference, not $this:
<?php // Correct: local copy of the service, static closure captures the copy. $poller = $this->poller; $timer = Loop::addPeriodicTimer(1.0, static fn() => $poller->poll());
The same rule applies to every closure passed to Task::of(), onDispose(), stream operators, and promise chains. Task::of() enforces this at runtime via reflection. Apply the same discipline manually everywhere else.
References:
Concurrent Request Handling
Every route handler has access to Phalanx's concurrency primitives through the scope. Fetch data from multiple sources concurrently within a single request:
<?php use Phalanx\Stoa\RequestScope; use Phalanx\Task; use Phalanx\Task\Executable; use React\Http\Message\Response; final class DashboardHandler implements Executable { public function __construct( private readonly PgPool $db, private readonly RedisClient $redis, ) {} public function __invoke(RequestScope $scope): mixed { // Extract service references before entering static closures -- static // closures cannot capture $this, so local copies are used instead. // See the Static Closures section below for why this matters in a // long-running event loop. $db = $this->db; $redis = $this->redis; [$stats, $alerts, $recent] = $scope->concurrent([ Task::of(static fn() => $db->query( 'SELECT count(*) as total FROM orders WHERE date = CURRENT_DATE' )), Task::of(static fn() => $redis->get('alerts:active')), Task::of(static fn() => $db->query( 'SELECT * FROM activity ORDER BY created_at DESC LIMIT 10' )), ]); return Response::json(compact('stats', 'alerts', 'recent')); } }
<?php use Phalanx\Stoa\RouteGroup; $routes = RouteGroup::of([ 'GET /dashboard' => DashboardHandler::class, ]);
Three I/O operations, one request, wall-clock time of the slowest. The handler reads like synchronous code -- no promises, no callbacks, no yield.
Middleware
Wrap an entire route group with middleware. wrap() accepts class-strings -- the framework resolves middleware from the container at dispatch time:
<?php use Phalanx\Stoa\RouteGroup; $api = RouteGroup::of([ 'GET /me' => GetProfile::class, 'PUT /me' => UpdateProfile::class, 'GET /settings' => GetSettings::class, ])->wrap(AuthMiddleware::class, CorsMiddleware::class);
Composition order is group (outermost) -> per-route config -> handler (innermost). Class-string deduplication applies -- innermost wins when the same middleware appears at multiple levels.
Mounting Sub-Groups
Nest route groups under a path prefix with mount():
<?php use Phalanx\Stoa\RouteGroup; $v1 = RouteGroup::of([ 'GET /users' => ListUsers::class, 'POST /users' => CreateUser::class, ]); $v2 = RouteGroup::of([ 'GET /users' => ListUsersV2::class, 'POST /users' => CreateUserV2::class, ]); $api = RouteGroup::of([]) ->mount('/api/v1', $v1) ->mount('/api/v2', $v2);
Requests to /api/v1/users and /api/v2/users dispatch to their respective handlers.
Loading Routes from Files
RouteLoader scans a directory of PHP files that each return a RouteGroup:
<?php use Phalanx\Stoa\RouteLoader; $routes = RouteLoader::loadDirectory(__DIR__ . '/routes');
Each file defines its routes with class-string handlers:
<?php // routes/users.php use Phalanx\Stoa\RouteGroup; return RouteGroup::of([ 'GET /users' => ListUsers::class, 'GET /users/{id}' => ShowUser::class, 'POST /users' => CreateUser::class, 'DELETE /users/{id}' => DeleteUser::class, ]);
Runner accepts directory paths directly:
<?php use Phalanx\Stoa\Runner; Runner::from($app) ->withRoutes(__DIR__ . '/routes') ->run();
Server-Sent Events
Push real-time updates to clients with SseResponse and SseChannel.
Single-stream SSE
SseResponse converts an Emitter into a streaming HTTP response:
<?php use Phalanx\Stoa\RequestScope; use Phalanx\Stoa\Sse\SseResponse; use Phalanx\Styx\Emitter; use Phalanx\Task\Executable; final class MetricsStream implements Executable { public function __construct( private readonly MetricsCollector $metrics, ) {} public function __invoke(RequestScope $scope): mixed { $metrics = $this->metrics; $source = Emitter::produce(static function ($ch, $ctx) use ($scope, $metrics) { while (!$ctx->isCancelled()) { $ch->emit(json_encode($metrics->snapshot())); $scope->delay(1.0); } }); return SseResponse::from($source, $scope, event: 'metrics'); } }
<?php use Phalanx\Stoa\RouteGroup; $routes = RouteGroup::of([ 'GET /events/metrics' => MetricsStream::class, ]);
Broadcast SSE with SseChannel
SseChannel manages multiple connected clients with automatic replay on reconnect:
<?php use Phalanx\Stoa\Sse\SseChannel; // Register the channel as a service $channel = new SseChannel(bufferSize: 200, defaultEvent: 'update'); // Connect clients (in a route handler) $channel->connect($responseStream, lastEventId: $request->getHeaderLine('Last-Event-ID') ?: null); // Publish from anywhere with access to the channel $channel->send(json_encode($payload), event: 'price-change');
Missed events replay automatically when a client reconnects with Last-Event-ID.
UDP Listeners
The runner supports UDP alongside HTTP on the same event loop. Implement UdpHandler for named, traceable UDP handlers:
<?php use Phalanx\ExecutionScope; use Phalanx\Stoa\Runner; use Phalanx\Stoa\UdpHandler; final class IngestMetrics implements UdpHandler { public function __construct(private readonly MetricsIngester $ingester) {} public function __invoke(ExecutionScope $scope, string $data, string $remote): void { $this->ingester->ingest($data, $remote); } } // Resolve or construct the handler, then pass the instance to withUdp() $handler = new IngestMetrics($app->createScope()->service(MetricsIngester::class)); Runner::from($app) ->withRoutes($routes) ->withUdp(handler: $handler, port: 8081) ->run();
Handler argument order: scope first, then the datagram payload, then the sender address. HTTP on 8080, UDP on 8081, single process.
Authentication
Protect routes with the built-in Authenticate middleware. Implement a Guard to resolve identity from the request, and an Identity for your user model:
<?php use Phalanx\Auth\AuthContext; use Phalanx\Auth\Guard; use Psr\Http\Message\ServerRequestInterface; final class JwtGuard implements Guard { public function __construct(private readonly string $secret) {} public function authenticate(ServerRequestInterface $request): ?AuthContext { $token = $this->extractBearer($request); $claims = $this->verifyJwt($token, $this->secret); if ($claims === null) { return null; } return AuthContext::authenticated( new AppUser($claims['sub']), $token, $claims['abilities'] ?? [], ); } }
Register JwtGuard as a service and apply Authenticate to a route group:
<?php use Phalanx\Stoa\Auth\Authenticate; use Phalanx\Stoa\RouteGroup; $api = RouteGroup::of([ 'GET /me' => GetProfile::class, 'PUT /me' => UpdateProfile::class, ])->wrap(Authenticate::class);
Inside handlers, the auth context is available as an attribute:
<?php $auth = $scope->attribute('auth'); $userId = $auth->identity->id; if ($auth->can('admin')) { // ... }
For typed access, use AuthenticatedRequestScope which adds $scope->auth:
<?php use Phalanx\Stoa\AuthenticatedRequestScope; /** @var AuthenticatedRequestScope $scope */ $scope->auth->identity->id; $scope->auth->can('write'); $scope->auth->token();
ToResponse Interface
Domain objects can implement ToResponse to control their own HTTP serialization. The Runner calls toResponse() automatically when a handler returns a ToResponse instance.
The interface requires a $status property hook alongside the toResponse() method -- both are needed for the runner and OpenAPI generator to work correctly:
<?php use Phalanx\Stoa\ToResponse; use Psr\Http\Message\ResponseInterface; use React\Http\Message\Response; final readonly class ApiResult implements ToResponse { public int $status { get => 200; } public function __construct( private array $data, ) {} public function toResponse(): ResponseInterface { return Response::json($this->data)->withStatus($this->status); } }
Domain exception classes can also implement ToResponse. The runner catches these during dispatch and converts them to HTTP responses automatically -- no try/catch boilerplate in handler code.
Response Wrappers
Three concrete ToResponse implementations cover the most common non-200 success statuses:
| Class | Status | Body |
|---|---|---|
Created |
201 | JSON-encoded $data |
Accepted |
202 | JSON-encoded $data |
NoContent |
204 | empty |
All three are in the Phalanx\Stoa\Response namespace and implement ToResponse, so they flow through the same dispatch path as any custom ToResponse object.
<?php use Phalanx\Stoa\RequestScope; use Phalanx\Stoa\Response\Created; use Phalanx\Stoa\Response\NoContent; use Phalanx\Task\Executable; final class CreateUser implements Executable { public function __construct( private readonly UserRepository $users, ) {} public function __invoke(RequestScope $scope, CreateUserInput $input): Created { return new Created($this->users->create($input)); } } final class DeleteUser implements Executable { public function __construct( private readonly UserRepository $users, ) {} public function __invoke(RequestScope $scope): NoContent { $this->users->delete($scope->params->get('id')); return new NoContent(); } }
Handlers that return void or null produce an empty 200 response. There is no wrapper for this case -- it is the implicit default.
Request Validators
Validate body parameters inline with RequestValidator:
<?php use Phalanx\Stoa\RequestValidator; final class MinLength implements RequestValidator { public function __construct(private readonly int $min) {} public function __invoke(mixed $value): bool { return is_string($value) && strlen($value) >= $this->min; } }
Use validators on any body accessor:
<?php $name = $scope->body->string('name', validate: new MinLength(3)); $age = $scope->body->int('age', validate: new Min(18)); $email = $scope->body->required('email', validate: new EmailFormat());
Failed validation throws ValidationException with $e->errors -- an array<string, list<string>> mapping field names to error messages. Validation results are cached per key+validator pair within the same RequestBody instance.
WebSocket Integration
The HTTP runner handles WebSocket upgrades natively. See phalanx/hermes for the WebSocket API, then wire it in:
<?php use Phalanx\Stoa\Runner; use Phalanx\Hermes\WsRouteGroup; Runner::from($app) ->withRoutes($httpRoutes) ->withWebsockets($wsRouteGroup) ->run();
HTTP and WebSocket traffic share a single TCP listener. The runner detects upgrade requests and routes them to the appropriate WsRouteGroup.
OpenAPI Generation
OpenApiGenerator reflects on route handler signatures to produce an OpenAPI 3.1 spec. No running server is required -- generation is a pure static analysis pass over a RouteGroup.
<?php use Phalanx\Stoa\OpenApi\OpenApiGenerator; $generator = new OpenApiGenerator( title: 'Task API', version: '2.0.0', description: 'Async task management', ); $spec = $generator->generate($routes); file_put_contents('openapi.json', json_encode($spec, JSON_PRETTY_PRINT));
What the generator derives from each route automatically:
- Path parameters -- extracted from
{name}segments in the route pattern - Request body -- reflected from the typed DTO parameter on
POST/PUT/PATCHhandlers; schema built from constructor parameter types - Query parameters -- reflected from the typed DTO parameter on
GET/DELETEhandlers - Response status -- inferred from the return type (
Created→ 201,NoContent→ 204, etc.) - 422 response -- included automatically on any route with a typed input parameter
- 404 response -- included automatically on any route with path parameters
- Summary and tags -- read from
SelfDescribedandTaggedinterfaces on the handler class if implemented
Kubb integration
The generated spec is designed for consumption by Kubb, which generates typed TypeScript clients and React Query hooks directly from an OpenAPI document:
{
"openapi": "3.1.0",
"info": { "title": "Task API", "version": "2.0.0" },
"paths": {
"/tasks": {
"post": {
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"title": { "type": "string" },
"description": { "type": "string", "nullable": true },
"priority": { "type": "integer" }
},
"required": ["title"]
}
}
}
},
"responses": {
"201": { "description": "Created" },
"422": { "description": "Validation Failed" }
}
}
}
}
}
The spec round-trips cleanly: PHP constructor types become JSON Schema types, PHP return type annotations become response status codes, and route patterns become OpenAPI path templates.