Sagas: Workflows That Remember
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.
When Do You Need Sagas?
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)
Think of Sagas as: A workflow coordinator that remembers what happened and decides what to do next based on that history.
Creating Your First Saga
A Saga is like a persistent coordinator that remembers its state between events. Let's build an order processing saga step by step.
Step 1: Define the Saga Structure
#[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 instancePrivate properties - The state that gets remembered between events
Step 2: Start the Saga
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:
OrderWasPlaced
event occursEcotone creates a new
OrderProcess
saga instanceThe saga is saved to storage (database, etc.)
Now it can react to future events for this order
Storage
Sagas are automatically stored using Repositories. You can use Doctrine ORM, Eloquent, or Ecotone's Document Store - no extra configuration needed!
Saga vs Aggregate: Both can handle events and commands, but use Sagas for business processes (workflows) and Aggregates for business rules (data consistency).
Step 3: React to Events and Take Actions
Now let's make the saga react to events and coordinate the workflow:
Triggering Commands
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:
PaymentWasSuccessful
event arrivesSaga updates its internal state
Saga returns
ShipOrder
commandCommand goes to
shipOrder
channelShipping handler processes the order
Alternative: Using Command Bus
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));
}
Step 4: Publishing Events and Timeouts
Sagas can also publish events to trigger other parts of your system or set up timeouts.
Publishing Events
#[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));
}
}
Setting Up Timeouts
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
Advanced Patterns
Conditional Event Handling
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
Querying Saga State
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
Handling Unordered Events
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
Pro tip: This pattern is perfect for handling duplicate events or events that can arrive at different workflow stages.
Saga Identification and Correlation
Finding the Right Saga Instance
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
}
}
Using Correlation IDs
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
Testing Sagas with Ecotone Lite
Testing sagas is essential for ensuring your stateful workflows behave correctly. Ecotone Lite makes saga testing straightforward and comprehensive.
Setting Up Saga Tests
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']);
}
}
Testing Saga State Changes
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']);
}
Summary: When to Use Sagas
β 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
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.
Last updated
Was this helpful?