---
title: "Triggers — Workflows for Laravel | Roundly"
description: "Start workflows manually with validated input or a form, from app and mapped Laravel events, on a cron schedule, or from a signed inbound webhook."
url: https://roundly-consulting.com/open-source/docs/workflows-for-laravel/triggers
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Workflows for Laravel](https://roundly-consulting.com/open-source/docs/workflows-for-laravel.md)

# Triggers

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

```php
$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
```

```jsonc
{ "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

```php
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:

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

```php
// 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 '\*':

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

```jsonc
{ "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 } }
```

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

```jsonc
{ "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)

```php
// config/workflows.php
'webhooks' => [
    'enabled' => 'on',
    'prefix' => 'workflows',
    'middleware' => ['api'],
    'verify_middleware' => ['sentinel.signed:partners'],   // REQUIRED
    'max_bytes' => 262144,
],
```

```php
// 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 id
```

POST /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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

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

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/workflows-for-laravel.md)
