What If 80% of Your Workflow Code Shouldn't Exist?

Before reaching for state machines or saga patterns, ask yourself: is this actually a multi-step process, or just a delayed action? The answer will save you hundreds of lines of code.

What If 80% of Your Workflow Code Shouldn't Exist?

I'm going to make a claim: You don't need a workflows.

Not for order cancellation timeouts. Not for reminder emails. Probably not even for that multi-step process you're sketching on a whiteboard.

What you need is the right tool for the right job.

The Simplicity Trap: When You Don't Need a Workflow at All

Before diving into workflow patterns, let's address an uncomfortable truth: many "workflow" implementations are over-engineered.
Consider this requirement:

"Cancel the order if it's not paid within 24 hours."

The typical developer instinct kicks in: design a scheduled_tasks table, write a command that queries orders created more than 24 hours ago where status equals unpaid, handle the edge case where payment arrives during your batch run, set up a cron job...

Stop. This isn't a workflow problem. It's a delayed action.

With Ecotone, this becomes a single handler with a #[Delayed] attribute:

#[Delayed(new TimeSpan(days: 1))]
#[Asynchronous('async')]
#[EventHandler(endpointId: 'cancelUnpaidOrder')]
public function cancelIfUnpaid(
    OrderWasPlaced $event, 
    OrderRepository $orderRepository
): void 
{
    $order = $orderRepository->get($event->orderId);
    
    if ($order->isPaid()) {
        return; // Payment arrived, nothing to do
    }
    
    $order->cancel('Payment not received within 24 hours');
    $orders->save($order);
}

That's it. When an order is placed, this handler gets scheduled to run exactly 24 hours later, for specific Order. No polling, no custom scripts and cron jobs.

If your requirement is 'do X after Y time passes,' you don't need workflow. You need a delayed action: one handler, one attribute, zero state management.

With Ecotone multiple Handlers can subscribe to same Event with different timings

// Process payment immediately
#[Asynchronous('payments')]
#[EventHandler(endpointId: 'processPayment')]
public function processPayment(OrderWasPlaced $event): void
{
    // Executes immediately
}

// Send confirmation after 30 minutes (gives time for payment processing)
#[Delayed(new TimeSpan(minutes: 30))]
#[Asynchronous('notifications')]
#[EventHandler(endpointId: 'sendConfirmation')]
public function sendOrderConfirmation(OrderWasPlaced $event): void
{
    // Executes 30 minutes later
}

// Follow up if not shipped after 3 days
#[Delayed(new TimeSpan(days: 3))]
#[Asynchronous('notifications')]
#[EventHandler(endpointId: 'shippingReminder')]
public function remindAboutShipping(OrderWasPlaced $event): void
{
    // Executes 3 days later
}

Each handler operates independently. If the shipping reminder fails, it doesn't affect payment processing. If you need to change the confirmation delay from 30 minutes to an hour, you modify one attribute—no migration scripts, no configuration files to update.

Basically the most common problem we may face - delayed action, becomes solved by putting single attribute on top of the method.

Dynamic Delays for Real Business Logic

Sometimes the delay isn't fixed. Subscription renewals depend on billing cycles. Delivery timeouts vary by shipping method. Ecotone supports expression language for runtime calculation:

#[Delayed(expression: 'payload.getRenewalDate()')]
#[Asynchronous('subscriptions')]
#[EventHandler(endpointId: 'processRenewal')]
public function renewSubscription(SubscriptionWasCreated $event): void
{
    // Triggers at the DateTime returned by getRenewalDate()
}

We could even delegate calculating the delay to external Service available in our Dependency Container

#[Delayed(expression: "reference('delayingService').calculate(payload.id))]
#[Asynchronous('subscriptions')]
#[EventHandler(endpointId: 'processRenewal')]
public function renewSubscription(SubscriptionWasCreated $event): void
{
    // Triggers at the DateTime returned by getRenewalDate()
}

This pattern handles an enormous range of "do something later" requirements without any workflow infrastructure. Before reaching for state machines or saga patterns, ask yourself: is this actually a multi-step process, or just a delayed action?

Stateless Workflows: When Steps Must Flow Together

Some processes genuinely require multiple steps that must execute in sequence. Image processing pipelines. Data validation flows. Multi-stage approval processes where each stage transforms the data for the next.

The instinct here is often to reach for a state machine. Tomas Votruba, creator of Rector, voiced what many feel about that path:

"Yet, there is not a single post about how terrible the configuration is... Workflows configuration with a fractal array of strings. It's like a minefield for a developer who's tasked with adding a new line there."

"I've seen 700+ lines long definitions and I'm scared to even look at it."

The problem with state machines for workflows is fundamental: state machines focus on state, not behavior. The actual business logic—the thing you're building the workflow for—gets pushed to the edges, scattered across transition event handlers.

Ecotone offers a different approach: stateless workflows based on Input and Output - meaning Pipe and Filters approach. Instead of storing workflow state in a database, each message carries its own routing information. No workflow tables to manage. No cleanup procedures. No migrations when logic changes. No huge configuration files.

Here's an image processing pipeline:

class ImageProcessingWorkflow
{
    #[CommandHandler('process.image', outputChannelName: 'validate.image')]
    public function startProcessing(ProcessImage $command): ProcessImage
    {
        return $command;
    }

    #[InternalHandler(
        inputChannelName: 'validate.image',
        outputChannelName: 'resize.image'
    )]
    public function validateImage(ProcessImage $command): ProcessImage
    {
        if (!$this->isValidFormat($command->imageData)) {
            throw new InvalidImageException('Unsupported image format');
        }
        
        return $command;
    }

    #[InternalHandler(
        inputChannelName: 'resize.image',
        outputChannelName: 'upload.image'
    )]
    public function resizeImage(ProcessImage $command): ProcessImage
    {
        $resized = $this->imageService->resize(
            $command->imageData,
            $command->targetWidth,
            $command->targetHeight
        );
        
        return $command->withImageData($resized);
    }

    #[InternalHandler(inputChannelName: 'upload.image')]
    public function uploadImage(ProcessImage $command): void
    {
        $this->storage->upload($command->imageData, $command->targetPath);
    }
}

The #[InternalHandler] attribute creates handlers that aren't exposed via the Command Bus—they're internal to the workflow. Messages flow from one step to the next through the outputChannelName connections.

Adding Asynchronous Processing to Workflow Steps

Any step can become asynchronous by adding the #[Asynchronous] attribute:

#[Asynchronous('image-processing')]
#[InternalHandler(
    inputChannelName: 'resize.image',
    outputChannelName: 'optimize.image'
)]
public function resizeImage(ProcessImage $command): ProcessImage
{
    // Now processed asynchronously
}

This is powerful for pipelines where some steps are fast (validation) and others are slow (resizing large images). The slow steps can run on dedicated workers without blocking the fast ones.

Orchestrators: When Workflow Definition Should Be Separate from Steps

The input/output channel approach works well, but the workflow definition is embedded in each handler's outputChannelName. For complex workflows with many steps, conditional branching, or runtime variations, Ecotone provides Orchestrators.

With Orchestrators, the workflow definition becomes explicit and separate from step implementation:

class OrderOrchestrator
{
    #[Orchestrator(inputChannelName: 'process.order')]
    public function processOrder(Order $order): array
    {
        return [
            'validate.order',
            'process.payment',
            'reserve.inventory',
            'send.confirmation',
            'audit.transaction'
        ];
    }
}

The business process becomes the code itself. Each step then is implemented as a focused, testable handler:

class OrderProcessingSteps
{
    #[InternalHandler(inputChannelName: 'validate.order')]
    public function validateOrder(OrderData $order): OrderData
    {
        if (!$order->hasItems()) {
            throw new InvalidOrderException('Order must contain items');
        }
        
        return $order;
    }

    #[Asynchronous("async")]
    #[InternalHandler(inputChannelName: 'process.payment')]
    public function processPayment(OrderData $order, PaymentService $paymentService): OrderData
    {
        $result = $paymentService->charge($order->getTotal());
        return $order->markAsPaid($result->getTransactionId());
    }
    
    #[InternalHandler(inputChannelName: 'reserve.inventory')]
    public function reserveInventory(OrderData $order, InventoryService $inventory): OrderData
    {
        $inventory->reserve($order->getItems());
        return $order->markAsReserved();
    }
}

Dynamic Workflows: Runtime Decisions

Real business processes aren't static. Customer types evolve, regulations change, new requirements emerge. Orchestrators handle this naturally:

#[Orchestrator(inputChannelName: 'process.order')]
public function processOrder(OrderData $order): array
{
    $workflow = ['validate.order', 'process.payment'];
    
    // Premium customers get additional steps
    if ($order->getCustomer()->isPremium()) {
        $workflow[] = 'apply.premium.discount';
        $workflow[] = 'priority.inventory.check';
        
        if ($order->getTotal() > 1000) {
            $workflow[] = 'executive.approval';
        }
    }
    
    // International orders need customs documentation
    if ($order->isInternational()) {
        $workflow[] = 'customs.documentation';
        $workflow[] = 'international.shipping.calculation';
    }
    
    $workflow[] = 'reserve.inventory';
    $workflow[] = 'send.confirmation';
    
    return $workflow;
}

The same orchestrator elegantly handles premium customers, international orders, high-value transactions—each with their specific requirements clearly expressed and easily modifiable. What steps will Workflow trigger, can be easily tested in isolation, ensuring dynamic flow for given scenarios follow given path.

Orchestrator Gateways: API-Driven Workflows

For maximum flexibility, Orchestrator Gateways allow constructing workflows entirely at runtime based on external input:

interface DocumentProcessingGateway
{
    #[OrchestratorGateway]
    public function processDocument(array $steps, Document $document): ProcessingResult;
}

Now your API can accept workflow definitions from clients:

class DocumentController
{
    public function __construct(
        private DocumentProcessingGateway $gateway
    ) {}

    public function processDocument(Request $request): JsonResponse
    {
        $document = Document::fromRequest($request);
        
        // Build workflow based on request parameters
        $steps = ['validate.document', 'extract.content'];
        
        if ($request->get('requires_approval')) {
            $steps[] = 'legal.review';
            
            if ($document->getValue() > 100000) {
                $steps[] = 'executive.approval';
            }
        }
        
        if ($request->get('priority') === 'urgent') {
            $steps[] = 'priority.processing';
        }
        
        $steps[] = 'finalize.document';
        
        // Execute the dynamically built workflow
        $result = $this->gateway->processDocument($steps, $document);
        
        return new JsonResponse(['status' => $result->getStatus()]);
    }
}

This enables A/B testing workflow variations, customer-specific processing, and feature flags—all without code changes.

Sagas: When State Must Persist Across Events

Order fulfillment. Subscription management. Approval workflows with human-in-the-loop steps. These processes share a common characteristic: they must track state over time and react to events that arrive in unpredictable order.

An order might receive payment confirmation before inventory reservation completes. A subscription renewal might fail, requiring retry logic that remembers how many attempts have been made. An approval process might wait days for a manager's response.

Richard McDaniel, creator of Laravel Workflow, described discovering Temporal for these problems:

"Their PHP SDK was a breath of fresh air. You could write workflows like regular PHP code, yield async steps, and the system would magically resume where it left off. It was elegant... I was sold."

Then came the reality check:

"I pitched it to the DevOps team. And hit a brick wall. They stared at me like I'd suggested launching a spaceship to run PHP jobs. Temporal required a Kubernetes cluster or a subscription to their cloud service."

This tension—between Temporal's powerful durability model and PHP ecosystem realities—is real. Ecotone's Saga pattern offers a middle ground: stateful workflow coordination without external infrastructure dependencies.

Building a Saga

A Saga is a PHP class with an identifier that persists state across events:

#[Saga]
final class OrderFulfillmentProcess
{
    use WithEvents;

    #[Identifier]
    private string $orderId;
    private OrderStatus $status;
    private bool $paymentReceived = false;
    private bool $inventoryReserved = false;
    private int $paymentRetryCount = 0;

    private function __construct(string $orderId)
    {
        $this->orderId = $orderId;
        $this->status = OrderStatus::PLACED;
    }

    #[EventHandler]
    public static function startWhen(OrderWasPlaced $event): self
    {
        return new self($event->orderId);
    }

    #[EventHandler]
    public function whenPaymentSucceeded(PaymentWasSuccessful $event): void
    {
        $this->paymentReceived = true;
        $this->tryToFulfill();
    }

    #[EventHandler]
    public function whenInventoryReserved(InventoryWasReserved $event): void
    {
        $this->inventoryReserved = true;
        $this->tryToFulfill();
    }

    private function tryToFulfill(): void
    {
        if ($this->paymentReceived && $this->inventoryReserved) {
            $this->status = OrderStatus::READY_FOR_SHIPMENT;
            $this->recordThat(new OrderReadyForShipment($this->orderId));
        }
    }
}

The Saga starts when OrderWasPlaced is published. It then listens for PaymentWasSuccessful and InventoryWasReserved events—which might arrive in any order. Only when both conditions are met does it transition to READY_FOR_SHIPMENT.

After that we can either send a Command using Command Bus, or simply record an Event from within the Saga, which relates subscribes can react on.

Timeouts and Deadlines

Sagas can enforce business deadlines by combining event handlers with delays:

#[Delayed(new TimeSpan(days: 7))]
#[Asynchronous('async')]
#[EventHandler(endpointId: 'orderTimeout')]
public function handleOrderTimeout(OrderWasPlaced $event): void
{
    if ($this->status === OrderStatus::PLACED) {
        $this->status = OrderStatus::CANCELLED;
        $this->recordThat(new OrderWasCancelled(
            $this->orderId,
            'Order not completed within 7 days'
        ));
    }
}

Seven days after order placement, this handler checks if the order is still in PLACED status. If so, it cancels. If the order already progressed, the check passes harmlessly.

Event Correlation

Ecotone automatically correlates events to Sagas when property names match. The OrderWasPlaced event has an orderId property; the Saga has an #[Identifier] property named orderId. Ecotone routes the event to the correct Saga instance automatically.

For events with different property names, use identifierMapping.

#[EventHandler(identifierMapping: ['orderId' => 'payload.id']))]
public function handleOrderTimeout(OrderWasPlaced $event): void
{
    if ($this->status === OrderStatus::PLACED) {
        $this->status = OrderStatus::CANCELLED;
        $this->recordThat(new OrderWasCancelled(
            $this->orderId,
            'Order not completed within 7 days'
        ));
    }
}

Now orderReference maps to the Saga's orderId identifier. We could also use metadata header, or execute Service from Dependency Container to do the mapping.

Testing Without the Pain

A common lament in PHP developer forums captures the testing dilemma perfectly: "You want to test that unpaid orders get cancelled after 24 hours. Your options: mock everything until the test proves nothing, or actually wait 24 hours."

Ecotone provides EcotoneLite for testing workflows with simulated time:

public function test_order_cancelled_after_24_hours_without_payment(): void
{
    $orderId = Uuid::uuid4()->toString();
    
    $testSupport = EcotoneLite::bootstrapFlowTesting([
        OrderFulfillmentProcess::class
    ], enableAsynchronousProcessing: true);
    
    $testSupport
        ->publishEvent(new OrderWasPlaced($orderId))
        ->releaseAwaitingMessagesAndRunConsumer(
            'async',
            releaseAwaitingFor: new TimeSpan(days: 1)
        );
    
    $events = $testSupport->getRecordedEvents();
    
    $this->assertContainsInstanceOf(
        OrderWasCancelled::class, 
        $events
    );
}

The releaseAwaitingFor parameter simulates time passage for delayed messages. Tests run in milliseconds while verifying behavior that would take 24 hours in production. In-memory channels replace RabbitMQ or database queues, making tests fast, deterministic, and infrastructure-independent.

Choosing the Right Pattern

The three patterns address different levels of process complexity:

Delayed Messages work for standalone scheduled actions:

  • Send reminder email after 3 days
  • Cancel unpaid order after 24 hours
  • Retry failed API call with exponential backoff
  • Trigger subscription renewal at billing date

No workflow state needed—just defer a single handler's execution.

Pipe&Filters and Orchestrators (Stateless Workflows) fit sequential pipelines:

  • Image processing (validate → resize → optimize → upload)
  • Data import (parse → validate → transform → store)
  • Document generation (gather data → render → convert → deliver)

Messages carry their routing; no database involvement in workflow progression.

Sagas (Stateful Workflow) handle complex coordination:

  • Order fulfillment with payment, inventory, and shipping
  • Subscription management with billing cycles and grace periods
  • Approval workflows with multiple human decision points
  • Any process where events arrive in unpredictable order

State persists across events; decisions depend on accumulated context.

The key insight: start with the simplest pattern that handles your requirements. Most "workflow" needs are actually delayed actions. Many multi-step processes are stateless pipelines. Reserve Sagas for genuine coordination challenges.

Conclusion

At the beginning of this article, I stated that you don't need workflows. This relates to what's typically understood as workflow architecture—stateful solutions running on state machines or external services.

You shouldn't need a Kubernetes cluster to cancel unpaid orders after 24 hours. You shouldn't fear adding a step because the config file is already 400 lines. You shouldn't be in need to maintain different versions of workflows for most common cases.

Delayed messages, the Pipes & Filters approach, and Orchestrators will solve most business workflows in a stateless way—without cron polling, without workflow tables, without infrastructure your DevOps team will reject. And when genuine complexity demands it, Sagas let you handle workflows statefully while keeping the same declarative style.

Having these different tools in your toolkit means choosing the right abstraction for the problem, not forcing every process through the same heavyweight machinery. Start simple. Add complexity only when the business requires it.