---
title: "Bridges to other packages — Workflows for Laravel | Roundly"
description: "Optional bridges — approval steps, lifecycle transitions and events, notify steps, opening-hours calendars, form triggers, teams and signed webhooks."
url: https://roundly-consulting.com/open-source/docs/workflows-for-laravel/bridges
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)

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

| Package | Adds |
| --- | --- |
| `approvals-for-laravel` | The approval step type (+ reassign escalations). Needs 2.1 or newer. |
| `lifecycle-for-laravel` | The default TransitionAdapter for the transition step; publish checks; optional transition events. |
| `notifications-for-laravel` | The notify step type and the notify escalation action. |
| `opening-hours-for-laravel` | opening\_hours:<name> business calendars. Needs 2.1 or newer. |
| `forms-for-laravel` | Form-backed manual triggers. |
| `teams-for-laravel` | Owner context, role parties, membership checks, subject ownership by team. |
| `sentinel-for-laravel` | The 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

```jsonc
// 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).

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

```bash
# every lifecycle state change emits lifecycle.<lifecycle>.<transition>
WORKFLOWS_LIFECYCLE_EMIT_TRANSITIONS=on
```

## notifications — the notify step and escalation action

```json
{ "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:

```php
// 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](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)
