NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

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

KeyEnvDefaultAcceptedMeaning
key_typeWORKFLOWS_KEY_TYPEbigintbigint, uuid, ulidUser morph keys (started_by, published_by, requested_by).
owner_key_typeWORKFLOWS_OWNER_KEY_TYPE= key_typebigint, uuid, ulidOwner morph keys.
subject_key_typeWORKFLOWS_SUBJECT_KEY_TYPE= key_typebigint, uuid, ulidSubject morph keys; every registered subject type must fit it.
primary_key_typeWORKFLOWS_PRIMARY_KEY_TYPEbigintbigint, uuid, ulidThe package’s own ids.
models.*—packaged modelsa subclass of the packaged modelSwap 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

KeyEnvDefaultAcceptedMeaning
queue.enabledWORKFLOWS_QUEUE_ENABLEDonboolean (on/off, true/false, 1/0)Off = steps run inline.
queue.connectionWORKFLOWS_QUEUE_CONNECTIONapp defaultstringWhere the jobs go.
queue.queueWORKFLOWS_QUEUE_NAMEapp defaultstringThe queue name.
steps—[]list of StepType class-stringsHost step type classes.
subjects—[]list of DescribesWorkflowSubject class-stringsSubject (and owner) types.
events—[]mapLaravel event → trigger key: a key, '*' for a WorkflowEvent, or {key, subject, user, payload, idempotency_key}.
timezoneWORKFLOWS_TIMEZONEapp.timezoneIANA timezoneDefault version timezone.

Engine, wake and retries

KeyEnvDefaultAcceptedMeaning
engine.lease—PT10M60 s – 1 dayHow long a claimed step may run (job timeout = lease − 30 s).
engine.requeue_after—PT5M60 s – 1 dayRe-push a queued step whose job is older than this.
wake.batch—5001 – 10 000Rows claimed per pass (and the for_each cap per slot).
wake.claim_ttl—PT10M60 s – 1 dayA claim older than this reverts.
retry.default.max_attempts—31 – max_attempts_capDefault attempts.
retry.default.backoff—[30, 120, 600]list of 0 – 86 400 sSeconds before attempt 2, 3, …; the last value repeats.
retry.default.jitter—0.10.0 – 1.0Random spread of each backoff.
retry.max_attempts_cap—101 – 100Upper bound for any policy.

Limits

KeyEnvDefaultAcceptedMeaning
limits.max_steps—2002 – 100 000Steps per instance.
limits.max_depth—30 – 50start_workflow nesting.
limits.max_starts_per_minuteWORKFLOWS_MAX_STARTS_PER_MINUTE601 – 1 000 000, or off / none / nullPer definition; off = no limit.
limits.max_loop_iterations—10001 – 100 000loop.max ceiling.
graph.max_nodes—5002 – 10 000Nodes per graph.
graph.max_edges—20001 – 100 000Edges per graph.
graph.max_bytes—524 288 (512 KiB)1 KiB – 16 MiBEncoded graph size.
conditions.max_depth—61 – 32Condition nesting.
conditions.max_clauses—1001 – 10 000Clauses per condition.
steps_output.max_bytes—65 536 (64 KiB)256 B – 16 MiBStep output cap.
steps_input.max_bytes—65 536 (64 KiB)1 KiB – 1 MiBA step run’s recorded input cap; larger inputs are stored as {"truncated": true, "bytes": n}.
escalations.max_levels—51 – 20Levels per node.
idempotency.ttl—P30D60 s – 366 daysTrigger key lifetime.
schedule.catch_up—PT1H60 s – 1 dayMissed slots coalesced within this window.

Authorization, owners, parties and adapters

KeyEnvDefaultAcceptedMeaning
authorization.authorizer—GateStepAuthorizerAuthorizesSteps class-stringThe authorization hook.
authorization.abilities—[]map of step type key → abilityOverrides a type’s ability().
actor.system_user—nullinvokable class-stringReturns the service account the system actor authorizes as.
owners.context—teams or noneRestoresOwnerContext class-stringOwner context around every job.
owners.membership—teams or allowChecksOwnerMembership class-stringParty membership check.
parties.user_model—auth.providers.users.modelmodel class-stringThe model user parties resolve to.
parties.roles—teams or noneResolvesRoles class-stringRole party resolver.
adapters.transition—lifecycle bridgeTransitionAdapter class-stringThe transition step’s adapter.
adapters.webhooks—nullDeliversWebhooks class-stringRegisters the outbound webhook step.

Business time, approvals and subjects

KeyEnvDefaultAcceptedMeaning
business_time.weekdays—[1, 2, 3, 4, 5]ISO weekdays 1 – 7Days of the weekdays calendar (duplicates dropped).
business_time.max_search_days—3667 – 3660Bound of every business-time search the package’s clock does; opening_hours: calendars use opening-hours.search_days.
business_time.calendar_owner—nullinvokable class-stringReturns the calendar owner of app-wide definitions.
approval.expiry_grace—PT5M0 – 1 dayApproval expiry safety-net delay.
subjects_on_deleted—cancelcancel, keepWhat happens to running instances of a deleted subject.
bridges.lifecycle.emit_transitionsWORKFLOWS_LIFECYCLE_EMIT_TRANSITIONSoffbooleanEmit lifecycle.<lifecycle>.<transition> for every lifecycle state change.

Files, dry runs, webhooks, resources and pruning

KeyEnvDefaultAcceptedMeaning
sync.pathWORKFLOWS_SYNC_PATHresources/workflowsabsolute, or relative to the base pathWhere workflows:sync and workflows:export read and write definition files.
dry_run.logWORKFLOWS_DRY_RUN_LOGoffbooleanLog every dry run.
webhooks.enabledWORKFLOWS_WEBHOOKS_ENABLEDoffbooleanThe inbound route.
webhooks.prefix—workflowsstring (slashes trimmed)Route prefix.
webhooks.middleware—['api']list of middlewareRoute middleware.
webhooks.verify_middleware—[]list of middlewareRequired when enabled: the signature check; empty keeps the route unregistered.
webhooks.max_bytes—262 144 (256 KiB)256 B – 16 MiBRequest body cap (413 above it).
resources.owner / subject / user—packaged reference resourcesinstantiable JsonResource subclassSwap with your own to add a name or an avatar.
prune.instances_after—P90D1 hour – 10 yearsRetention of ended instances.
prune.dry_runs_after—P30D1 hour – 10 yearsRetention 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=off

Show 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 crypto

By 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.