Skip to main content

Quick Start: Persistent Actors

The basic quick start showed a counter actor that loses its state when the process stops. This tutorial makes the counter persistent -- it survives restarts by storing events in a database.

We'll build the same counter, but this time every increment is recorded as an event. When the actor restarts, it replays those events to recover its state automatically.

Step 1: Define commands and events

Persistent actors distinguish between commands (what the outside world asks for) and events (what actually happened). Commands are input; events are facts.

<?php
declare(strict_types=1);

namespace App\Messages;

use Monadial\Nexus\Core\Actor\ActorRef;

// Commands — requests from the outside world
final readonly class Increment {}

final readonly class GetCount
{
/** @param ActorRef<object> $replyTo */
public function __construct(public ActorRef $replyTo) {}
}

// Events — immutable facts that are persisted
final readonly class Incremented {}

// Replies
final readonly class CountReply
{
public function __construct(public int $count) {}
}

The Increment command causes an Incremented event. The event is what gets stored. The command is discarded after processing.

Step 2: Define the state

State is a plain readonly class. It is rebuilt from events on every recovery -- never stored directly (that's the event sourcing model).

<?php
declare(strict_types=1);

namespace App;

final readonly class CounterState
{
public function __construct(public int $count = 0) {}
}

Step 3: Create the event-sourced behavior

An EventSourcedBehavior has two handlers:

  • Command handler -- receives the current state and a command, returns an Effect describing what events to persist.
  • Event handler -- receives the current state and an event, returns the new state. This runs both when persisting new events and when replaying on recovery.
<?php
declare(strict_types=1);

namespace App;

use App\Messages\CountReply;
use App\Messages\GetCount;
use App\Messages\Increment;
use App\Messages\Incremented;
use Monadial\Nexus\Core\Actor\ActorContext;
use Monadial\Nexus\Persistence\EventSourced\EventSourcedBehavior;
use Monadial\Nexus\Persistence\EventSourced\Effect;
use Monadial\Nexus\Persistence\Event\InMemoryEventStore;
use Monadial\Nexus\Persistence\PersistenceId;

$behavior = EventSourcedBehavior::create(
persistenceId: PersistenceId::of('counter', 'counter-1'),
emptyState: new CounterState(),

// Command handler: what should happen?
commandHandler: static function (
object $state,
ActorContext $ctx,
object $command,
): Effect {
if ($command instanceof Increment) {
// Persist an event — state will update via the event handler
return Effect::persist(new Incremented());
}

if ($command instanceof GetCount) {
// Read-only query — reply without persisting anything
return Effect::reply($command->replyTo, new CountReply($state->count));
}

return Effect::none();
},

// Event handler: apply the fact to state
eventHandler: static function (object $state, object $event): object {
if ($event instanceof Incremented) {
return new CounterState($state->count + 1);
}

return $state;
},
)
->withEventStore(new InMemoryEventStore())
->toBehavior();

Key differences from the basic counter:

  • State changes go through events, not direct mutation.
  • Effect::persist() saves the event, then the event handler updates state.
  • Effect::reply() responds to queries without touching the event log.
  • The event handler is pure -- no side effects, no I/O.

Step 4: Run it

<?php
declare(strict_types=1);

namespace App;

use App\Messages\CountReply;
use App\Messages\GetCount;
use App\Messages\Increment;
use App\Messages\Incremented;
use Monadial\Nexus\Core\Actor\ActorContext;
use Monadial\Nexus\Core\Actor\ActorSystem;
use Monadial\Nexus\Core\Actor\Behavior;
use Monadial\Nexus\Core\Actor\Props;
use Monadial\Nexus\Core\Duration;
use Monadial\Nexus\Persistence\EventSourced\EventSourcedBehavior;
use Monadial\Nexus\Persistence\EventSourced\Effect;
use Monadial\Nexus\Persistence\Event\InMemoryEventStore;
use Monadial\Nexus\Persistence\PersistenceId;
use Monadial\Nexus\Runtime\Fiber\FiberRuntime;

require __DIR__ . '/vendor/autoload.php';

$eventStore = new InMemoryEventStore();

// 1. Create actor system
$runtime = new FiberRuntime();
$system = ActorSystem::create('persistent-demo', $runtime);

// 2. Define the persistent counter behavior
$behavior = EventSourcedBehavior::create(
persistenceId: PersistenceId::of('counter', 'counter-1'),
emptyState: new CounterState(),
commandHandler: static function (
object $state,
ActorContext $ctx,
object $command,
): Effect {
if ($command instanceof Increment) {
return Effect::persist(new Incremented());
}
if ($command instanceof GetCount) {
return Effect::reply($command->replyTo, new CountReply($state->count));
}
return Effect::none();
},
eventHandler: static function (object $state, object $event): object {
if ($event instanceof Incremented) {
return new CounterState($state->count + 1);
}
return $state;
},
)
->withEventStore($eventStore)
->toBehavior();

// 3. Spawn and send messages
$counterRef = $system->spawn(Props::fromBehavior($behavior), 'counter');

for ($i = 0; $i < 5; $i++) {
$counterRef->tell(new Increment());
}

// 4. Probe actor to capture the reply
/** @var list<object> $captured */
$captured = [];
$probeRef = $system->spawn(Props::fromBehavior(
Behavior::receive(static function (ActorContext $ctx, object $msg) use (&$captured): Behavior {
$captured[] = $msg;
return Behavior::same();
}),
), 'probe');

$counterRef->tell(new GetCount($probeRef));

// 5. Shut down and check
$runtime->scheduleOnce(Duration::millis(500), static function () use ($system): void {
$system->shutdown(Duration::seconds(1));
});

$system->run();

assert($captured[0] instanceof CountReply);
echo 'Count: ' . $captured[0]->count . PHP_EOL; // Count: 5

This looks similar to the basic counter -- the difference is what happens when the actor crashes or restarts. With persistence, the counter recovers its state automatically.

What recovery looks like

When a persistent actor starts, it replays its event log before accepting new commands:

  1. Load events -- the event store returns all events for PersistenceId::of('counter', 'counter-1').
  2. Replay -- each event passes through the event handler, rebuilding the state step by step: 0 → 1 → 2 → 3 → 4 → 5.
  3. Ready -- the actor starts processing new commands with count = 5.

Commands that arrive during recovery are automatically stashed and replayed once recovery completes. Senders do not need to know whether the actor has finished recovering.

Using a real database

Replace InMemoryEventStore with a DBAL or Doctrine store for production:

use Monadial\Nexus\Persistence\Dbal\DbalEventStore;

// Doctrine DBAL connection
$connection = DriverManager::getConnection($params);

$eventStore = new DbalEventStore($connection, $serializer);

The actor code stays exactly the same. Only the store changes.

Adding snapshots

For actors with long event histories, replay can be slow. Snapshots save the full state periodically so recovery only needs to replay events after the snapshot:

use Monadial\Nexus\Persistence\EventSourced\SnapshotStrategy;

$behavior = EventSourcedBehavior::create(/* ... */)
->withEventStore($eventStore)
->withSnapshotStore($snapshotStore)
->withSnapshotStrategy(SnapshotStrategy::everyN(100))
->toBehavior();

Now recovery loads the latest snapshot and replays only the events that occurred after it -- instead of replaying the entire history.

Durable State alternative

If you don't need an event history -- just the latest state -- use DurableStateBehavior instead:

use Monadial\Nexus\Persistence\State\DurableStateBehavior;
use Monadial\Nexus\Persistence\State\DurableEffect;
use Monadial\Nexus\Persistence\State\InMemoryDurableStateStore;

$behavior = DurableStateBehavior::create(
persistenceId: PersistenceId::of('counter', 'counter-1'),
emptyState: new CounterState(),
commandHandler: static function (
object $state,
ActorContext $ctx,
object $command,
): DurableEffect {
if ($command instanceof Increment) {
return DurableEffect::persist(
new CounterState($state->count + 1),
);
}
if ($command instanceof GetCount) {
return DurableEffect::reply(
$command->replyTo,
new CountReply($state->count),
);
}
return DurableEffect::none();
},
)
->withStateStore(new InMemoryDurableStateStore())
->toBehavior();

Simpler: no events, no event handler, no replay. The store saves the current state directly and loads it on recovery.

Comparison

Basic actorEvent-sourced actorDurable state actor
State survives restartNoYesYes
Audit trailNoYes (full event history)No
Recovery methodNoneReplay events (or snapshot + tail)Load latest state
Handler count1 (message handler)2 (command + event)1 (command handler)
Storage growthNoneGrows with eventsFixed per actor

Next steps