Skip to main content
The Saga pattern provides a way to maintain data consistency across multiple activities by defining compensating transactions that can undo work when failures occur.

Overview

Sagas handle failures in distributed transactions by executing a series of local transactions, each with a corresponding compensation action. If any step fails, compensation actions run in reverse order to undo the completed work.

Basic Usage

Use the Sagas trait and register compensation actions with addCompensation():
use Workflow\Workflow;
use Workflow\Traits\Sagas;
use function Workflow\activity;

class BookingWorkflow extends Workflow
{
    use Sagas;
    
    public function execute($bookingId)
    {
        try {
            // Reserve hotel
            yield activity(ReserveHotelActivity::class, $bookingId);
            $this->addCompensation(fn () => activity(CancelHotelActivity::class, $bookingId));
            
            // Book flight
            yield activity(BookFlightActivity::class, $bookingId);
            $this->addCompensation(fn () => activity(CancelFlightActivity::class, $bookingId));
            
            // Charge payment
            yield activity(ChargePaymentActivity::class, $bookingId);
            $this->addCompensation(fn () => activity(RefundPaymentActivity::class, $bookingId));
            
            return 'booking_completed';
        } catch (\Throwable $e) {
            // Run compensations in reverse order
            yield from $this->compensate();
            throw $e;
        }
    }
}
Compensations are executed in reverse order - last added runs first. This ensures proper rollback sequence.

How It Works

1

Execute activity

Perform a local transaction (e.g., reserve hotel)
2

Register compensation

Add a compensation action that can undo the work (e.g., cancel hotel)
3

Continue or compensate

If all activities succeed, complete normally. If any fails, run compensations in reverse.

Compensation Order

Compensations run in reverse order of registration:
public function execute()
{
    try {
        // Step 1
        yield activity(CreateOrderActivity::class);
        $this->addCompensation(fn () => activity(DeleteOrderActivity::class)); // Runs 3rd
        
        // Step 2
        yield activity(ReserveInventoryActivity::class);
        $this->addCompensation(fn () => activity(ReleaseInventoryActivity::class)); // Runs 2nd
        
        // Step 3
        yield activity(ProcessPaymentActivity::class);
        $this->addCompensation(fn () => activity(RefundPaymentActivity::class)); // Runs 1st
        
        return 'success';
    } catch (\Throwable $e) {
        yield from $this->compensate();
        throw $e; // Or return 'compensated'
    }
}

Parallel Compensation

By default, compensations run sequentially in reverse order. Use setParallelCompensation() to run them in parallel:
use Workflow\Traits\Sagas;

class ParallelSagaWorkflow extends Workflow
{
    use Sagas;
    
    public function execute()
    {
        // Enable parallel compensation
        $this->setParallelCompensation(true);
        
        try {
            yield activity(ReserveHotelActivity::class);
            $this->addCompensation(fn () => activity(CancelHotelActivity::class));
            
            yield activity(BookFlightActivity::class);
            $this->addCompensation(fn () => activity(CancelFlightActivity::class));
            
            yield activity(RentCarActivity::class);
            $this->addCompensation(fn () => activity(CancelCarActivity::class));
            
            return 'success';
        } catch (\Throwable $e) {
            // All compensations run in parallel
            yield from $this->compensate();
            throw $e;
        }
    }
}
Parallel compensation is faster but should only be used when compensations are independent of each other.

Continue with Errors

By default, if a compensation fails, the entire compensation process stops. Use setContinueWithError() to continue compensating even if some fail:
public function execute()
{
    // Continue compensating even if some compensations fail
    $this->setContinueWithError(true);
    
    try {
        yield activity(Step1Activity::class);
        $this->addCompensation(fn () => activity(UndoStep1Activity::class));
        
        yield activity(Step2Activity::class);
        $this->addCompensation(fn () => activity(UndoStep2Activity::class));
        
        yield activity(Step3Activity::class);
        $this->addCompensation(fn () => activity(UndoStep3Activity::class));
        
        return 'success';
    } catch (\Throwable $e) {
        yield from $this->compensate();
        // Even if some compensations failed, we continue
        return 'compensated_with_errors';
    }
}
When using setContinueWithError(true), ensure you have monitoring and alerting for partial compensation failures.

Partial Compensation

You can handle specific errors and decide whether to compensate:
public function execute($orderId)
{
    try {
        yield activity(ValidateOrderActivity::class, $orderId);
        // No compensation needed for validation
        
        yield activity(ChargePaymentActivity::class, $orderId);
        $this->addCompensation(fn () => activity(RefundPaymentActivity::class, $orderId));
        
        yield activity(UpdateInventoryActivity::class, $orderId);
        $this->addCompensation(fn () => activity(RestoreInventoryActivity::class, $orderId));
        
        return 'completed';
    } catch (ValidationException $e) {
        // Don't compensate for validation errors
        return 'validation_failed';
    } catch (\Throwable $e) {
        // Compensate for other errors
        yield from $this->compensate();
        throw $e;
    }
}

Successful Completion Without Exception

You don’t need to compensate on success - compensations are only for failure scenarios:
public function execute($orderId, $shouldFail = false)
{
    try {
        yield activity(ReserveInventoryActivity::class, $orderId);
        $this->addCompensation(fn () => activity(ReleaseInventoryActivity::class, $orderId));
        
        yield activity(ProcessPaymentActivity::class, $orderId);
        $this->addCompensation(fn () => activity(RefundPaymentActivity::class, $orderId));
        
        if ($shouldFail) {
            throw new \Exception('Simulated failure');
        }
        
        // Success - compensations are registered but never executed
        return 'success';
    } catch (\Throwable $e) {
        // Only compensate on failure
        yield from $this->compensate();
        throw $e;
    }
}

Real-World Example

E-commerce order processing with multiple payment providers:
class OrderSagaWorkflow extends Workflow
{
    use Sagas;
    
    public function execute($orderId, $customerId, $amount)
    {
        $this->setParallelCompensation(false);
        $this->setContinueWithError(false);
        
        try {
            // Create order record
            $order = yield activity(CreateOrderActivity::class, $orderId, $customerId, $amount);
            $this->addCompensation(
                fn () => activity(DeleteOrderActivity::class, $orderId)
            );
            
            // Reserve inventory
            yield activity(ReserveInventoryActivity::class, $order);
            $this->addCompensation(
                fn () => activity(ReleaseInventoryActivity::class, $order)
            );
            
            // Authorize payment
            $authCode = yield activity(AuthorizePaymentActivity::class, $customerId, $amount);
            $this->addCompensation(
                fn () => activity(VoidPaymentActivity::class, $authCode)
            );
            
            // Capture payment
            yield activity(CapturePaymentActivity::class, $authCode);
            $this->addCompensation(
                fn () => activity(RefundPaymentActivity::class, $authCode)
            );
            
            // Update customer loyalty points
            yield activity(AddLoyaltyPointsActivity::class, $customerId, $amount);
            $this->addCompensation(
                fn () => activity(RemoveLoyaltyPointsActivity::class, $customerId, $amount)
            );
            
            // Send confirmation email
            yield activity(SendConfirmationEmailActivity::class, $order);
            // Note: No compensation for email - it's idempotent/informational
            
            return 'order_completed';
        } catch (\Throwable $e) {
            // Rollback all changes
            yield from $this->compensate();
            
            // Optionally log the failure
            yield activity(LogFailureActivity::class, $orderId, $e->getMessage());
            
            return 'order_failed';
        }
    }
}

Use Cases

Distributed Transactions

Maintain consistency across multiple services without 2PC

Booking Systems

Reserve multiple resources and rollback if any fails

Payment Processing

Handle multi-step payment flows with refund capabilities

Order Fulfillment

Coordinate inventory, shipping, and payment with rollback

Best Practices

Compensation activities should be idempotent - safe to run multiple times in case of retries.
Add compensations right after the activity that requires them - don’t defer registration.
Always test your saga workflows with simulated failures to ensure compensations work correctly.
Set up alerts for compensation failures - they indicate potential data inconsistency.
Some compensations may not be instant (e.g., refunds). Document expected timing.

Saga vs Alternatives

When to use:
  • Multiple independent services/activities
  • Can’t use distributed transactions
  • Need eventual consistency
  • Each step can be compensated

Build docs developers (and LLMs) love