| description | Learn how to build long-running workflows that remember state using Sagas |
|---|
While connecting handlers with channels works great for simple workflows, some business processes need to remember what happened before. This is where Sagas come in.
Use Sagas when your workflow needs to:
🌳 Branch and merge: Split into multiple paths that later combine ⏳ Wait for humans: Pause for manual approval or external actions 📊 Track progress: Monitor long-running processes (hours, days, weeks) 🔄 Handle complexity: Make decisions based on accumulated state
Examples:
- Order processing (payment → shipping → delivery)
- Loan approval (application → verification → decision)
- User onboarding (signup → verification → welcome)
{% hint style="success" %}
Sagas are durable workflows. A saga's state lives in your own database, persisted per #[Identifier]. When the saga's process crashes, when you deploy mid-flow, when a worker restarts — the saga survives, because its state is a database row, not process memory. On the next event arrival, Ecotone rehydrates the saga and the workflow continues. This is one of Ecotone's Durable Execution primitives — durable workflows on the database and broker you already run.
{% endhint %}
{% hint style="success" %} Think of Sagas as: A workflow coordinator that remembers what happened and decides what to do next based on that history. {% endhint %}
{% hint style="info" %} Prerequisites: Familiarity with connecting handlers and aggregates will help you understand Sagas better. {% endhint %}
A Saga is like a persistent coordinator that remembers its state between events. Let's build an order processing saga step by step.
#[Saga]
final class OrderProcess
{
private function __construct(
#[Identifier] private string $orderId, // 👈 Unique ID to find this saga
private OrderStatus $status, // 👈 Current state
private bool $isPaid = false, // 👈 Remember payment status
private bool $isShipped = false, // 👈 Remember shipping status
) {}
}Key parts:
#[Saga]- Tells Ecotone this is a stateful workflow#[Identifier]- Unique ID to find and update this specific saga instance- Private properties - The state that gets remembered between events
Sagas begin when something important happens (usually an event):
#[Saga]
final class OrderProcess
{
// ... constructor ...
#[EventHandler] // 👈 This starts the saga
public static function startWhen(OrderWasPlaced $event): self
{
return new self(
orderId: $event->orderId,
status: OrderStatus::PLACED
);
}
}What happens:
OrderWasPlacedevent occurs- Ecotone creates a new
OrderProcesssaga instance - The saga is saved to storage (database, etc.)
- Now it can react to future events for this order
Sagas are automatically stored using Repositories. You can use Doctrine ORM, Eloquent, or Ecotone's Document Store — no extra configuration needed.
The saga survives anything that doesn't destroy your database. Every time a saga handles an event, its updated state is committed to the database in the same transaction as the events it publishes. If the process dies mid-handler, the channel redelivers the message (delivery is at-least-once); the saga is reloaded from storage and the handler runs again — and because a re-dispatched command is tolerated by deduplication, the Timing difference below (when a saga sends a command via the CommandBus before its state is saved) doesn't lose or double-apply work. Deploys, restarts, OOM kills — none of them lose work, because the work isn't in memory.
For full replay semantics — every state transition recorded as an event in your own database, queryable and projectable — use #[EventSourcingSaga] (with WithAggregateVersioning + #[EventSourcingHandler] methods that rebuild state from events). The events are in your schema; build any view you need over them. See Durable Execution in PHP for the side-by-side with Temporal.
{% hint style="success" %} Saga vs Aggregate: Both can handle events and commands, but use Sagas for business processes (workflows) and Aggregates for business rules (data consistency). {% endhint %}
Now let's make the saga react to events and coordinate the workflow:
When payment succeeds, we want to start shipping:
#[Saga]
final class OrderProcess
{
// ... previous methods ...
#[EventHandler(outputChannelName: "shipOrder")] // 👈 Send to shipping
public function whenPaymentSucceeded(PaymentWasSuccessful $event): ShipOrder
{
// Update saga state
$this->isPaid = true;
$this->status = OrderStatus::READY_TO_SHIP;
// Return command to trigger shipping
return new ShipOrder($this->orderId);
}
}The flow:
PaymentWasSuccessfulevent arrives- Saga updates its internal state
- Saga returns
ShipOrdercommand - Command goes to
shipOrderchannel - Shipping handler processes the order
You can also send commands directly:
#[EventHandler]
public function whenPaymentSucceeded(PaymentWasSuccessful $event, CommandBus $commandBus): void
{
$this->isPaid = true;
$this->status = OrderStatus::READY_TO_SHIP;
$commandBus->send(new ShipOrder($this->orderId));
}{% hint style="info" %} Timing difference:
outputChannelName: Saga state saved first, then command is sentCommandBus: Command sent first, then saga state saved {% endhint %}
Sagas can also publish events to trigger other parts of your system or set up timeouts.
#[Saga]
final class OrderProcess
{
use WithEvents; // 👈 Enables event publishing
private function __construct(
#[Identifier] private string $orderId,
private OrderStatus $status,
private bool $isPaid = false,
) {
// Publish event when saga starts
$this->recordThat(new OrderProcessStarted($this->orderId));
}
#[EventHandler]
public function whenShippingCompleted(OrderWasShipped $event): void
{
$this->isShipped = true;
$this->status = OrderStatus::COMPLETED;
// Publish completion event
$this->recordThat(new OrderProcessCompleted($this->orderId));
}
}Cancel orders that aren't paid within 24 hours:
#[Saga]
final class OrderProcess
{
// ... other methods ...
#[Delayed(new TimeSpan(days: 1))] // 👈 Wait 24 hours
#[Asynchronous('async')]
#[EventHandler]
public function cancelUnpaidOrder(OrderProcessStarted $event): void
{
if (!$this->isPaid) {
$this->status = OrderStatus::CANCELLED;
$this->recordThat(new OrderWasCancelled($this->orderId, 'Payment timeout'));
}
}
}Timeline:
- ⏰ T+0: Order placed, saga starts, timeout scheduled
- ⏰ T+24h: If still unpaid, automatically cancel
Sometimes you only want to handle events if the saga already exists. Use dropMessageOnNotFound:
#[Saga]
class CustomerPromotion
{
private function __construct(
#[Identifier] private string $customerId,
private bool $hasGreatCustomerBadge = false
) {}
#[EventHandler]
public static function startWhen(ReceivedGreatCustomerBadge $event): self
{
return new self($event->customerId, hasGreatCustomerBadge: true);
}
#[EventHandler(dropMessageOnNotFound: true)] // 👈 Only if saga exists
public function whenOrderPlaced(OrderWasPlaced $event, CommandBus $commandBus): void
{
// Only send promo if customer has the badge (saga exists)
$commandBus->send(new SendPromotionCode($this->customerId));
}
}How it works:
- ✅ If saga exists: Event is processed
- ❌ If saga doesn't exist: Event is ignored (dropped)
- 🎯 Use case: Features that depend on previous conditions
Expose saga state to your application (great for status pages, dashboards):
#[Saga]
final class OrderProcess
{
// ... other methods ...
#[QueryHandler("order.getStatus")]
public function getStatus(): array
{
return [
'orderId' => $this->orderId,
'status' => $this->status->value,
'isPaid' => $this->isPaid,
'isShipped' => $this->isShipped,
];
}
#[QueryHandler("order.getProgress")]
public function getProgress(): int
{
$steps = 0;
if ($this->isPaid) $steps++;
if ($this->isShipped) $steps++;
return ($steps / 2) * 100; // Progress percentage
}
}Using in your controllers:
class OrderController
{
public function __construct(private QueryBus $queryBus) {}
public function getOrderStatus(string $orderId): JsonResponse
{
$status = $this->queryBus->sendWithRouting(
'order.getStatus',
metadata: ['aggregate.id' => $orderId] // 👈 Target specific saga
);
return new JsonResponse($status);
}
}Perfect for:
- Order tracking pages
- Progress indicators
- Admin dashboards
- Customer support tools
Real-world events don't always arrive in order. Ecotone handles this elegantly with method redirection:
#[Saga]
class OrderFulfillment
{
private function __construct(
#[Identifier] private string $orderId,
private bool $isStarted = false
) {}
// 👇 If saga doesn't exist, this creates it
#[EventHandler]
public static function startByOrderPlaced(OrderWasPlaced $event): self
{
return new self($event->orderId, isStarted: true);
}
// 👇 If saga exists, this handles the event
#[EventHandler]
public function whenOrderWasPlaced(OrderWasPlaced $event): void
{
// Handle additional order placed logic
// (maybe it's a duplicate event, or additional items)
}
}How Ecotone decides:
- 🎯 Same event, different behavior based on saga state
- 🆕 Saga doesn't exist → Calls static factory method
- ✅ Saga exists → Calls action method
Benefits:
- No complex if/else logic in your code
- Handles event ordering issues automatically
- Clean separation of initialization vs. processing logic
{% hint style="success" %} Pro tip: This pattern is perfect for handling duplicate events or events that can arrive at different workflow stages. {% endhint %}
Every event/command needs to find the correct saga instance. Ecotone uses Identifier Mapping for this:
// Event with orderId property
class PaymentWasSuccessful
{
public function __construct(public string $orderId) {}
}
// Saga with orderId identifier
#[Saga]
class OrderProcess
{
public function __construct(
#[Identifier] private string $orderId // 👈 Matches event property
) {}
#[EventHandler]
public function whenPaymentSucceeded(PaymentWasSuccessful $event): void
{
// Ecotone automatically finds the saga with matching orderId
}
}For complex workflows that branch and merge, use correlation IDs:
#[Saga]
class OrderProcess
{
public function __construct(
#[Identifier] private string $correlationId // 👈 Tracks across branches
) {}
#[EventHandler]
public static function startWhen(OrderWasPlaced $event): self
{
// Use correlation ID from message headers
return new self($event->getHeaders()['correlationId']);
}
}Benefits of correlation IDs:
- Track workflows across multiple services
- Handle branching and merging flows
- Automatic propagation through message chains
{% hint style="info" %} Correlation IDs are automatically propagated between messages, making them perfect for complex workflows that span multiple services or branches. {% endhint %}
Testing sagas is essential for ensuring your stateful workflows behave correctly. Ecotone Lite makes saga testing straightforward and comprehensive.
use Ecotone\Lite\EcotoneLite;
use PHPUnit\Framework\TestCase;
class OrderProcessSagaTest extends TestCase
{
private EcotoneLite $ecotoneLite;
protected function setUp(): void
{
$this->ecotoneLite = EcotoneLite::bootstrapFlowTesting(
[OrderProcess::class],
[],
);
}
public function test_saga_initialization(): void
{
// Act - Trigger saga creation
$event = new OrderWasPlaced('order-123');
$this->ecotoneLite->publishEvent($event);
// Assert - Verify saga was created and can be queried
$status = $this->ecotoneLite->sendQueryWithRouting(
'order.getStatus',
metadata: ['aggregate.id' => 'order-123']
);
$this->assertEquals('order-123', $status['orderId']);
$this->assertEquals('PLACED', $status['status']);
$this->assertFalse($status['isPaid']);
$this->assertFalse($status['isShipped']);
}
}Test how sagas respond to events and update their state:
public function test_payment_processing_updates_saga_state(): void
{
// Arrange - Create saga
$this->ecotoneLite->publishEvent(new OrderWasPlaced('order-123'));
// Act - Process payment
$this->ecotoneLite->publishEvent(new PaymentWasSuccessful('order-123'));
// Assert - Verify state changed
$status = $this->ecotoneLite->sendQueryWithRouting(
'order.getStatus',
metadata: ['aggregate.id' => 'order-123']
);
$this->assertTrue($status['isPaid']);
$this->assertEquals('READY_TO_SHIP', $status['status']);
}✅ Use Sagas when you need to:
- Remember state between events
- Coordinate long-running processes
- Handle branching/merging workflows
- Implement timeouts and cancellations
- Track progress of complex operations
❌ Don't use Sagas for:
- Simple linear workflows (use handler chaining)
- Stateless transformations
{% hint style="success" %} Key insight: Sagas are workflow coordinators that remember what happened and decide what to do next. They're perfect for orchestrating complex business processes that span across time and multiple services. {% endhint %}