Skip to main content
Open source · PHP 8.5+

Concurrent PHP,
done right.

Type-safe actors, supervision trees, event sourcing, multi-worker scaling, pluggable runtimes. Erlang/OTP and Akka patterns — in the PHP you already know.

composer require nexus-actors/nexus
demo.php
// Messages are plain readonly classes
readonly class Increment {}

// Stateful actor: receives messages, returns next state
$counter = Behavior::withState(0, function (
    ActorContext $ctx, object $msg, int $count,
): BehaviorWithState {
    if ($msg instanceof Increment) {
        $ctx->log()->info("count: " . ($count + 1));
        return BehaviorWithState::next($count + 1);
    }
    return BehaviorWithState::same();
});

// Create system, spawn actor, send messages
$system = ActorSystem::create('app', new FiberRuntime());
$ref = $system->spawn(Props::fromBehavior($counter), 'counter');
$ref->tell(new Increment());
$ref->tell(new Increment());
$ref->tell(new Increment());
$system->run();

Why Nexus, not the alternatives?

Every other PHP concurrency approach leaves critical gaps. Nexus closes them all.

Capability Queue workers Plain Fibers Threads ext Nexus
Typed messages
Supervision trees
Cross-worker messaging partial partial
Deterministic testing
Event sourcing
Backpressure / mailbox limits partial
Runtime swap (dev → prod)

Anatomy of an actor.

Three files. Complete working example.

nexus-app — OrderActor.php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Order\Actor;
6
7use App\Order\Message\PlaceOrder;
8use App\Order\Message\OrderPlaced;
9use Monadial\Nexus\Core\Actor\ActorContext;
10use Monadial\Nexus\Core\Actor\Behavior;
11
12final class OrderActor
13{
14 public static function behavior(): Behavior
15 {
16 return Behavior::receive(
17 static function (ActorContext $ctx, object $msg): Behavior {
18 if ($msg instanceof PlaceOrder) {
19 $ctx->log()->info('order received', ['id' => $msg->orderId]);
20
21 $msg->replyTo->tell(
22 new OrderPlaced($msg->orderId),
23 );
24 }
25
26 return Behavior::same();
27 },
28 );
29 }
30}
PHPLn 30, Col 1UTF-8

HTTP, the actor way.

Plug an actor system into PSR-15. Requests become messages; responses come back through ask.

nexus-app — OrderController.php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Order;
6
7use App\Order\Message\PlaceOrder;
8use App\Order\Message\OrderPlaced;
9use Monadial\Nexus\Core\Actor\ActorRef;
10use Monadial\Nexus\Http\Handler\Attribute\FromBody;
11use Monadial\Nexus\Http\Routing\Attribute\Route;
12use Monadial\Nexus\Runtime\Duration;
13use Monadial\Nexus\Http\Response\JsonResponse;
14
15#[Route('POST', '/orders')]
16final readonly class OrderController
17{
18 public function __construct(
19 private ActorRef $orders,
20 ) {}
21
22 public function __invoke(
23 #[FromBody] PlaceOrderRequest $body,
24 ): JsonResponse {
25 /** @var OrderPlaced $result */
26 $result = $this->orders->ask(
27 new PlaceOrder(orderId: $body->id, items: $body->items),
28 Duration::seconds(2),
29 )->await();
30
31 return JsonResponse::ok(['status' => 'placed', 'id' => $result->orderId]);
32 }
33}
PHPLn 33, Col 1UTF-8

→ See the full HTTP guide

Event sourcing, built in.

Persist events, not state. Recovery is automatic. Snapshots and retention are policy, not boilerplate.

nexus-app — OrderActor.php
1<?php
2
3declare(strict_types=1);
4
5namespace App\Order\Actor;
6
7use App\Order\Event\OrderPlaced;
8use App\Order\Event\OrderShipped;
9use App\Order\Message\PlaceOrder;
10use App\Order\Message\ShipOrder;
11use App\Order\State\OrderState;
12use Monadial\Nexus\Core\Actor\ActorContext;
13use Monadial\Nexus\Persistence\PersistenceId;
14use Monadial\Nexus\Persistence\EventSourced\EventSourcedBehavior;
15use Monadial\Nexus\Persistence\EventSourced\Effect;
16use Monadial\Nexus\Persistence\EventSourced\SnapshotStrategy;
17
18final class OrderActor
19{
20 public static function behavior(string $orderId): EventSourcedBehavior
21 {
22 return EventSourcedBehavior::create(
23 persistenceId: PersistenceId::of('Order', $orderId),
24 emptyState: OrderState::empty(),
25 commandHandler: static function (OrderState $state, ActorContext $ctx, object $cmd): Effect {
26 return match (true) {
27 $cmd instanceof PlaceOrder => Effect::persist(
28 new OrderPlaced($cmd->orderId, $cmd->items),
29 ),
30 $cmd instanceof ShipOrder => Effect::persist(
31 new OrderShipped($cmd->orderId, $cmd->tracking),
32 ),
33 };
34 },
35 eventHandler: static fn (OrderState $state, object $event): OrderState => match (true) {
36 $event instanceof OrderPlaced => $state->placed($event->items),
37 $event instanceof OrderShipped => $state->shipped($event->tracking),
38 },
39 )->withSnapshotStrategy(SnapshotStrategy::everyN(50));
40 }
41}
PHPLn 41, Col 1UTF-8

→ See persistence patterns

Every message leaves a trace.

OpenTelemetry traces, RED metrics, and trace-correlated logs — propagated across every actor, HTTP request, and worker thread. Off by default, zero overhead when disabled.

nexus-app — bootstrap.php
1<?php
2
3declare(strict_types=1);
4
5use Monadial\Nexus\App\NexusApp;
6use Monadial\Nexus\Observability\Config\ObservabilityConfig;
7use Monadial\Nexus\Observability\Otel\ObservabilityFactory;
8use Monadial\Nexus\Runtime\Swoole\SwooleRuntime;
9
10// One call wires traces, metrics, and logs into every actor.
11NexusApp::create('payments')
12 ->withObservability(ObservabilityFactory::fromConfig(
13 ObservabilityConfig::fromEnv($_SERVER),
14 ))
15 ->actor('payments', Props::fromBehavior($paymentBehavior))
16 ->run(new SwooleRuntime());
17
18// No call to withObservability()? A no-op provider — zero overhead.
PHPLn 18, Col 1UTF-8

→ See the full Observability guide

Security by isolation.

Actor patterns eliminate entire classes of vulnerability. Type checking enforces the contract.

isolation

No shared mutable state

Actors cannot corrupt each other's data. TOCTOU races and data races are removed at the language level — not by convention.

mailboxes

Explicit overflow policy

Mailbox capacity and overflow strategy are explicit configuration. DoS-by-flooding becomes a tunable policy decision, not an incident.

persistence

Single-writer guarantee

Each ActorSystem owns a unique ULID. Persistence stores reject foreign writes. Split-brain corruption cannot happen silently.

tooling

Verified by Psalm

Custom Psalm rules enforce readonly messages and immutable actor state. Security invariants are checked at analysis time, not runtime.

One codebase, three runtimes.

Same actor logic. Swap one line to change how it runs.

nexus-app — fiber.php
1use Monadial\Nexus\Core\Actor\ActorSystem;
2use Monadial\Nexus\Runtime\Fiber\FiberRuntime;
3
4// Cooperative multitasking — zero extensions required.
5// Each actor runs in its own PHP Fiber. Fibers suspend
6// when waiting for messages and resume on delivery.
7
8$system = ActorSystem::create('app', new FiberRuntime());
9$ref = $system->spawn($props, 'orders');
10$ref->tell(new PlaceOrder('ORD-1'));
11$system->run(); // blocks; fibers cooperate inside
PHPLn 11, Col 1UTF-8

How it fits together.

One model. Three runtimes. Production-ready supervision.

ActorSystem 'my-app' ActorRef <OrderMsg> ActorRef <PaymentMsg> ActorRef <UserMsg> Mailbox bounded(1000) Mailbox unbounded Mailbox bounded(500) ActorCell Running ActorCell Running ActorCell Suspended Runtime FiberRuntime | SwooleRuntime | StepRuntime Supervision Death watch Stashing

Try it in five minutes.

Install, create demo.php, and run it. That's the whole setup.

composer require nexus-actors/nexus