Skip to main content

Quick Start: Persistent Actors

This tutorial builds on the basic quick start by making the counter actor persistent — it survives restarts by storing events in a database. Every increment is recorded as an event, and 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:

src/Messages/Messages.php
<?php
declare(strict_types=1);

namespace App\Messages;

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

final readonly class GetCount {}

// 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:

src/CounterState.php
<?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​

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.
src/CounterBehavior.php
<?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\Event\InMemoryEventStore;
use Monadial\Nexus\Persistence\EventSourced\Effect;
use Monadial\Nexus\Persistence\EventSourced\EventSourcedBehavior;
use Monadial\Nexus\Persistence\PersistenceId;

$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) {
$ctx->reply(new CountReply($state->count));

return Effect::none();
}

return Effect::none();
},
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; the event handler then updates state.
  • The actor replies to GetCount via $ctx->reply(), so callers can use ask().
  • The event handler is pure — no side effects, no I/O.

Step 4: Run it​

src/main.php
<?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\Props;
use Monadial\Nexus\Persistence\Event\InMemoryEventStore;
use Monadial\Nexus\Persistence\EventSourced\Effect;
use Monadial\Nexus\Persistence\EventSourced\EventSourcedBehavior;
use Monadial\Nexus\Persistence\PersistenceId;
use Monadial\Nexus\Runtime\Duration;
use Monadial\Nexus\Runtime\Fiber\FiberRuntime;

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

$runtime = new FiberRuntime();
$system = ActorSystem::create('persistent-demo', $runtime);

$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) {
$ctx->reply(new CountReply($state->count));

return Effect::none();
}

return Effect::none();
},
eventHandler: static function (object $state, object $event): object {
if ($event instanceof Incremented) {
return new CounterState($state->count + 1);
}

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

$counterRef = $system->spawn(Props::fromBehavior($behavior), 'counter');

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

$runtime->spawn(static function () use ($counterRef, $system): void {
/** @var CountReply $reply */
$reply = $counterRef->ask(new GetCount(), Duration::seconds(5))->await();
echo 'Count: ' . $reply->count . PHP_EOL; // Count: 5

$system->shutdown(Duration::seconds(1));
});

$system->run();

The important difference is not visible in this output — it is what happens when the process restarts. With persistence, the counter recovers to 5 automatically without any external intervention.

How recovery works​

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 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 processed once recovery completes. Callers do not need to know whether the actor has finished recovering.

Use a real database​

Replace InMemoryEventStore with a DBAL or Doctrine store for production:

src/bootstrap.php
<?php
declare(strict_types=1);

use Doctrine\DBAL\DriverManager;
use Monadial\Nexus\Persistence\Dbal\DbalEventStore;

$connection = DriverManager::getConnection($params);
$eventStore = new DbalEventStore($connection, $serializer);

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

Add 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:

src/CounterBehavior.php
<?php
declare(strict_types=1);

use Monadial\Nexus\Persistence\EventSourced\SnapshotStrategy;

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

Recovery then loads the latest snapshot and replays only the events that occurred after it.

Durable State alternative​

If you do not need an event history — only the latest state — use DurableStateBehavior instead:

src/DurableCounterBehavior.php
<?php
declare(strict_types=1);

namespace App;

use App\Messages\CountReply;
use App\Messages\GetCount;
use App\Messages\Increment;
use Monadial\Nexus\Core\Actor\ActorContext;
use Monadial\Nexus\Persistence\PersistenceId;
use Monadial\Nexus\Persistence\State\DurableEffect;
use Monadial\Nexus\Persistence\State\DurableStateBehavior;
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) {
$ctx->reply(new CountReply($state->count));

return DurableEffect::none();
}

return DurableEffect::none();
},
)
->withStateStore(new InMemoryDurableStateStore())
->toBehavior();

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​

  • Persistence in depth — effects, snapshots, retention policies, and storage backends.
  • Supervision — automatic restart on failure (works with persistent actors too).
  • Key Concepts — the actor model fundamentals.