phalanx/core

Expression-based async coordination for PHP 8.4+

Maintainers

Package info

github.com/havy-tech/phalanx-core

pkg:composer/phalanx/core

Statistics

Installs: 1

Dependents: 9

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-03-27 06:41 UTC

This package is auto-updated.

Last update: 2026-03-27 22:51:11 UTC


README

Phalanx

Phalanx Core - Async PHP

Phalanx is an async coordination library for PHP 8.4+. It replaces callbacks with typed tasks—named computations that carry their own identity, behavior, and lifecycle through a unified execution model built on ReactPHP.

Substack write up

Table of Contents

Installation

composer require phalanx/core

Requires PHP 8.4+.

Quick Start

<?php

$app = Application::starting()->providers(new AppBundle())->compile();
$app->startup();

$scope = $app->createScope();

$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 splits scope into two interfaces:

Interface Purpose
Scope Service resolution, attributes, tracing
ExecutionScope Extends Scope with concurrency, cancellation, disposal
CommandScope Decorates ExecutionScope with typed $args and $options
RequestScope Decorates ExecutionScope with typed $params and $query

Scope is the minimal interface for most code—service access and attribute passing.

ExecutionScope adds execution capabilities: concurrent(), race(), execute(), throwIfCancelled(), dispose().

<?php

// Scope: minimal interface
interface Scope {
    public function service(string $type): object;
    public function attribute(string $key, mixed $default = null): mixed;
    public function withAttribute(string $key, mixed $value): Scope;
    public function trace(): Trace;
}

// ExecutionScope: full execution capabilities
interface ExecutionScope extends Scope {
    public bool $isCancelled { get; }
    public function execute(Scopeable|Executable $task): mixed;
    public function concurrent(array $tasks): array;
    public function throwIfCancelled(): void;
    public function onDispose(Closure $callback): void;
    // ... and more
}

Handler closures receive ExecutionScope for full concurrency control. File loading receives Scope for service resolution.

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, not Closure@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

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\Route;
use Phalanx\Http\RouteGroup;
use Phalanx\Scope;

return static fn(Scope $s): RouteGroup => RouteGroup::of([
    'GET /users'      => new Route(fn: new ListUsers()),
    'GET /users/{id}' => new Route(fn: new ShowUser()),
    'POST /users'     => new Route(fn: new CreateUser()),
]);

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

$api = RouteGroup::create()
    ->merge($publicRoutes)
    ->mount('/admin', $adminRoutes)
    ->wrap(new AuthMiddleware());

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\Command;
use Phalanx\Console\CommandConfig;
use Phalanx\Console\CommandGroup;
use Phalanx\Scope;

return static fn(Scope $s): CommandGroup => CommandGroup::of([
    'migrate' => new Command(
        fn: new RunMigrations(),
        config: static fn(CommandConfig $c) => $c
            ->withDescription('Run database migrations'),
    ),
    'db:seed' => new Command(
        fn: new SeedDatabase(),
        config: static fn(CommandConfig $c) => $c
            ->withDescription('Seed the database')
            ->withOption('fresh', shorthand: 'f', description: '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/websocket) 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\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