The trigger node’s config is the version’s trigger, copied to indexed columns (trigger_type, trigger_key) when the draft is saved:
| Trigger | Config | Start path | Idempotency key |
|---|---|---|---|
manual | inputs: list<{name, type, label?, required?, options?, rules?}> or form: "<forms key>"; optional condition | Workflows::start($key, $subject, $owner)->input([...])->dispatch() | The caller’s idempotencyKey(), else none. |
event | event: "request.submitted"; optional condition | Workflows::emit(...) or a mapped Laravel event | The emit’s key / the event’s idempotency key. |
schedule | cron: "0 9 * * 1-5" or preset: {every, at?, day?, interval?}; timezone?; for_each? | workflows:wake | schedule:{definition}:{local slot + zone} |
webhook | idempotency: {from: "header:<name>" | "payload:<path>"}; optional subject: {lookup: "<field>", value: {"ref": "payload.<path>"}}; optional condition | Workflows::triggers()->webhook($hookId, $payload, $headers) or the route | Required. |
start() serves manual triggers only: calling it for another trigger type throws TriggerMismatchException, so a manual call can never skip a trigger’s idempotency or signature rules. Every start refuses a retired definition (DefinitionNotActiveException) and a subject of another owner (SubjectOwnerMismatchException, nothing written).
Manual
$result = Workflows::start('purchase-approval', $request, $team)
->input(['reason' => 'New laptops'])
->by($user) // the triggering user (required for on_behalf_of)
->idempotencyKey("submit:{$request->id}")
->dispatch(); // StartResult{instance, created, definition}
$result->created; // false when the idempotency key was seen before
$result->wasSkipped(); // the trigger condition was false (skipped: condition_false)
$result->wasRefused(); // the start rate limit refused it — send the same start again later{ "trigger": "manual",
"inputs": [
{ "name": "reason", "type": "string", "label": "Reason", "required": true, "rules": ["max:200"] },
{ "name": "priority", "type": "enum", "options": ["low", "normal", "high"] },
{ "name": "approver", "type": "party", "label": "Approver" } ],
"condition": { "field": "subject.amount", "op": "gt", "value": "0" } }
// or a forms-for-laravel form
{ "trigger": "manual", "form": "purchase-form" }- Inputs are validated with Laravel’s validator: rules derive from type, required and options, plus an allow-list of extra rules (max:<n>, min:<n>, email, url; anything else, regex included, is refused). Errors throw InvalidTriggerInputException (422) with errors().
- Input types are the field types (string, text, integer, decimal, boolean, date, datetime, enum, party, list); options restricts the values (each option is one value; an enum with options takes them of any scalar type).
- A party input takes a user’s key (or the user model) of parties.user_model; an unknown key is refused. input.<name> reads as that user — in a condition and as a {kind: field, field: input.<name>} party (membership of the owner still applies).
- Forms (forms bridge): form: "purchase-form" validates through forms-for-laravel. Pass the request with ->withForm($request, submit: true) to store a submission; its uuid lands in the trigger payload.
- A repeated idempotencyKey returns the first instance (created: false). Without one, every call starts a new instance. A start the rate limit refuses (limits.max_starts_per_minute) comes back created: false, refused: 'start_rate_exceeded' with its failed instance and does not use up the key: send the same start again once the minute has room.
Events
Workflows::emit('purchase_request.submitted', $request, ['amount' => '1200'], idempotencyKey: "sub:{$request->id}", user: $user);
// or on the subject
$request->workflows()->emit('purchase_request.submitted', ['amount' => '1200']);
$result = Workflows::emit('invoice.paid', $invoice); // EmitResult
$result->started; // list<StartResult> — one per matching active definition
$result->resumed; // how many waiting steps it signalled
$result->skipped(); // starts whose trigger condition was false
$result->failed; // list<EmitFailure{definition, exception}> — definitions that refused to start; the rest still ran
foreach ($result->failed as $failure) {
$failure->definition; // the definition key
$failure->code(); // the exception's errorCode(), e.g. missing_triggering_user
}emit() starts one instance of every active definition whose current version is triggered by that key, of the subject’s owner (or app-wide) and of the subject type, and signals pending wait steps on that key — only of instances with the same owner. A definition that refuses to start (an on_behalf_of definition emitted without a user, one retired meanwhile) does not stop the others or the waits: it is listed in failed as an EmitFailure{definition, exception} with code(). Event keys match ^[a-z0-9_]+(\.[a-z0-9_]+)*$; keys starting with @ are internal and refused (InvalidEventKeyException).
Mapped Laravel events
Map a Laravel event to an emit in a service provider’s boot(), or in config:
use RoundlyConsulting\Workflows\Facades\Workflows;
Workflows::triggers()->map(PurchaseRequestSubmitted::class, 'purchase_request.submitted',
subject: 'request', // the event property holding the subject
user: 'user', // … and the triggering user
payload: ['amount', 'currency'], // public properties, or one method name returning an array
idempotencyKey: 'submissionId', // the property that makes the emit idempotent
);
Workflows::triggers()->listen(InvoiceOverdue::class); // a self-describing WorkflowEvent
Workflows::triggers()->events(); // event basename => trigger key// config/workflows.php — the same, as data
'events' => [
App\Events\RequestSubmitted::class => 'purchase_request.submitted',
App\Events\RequestApproved::class => ['key' => 'purchase_request.approved', 'subject' => 'request', 'user' => 'approver',
'payload' => ['amount'], 'idempotency_key' => 'approvalId'],
App\Events\InvoiceOverdue::class => '*',
],- Payload: a list of public property names or one method name returning an array. Models in it become {id, type} (type = the subject registry key, else the class basename) — never a class name. Readable in conditions as trigger.payload.<key>.
- Idempotency: the property’s value becomes "{event key}:{value}" (a model contributes its key): the same event dispatched twice starts one instance. Without one, every dispatch starts an instance and workflows:doctor warns.
- listen() refuses a class that isn’t a WorkflowEvent (InvalidWorkflowsConfigurationException); map() refuses '*' as a key.
- Every mapped class gets one listener, however often it is mapped. Mapping a class again replaces its mapping (code after config, the last one wins) and workflows:doctor reports the duplicate. Mappings made in a provider live in a singleton, so they survive Octane requests.
- The listener handles the event after the host’s transaction commits — a rolled-back submission starts nothing, and the emit never holds row locks inside the host’s transaction.
- Nobody reads a mapped event’s emit result, so a definition that refuses to start is reported to the exception handler (the log) instead of failing the host’s request.
A self-describing event implements Contracts\WorkflowEvent and is registered with listen() or mapped to '*':
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Workflows\Contracts\WorkflowEvent;
final class InvoiceOverdue implements WorkflowEvent
{
public function __construct(public Invoice $invoice, public int $daysOverdue) {}
public function workflowKey(): string { return 'invoice.overdue'; }
public function workflowSubject(): ?Model { return $this->invoice; }
public function workflowPayload(): array { return ['days_overdue' => $this->daysOverdue]; }
public function workflowUser(): ?Model { return null; }
public function workflowIdempotencyKey(): ?string { return "{$this->invoice->id}:{$this->daysOverdue}"; }
}Schedules
{ "trigger": "schedule", "cron": "0 9 * * 1-5", "timezone": "Europe/Bratislava" }
{ "trigger": "schedule", "preset": { "every": "weekday", "at": "09:00" } }
// one instance per matching subject each slot
{ "trigger": "schedule", "cron": "0 9 * * 1-5",
"for_each": { "where": { "field": "subject.status", "op": "equals", "value": "open" }, "limit": 200 } }Trigger::schedule('0 9 * * 1-5')->forEach(When::field('subject.status')->equals('open'), limit: 200);
Trigger::schedule(preset: ['every' => 'month', 'at' => '08:00', 'day' => 1], timezone: 'Europe/Bratislava');- cron: five fields; *, a, a-b, */n, a-b/n, lists; JAN–DEC, SUN–SAT; 0 and 7 are Sunday; macros @hourly, @daily, @weekly, @monthly, @yearly. Vixie semantics when both day-of-month and day-of-week are restricted. L, W, #, ?, seconds and years are refused (invalid_cron).
- preset: {every: day | weekday | week | month | hour | minutes, at: "09:00", day?, interval?} compiles to cron.
- workflows:wake starts due slots in the trigger’s timezone. Missed slots within schedule.catch_up (PT1H) are coalesced into one start. A repeated DST hour runs once; a skipped hour doesn’t run.
- Schedule triggers can’t run on_behalf_of (actor_requires_user).
- for_each, on a definition with a subject type, starts one instance per subject each slot: every subject of that type (of the definition’s owner, for an owned definition) that matches where, at most limit — and never more than wake.batch — per slot. Each start is keyed by the slot and the subject. The subjects are read in key order and filtered in PHP, inside the owner’s context (Teams::runAs($team) for a team-owned definition, so a team-scoped subject model sees that team’s rows although workflows:wake runs with no context): for a large table, prefer an event trigger.
Webhooks
{ "trigger": "webhook",
"idempotency": { "from": "payload:event.id" }, // or "header:Idempotency-Key"
"subject": { "lookup": "invoice_number", "value": { "ref": "payload.invoice" } } }Workflows::triggers()->webhook($hookId, $payload, $headers) starts the definition whose current version has a webhook trigger with that hook id (webhook_id, minted at first publish, rotated with rotateWebhook()). The idempotency key is required (MissingIdempotencyKeyException, 422) and comes from a header or a payload path. subject.lookup names a registered (non-relation) field, unique per owner, to find the subject by, and subject.value must be {"ref": "payload.<path>"}: the value is read from the webhook body at that dot path — only payload. refs are read here, and a missing or non-scalar value is SubjectOwnerMismatchException. An owned definition takes the first match that belongs to its owner, and no match is SubjectOwnerMismatchException (404 on the route).
The inbound route (opt-in)
// config/workflows.php
'webhooks' => [
'enabled' => 'on',
'prefix' => 'workflows',
'middleware' => ['api'],
'verify_middleware' => ['sentinel.signed:partners'], // REQUIRED
'max_bytes' => 262144,
],// POST /workflows/hooks/{hook} — route name workflows.webhooks.start
// 202 {instance, created} · 429 start_rate_exceeded (Retry-After: 60) · 413 payload_too_large · 422 · 404
// The same start from PHP (a queue consumer, a test):
$result = Workflows::triggers()->webhook($hookId, $payload, $headers);
Workflows::definition('invoice-intake', $team)->model()->webhook_id; // minted at first publish
Workflows::definition('invoice-intake', $team)->rotateWebhook(); // a new hook idPOST /workflows/hooks/{hook} (route name workflows.webhooks.start). It fails closed: enabled without verify_middleware, the route is not registered at all (workflows:doctor fails on it). Responses: 202 {instance, created}; 429 {code: start_rate_exceeded, message, instance, created: false} with Retry-After: 60 when the rate limit refused the start (its key stays free, so the sender’s retry starts it); 413 payload_too_large; 422 invalid_payload (not a JSON object) or a package code; 404 for an unknown, retired or other-owner definition (never confirms existence). With sentinel-for-laravel, define a signature profile and set verify_middleware = ['sentinel.signed:<profile>']: unsigned requests are refused before the controller runs.
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.