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

Bridges to other packages

A bridge is code for another Roundly package, active only when that package is installed. None is a runtime dependency: each is listed under suggest. A bridged step type whose package is missing stays in the catalogue with available: false and its package name, and publish reports adapter_missing. The whole package installs, boots and runs every built-in flow with no bridge at all.

PackageAdds
approvals-for-laravelThe approval step type (+ reassign escalations). Needs 2.1 or newer.
lifecycle-for-laravelThe default TransitionAdapter for the transition step; publish checks; optional transition events.
notifications-for-laravelThe notify step type and the notify escalation action.
opening-hours-for-laravelopening_hours:<name> business calendars. Needs 2.1 or newer.
forms-for-laravelForm-backed manual triggers.
teams-for-laravelOwner context, role parties, membership checks, subject ownership by team.
sentinel-for-laravelThe signature middleware for the inbound webhook route (configuration only).

Two bridges need a minimum version, enforced by Composer (conflict): approvals-for-laravel ≥ 2.1 (in-place stage hand-over) and opening-hours-for-laravel ≥ 2.1 (addOpenDays() / addOpenTime()). An older installed version blocks composer require / update instead of failing at run time.

approvals — the approval step

// flat
{ "approvers": [ { "kind": "field", "field": "subject.approver" } ], "rule": "any", "expires_in": { "amount": 2, "unit": "business_days" } }
// staged
{ "stages": [ { "name": "Legal", "approvers": [ { "kind": "role", "role": "legal" } ], "rule": "unanimous" },
              { "name": "CFO",   "approvers": [ { "kind": "role", "role": "cfo" } ],   "rule": "any" } ],
  "expires_in": "P5D", "continue_on_rejection": false }
// an approvals preset (config approvals.workflows), approvers per stage
{ "preset": "payout", "approvers": [ [ { "kind": "field", "field": "subject.approver" } ] ] }
  • Rules: unanimous, quorum (quorum, required), any, weighted — approvals’ own. Outcomes: approved, rejected, expired (required), cancelled (optional).
  • handle() resolves the parties per stage (nobody → fails no_approvers; never “anyone may decide”), computes the expiry in business time and opens the round once (a retry never opens a second). Output: {request_id, rounds, expires_at}. It waits for the round’s resolution plus a timer at expiry + approval.expiry_grace as a safety net, and probes once right after commit.
  • Several rounds may be open on one subject, so decide within the round — the step’s output.request_id, exposed by StepRunResource.
  • The resolution listener runs after the decider’s transaction commits and only signals: a decision that rolls back resumes nothing.
  • A round closed outside the workflow as cancelled takes the cancelled edge, else fails approval_cancelled. Cancelling the instance closes its open round — whether the step waits, is queued for a retry, or was still running. Only a pending round is closed; a decided one is left alone.
  • Reassign (escalation), once per escalation level: a staged round whose open stage has a name is handed over in place — the approvers who already decided stay named and their decisions keep counting, the undecided ones are replaced, the round keeps its absolute expiry and output.request_id doesn’t change. A flat round or an unnamed stage is replaced by a new round for the new parties with the original expiry; decisions on the old round don’t carry over. Name your stages to get the in-place hand-over.
  • A manual retry of a failed approval step keeps the same idempotency key, so it re-reads the round it already opened. Approval errors fail permanently (invalid_approval_request, unknown_approval_preset).
// Decide within the round the step opened — its output.request_id (exposed by StepRunResource)
Approvals::as($user)->for($subject)->within($round)->approve();

lifecycle — the transition step and transition events

  • Lifecycles::for($subject, $lifecycle)->by($user)|asSystem()->because($reason)->idempotencyKey($key)->attempt($transition). Applied → transitioned. Denied → the denied edge, or fails transition_denied:<code>.
  • IdempotencyConflictException → idempotency_conflict; a lost race → retried; misuse (unknown lifecycle, trashed or unsaved subject) → transition_unusable.
  • Publish checks the transition exists (unknown_transition) and refuses set_field on a lifecycle state column (field_not_writable). The lifecycle history reason is workflow {key} v{n} #{instance ulid} (or the node’s reason).
  • Opt-in: with workflows.bridges.lifecycle.emit_transitions on, every lifecycle state change emits lifecycle.<lifecycle>.<transition> about the subject after its transaction commits — so a workflow can start (or a wait resume) when a record reaches a state, whoever moved it. An expiry or adoption uses its kind (lifecycle.purchase.expired). The payload is {lifecycle, transition, kind, from, to, history_id}, the actor is the triggering user, and the lifecycle history id is the idempotency key.
# every lifecycle state change emits lifecycle.<lifecycle>.<transition>
WORKFLOWS_LIFECYCLE_EMIT_TRANSITIONS=on

notifications — the notify step and escalation action

{ "notification": "purchase.approved", "recipients": [ { "kind": "field", "field": "subject.requester" } ],
  "data": { "amount": { "ref": "subject.amount" } }, "actor": "triggered_by" }

Notifications::send($type, $subject, $recipients, $data, $actor, null, $idempotencyKey). Output {recipients: n}; nobody to notify completes with 0. Publish checks the type (unknown_notification_type) and the recipients. A refusal by notifications fails permanently as notification_refused:<notifications code> — never retried.

opening-hours — calendars

opening_hours:<name> reads the owner’s opening-hours schedule <name> (open intervals, holidays, exceptions), and opening-hours does the business arithmetic itself: “2 business days” and “4 business hours” are counted exactly as opening-hours counts them, bounded by opening-hours.search_days. The owner model must implement OpeningHoursOwner (use HasOpeningHours) — workflows:doctor fails an active definition whose owner model lacks it. With teams, the owner is the team, so swap teams’ model for one that adds it:

// config/teams.php: 'models' => ['team' => App\Models\Team::class]
namespace App\Models;

use RoundlyConsulting\OpeningHours\Concerns\HasOpeningHours;
use RoundlyConsulting\OpeningHours\Contracts\OpeningHoursOwner;
use RoundlyConsulting\Teams\Models\Team as BaseTeam;

class Team extends BaseTeam implements OpeningHoursOwner
{
    use HasOpeningHours;
}

forms, teams and sentinel

  • forms — {"trigger": "manual", "form": "purchase-form"} validates input through forms-for-laravel; ->withForm($request, submit: true) stores a submission and puts its uuid in the trigger payload. Unknown form → unknown_form; a form trigger without forms installed → adapter_missing.
  • teams — steps of a team-owned instance run inside Teams::runAs($team), whatever context dispatched them; condition polls, escalations and a for_each schedule’s subject scan and its where too. {"kind": "role", "role": "legal"} resolves to the team’s active members with that role; user, field and triggered_by parties must be active members; BelongsToTeam / ScopedToTeam subjects are owned by their team.
  • sentinel — no code: set workflows.webhooks.verify_middleware = ['sentinel.signed:<profile>'] to check RFC 9421 signatures on the inbound webhook route.

Outbound webhooks

Plug in your own delivery service as a workflow step: implement Contracts\DeliversWebhooks (deliver(?Model $owner, string $endpoint, string $event, array $payload, string $idempotencyKey): int|string) and name it in workflows.adapters.webhooks. The webhook step ({endpoint, event, payload?}, outcome queued) is then registered; the delivery runs once per visit through once().

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.