Doctrine Integration
One actor. One connection. Zero contention.
Nexus wraps Doctrine entities as single-writer aggregate actors. Each actor owns its Entity Manager — no shared state, no optimistic locking races, no stale reads. Commands flow in; events flow out; Doctrine writes are race-free by construction.
<?php
// One actor per aggregate. One connection per actor. No connection contention.
use Monadial\Nexus\Doctrine\Orm\Behavior\EntityBehavior;
use Monadial\Nexus\Doctrine\Orm\Behavior\EntityEffect;
$behavior = EntityBehavior::create(
Wallet::class,
$walletId,
static fn (ActorContext $ctx, object $cmd, Wallet $w): EntityEffect => match (true) {
$cmd instanceof Deposit => EntityEffect::persist(),
$cmd instanceof Withdraw => $w->canWithdraw($cmd->amount)
? EntityEffect::persist()
: EntityEffect::reply($cmd->replyTo, new InsufficientFunds()),
default => EntityEffect::same(),
},
)->toBehavior();Swoole-pooled connections that scale linearly.
Traditional PHP-FPM gives each request its own connection — expensive to open, expensive to close, and totally wasted between queries. Under Nexus + Swoole, connections are pooled once per worker thread and reused across thousands of coroutines. Scale to 8 threads and you need exactly 8 connections — not 800.
- → One EntityManager per worker — no cross-actor EM sharing
- → autoFlush after each successful command handler
- → Connection errors trigger supervisor restart, not a 500
- → Works with Doctrine ORM 3.x and DBAL 4.x
<?php
// Under Swoole, wire an EntityManager pool via DoctrineEmPool::forConfig().
// Each actor draws from the pool — writes are never interleaved across actors.
use Monadial\Nexus\Doctrine\Orm\DoctrineEmPool;
use Monadial\Nexus\Runtime\Swoole\SwooleRuntime;
use Monadial\Nexus\App\NexusApp;
$emPool = DoctrineEmPool::forConfig(
name: 'wallets',
connParams: (new \Doctrine\DBAL\Tools\DsnParser(['postgres' => 'pdo_pgsql']))->parse($_ENV['DATABASE_URL']),
ormSetup: $ormConfig,
);
NexusApp::create('wallet-service')
->actor('wallets', Props::fromBehavior($walletBehavior))
->onStart(static function ($system) use ($emPool): void {
// Inject $emPool into actors that need an EntityManager.
})
->run(new SwooleRuntime());<?php
// EntityBehavior is event-sourced by default. Commands produce events.
// Events mutate the entity. The Doctrine EM writes atomically.
final class Wallet
{
public function __construct(
public readonly string $id,
public int $balance = 0,
) {}
public function apply(Deposited|Withdrawn $event): self
{
return match(true) {
$event instanceof Deposited => new self($this->id, $this->balance + $event->amount),
$event instanceof Withdrawn => new self($this->id, $this->balance - $event->amount),
};
}
}Entities as pure value objects.
EntityBehavior separates command
handling from entity mutation. Your entity is a readonly
value object with an apply() method. No
@Entity annotations needed — Nexus uses Doctrine
DBAL under the hood, not the ORM class mapper, so your domain model stays clean.
Events are persisted first. If the Doctrine write fails, the actor restarts from its last good snapshot. No partial state. No phantom writes.
Full EntityBehavior reference →Distributed transactions without a transaction manager.
Multi-entity workflows that span aggregates use the Saga pattern — a dedicated coordinator actor that sends compensating commands on failure. No two-phase commit. No distributed lock. The saga actor is durable: it persists its own state and resumes after a restart.
Each step in the saga is a typed actor message. The compensation path is an
explicit Behavior branch — visible, testable, and
Psalm-checked.
<?php
// Multi-entity transactions via Saga actors.
// Each step is an actor message; compensation is a Behavior branch.
$saga = Behavior::setup(static function (ActorContext $ctx) use ($order, $inventoryRef): Behavior {
$inventoryRef->tell(new ReserveStock($order));
return Behavior::receive(static function ($c, $msg) use ($paymentRef, $replyTo, $order) {
if ($msg instanceof StockReserved) {
$paymentRef->tell(new ChargeCard($order));
return Behavior::same();
}
if ($msg instanceof StockFailed) {
$replyTo->tell(new OrderRejected('out of stock'));
return Behavior::stopped();
}
if ($msg instanceof PaymentCharged) {
$replyTo->tell(new OrderConfirmed($order->id));
return Behavior::stopped();
}
return Behavior::unhandled();
});
});Why not optimistic locking?
Optimistic locking works until it doesn't — under load, retry storms saturate your database before they converge. With Nexus, each aggregate has one writer by definition. Contention is structurally impossible, not just unlikely. Your database sees sequential writes per entity, not competing UPDATE+version transactions.
No optimistic lock retries
Single-writer actors eliminate the retry loop. Conflicting writes queue in the mailbox — no database roundtrip wasted.
Snapshot + event replay
Configure snapshot frequency. Startup recovery replays only events since the last snapshot — cold-start latency stays bounded.
DBAL + ORM both supported
Use nexus-persistence-dbal for raw DBAL or
nexus-persistence-doctrine for ORM. Switch without
touching actor code.
Repository injection into actors.
Not every actor needs event sourcing. Some actors are read-heavy — they answer
queries by fetching data from a Doctrine repository and replying.
Props::fromContainer() resolves
your actor class from the PSR-11 container, injecting repositories, services, or
any other dependency via the constructor.
The repository is resolved once at actor spawn time. After that, the actor uses it directly — no service locator inside the handler, no container calls mid-message. Psalm verifies the constructor types; there are no runtime surprises.
This pattern works best for catalog lookups, reporting queries, and read projections — anything where you want Doctrine's query builder without the overhead of full event sourcing on each entity.
Doctrine ORM docs →<?php
// Repository injection: pass a Doctrine repository into an actor via Props.
// The repository is resolved once at spawn time; no DI container magic inside handlers.
final class ProductCatalogActor implements ActorHandler
{
public function __construct(
private readonly ProductRepository $products,
) {}
public function handle(ActorContext $ctx, object $msg): Behavior
{
if ($msg instanceof FindProduct) {
$product = $this->products->find($msg->id);
$msg->replyTo->tell($product ?? new ProductNotFound($msg->id));
}
return Behavior::same();
}
}
// Props wires it up:
Props::fromContainer($container, ProductCatalogActor::class);<?php
// Schema migrations work normally — Nexus does not interfere with Doctrine Migrations.
// Run migrations before deploying new actor versions, just as you would with any ORM.
// doctrine.php — standard Doctrine CLI configuration (ORM 3 / DBAL 4)
use Doctrine\DBAL\DriverManager;
use Doctrine\Migrations\Configuration\EntityManager\ExistingEntityManager;
use Doctrine\Migrations\DependencyFactory;
use Doctrine\ORM\EntityManager;
$dsnParser = new \Doctrine\DBAL\Tools\DsnParser(['postgres' => 'pdo_pgsql', 'mysql' => 'pdo_mysql']);
$connection = DriverManager::getConnection($dsnParser->parse($_ENV['DATABASE_URL']));
$entityManager = new EntityManager($connection, $ormConfig);
return DependencyFactory::fromEntityManager(
new Doctrine\Migrations\Configuration\Migration\PhpFile('migrations.php'),
new ExistingEntityManager($entityManager),
);
// Persistence schema is created by Nexus on first run:
// event_store: (persistence_id, sequence_number, event_type, payload, written_at, writer_id)
// snapshot_store: (persistence_id, sequence_number, snapshot_type, payload, written_at)
// Both tables are append-only — no UPDATE or DELETE in hot paths.Schema migrations work normally.
Nexus does not replace Doctrine Migrations — it sits alongside it.
Your domain schema is managed by Doctrine Migrations exactly as before.
The Nexus persistence tables (event_store and
snapshot_store) are created by Nexus on first run
and are separate from your application schema.
Both persistence tables are append-only. There are no UPDATE
or DELETE operations in hot paths — only
INSERT and SELECT.
This makes the event log safe to back up with point-in-time recovery and
straightforward to replicate with standard Postgres/MySQL streaming replication.
DBAL or ORM: pick what fits.
nexus-persistence-dbal writes events
via raw DBAL — no ORM overhead, no identity map, no change-tracking. It is the
right choice when throughput matters more than integration with the rest of your
Doctrine setup: IoT telemetry, high-frequency trading signals, audit logs.
nexus-persistence-doctrine uses the
EntityManager and participates in your existing Doctrine lifecycle. If your
application already has an EntityManager wired through a DI container and you
want schema management in one place, this is the correct starting point.
Both packages implement the same EventStore and
SnapshotStore interfaces. Swap between them with
one line in your bootstrap — actor code never changes.
<?php
// DBAL store: zero ORM overhead, best for high-throughput event append.
$eventStore = new DbalEventStore($dbalConnection);
// Doctrine ORM store: EntityManager integration, works with existing ORM setup.
$eventStore = new DoctrineEventStore($entityManager);
// Both implement the same EventStore interface — swap without touching actor code.
// Rule of thumb:
// Use DBAL if you care about raw insert throughput (IoT, trading, logging).
// Use Doctrine ORM if you already have an EM in your container and
// want migrations + schema management in one place.Transaction boundaries without surprise.
Each actor processes one message at a time. That means each command handler
runs inside a single database transaction by default when using
autoFlush(true). If the flush throws, the actor's
supervisor catches the exception and restarts — the event is not acknowledged,
and the actor replays from its last persisted snapshot.
The single-writer guarantee means you never need pessimistic locking. Two commands on the same aggregate never run concurrently — the second queues in the mailbox until the first is fully committed. Your database sees a clean sequential write log per aggregate, which is friendlier to indexes, WAL replication, and point-in-time recovery than concurrent writes.
For multi-aggregate workflows, use the Saga pattern above. Sagas are explicit, durable, and their compensation paths are statically analysed by Psalm — not hidden inside a two-phase commit protocol.
Transaction isolation per actor.
Each actor that needs database access should receive its own
EntityManager — either injected request-scoped via
the PSR-11 container at spawn time, or obtained per message through a dedicated
factory. Do not share an EntityManager across
actors: Doctrine's unit-of-work is not thread-safe or coroutine-safe. A shared
EM means interleaved identity maps, flushed state from the wrong actor, and
opaque hydration bugs that only appear under concurrent load. Under Swoole, the
connection pool ensures one connection per worker thread — the actor model's
single-writer property means there is no contention within a worker.
Long-running actor handlers that process many commands in sequence should call
$em->clear() after each command to release the
identity map. Without clearing, the in-memory entity graph grows unbounded with
each message, leaking memory and potentially surfacing stale state to the next
command. Use autoFlush(true) in the Doctrine config
to flush after each successful command automatically; pair with
clear() to reset the identity map between commands
in high-throughput handlers.
Start with EntityBehavior.
The getting-started guide creates a bank account aggregate in under fifteen minutes.
composer require nexus-actors/nexus