Skip to main content

Workflows

Workflows provide a powerful way to orchestrate complex, long-running processes in your application. They are built on top of the Laravel Workflow package and provide additional features like progress tracking, resourceable relationships, retryable activities, and termination support.

Overview

A workflow is a sequence of activities that execute in order. Workflows are:
  • Persistent: State is stored in the database, allowing workflows to resume after failures
  • Trackable: Progress, status, and logs are automatically tracked
  • Resumable: Can be paused and resumed
  • Retryable: Activities can be retried on failure
  • Terminable: Can be gracefully terminated by user request

Laravel Workflow Foundation

This library builds on top of the Laravel Workflow package. Understanding the core concepts helps when working with workflows.

Basic Workflow Structure

In Laravel Workflow, workflows extend the Workflow class and implement an execute() method. The yield keyword is used to pause execution and wait for activity completion:
use function Workflow\activity;
use Workflow\Workflow;

class MyWorkflow extends Workflow
{
    public function execute()
    {
        $result = yield activity(MyActivity::class);
        return $result;
    }
}

Basic Activity Structure

Activities extend the Activity class and implement an execute() method. They perform specific tasks like API requests, data processing, or sending emails:
use Workflow\Activity;

class MyActivity extends Activity
{
    public function execute()
    {
        // Perform some work...
        return $result;
    }
}

Starting Workflows with WorkflowStub

Workflows are started using WorkflowStub::make() and the start() method. The start() method returns immediately and executes asynchronously:
use Workflow\WorkflowStub;

$workflow = WorkflowStub::make(MyWorkflow::class);
$workflow->start();
To load an existing workflow by ID:
$workflow = WorkflowStub::load($id);

Passing Data

Data can be passed to workflows via the start() method and will be available as arguments to execute():
$workflow = WorkflowStub::make(MyWorkflow::class);
$workflow->start('world');
Similarly, data can be passed to activities:
yield activity(MyActivity::class, $name);
Important: Only pass small amounts of data. For large datasets, write to the database or cache and pass keys/paths instead. When passing Eloquent models, only the ModelIdentifier is serialized (id, class, relations, connection). The full model is retrieved from the database when the workflow or activity runs. Workflow output can be retrieved after completion:
$workflow->output(); // Returns the return value from execute()
Dependency injection is supported - type-hint dependencies in execute() methods and Laravel’s service container will inject them automatically.

Workflow Status

Monitor workflow status using the running() method (returns true if still running) or status() method:
while ($workflow->running()) {
    // Wait for completion
}

$status = $workflow->status();
Possible status values:
  • WorkflowCreatedStatus
  • WorkflowCompletedStatus
  • WorkflowContinuedStatus
  • WorkflowFailedStatus
  • WorkflowPendingStatus
  • WorkflowRunningStatus
  • WorkflowWaitingStatus

Workflow ID

Get the workflow ID when starting:
$workflow = WorkflowStub::make(MyWorkflow::class);
$workflowId = $workflow->id();
Inside activities, access the workflow ID:
class MyActivity extends Activity
{
    public function execute()
    {
        $workflowId = $this->workflowId();
        // Use workflow ID for tracking, caching, etc.
    }
}
The workflow ID is useful for storing workflow-related data, generating signed URLs, or querying/signaling workflows later.

Laravel Workflow Features

Laravel Workflow provides powerful features for building complex, reliable workflows. These features are available in workflows that extend Inly\Core\Workflows\Base\Workflow.

Signals

Signals allow external processes to communicate with a running workflow. Define signal methods using the #[SignalMethod] attribute:
use Workflow\Attributes\SignalMethod;

class MyWorkflow extends Workflow
{
    private bool $ready = false;

    #[SignalMethod]
    public function setReady(bool $ready): void
    {
        $this->ready = $ready;
    }

    public function execute()
    {
        // Wait for signal
        yield $this->await(fn() => $this->ready);

        // Continue execution...
    }
}
Trigger signals externally:
$workflow = WorkflowStub::load($workflowId);
$workflow->setReady(true);
The await() helper pauses workflow execution until a condition becomes true, commonly used with signals.

Queries

Queries allow you to inspect a workflow’s current state without altering its execution. Define query methods using the #[QueryMethod] attribute:
use Workflow\Attributes\QueryMethod;

class MyWorkflow extends Workflow
{
    private int $processedCount = 0;

    #[QueryMethod]
    public function getProcessedCount(): int
    {
        return $this->processedCount;
    }
}
Query methods can be called externally:
$workflow = WorkflowStub::load($workflowId);
$count = $workflow->getProcessedCount();
Important: Query methods must not cause side effects or advance workflow execution—they’re read-only for monitoring purposes.

Timers

Durable timers allow workflows to pause for a specified duration and resume after system restarts or failures:
use function Workflow\timer;
use function Workflow\seconds;
use function Workflow\minutes;
use function Workflow\now;

class MyWorkflow extends Workflow
{
    public function execute()
    {
        // Wait 5 seconds
        yield timer('5 seconds');

        // Or use helper functions
        yield timer(seconds(30));
        yield timer(minutes(1));

        // Get current time (from workflow's event log, not system time)
        $currentTime = now();
    }
}
Critical: Always use Workflow\now() instead of Carbon::now() or now() inside workflows to maintain determinism during replay.

Signal + Timer

Combine waiting for a signal with a timeout using awaitWithTimeout():
use function Workflow\awaitWithTimeout;

class MyWorkflow extends Workflow
{
    private bool $approved = false;

    #[SignalMethod]
    public function approve(): void
    {
        $this->approved = true;
    }

    public function execute()
    {
        // Wait up to 1 hour for approval, or timeout
        $approved = yield awaitWithTimeout(
            hours(1),
            fn() => $this->approved
        );

        if ($approved) {
            // Process approval...
        } else {
            // Handle timeout...
        }
    }
}
Returns true if the condition becomes true before timeout, false if the timer expires.

Heartbeats

Heartbeats prevent activity timeouts during long-running operations. Use them inside activities:
class LongRunningActivity extends Activity
{
    public int $timeout = 60; // 60 seconds

    public function execute()
    {
        for ($i = 0; $i < 1000; $i++) {
            // Do work...
            processItem($i);

            // Send heartbeat every 10 items to prevent timeout
            if ($i % 10 === 0) {
                $this->heartbeat();
            }
        }
    }
}
If heartbeats stop, the activity may be considered frozen and timed out.

Side Effects

Side effects wrap non-deterministic operations that should execute exactly once. The result is stored and reused during replay:
use function Workflow\sideEffect;

class MyWorkflow extends Workflow
{
    public function execute()
    {
        // Generate UUID only once, even on replay
        $uuid = yield sideEffect(fn() => Str::uuid()->toString());

        // Random number (same value on replay)
        $random = yield sideEffect(fn() => random_int(1, 100));
    }
}
Important: Side effect closures should never fail. If an operation might fail, implement it as an activity instead.

Child Workflows

Workflows can spawn child workflows for modularization and reuse:
use function Workflow\child;

class ParentWorkflow extends Workflow
{
    public function execute()
    {
        // Spawn child workflow
        $childHandle = yield child(ChildWorkflow::class, $data);

        // Get child workflow ID
        $childId = $childHandle->id();

        // Signal child workflow
        $childHandle->process();
    }
}
Child workflows can be executed in parallel using all():
use function Workflow\all;

$results = yield all([
    child(ProcessOrderWorkflow::class, $order1),
    child(ProcessOrderWorkflow::class, $order2),
    child(ProcessOrderWorkflow::class, $order3),
]);

Concurrency

Execute multiple activities or workflows in parallel or series:
use function Workflow\activity;
use function Workflow\all;

class MyWorkflow extends Workflow
{
    public function execute()
    {
        // Sequential execution
        $result1 = yield activity(Activity1::class);
        $result2 = yield activity(Activity2::class);

        // Parallel execution
        [$result3, $result4, $result5] = yield all([
            activity(Activity3::class),
            activity(Activity4::class),
            activity(Activity5::class),
        ]);

        // Mixed: sequential then parallel
        $result6 = yield activity(Activity6::class);
        [$result7, $result8] = yield all([
            activity(Activity7::class),
            activity(Activity8::class),
        ]);
    }
}

Sagas

Sagas implement distributed transactions by coordinating multiple activities with compensation logic:
class OrderSaga extends Workflow
{
    public function execute(Order $order)
    {
        try {
            yield activity(ReserveInventoryActivity::class, $order);
            yield activity(ChargePaymentActivity::class, $order);
            yield activity(ShipOrderActivity::class, $order);
        } catch (Exception $e) {
            // Compensate: undo previous steps
            yield activity(CancelReservationActivity::class, $order);
            yield activity(RefundPaymentActivity::class, $order);
            throw $e;
        }
    }
}

Events

Workflows can emit and listen to events. Events are typically implemented using signals or by dispatching Laravel events from activities:
use Workflow\Attributes\SignalMethod;
use Illuminate\Support\Facades\Event;

class OrderWorkflow extends Workflow
{
    #[SignalMethod]
    public function onOrderProcessed(Order $order): void
    {
        // Signal can be called externally to notify workflow
        Event::dispatch(new OrderProcessed($order));
    }

    public function execute(Order $order)
    {
        yield activity(ProcessOrderActivity::class, $order);

        // Emit event via signal
        $this->onOrderProcessed($order);
    }
}
Listen to workflow events in your application:
Event::listen(OrderProcessed::class, function (OrderProcessed $event) {
    // Handle order processed event
    Notification::send($event->order->user, new OrderNotification());
});

Webhooks

Workflows can be triggered via webhooks. Create webhook endpoints in your routes:
// routes/web.php
Route::post('/webhooks/orders', function (Request $request) {
    $order = Order::findOrFail($request->input('order_id'));

    $workflow = ProcessOrderWorkflow::start($order);

    return response()->json([
        'workflow_id' => $workflow->id(),
        'status' => 'started',
    ]);
});
Webhooks are useful for integrating with external systems that need to trigger workflows asynchronously.

Continue As New

For workflows that process large datasets over long periods, use “continue as new” to reset workflow history and prevent unbounded event log growth:
use function Workflow\continueAsNew;

class ProcessLargeDatasetWorkflow extends Workflow
{
    public function execute(array $items, int $offset = 0)
    {
        $batchSize = 100;
        $batch = array_slice($items, $offset, $batchSize);

        foreach ($batch as $item) {
            yield activity(ProcessItemActivity::class, $item);
        }

        // If more items remain, continue as new workflow instance
        if ($offset + $batchSize < count($items)) {
            yield continueAsNew($items, $offset + $batchSize);
        }
    }
}
Benefits:
  • Prevents event log from growing indefinitely
  • Improves performance for long-running workflows
  • Allows workflow to process millions of items without memory issues
Note: The new workflow instance starts fresh but receives the updated parameters, effectively continuing where the previous instance left off.

Versioning

When updating workflow code with breaking changes, use versioning to handle running workflows gracefully:
class MyWorkflow extends Workflow
{
    public static function version(): int
    {
        return 2; // Increment when making breaking changes
    }

    public function execute()
    {
        // New implementation with different logic
        if (static::version() >= 2) {
            // New v2 logic
            yield activity(NewActivity::class);
        }
    }
}
Versioning Strategy:
  • Increment version when changing workflow structure or logic
  • Old workflow instances continue with their original version
  • New instances use the latest version
  • Consider backward compatibility when possible
Migration: For breaking changes, you may need to:
  1. Keep old workflow code available until all instances complete
  2. Manually migrate or terminate old workflow instances
  3. Use version checks to handle different workflow versions in your code

Laravel Workflow Configuration

Publishing Config

Publish the workflow configuration file:
php artisan vendor:publish --tag=workflow-config
This creates config/workflow.php with customizable options.

Options

Key configuration options include:
  • Connection: Database connection for workflow storage
  • Queue: Queue connection for workflow execution
  • Timeout: Default activity timeout
  • Retry: Default retry configuration
  • Sticky: Whether to ensure same server execution

Ensuring Same Server

Configure workflows to run on the same server instance for stateful operations:
// config/workflow.php
'sticky' => true,
This ensures workflow execution stays on the same worker process.

Database Connection

Specify a custom database connection for workflow storage:
// config/workflow.php
'connection' => 'workflow',

Microservices

Workflows can span multiple services. Configure service-specific settings and ensure proper serialization of workflow data across service boundaries.

Pruning Workflows

Automatically clean up old completed workflows:
// In a scheduled command
Workflow::prune(now()->subDays(30));
Configure automatic pruning in your workflow configuration.

Laravel Workflow Constraints

To ensure workflows execute correctly and can be replayed deterministically, certain constraints must be followed.

Overview

Workflows must be deterministic—they must produce the same result when replayed with the same inputs. Activities must be idempotent—they should safely handle retries without unintended side effects.

Workflow Constraints

Workflows must not:
  • Use Carbon::now(), now(), or system time functions (use Workflow\now() instead)
  • Access Auth::user() or other request context
  • Make direct network/HTTP requests
  • Use random functions (unless wrapped in sideEffect())
  • Access mutable global state
  • Perform database queries directly (use activities instead)
Why: Workflows are replayed from event history. Non-deterministic operations would produce different results on replay, breaking workflow consistency.

Activity Constraints

Activities should:
  • Be idempotent (safe to retry)
  • Handle duplicate invocations gracefully
  • Use transactions where appropriate
  • Validate inputs before performing operations
Activities can:
  • Make network requests
  • Access the database
  • Use system time functions
  • Access request context (with caution)
Why: Activities may be retried on failure, so they must handle being called multiple times safely.

Constraints Summary

ConstraintWorkflowsActivities
Deterministic✅ Required❌ Not required
Idempotent✅ Recommended✅ Strongly required
External IO❌ Forbidden✅ Allowed
Network calls❌ Forbidden✅ Allowed
System time❌ Use Workflow\now()✅ Allowed
Random functions❌ Use sideEffect()✅ Allowed
Database queries❌ Use activities✅ Allowed

Testing Workflows

Test workflows using WorkflowStub::fake() to run them synchronously:
use Workflow\WorkflowStub;

it('processes orders correctly', function () {
    WorkflowStub::fake();

    $workflow = ProcessOrderWorkflow::start($order);

    expect($workflow->output())->toBe($expectedResult);
});
Mock activities and child workflows:
WorkflowStub::fake([
    ProcessPaymentActivity::class => fn() => 'payment-id-123',
]);
Test timers by traveling forward in time:
use Illuminate\Support\Facades\Date;

it('waits for approval timeout', function () {
    WorkflowStub::fake();

    $workflow = ApprovalWorkflow::start($request);

    // Travel forward 1 hour
    Date::setTestNow(now()->addHour());

    // Process workflow...
    expect($workflow->status())->toBeInstanceOf(WorkflowCompletedStatus::class);
});

Failures and Recovery

Activity Failures

Activities can throw exceptions. By default, they retry indefinitely with exponential backoff:
class MyActivity extends Activity
{
    public int $tries = 3; // Limit retries

    public function execute()
    {
        // May throw exception
        $this->processData();
    }
}

Non-Retryable Exceptions

Mark exceptions as non-retryable to fail immediately:
use Workflow\NonRetryableException;

class MyActivity extends Activity
{
    public function execute()
    {
        if ($this->isInvalidInput()) {
            throw new NonRetryableException('Invalid input');
        }
    }
}

Workflow Status During Failures

When activities fail:
  • Workflow status remains RUNNING while retries are attempted
  • Workflow transitions to FAILED only after all retries are exhausted
  • Workflow can be recovered by fixing the underlying issue and redeploying

Recovery Process

  1. Identify the failure: Check workflow logs and activity exceptions
  2. Fix the root cause: Update code, fix data, resolve external issues
  3. Redeploy: Deploy the fix
  4. Automatic retry: Activities continue retrying automatically
  5. Monitor: Watch workflow progress until completion or permanent failure
Workflows remain in the database and can be queried, signaled, or manually retried even after failures.

How It Works

Laravel Workflow uses an event-sourcing approach:
  1. Event Log: All workflow actions (activities, signals, timers) are stored as events in the database
  2. Deterministic Replay: Workflows are replayed from events, ensuring consistent state
  3. Persistence: Workflow state is derived from events, not stored directly
  4. Resumability: After failures, workflows resume from the last event
  5. Queue-Based: Workflows execute asynchronously via Laravel queues
This architecture provides:
  • Reliability: Workflows survive server crashes and restarts
  • Consistency: Deterministic execution ensures correct results
  • Debuggability: Event log provides complete audit trail
  • Scalability: Workflows can be distributed across multiple workers

Creating a Workflow

Workflows extend the Inly\Core\Workflows\Base\Workflow class and implement an execute() method:
use Inly\Core\Workflows\Base\Workflow;
use Inly\Core\Enums\WorkflowCategory;
use Workflow\ActivityStub;

class ImportDataWorkflow extends Workflow
{
    public static $category = WorkflowCategory::FILE_IMPORT;

    public static function label(): ?string
    {
        return __('Import data');
    }

    public function execute(string $filePath)
    {
        // Execute activities using ActivityStub::make()
        yield ActivityStub::make(LoadDataActivity::class, $filePath);
        yield ActivityStub::make(ProcessDataActivity::class);
        yield ActivityStub::make(SaveDataActivity::class);
    }
}

Workflow Categories

Set a category to group related workflows together:
public static $category = WorkflowCategory::HUBSPOT;
Available categories include: FORTNOX, SHOPIFY, HUBSPOT, SNOWFLAKE, BUSINESS_CENTRAL, FILE_IMPORT, HARVEST, NOTION.

Dispatchable Workflows

To allow workflows to be started from the frontend, add the #[Dispatchable] attribute:
use Inly\Core\Workflows\Base\Dispatchable;

#[Dispatchable]
class ImportDataWorkflow extends Workflow
{
    // ...
}

Creating an Activity

Activities are the building blocks of workflows. They extend Inly\Core\Workflows\Base\Activity and implement an execute() method:
use Inly\Core\Workflows\Base\Activity;

class ProcessOrderActivity extends Activity
{
    public static function label(): string
    {
        return __('Process order');
    }

    public function execute(Order $order)
    {
        // Process the order...
        $this->incrementProcessedCount();
    }
}

Activity Execution Methods

Activities can be executed in three ways:
  1. As part of a workflow (most common):
yield ActivityStub::make(ProcessOrderActivity::class, $order);
  1. As a standalone workflow:
ProcessOrderActivity::executeAsWorkflow($order);
  1. As a synchronous action (for testing or simple operations):
ProcessOrderActivity::executeAsAction($order);

Starting Workflows

Using the start() Method

The SelfStartingWorkflow trait provides a start() method that automatically sets the category and causer:
$workflow = ImportDataWorkflow::start($filePath);
The causer (user who initiated the workflow) is automatically resolved using CauserResolver.

Workflow Arguments

Workflows can accept any number of arguments:
public function execute(string $filePath, ?Carbon $since = null, bool $force = false)
{
    // ...
}
Arguments are automatically serialized and stored with the workflow.

Progress Tracking

Workflows support progress tracking to show completion status in the UI.

Setting Total Count

Set the total number of items to process:
public function execute()
{
    $items = Item::all();
    $this->setTotalCount($items->count());

    foreach ($items as $item) {
        yield ActivityStub::make(ProcessItemActivity::class, $item);
        $this->incrementProgress();
    }
}

Tracking in Activities

Activities automatically track processed items:
class ProcessItemActivity extends Activity
{
    public function execute(Item $item)
    {
        // Process item...
        $this->incrementProcessedCount(); // Increments workflow's processed_count
    }
}
The activity’s incrementProcessedCount() method automatically updates the parent workflow’s progress. Progress is saved in batches of 100 for performance.

Failed Count Tracking

Track failed items separately:
try {
    $this->processItem($item);
    $this->incrementProcessedCount();
} catch (Exception $e) {
    $this->incrementFailedProcessedCount();
    $this->handleException($e);
}

Resourceable Relationships

Workflows can be associated with Eloquent models using the resourceable morph relationship. This allows you to track which workflows are related to specific resources.

Attaching Resources

Attach a resource from within an activity:
class ProcessOrderActivity extends Activity
{
    public function execute(Order $order)
    {
        $this->attachResource($order);
        // Process the order...
    }
}
Or attach from a workflow:
class ImportOrderWorkflow extends Workflow
{
    public function execute(Order $order)
    {
        $this->attachResource($order);
        yield ActivityStub::make(ProcessOrderActivity::class, $order);
    }
}

Querying Workflows by Resource

Find all workflows for a specific resource:
use Inly\Core\Models\StoredWorkflow;

$workflows = StoredWorkflow::where('resourceable_type', Order::class)
    ->where('resourceable_id', $order->id)
    ->get();

Inverse Relationships

Add the HasWorkflows trait to your models:
use Inly\Core\Models\Traits\HasWorkflows;

class Order extends Model
{
    use HasWorkflows;

    // Now you can use: $order->workflows
}

Tracking Latest Workflow

If your model implements TracksLatestWorkflow, the latest workflow ID is automatically updated when a resource is attached:
use Inly\Core\Models\Contracts\TracksLatestWorkflow;

class Order extends Model implements TracksLatestWorkflow
{
    public function setLatestWorkflow(StoredWorkflow $workflow): void
    {
        $this->update(['latest_workflow_id' => $workflow->id]);
    }

    public function hasLatestWorkflowId(): bool
    {
        return $this->latest_workflow_id !== null;
    }
}

Retryable Activities

The retryable() method allows activities to be retried when they fail. This is useful for handling transient errors or network issues.

Using retryable

class ImportDataWorkflow extends Workflow
{
    public function execute()
    {
        // This activity will pause on failure and wait for a retry signal
        yield from $this->retryable(ProcessPaymentActivity::class, $order);
        yield from $this->retryable(SendNotificationActivity::class, $order);
    }
}

How Retryable Works

  1. The activity is executed using ActivityStub::make()
  2. If an exception occurs, the workflow pauses and waits for a retry signal
  3. The retry() signal method can be called to trigger a retry
  4. The activity is re-executed until it succeeds

Manual Retry Control

Trigger a retry manually:
// Using WorkflowStub
WorkflowStub::load($workflowId)->retry();

// Using StoredWorkflow
StoredWorkflow::find($workflowId)->toWorkflow()->retry();

Termination

Workflows can be gracefully terminated by setting the termination_requested_at timestamp. The workflow will check for termination at key points and throw a RuntimeException if termination is requested.

Requesting Termination

$workflow = StoredWorkflow::find($id);
$workflow->update(['termination_requested_at' => now()]);

Automatic Termination Checks

Workflows automatically check for termination:
  • At the start of handle()
  • Every 100 processed items in activities
  • Before saving progress updates

Environment

Set the environment to track which instance of a category the workflow is running on:
public function execute()
{
    $this->setEnvironment('usa'); // e.g., "BusinessCentral" category with "usa" environment
    // ...
}

Workflow Status

Workflows have several statuses:
  • PENDING: Workflow is queued but not started
  • RUNNING: Workflow is currently executing
  • WAITING: Workflow is paused (e.g., waiting for retry signal)
  • COMPLETED: Workflow finished successfully
  • FAILED: Workflow encountered an error
  • TERMINATED: Workflow was terminated by user request
  • TERMINATION_REQUESTED: Termination has been requested but not yet processed
  • RETRYABLE: Workflow is waiting for a retry signal

Best Practices

  1. Use descriptive labels: Implement label() methods for better UI display
  2. Set categories: Always set a category for better organization
  3. Track progress: Use progress tracking for long-running workflows
  4. Handle exceptions: Use incrementFailedProcessedCount() and handleException() in activities
  5. Attach resources: Attach resources early in the workflow for better tracking
  6. Use retryable for transient errors: Wrap activities that might fail due to network issues
  7. Keep activities focused: Each activity should do one thing well

Example: Complete Workflow

use Inly\Core\Workflows\Base\Workflow;
use Inly\Core\Workflows\Base\Activity;
use Inly\Core\Enums\WorkflowCategory;
use Workflow\ActivityStub;

#[Dispatchable]
class ImportOrdersWorkflow extends Workflow
{
    public static $category = WorkflowCategory::HUBSPOT;

    public static function label(): ?string
    {
        return __('Import orders from HubSpot');
    }

    public function execute(?Carbon $updatedAt = null)
    {
        $this->setEnvironment('production');

        $orders = Order::where('updated_at', '>=', $updatedAt)->get();
        $this->setTotalCount($orders->count());

        foreach ($orders as $order) {
            $this->attachResource($order);

            yield from $this->retryable(LoadOrderDataActivity::class, $order);
            yield ActivityStub::make(ProcessOrderActivity::class, $order);

            $this->incrementProgress();
        }
    }
}

class ProcessOrderActivity extends Activity
{
    public function execute(Order $order)
    {
        try {
            // Process order...
            $this->incrementProcessedCount();
        } catch (Exception $e) {
            $this->incrementFailedProcessedCount();
            $this->handleException($e);
        }
    }
}