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

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 migrate

workflows: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 ids

A 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_after

4. 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 waits

Publish tags

TagPublishes
workflows-configconfig/workflows.php
workflows-migrationsThe eight migrations, timestamped on publish.
workflows-translationslang/vendor/workflows/{en,sk}
workflows-routesThe 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 file

Tables

TableHolds
workflow_definitionsNamed workflows per owner: key, name, subject type key, status, current version, webhook id.
workflow_versionsNumbered graph snapshots: graph, trigger, actor, settings, checksum, review flags, publisher.
workflow_instancesRuns: version, owner, subject, trigger, status, current node, failure, parent and depth.
workflow_step_runsThe step log: one row per attempt of a visit, with input, output, outcome, error and lease.
workflow_waitsTimers, events, condition polls, retries and timeouts the engine wakes on.
workflow_escalationsThe next pending escalation level of each waiting visit.
workflow_idempotency_keysStart and effect keys, stored as digests.
workflow_dry_runsLogged 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, webhooks

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