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)

Prerequisites: Familiarity with connecting handlers and aggregates will help you understand Sagas better.

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 instance

  • Private 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:

  1. OrderWasPlaced event occurs

  2. Ecotone creates a new OrderProcess saga instance

  3. The saga is saved to storage (database, etc.)

  4. 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!

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:

  1. PaymentWasSuccessful event arrives

  2. Saga updates its internal state

  3. Saga returns ShipOrder command

  4. Command goes to shipOrder channel

  5. Shipping 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));
}

Timing difference:

  • outputChannelName: Saga state saved first, then command is sent

  • CommandBus: Command sent first, then saga state saved

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

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

Correlation IDs are automatically propagated between messages, making them perfect for complex workflows that span multiple services or branches.

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:

Last updated

Was this helpful?