Installation
Require the package, publish its config and migrations with the install command, and migrate. The service provider and the Workflows facade alias are auto-discovered, together with the providers of the Roundly packages it builds on — crypto (idempotency digests), enums and package-toolkit:
composer require roundly-consulting/workflows-for-laravel roundly-consulting/approvals-for-laravel
php artisan workflows:install # publishes config/workflows.php and the eight migrations
php artisan vendor:publish --tag="approvals-migrations" # approvals powers the approval step in the example below
php artisan migrateworkflows:install refuses to run when any of the package’s tables already exists. It prints the scheduler lines and the subject-registration hint. Migrations are publish-only (never auto-loaded) and forward-only.
approvals-for-laravel powers the approval step in the example below; leave it and its migrations out if you don’t need approvals. Without it, publishing a graph with an approval step fails adapter_missing.
1. Settle the key types before migrating
The polymorphic columns are sized at migration time. With bigint ids everywhere the defaults fit; with UUID or ULID keys, set them before php artisan migrate:
# Before php artisan migrate — bigint, uuid or ulid
WORKFLOWS_KEY_TYPE=bigint # user morphs: started_by, published_by, requested_by
WORKFLOWS_OWNER_KEY_TYPE= # owner morphs; blank = WORKFLOWS_KEY_TYPE
WORKFLOWS_SUBJECT_KEY_TYPE= # subject morphs; blank = WORKFLOWS_KEY_TYPE
WORKFLOWS_PRIMARY_KEY_TYPE=bigint # the package's own idsA model whose key does not fit the configured type is refused before any SQL with InvalidWorkflowsConfigurationException naming the key — PostgreSQL never answers with a raw “invalid input syntax”. workflows:doctor checks this too.
2. Schedule wake and prune
Delays, timeouts, retries, escalations and schedules wake on the scheduler. Nothing is scheduled automatically — without this line a delay never ends:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('workflows:wake')->everyMinute()->withoutOverlapping(10)->onOneServer();
Schedule::command('workflows:prune')->daily();workflows:wake fires due timers, retries, wait timeouts, condition polls, escalations and schedule triggers, reclaims expired leases and re-pushes lost jobs. onOneServer() needs a cache store shared by every server (redis, database, memcached, dynamodb). Correctness never depends on it — every claim is a compare-and-set.
3. Run a queue worker
With workflows.queue.enabled on (the default) each step runs as a job — RunStepJob, ResumeStepJob, FireEscalationJob; ids only, tries = 1, dispatched after commit. The job timeout is engine.lease − 30 s (570 s by default): the queue connection’s retry_after must be longer than that, and the worker needs pcntl to enforce it. Laravel’s default database connection has retry_after 90 s, so a fresh app raises it — or lowers engine.lease if every step is short. workflows:doctor fails on a too-short retry_after and warns without pcntl. With the queue off, steps run inline in the request that starts or resumes them, through a trampoline (no recursion), bounded by limits.max_steps.
# A step job may run for up to 570 s (engine.lease − 30 s): retry_after must be longer.
DB_QUEUE_RETRY_AFTER=600 # Laravel's default database queue (90 s otherwise)
REDIS_QUEUE_RETRY_AFTER=600 # the redis queue
php artisan workflows:doctor # fails on a too-short retry_after4. Register a subject type and use the trait
A subject is the model an instance is about. Register each type once, in a service provider’s boot():
// AppServiceProvider::boot()
use RoundlyConsulting\Workflows\Facades\Workflows;
Workflows::subjects()->register(
Workflows::subject('purchase_request', PurchaseRequest::class)
->label('Purchase request')
->ownedBy('team')
->field('amount', 'decimal', 'Amount')
->field('requester', 'party', 'Requester'),
);use RoundlyConsulting\Workflows\Traits\HasWorkflows;
class PurchaseRequest extends Model
{
use HasWorkflows; // $request->workflows(), touch on update, cancel on delete
}5. Define, publish and start a workflow
use RoundlyConsulting\Workflows\Facades\Workflows;
use RoundlyConsulting\Workflows\Graph\Build\{Party, Trigger, When};
// Once, in a service provider: what a purchase request looks like to the engine.
Workflows::subjects()->register(
Workflows::subject('purchase_request', PurchaseRequest::class)
->label('Purchase request')
->ownedBy('team')
->field('amount', 'decimal', 'Amount')
->field('requester', 'party', 'Requester')
->field('approver', 'party', 'Approver'),
);
// The workflow itself is data: a JSON graph your users edit in a builder — or written in PHP.
$definition = Workflows::definitions()->create(key: 'purchase-approval', name: 'Purchase approval',
subjectType: 'purchase_request', owner: $team);
Workflows::definition('purchase-approval', $team)->edit(Workflows::graph() // or ->edit(json_decode($json, true))
->trigger('start', Trigger::manual())
->condition('check', When::field('subject.amount')->gt('500'))
->approval('approve', approvers: [Party::field('subject.approver')])
->end('done')
->connect('start', 'check')
->connect('check', 'approve', on: 'true')
->connect('check', 'done', on: 'false')
->connect('approve', 'done', on: ['approved', 'rejected', 'expired']));
Workflows::definition('purchase-approval', $team)->publish(by: $admin);
// Start it (this graph's trigger is `manual`) — with an `event` trigger the app event starts it instead.
$result = Workflows::start('purchase-approval', subject: $request, owner: $team)
->by($request->requester)
->idempotencyKey("submit:{$request->id}")
->dispatch();
$result->instance->status; // InstanceStatus::Running — a queue worker runs it on to the approval step, where it waitsPublish tags
| Tag | Publishes |
|---|---|
workflows-config | config/workflows.php |
workflows-migrations | The eight migrations, timestamped on publish. |
workflows-translations | lang/vendor/workflows/{en,sk} |
workflows-routes | The opt-in inbound webhook route file. |
php artisan vendor:publish --tag="workflows-config" # config/workflows.php
php artisan vendor:publish --tag="workflows-migrations" # the eight migrations, timestamped on publish
php artisan vendor:publish --tag="workflows-translations" # lang/vendor/workflows/{en,sk}
php artisan vendor:publish --tag="workflows-routes" # the opt-in inbound webhook route fileTables
| Table | Holds |
|---|---|
workflow_definitions | Named workflows per owner: key, name, subject type key, status, current version, webhook id. |
workflow_versions | Numbered graph snapshots: graph, trigger, actor, settings, checksum, review flags, publisher. |
workflow_instances | Runs: version, owner, subject, trigger, status, current node, failure, parent and depth. |
workflow_step_runs | The step log: one row per attempt of a visit, with input, output, outcome, error and lease. |
workflow_waits | Timers, events, condition polls, retries and timeouts the engine wakes on. |
workflow_escalations | The next pending escalation level of each waiting visit. |
workflow_idempotency_keys | Start and effect keys, stored as digests. |
workflow_dry_runs | Logged dry runs (only with ->log() or dry_run.log on). |
Table names are configurable (workflows.tables.*); every column is listed under Database.
Translations
English and Slovak ship under the workflows:: namespace — enums, exceptions, failures, fields, steps and validation. Step labels, validation messages, failure messages and enum labels all translate.
Check the setup
php artisan workflows:doctor # --json for CI and health checks, --strict to fail on warnings
php artisan about --only=workflows # step types, bridges, queue mode, key types, limits, webhooksphp artisan about shows a Workflows section: the number of step types, installed bridges, the queue mode, the four key types, the limits and whether the webhook route is on.
Upgrading from 1.0.0
No migration and no config change. Check three things:
- The queue connection’s retry_after must be above the 570 s step job timeout (DB_QUEUE_RETRY_AFTER=600 for the database queue) — run workflows:doctor, which fails otherwise.
- Graphs with an approval step need approvals-for-laravel: require it and publish its migrations (approvals-migrations) before migrating.
- Workflows::emit() no longer throws when one matching definition refuses to start: the others still start and the refusal is listed in EmitResult::$failed. Code that caught that exception should read $result->failed instead.
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 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.