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
'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:
'handler' => 'process_payment' // Looked up in registryThis 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:
'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
| Format | Example | Resolution |
|---|---|---|
| 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:
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:
| Method | Description |
|---|---|
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 --> ...- When a workflow starts, the
WorkflowEventconstructor parameters become the initial variables - Each handler receives all accumulated variables via the
ExecutionContext - The array returned by the handler is merged into the variables (new values override existing keys)
- The next step's handler sees the merged result
// 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:
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).
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
Via Config (Recommended)
Register handlers in config/workflowable.php. There are two scopes:
General actions -- available to all workflows:
'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:
'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:
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:
$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
| Method | Description |
|---|---|
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:
- The
handlerfield is present and is a string - If the handler is a registered action name, it passes validation
- 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:
'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:
'send_email' => [
'type' => 'action',
'handler' => 'send_email',
'template' => 'order-confirmation',
'subject' => 'Your order is confirmed',
]The handler reads this config via getStepConfig():
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
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
| Type | Class | Purpose |
|---|---|---|
| Text | TextParameter | Text input with placeholder(), multiline() |
| Number | NumberParameter | Numeric input with min(), max(), step() |
| Boolean | BooleanParameter | Toggle / checkbox |
| Select | SelectParameter | Dropdown with static options(), multiple() |
| Model | ModelParameter | Typeahead search via route(), valueKey(), labelKey() |
| Date | DateParameter | Date picker with withTime(), min(), max() |
Common Builder Methods
All parameter types share these methods from the base Parameter class:
| Method | Description |
|---|---|
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:
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:
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:
$registry = app(ActionRegistry::class);
$schema = $registry->schema();
// Returns structured array with descriptions + serialized parametersThis 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:
// 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:
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:
- The
ActionStepType::execute()method is called - It extracts the
handlerstring from the step definition - It calls
resolveHandler()which follows the resolution order described above - The resolved callable is invoked with the
ExecutionContextviacall_user_func() - The returned array is wrapped in
StepResult::success($result) - If any exception is thrown, it returns
StepResult::failed($e->getMessage()) - The engine merges the output into the workflow's variables