Skip to content

Action

The action step type executes a registered handler class that performs business logic. It is the most commonly used step type and the primary way to run code within a workflow.

Definition

php
'process_payment' => [
    'type' => 'action',
    'handler' => 'process_payment',
]

The only required field beyond type is handler, which tells the engine which action to execute.

Handler Resolution

When the engine encounters an action step, it resolves the handler string to a callable using the following resolution order:

1. Registry Lookup (Preferred)

The engine first checks the ActionRegistry for a friendly name match:

php
'handler' => 'process_payment'  // Looked up in registry

This is the preferred approach because it decouples workflow definitions from PHP namespaces, making definitions portable and understandable by non-developers.

2. Plain Class Name

If the handler is not found in the registry, the engine resolves it as a class name from the Laravel container and calls its handle() method:

php
'handler' => 'App\\Actions\\ProcessPayment'
// Resolved via container, calls handle()

If the class doesn't exist or doesn't have a handle() method, a StepExecutionException is thrown.

Resolution Summary

FormatExampleResolution
Registry name'process_payment'Looked up in ActionRegistry
Plain class'App\\Actions\\Payment'Container-resolved, calls handle()

Writing a Handler

A handler is any class with a method that accepts an ExecutionContext and returns an array:

php
use Workflowable\Workflowable\Engine\ExecutionContext;

class ProcessPayment
{
    public function handle(ExecutionContext $context): array
    {
        $amount = $context->getVariable('amount');
        $orderId = $context->getVariable('orderId');

        $result = PaymentGateway::charge($amount);

        return [
            'payment_id' => $result->id,
            'charged_at' => now()->toISOString(),
        ];
    }
}

ExecutionContext API

The ExecutionContext is a mutable runtime state holder passed to every handler. It provides:

MethodDescription
getVariable(string $key, mixed $default = null)Get a single workflow variable
getVariables()Get all workflow variables as an array
setVariable(string $key, mixed $value)Set a variable (available to subsequent steps)
getCurrentStep()Get the current step name being executed
getStepHistory()Get array of previously executed step names
getInstance()Get the WorkflowInstance Eloquent model
getWorkflow()Get the Workflow definition Eloquent model
getStepConfig(string $key, mixed $default = null)Get a configuration value from the step definition
getStepDefinition()Get the full step definition array

Variable Flow

Variables flow through the workflow and accumulate as steps execute:

Initial event data --> Step 1 handler --> Step 1 output merged --> Step 2 handler --> ...
  1. When a workflow starts, the WorkflowEvent constructor parameters become the initial variables
  2. Each handler receives all accumulated variables via the ExecutionContext
  3. The array returned by the handler is merged into the variables (new values override existing keys)
  4. The next step's handler sees the merged result
php
// Step 1: process_order
public function handle(ExecutionContext $context): array
{
    $orderId = $context->getVariable('orderId'); // From event DTO
    return ['processed' => true, 'receipt_id' => 'RCP-123'];
}

// Step 2: fulfill_order
public function handle(ExecutionContext $context): array
{
    $orderId = $context->getVariable('orderId');       // Still available
    $receiptId = $context->getVariable('receipt_id');  // From step 1
    $processed = $context->getVariable('processed');   // From step 1
    return ['fulfilled' => true];
}

Dependency Injection

Handler classes are resolved from the Laravel container, so constructor injection works:

php
class ProcessPayment
{
    public function __construct(
        private PaymentGateway $gateway,
        private AuditLogger $logger,
    ) {}

    public function handle(ExecutionContext $context): array
    {
        $result = $this->gateway->charge($context->getVariable('amount'));
        $this->logger->log('payment_processed', $result);

        return ['payment_id' => $result->id];
    }
}

Error Handling

If a handler throws an exception, the step type catches it and returns a StepResult::failed() with the exception message. The workflow instance transitions to a failed state (unless a retry policy is configured).

php
class ProcessPayment
{
    public function handle(ExecutionContext $context): array
    {
        // If this throws, the step fails with the exception message
        $result = PaymentGateway::charge($context->getVariable('amount'));

        return ['payment_id' => $result->id];
    }
}

Registering Handlers

Register handlers in config/workflowable.php. There are two scopes:

General actions -- available to all workflows:

php
'actions' => [
    'send_email' => \App\Actions\SendEmail::class,
    'log_entry' => \App\Actions\LogEntry::class,
],

Event-scoped actions -- only available to workflows triggered by a specific event:

php
'events' => [
    'order_submitted' => [
        'class' => \App\WorkflowEvents\OrderSubmitted::class,
        'description' => 'Triggered when a new order is submitted',
        'actions' => [
            'process_order' => \App\Actions\ProcessOrder::class,
            'fulfill_order' => \App\Actions\FulfillOrder::class,
        ],
    ],
],

Via Service Provider

Register programmatically using the ActionRegistry:

php
use Workflowable\Workflowable\Registries\ActionRegistry;

public function boot(): void
{
    $registry = app(ActionRegistry::class);
    $registry->register('process_payment', ProcessPayment::class);
    $registry->register('send_invoice', SendInvoice::class, 'Send invoice to customer');
}

The register() method signature:

php
$registry->register(
    string $name,        // Friendly name used in definitions
    string $class,       // Handler class (must exist, must have handle() method)
    string $description = '',   // Optional description for UI/discovery
);

Registry API

MethodDescription
register($name, $class, $description)Register an action
resolve($name)Resolve to a callable (throws if not found)
has($name)Check if a name is registered
names()Get all registered names
all()Get all actions with metadata
catalog()Get name => description map (for UIs)

Validation

When a workflow definition is validated (at creation time), the engine checks:

  1. The handler field is present and is a string
  2. If the handler is a registered action name, it passes validation
  3. If not registered, it must be a valid class name that exists

Invalid handlers are caught at definition time, not at execution time.

Retry Support

Actions support retry policies for handling transient failures:

php
'process_payment' => [
    'type' => 'action',
    'handler' => 'charge_card',
    'retry' => [
        'max_attempts' => 3,
        'backoff' => 'exponential',
        'delay_seconds' => 5,
    ],
]

If the handler throws an exception and a retry policy is configured, the step will be re-attempted according to the backoff strategy.

Step Configuration

Action steps can include custom configuration keys alongside type and handler. These are accessible to the handler at runtime via the ExecutionContext:

php
'send_email' => [
    'type' => 'action',
    'handler' => 'send_email',
    'template' => 'order-confirmation',
    'subject' => 'Your order is confirmed',
]

The handler reads this config via getStepConfig():

php
public function handle(ExecutionContext $context): array
{
    $template = $context->getStepConfig('template');
    $subject = $context->getStepConfig('subject', 'Default Subject');

    // Use template and subject to send the email...

    return ['email_sent' => true];
}

This decouples the handler's behavior from the workflow's variables — the handler reads its config from the step definition (static, design-time values) and reads runtime data from getVariable().

Action Parameters

Handlers can declare the parameters they accept by implementing the DefinesParameters interface. This enables discovery, validation, and UI rendering.

Declaring Parameters

php
use Workflowable\Workflowable\Contracts\DefinesParameters;
use Workflowable\Workflowable\Support\Parameters\TextParameter;
use Workflowable\Workflowable\Support\Parameters\SelectParameter;
use Workflowable\Workflowable\Support\Parameters\ModelParameter;

class SendEmail implements DefinesParameters
{
    public static function parameters(): array
    {
        return [
            SelectParameter::make('template', 'Email Template')
                ->options([
                    'order-confirmation' => 'Order Confirmation',
                    'invoice-reminder' => 'Invoice Reminder',
                ]),

            TextParameter::make('subject', 'Subject Line')
                ->optional()
                ->default('Notification')
                ->placeholder('Enter email subject...')
                ->description('Supports {{variable}} interpolation'),

            ModelParameter::make('recipient_id', 'Recipient')
                ->route('api.users.search')
                ->labelKey('name'),
        ];
    }

    public function handle(ExecutionContext $context): array
    {
        $template = $context->getStepConfig('template');
        $subject = $context->getStepConfig('subject', 'Notification');
        return ['email_sent' => true];
    }
}

Available Parameter Types

TypeClassPurpose
TextTextParameterText input with placeholder(), multiline()
NumberNumberParameterNumeric input with min(), max(), step()
BooleanBooleanParameterToggle / checkbox
SelectSelectParameterDropdown with static options(), multiple()
ModelModelParameterTypeahead search via route(), valueKey(), labelKey()
DateDateParameterDate picker with withTime(), min(), max()

Common Builder Methods

All parameter types share these methods from the base Parameter class:

MethodDescription
make($name, $label)Static factory (on each subclass)
required() / optional()Set whether the parameter is required (default: required)
default($value)Set a default value
description($text)Help text for the workflow designer
rules($array)Laravel validation rules, checked at definition time
when($condition, $callback)Conditional builder chain (via Conditionable)

Validation Rules

Parameters can declare Laravel validation rules that are checked when a workflow definition is created:

php
TextParameter::make('email', 'Recipient Email')
    ->rules(['email', 'max:255']),

NumberParameter::make('quantity', 'Quantity')
    ->rules(['integer', 'min:1', 'max:100']),

If a step definition fails validation, an InvalidWorkflowDefinitionException is thrown at definition time.

ModelParameter and Data Sources

ModelParameter is for selecting from large datasets where a static options list isn't practical. Instead of embedding options, you point it to an application-owned search endpoint:

php
ModelParameter::make('customer_id', 'Customer')
    ->route('api.customers.search')   // Laravel named route
    ->valueKey('id')                   // Field to store
    ->labelKey('name')                 // Field to display
    ->searchParam('q')                 // Query parameter name (default: 'q')

The package resolves the named route to a URL in the serialized schema. The search endpoint is owned by your application — the package doesn't query models directly.

Schema Serialization

Parameters serialize to a standardized JSON format via toArray(). The ActionRegistry::schema() method returns the full catalog including parameter schemas:

php
$registry = app(ActionRegistry::class);
$schema = $registry->schema();
// Returns structured array with descriptions + serialized parameters

This schema can be consumed by a frontend workflow builder to render the appropriate form controls for each action's configuration.

Extending with Macros

The base Parameter class uses Laravel's Macroable trait. Add custom builder methods without subclassing:

php
// In a service provider
TextParameter::macro('currency', function () {
    return $this->placeholder('0.00')->rules(['numeric', 'min:0']);
});

// In a handler
TextParameter::make('amount', 'Amount')->currency();

For entirely new input types, extend Parameter directly:

php
class ColorParameter extends Parameter
{
    public static function make(string $name, string $label): static
    {
        return new static($name, $label);
    }

    public function type(): string
    {
        return 'color';
    }
}

Internal Behavior

Under the hood, when the engine executes an action step:

  1. The ActionStepType::execute() method is called
  2. It extracts the handler string from the step definition
  3. It calls resolveHandler() which follows the resolution order described above
  4. The resolved callable is invoked with the ExecutionContext via call_user_func()
  5. The returned array is wrapped in StepResult::success($result)
  6. If any exception is thrown, it returns StepResult::failed($e->getMessage())
  7. The engine merges the output into the workflow's variables