Persistence
Actors are inherently stateless across restarts. When an actor stops -- whether due to a failure, a deployment, or a system shutdown -- its in-memory state is lost. Persistence solves this by automatically saving and recovering state so that an actor can pick up exactly where it left off.
Nexus supports two persistence models. Event Sourcing persists a sequence of
events and rebuilds state by replaying them. Durable State persists the
current state directly as a single value. Both models share the same
PersistenceId addressing scheme, storage backend abstraction, and recovery
lifecycle.
Choose the model that fits your domain. Event Sourcing gives you a full audit trail and temporal queries; Durable State gives you simplicity and lower storage overhead. Both can be mixed within the same actor system.
Event Sourcing
Event Sourcing follows the pattern: commands arrive, the command handler produces effects, effects persist events, and events are applied to the state. The actor's state is never persisted directly -- it is always derived by replaying the event log from the beginning (or from a snapshot).
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\Core\Actor\ActorContext;
use Monadial\Nexus\Core\Actor\Props;
// Messages
readonly class AddItem
{
public function __construct(public string $item) {}
}
readonly class ItemAdded
{
public function __construct(public string $item) {}
}
// State
readonly class ShoppingCart
{
public function __construct(public array $items = []) {}
}
$behavior = EventSourcedBehavior::create(
PersistenceId::of('cart', 'cart-1'),
new ShoppingCart(),
// Command handler: receives (state, context, command), returns Effect
static function (object $state, ActorContext $ctx, object $command): Effect {
if ($command instanceof AddItem) {
return Effect::persist(new ItemAdded($command->item));
}
return Effect::none();
},
// Event handler: receives (state, event), returns new state
static function (object $state, object $event): object {
if ($event instanceof ItemAdded) {
return new ShoppingCart([...$state->items, $event->item]);
}
return $state;
},
)
->withEventStore(new InMemoryEventStore())
->toBehavior();
$ref = $system->spawn(Props::fromBehavior($behavior), 'cart');
$ref->tell(new AddItem('apple'));
The command handler must be pure -- it inspects the current state and the
incoming command, then returns an Effect describing what should happen. The
event handler is also pure: it takes the current state and an event, and returns
the new state. Side effects belong in thenRun callbacks (see below).
Effects
The Effect class describes what the actor system should do after a command is
handled. Effects are composable -- you can chain persistence with replies and
side effects.
| Effect | Description |
|---|---|
Effect::persist(new Event1(), new Event2()) | Persist one or more events, then apply them to the state |
Effect::none() | Do nothing |
Effect::reply($ref, new Response()) | Send a reply without persisting anything |
Effect::stash() | Buffer the current message for later replay |
Effect::stop() | Stop the actor |
Effects can be chained with thenReply and thenRun:
// Persist events, then reply with the updated state
Effect::persist(new OrderPlaced($orderId))
->thenReply($replyTo, fn(object $state) => new OrderConfirmation($state->id));
// Persist events, then run a side effect
Effect::persist(new PaymentReceived($amount))
->thenRun(fn(object $state) => $ctx->log()->info("Payment processed: {$state->total}"));
Durable State
Durable State is the simpler alternative. Instead of persisting events and replaying them, the actor persists its entire current state as a single value. On recovery, the latest state is loaded directly -- no replay step.
use Monadial\Nexus\Persistence\State\DurableStateBehavior;
use Monadial\Nexus\Persistence\State\DurableEffect;
use Monadial\Nexus\Persistence\State\InMemoryDurableStateStore;
use Monadial\Nexus\Persistence\PersistenceId;
use Monadial\Nexus\Core\Actor\ActorContext;
readonly class UpdateTheme
{
public function __construct(public string $theme) {}
}
readonly class UserPreferences
{
public function __construct(
public string $theme = 'light',
public string $language = 'en',
) {}
}
$behavior = DurableStateBehavior::create(
PersistenceId::of('prefs', 'user-42'),
new UserPreferences(),
static function (object $state, ActorContext $ctx, object $command): DurableEffect {
if ($command instanceof UpdateTheme) {
return DurableEffect::persist(
new UserPreferences($command->theme, $state->language),
);
}
return DurableEffect::none();
},
)
->withStateStore(new InMemoryDurableStateStore())
->toBehavior();
DurableEffect supports the same chaining as Effect -- thenReply,
thenRun, stash, and stop all work the same way.
Class-Based API
For users who prefer an object-oriented style, Nexus provides abstract base
classes for both persistence models. Extend AbstractEventSourcedActor or
AbstractDurableStateActor and override the handler methods.
use Monadial\Nexus\Persistence\EventSourced\AbstractEventSourcedActor;
use Monadial\Nexus\Persistence\EventSourced\Effect;
use Monadial\Nexus\Persistence\Event\EventStore;
use Monadial\Nexus\Persistence\PersistenceId;
use Monadial\Nexus\Core\Actor\ActorContext;
use Monadial\Nexus\Core\Actor\Props;
final class OrderActor extends AbstractEventSourcedActor
{
public function __construct(
EventStore $eventStore,
private readonly string $orderId,
) {
parent::__construct($eventStore);
}
public function persistenceId(): PersistenceId
{
return PersistenceId::of('order', $this->orderId);
}
public function emptyState(): object
{
return new OrderState();
}
public function handleCommand(object $state, ActorContext $ctx, object $command): Effect
{
// ...
}
public function applyEvent(object $state, object $event): object
{
// ...
}
}
$actor = new OrderActor($eventStore, 'order-123');
$ref = $system->spawn($actor->toProps(), 'order-123');
Snapshots
For actors with long event histories, replaying every event on recovery can be slow. Snapshots solve this by periodically saving the full state alongside the event log. On recovery, the actor loads the latest snapshot and only replays events that occurred after it.
use Monadial\Nexus\Persistence\EventSourced\SnapshotStrategy;
use Monadial\Nexus\Persistence\EventSourced\RetentionPolicy;
$behavior = EventSourcedBehavior::create(
PersistenceId::of('account', 'acc-1'),
new AccountState(),
$commandHandler,
$eventHandler,
)
->withEventStore($eventStore)
->withSnapshotStore($snapshotStore)
->withSnapshotStrategy(SnapshotStrategy::everyN(100))
->withRetention(RetentionPolicy::snapshotAndEvents(
keepSnapshots: 2,
deleteEventsTo: true,
))
->toBehavior();
SnapshotStrategy::everyN(100) takes a snapshot every 100 events.
RetentionPolicy::snapshotAndEvents() controls cleanup -- in this example, the
system keeps the two most recent snapshots and deletes all events that precede
the oldest retained snapshot.
Recovery
When a persistent actor starts, it goes through a recovery phase before accepting commands:
- Load snapshot -- if a snapshot store is configured and a snapshot exists, load it as the starting state.
- Replay events -- replay all events that occurred after the snapshot (or from the beginning if no snapshot exists). Each event is passed through the event handler to rebuild the current state.
- Ready -- the actor begins processing commands from its mailbox.
Commands that arrive during recovery are automatically stashed and replayed in order once recovery completes. This means senders do not need to know whether an actor has finished recovering -- they can start sending messages immediately.
Storage Backends
Nexus ships with several storage backend implementations:
| Store | Use case |
|---|---|
InMemoryEventStore / InMemorySnapshotStore / InMemoryDurableStateStore | Testing and prototyping |
DbalEventStore / DbalSnapshotStore / DbalDurableStateStore | Doctrine DBAL -- works with any SQL database |
DoctrineEventStore / DoctrineSnapshotStore / DoctrineDurableStateStore | Doctrine ORM |
All stores implement the same interfaces (EventStore, SnapshotStore,
DurableStateStore), so you can swap backends without changing actor code.
Single-Writer Guarantee
Nexus follows Akka's single-writer principle: each ActorSystem instance is
assigned a unique ULID at startup, and every persisted envelope is stamped with
that writer identity. This makes it possible to detect when two systems
accidentally write to the same persistence ID.
Every EventEnvelope, SnapshotEnvelope, and DurableStateEnvelope carries a
writerId field (a Symfony\Component\Uid\Ulid). Stores record this value in a
writer_id column. If a store detects a write from a different writer than
expected, it throws a WriterConflictException.
Replay Filtering
During recovery, the ReplayFilter checks that replayed events come from a
consistent writer. If events from multiple writers are detected (e.g. due to a
split-brain or misconfiguration), the filter's mode determines what happens:
| Mode | Behavior |
|---|---|
ReplayFilterMode::Fail | Throw a RecoveryException on writer interleave |
ReplayFilterMode::Warn | Log a warning and continue |
ReplayFilterMode::RepairByDiscardOld | Keep only events from the latest writer |
ReplayFilterMode::Off | Skip filtering entirely |
use Monadial\Nexus\Persistence\Recovery\ReplayFilterMode;
$behavior = EventSourcedBehavior::create(/* ... */)
->withEventStore($eventStore)
->withReplayFilter(ReplayFilterMode::Fail)
->toBehavior();
Both EventSourcedBehavior and DurableStateBehavior support
withWriterId() and withReplayFilter(). The class-based APIs
(AbstractEventSourcedActor, AbstractDurableStateActor) also expose these
methods.
Choosing Between Event Sourcing and Durable State
| Event Sourcing | Durable State | |
|---|---|---|
| Audit trail | Full history of every change | Only the current state |
| Temporal queries | Query state at any point in time | Not possible |
| Storage | Grows with every event (mitigated by snapshots) | Fixed size per actor |
| Complexity | Higher -- two handlers (command + event) | Lower -- one handler |
| Recovery | Replay events (or snapshot + tail) | Load single value |
| Best for | Domains where history matters (finance, ordering, compliance) | Domains where only current state matters (preferences, caches, sessions) |
When in doubt, start with Durable State. You can migrate to Event Sourcing later if you discover you need the audit trail or temporal query capabilities.