Configuration
workflows:install publishes config/workflows.php; to publish it alone:
php artisan vendor:publish --tag="workflows-config"Every key is read through strict readers: a wrong type throws InvalidWorkflowsConfigurationException naming the key, a value outside its accepted range throws naming the key and the range, and a blank value (an env KEY=) takes the default. Every configuration error, the toolkit readers’ included, is that one class, so a single catch covers them. Durations are ISO 8601 strings. Class settings must name an existing class implementing the contract.
The config file
<?php
declare(strict_types=1);
use RoundlyConsulting\Workflows\Http\Resources\WorkflowOwnerResource;
use RoundlyConsulting\Workflows\Http\Resources\WorkflowSubjectResource;
use RoundlyConsulting\Workflows\Http\Resources\WorkflowUserResource;
use RoundlyConsulting\Workflows\Models\StepRun;
use RoundlyConsulting\Workflows\Models\WorkflowDefinition;
use RoundlyConsulting\Workflows\Models\WorkflowDryRun;
use RoundlyConsulting\Workflows\Models\WorkflowEscalation;
use RoundlyConsulting\Workflows\Models\WorkflowIdempotencyKey;
use RoundlyConsulting\Workflows\Models\WorkflowInstance;
use RoundlyConsulting\Workflows\Models\WorkflowVersion;
use RoundlyConsulting\Workflows\Models\WorkflowWait;
use RoundlyConsulting\Workflows\Support\GateStepAuthorizer;
/*
| Every key is read through the package's strict readers: a typo throws naming the key, and a
| blank value (a host's `KEY=`) takes the default. Pass env values through raw.
*/
return [
/*
| Key types of the polymorphic columns: `bigint`, `uuid` or `ulid`. `key_type` governs the
| user morphs (started_by, published_by, requested_by); a blank `owner_key_type` or
| `subject_key_type` falls back to it. Every registered subject type must use the subject
| key type. `primary_key_type` is the type of the package's own ids.
*/
'key_type' => env('WORKFLOWS_KEY_TYPE', 'bigint'),
'owner_key_type' => env('WORKFLOWS_OWNER_KEY_TYPE'),
'subject_key_type' => env('WORKFLOWS_SUBJECT_KEY_TYPE'),
'primary_key_type' => env('WORKFLOWS_PRIMARY_KEY_TYPE', 'bigint'),
/*
| Swappable models: extend the packaged class and name the subclass here.
*/
'models' => [
'definition' => WorkflowDefinition::class,
'version' => WorkflowVersion::class,
'instance' => WorkflowInstance::class,
'step_run' => StepRun::class,
'wait' => WorkflowWait::class,
'escalation' => WorkflowEscalation::class,
'idempotency_key' => WorkflowIdempotencyKey::class,
'dry_run' => WorkflowDryRun::class,
],
'tables' => [
'definitions' => 'workflow_definitions',
'versions' => 'workflow_versions',
'instances' => 'workflow_instances',
'step_runs' => 'workflow_step_runs',
'waits' => 'workflow_waits',
'escalations' => 'workflow_escalations',
'idempotency_keys' => 'workflow_idempotency_keys',
'dry_runs' => 'workflow_dry_runs',
],
/*
| Every step runs as a queued job. With the queue off, the same steps run inline, one after
| another (an iterative runner, never nested dispatches). Blank connection / queue = the
| application defaults.
*/
'queue' => [
'enabled' => env('WORKFLOWS_QUEUE_ENABLED', true),
'connection' => env('WORKFLOWS_QUEUE_CONNECTION'),
'queue' => env('WORKFLOWS_QUEUE_NAME'),
],
// Host step types: a list of classes extending RoundlyConsulting\Workflows\Steps\StepType.
'steps' => [],
// Subject types: a list of classes implementing Contracts\DescribesWorkflowSubject.
'subjects' => [],
// Laravel events mapped to trigger keys: [Event::class => 'request.submitted'], or
// [Event::class => ['key' => 'request.submitted', 'subject' => 'request', 'user' => 'user',
// 'payload' => ['amount', 'currency'], 'idempotency_key' => 'submissionId']], or a
// self-describing WorkflowEvent as [Event::class => '*']. Workflows::triggers()->map() and
// ->listen() do the same from a service provider.
'events' => [],
// The default timezone of a workflow version (blank = app.timezone).
'timezone' => env('WORKFLOWS_TIMEZONE'),
'engine' => [
// How long a claimed step may run before wake reclaims it (the job timeout is 30 s less).
'lease' => 'PT10M',
// A queued step whose job was pushed longer ago than this is pushed again by wake.
'requeue_after' => 'PT5M',
],
'wake' => [
'batch' => 500,
'claim_ttl' => 'PT10M',
],
'retry' => [
'default' => [
'max_attempts' => 3,
'backoff' => [30, 120, 600],
'jitter' => 0.1,
],
'max_attempts_cap' => 10,
],
'limits' => [
'max_steps' => 200,
'max_depth' => 3,
// Instances a definition may start per minute; blank = 60, `off` = no limit.
'max_starts_per_minute' => env('WORKFLOWS_MAX_STARTS_PER_MINUTE', 60),
'max_loop_iterations' => 1000,
],
'graph' => [
'max_nodes' => 500,
'max_edges' => 2000,
'max_bytes' => 524288,
],
'conditions' => [
'max_depth' => 6,
'max_clauses' => 100,
],
'steps_output' => [
'max_bytes' => 65536,
],
// Each step run records its input: the node config with every value reference resolved,
// as the handler saw it. A larger input is stored as {"truncated": true, "bytes": n}.
'steps_input' => [
'max_bytes' => 65536,
],
'escalations' => [
'max_levels' => 5,
],
'idempotency' => [
// Trigger keys expire after this; a key re-used later starts a new instance.
'ttl' => 'P30D',
],
'schedule' => [
// Missed schedule slots within this window are coalesced into one start.
'catch_up' => 'PT1H',
],
'authorization' => [
// class-string<Contracts\AuthorizesSteps>: called before every side-effect step and escalation
// action, a null ability included (the default allows that).
'authorizer' => GateStepAuthorizer::class,
// Step type key => policy ability, overriding the type's own ability().
'abilities' => [],
],
'actor' => [
// class-string of an invokable returning the service-account user the `system` actor
// authorizes as (null: a null user, so policies deny unless they accept ?User).
'system_user' => null,
],
'owners' => [
// class-string<Contracts\RestoresOwnerContext>; null = teams when installed, else none.
'context' => null,
// class-string<Contracts\ChecksOwnerMembership>; null = teams when installed, else allow.
'membership' => null,
],
'parties' => [
// The user model `{"kind":"user"}` parties resolve to (null = auth.providers.users.model).
'user_model' => null,
// class-string<Contracts\ResolvesRoles>; null = teams when installed, else no roles.
'roles' => null,
],
'adapters' => [
// class-string<Contracts\TransitionAdapter>; null = the lifecycle bridge when installed.
'transition' => null,
// class-string<Contracts\DeliversWebhooks>; null = no outbound webhook step.
'webhooks' => null,
],
'business_time' => [
// ISO weekday numbers of the built-in `weekdays` calendar (1 = Monday).
'weekdays' => [1, 2, 3, 4, 5],
'max_search_days' => 366,
// class-string of an invokable returning the owner model of app-wide definitions'
// opening-hours calendars.
'calendar_owner' => null,
],
'approval' => [
// Safety net after an approval round's expiry, in case its resolution event was lost.
'expiry_grace' => 'PT5M',
],
// What happens to running instances when their subject is deleted: cancel | keep.
'subjects_on_deleted' => 'cancel',
'bridges' => [
'lifecycle' => [
// Emit `lifecycle.<lifecycle>.<transition>` for every lifecycle state change.
'emit_transitions' => env('WORKFLOWS_LIFECYCLE_EMIT_TRANSITIONS', 'off'),
],
],
// Definition files (`workflows:sync` / `workflows:export`): absolute or relative to the base path.
'sync' => [
'path' => env('WORKFLOWS_SYNC_PATH', 'resources/workflows'),
],
'dry_run' => [
// Write a workflow_dry_runs row for every dry run (on | off).
'log' => env('WORKFLOWS_DRY_RUN_LOG', 'off'),
],
'webhooks' => [
// The opt-in inbound start route: POST {prefix}/hooks/{hook}.
'enabled' => env('WORKFLOWS_WEBHOOKS_ENABLED', 'off'),
'prefix' => 'workflows',
'middleware' => ['api'],
// REQUIRED when enabled: the signature check (e.g. 'sentinel.signed:<profile>'). An
// empty list keeps the route unregistered (fail closed).
'verify_middleware' => [],
'max_bytes' => 262144,
],
'resources' => [
// How a host model renders. Point a key at your own JsonResource to add a name or avatar.
'owner' => WorkflowOwnerResource::class, // receives the owner model
'subject' => WorkflowSubjectResource::class, // receives the subject model
'user' => WorkflowUserResource::class, // receives a user model
],
'prune' => [
'instances_after' => 'P90D',
'dry_runs_after' => 'P30D',
],
];Key types, models and tables
| Key | Env | Default | Accepted | Meaning |
|---|---|---|---|---|
key_type | WORKFLOWS_KEY_TYPE | bigint | bigint, uuid, ulid | User morph keys (started_by, published_by, requested_by). |
owner_key_type | WORKFLOWS_OWNER_KEY_TYPE | = key_type | bigint, uuid, ulid | Owner morph keys. |
subject_key_type | WORKFLOWS_SUBJECT_KEY_TYPE | = key_type | bigint, uuid, ulid | Subject morph keys; every registered subject type must fit it. |
primary_key_type | WORKFLOWS_PRIMARY_KEY_TYPE | bigint | bigint, uuid, ulid | The package’s own ids. |
models.* | — | packaged models | a subclass of the packaged model | Swap a model: extend it and name the subclass (definition, version, instance, step_run, wait, escalation, idempotency_key, dry_run). |
tables.* | — | workflow_* | ^[a-z][a-z0-9_]{0,39}$ | Table names: definitions, versions, instances, step_runs, waits, escalations, idempotency_keys, dry_runs. |
Queue, registries and time
| Key | Env | Default | Accepted | Meaning |
|---|---|---|---|---|
queue.enabled | WORKFLOWS_QUEUE_ENABLED | on | boolean (on/off, true/false, 1/0) | Off = steps run inline. |
queue.connection | WORKFLOWS_QUEUE_CONNECTION | app default | string | Where the jobs go. |
queue.queue | WORKFLOWS_QUEUE_NAME | app default | string | The queue name. |
steps | — | [] | list of StepType class-strings | Host step type classes. |
subjects | — | [] | list of DescribesWorkflowSubject class-strings | Subject (and owner) types. |
events | — | [] | map | Laravel event → trigger key: a key, '*' for a WorkflowEvent, or {key, subject, user, payload, idempotency_key}. |
timezone | WORKFLOWS_TIMEZONE | app.timezone | IANA timezone | Default version timezone. |
Engine, wake and retries
| Key | Env | Default | Accepted | Meaning |
|---|---|---|---|---|
engine.lease | — | PT10M | 60 s – 1 day | How long a claimed step may run (job timeout = lease − 30 s). |
engine.requeue_after | — | PT5M | 60 s – 1 day | Re-push a queued step whose job is older than this. |
wake.batch | — | 500 | 1 – 10 000 | Rows claimed per pass (and the for_each cap per slot). |
wake.claim_ttl | — | PT10M | 60 s – 1 day | A claim older than this reverts. |
retry.default.max_attempts | — | 3 | 1 – max_attempts_cap | Default attempts. |
retry.default.backoff | — | [30, 120, 600] | list of 0 – 86 400 s | Seconds before attempt 2, 3, …; the last value repeats. |
retry.default.jitter | — | 0.1 | 0.0 – 1.0 | Random spread of each backoff. |
retry.max_attempts_cap | — | 10 | 1 – 100 | Upper bound for any policy. |
Limits
| Key | Env | Default | Accepted | Meaning |
|---|---|---|---|---|
limits.max_steps | — | 200 | 2 – 100 000 | Steps per instance. |
limits.max_depth | — | 3 | 0 – 50 | start_workflow nesting. |
limits.max_starts_per_minute | WORKFLOWS_MAX_STARTS_PER_MINUTE | 60 | 1 – 1 000 000, or off / none / null | Per definition; off = no limit. |
limits.max_loop_iterations | — | 1000 | 1 – 100 000 | loop.max ceiling. |
graph.max_nodes | — | 500 | 2 – 10 000 | Nodes per graph. |
graph.max_edges | — | 2000 | 1 – 100 000 | Edges per graph. |
graph.max_bytes | — | 524 288 (512 KiB) | 1 KiB – 16 MiB | Encoded graph size. |
conditions.max_depth | — | 6 | 1 – 32 | Condition nesting. |
conditions.max_clauses | — | 100 | 1 – 10 000 | Clauses per condition. |
steps_output.max_bytes | — | 65 536 (64 KiB) | 256 B – 16 MiB | Step output cap. |
steps_input.max_bytes | — | 65 536 (64 KiB) | 1 KiB – 1 MiB | A step run’s recorded input cap; larger inputs are stored as {"truncated": true, "bytes": n}. |
escalations.max_levels | — | 5 | 1 – 20 | Levels per node. |
idempotency.ttl | — | P30D | 60 s – 366 days | Trigger key lifetime. |
schedule.catch_up | — | PT1H | 60 s – 1 day | Missed slots coalesced within this window. |
Authorization, owners, parties and adapters
| Key | Env | Default | Accepted | Meaning |
|---|---|---|---|---|
authorization.authorizer | — | GateStepAuthorizer | AuthorizesSteps class-string | The authorization hook. |
authorization.abilities | — | [] | map of step type key → ability | Overrides a type’s ability(). |
actor.system_user | — | null | invokable class-string | Returns the service account the system actor authorizes as. |
owners.context | — | teams or none | RestoresOwnerContext class-string | Owner context around every job. |
owners.membership | — | teams or allow | ChecksOwnerMembership class-string | Party membership check. |
parties.user_model | — | auth.providers.users.model | model class-string | The model user parties resolve to. |
parties.roles | — | teams or none | ResolvesRoles class-string | Role party resolver. |
adapters.transition | — | lifecycle bridge | TransitionAdapter class-string | The transition step’s adapter. |
adapters.webhooks | — | null | DeliversWebhooks class-string | Registers the outbound webhook step. |
Business time, approvals and subjects
| Key | Env | Default | Accepted | Meaning |
|---|---|---|---|---|
business_time.weekdays | — | [1, 2, 3, 4, 5] | ISO weekdays 1 – 7 | Days of the weekdays calendar (duplicates dropped). |
business_time.max_search_days | — | 366 | 7 – 3660 | Bound of every business-time search the package’s clock does; opening_hours: calendars use opening-hours.search_days. |
business_time.calendar_owner | — | null | invokable class-string | Returns the calendar owner of app-wide definitions. |
approval.expiry_grace | — | PT5M | 0 – 1 day | Approval expiry safety-net delay. |
subjects_on_deleted | — | cancel | cancel, keep | What happens to running instances of a deleted subject. |
bridges.lifecycle.emit_transitions | WORKFLOWS_LIFECYCLE_EMIT_TRANSITIONS | off | boolean | Emit lifecycle.<lifecycle>.<transition> for every lifecycle state change. |
Files, dry runs, webhooks, resources and pruning
| Key | Env | Default | Accepted | Meaning |
|---|---|---|---|---|
sync.path | WORKFLOWS_SYNC_PATH | resources/workflows | absolute, or relative to the base path | Where workflows:sync and workflows:export read and write definition files. |
dry_run.log | WORKFLOWS_DRY_RUN_LOG | off | boolean | Log every dry run. |
webhooks.enabled | WORKFLOWS_WEBHOOKS_ENABLED | off | boolean | The inbound route. |
webhooks.prefix | — | workflows | string (slashes trimmed) | Route prefix. |
webhooks.middleware | — | ['api'] | list of middleware | Route middleware. |
webhooks.verify_middleware | — | [] | list of middleware | Required when enabled: the signature check; empty keeps the route unregistered. |
webhooks.max_bytes | — | 262 144 (256 KiB) | 256 B – 16 MiB | Request body cap (413 above it). |
resources.owner / subject / user | — | packaged reference resources | instantiable JsonResource subclass | Swap with your own to add a name or an avatar. |
prune.instances_after | — | P90D | 1 hour – 10 years | Retention of ended instances. |
prune.dry_runs_after | — | P30D | 1 hour – 10 years | Retention of dry-run logs. |
Environment
WORKFLOWS_KEY_TYPE=bigint
WORKFLOWS_OWNER_KEY_TYPE=
WORKFLOWS_SUBJECT_KEY_TYPE=
WORKFLOWS_PRIMARY_KEY_TYPE=bigint
WORKFLOWS_QUEUE_ENABLED=true
WORKFLOWS_QUEUE_CONNECTION=
WORKFLOWS_QUEUE_NAME=
WORKFLOWS_TIMEZONE=
WORKFLOWS_MAX_STARTS_PER_MINUTE=60
WORKFLOWS_LIFECYCLE_EMIT_TRANSITIONS=off
WORKFLOWS_SYNC_PATH=resources/workflows
WORKFLOWS_DRY_RUN_LOG=off
WORKFLOWS_WEBHOOKS_ENABLED=offShow your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.