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

Fluent builders

Approvals::for(), as() and request() return small builders that collect context and write nothing until a terminal call — approve(), reject(), ask(), cancel(), toggle(), close() or open(). Every terminal goes through ApprovalsManager, so Approvals::fake() records it. The method tables are under The Approvals facade.

Pinning a decision to one request

By default a decision joins the approvable’s latest open request. within() pins it to a specific one — which must belong to the approvable and still be open, so a decision on one model never counts towards another model’s request and nothing lands in a finished round:

use RoundlyConsulting\Approvals\Facades\Approvals;

$request = Approvals::request($release)->from([$lead, $qa])->open();

Approvals::for($release)->as($lead)->within($request)->because('Ship it')->approve();

Approvals::for($otherRelease)->as($lead)->within($request)->approve();
// InvalidApprovalRequestException — the request belongs to another subject; nothing is written

// …later, once $request has resolved:
Approvals::for($release)->as($qa)->within($request)->reject();
// ClosedApprovalRequestException — its round is over; nothing is written

The approvals() helper

A global approvals() helper resolves the same singleton manager — handy where you prefer a function to a facade:

approvals()->for($deployment)->as($user)->approve();   // the same manager as the facade

Approvals::as($user)->for($deployment)->approve();     // start from either side

Absolute expiry

expiresIn() takes seconds from now; expiringAt() takes any Carbon instance. Both exist on the decision builder, the request builder and — since 1.2.0 — the workflow builder:

use Carbon\CarbonImmutable;
use RoundlyConsulting\Approvals\Facades\Approvals;

Approvals::for($budget)->as($cfo)
    ->because('Within the quarterly limit')
    ->expiringAt(CarbonImmutable::parse('2026-12-31 23:59'))
    ->approve();

Incomplete builders

Decision terminals need both an actor and an approvable. A missing one throws IncompletePendingApprovalException; hasPending() and close() only need the approvable — and close() refuses an actor:

use RoundlyConsulting\Approvals\Facades\Approvals;

Approvals::for($deployment)->approve();
// IncompletePendingApprovalException: No actor was set on the pending approval. Call as() first.

Settings a call has no place for

Every setter stores something, but not every terminal has a place for it. Rather than drop a setting silently, a terminal that can’t use it throws InvalidApprovalRequestException before anything is written — so Approvals::fake() records nothing either:

CallTakesRefuses
approve()because(), weight(), the expiry, within()nothing
reject()because(), weight(), within()the expiry (expiryOnReject) — a rejection stands until it is withdrawn or superseded; withdraw it with cancel() when it should stop counting
ask()because() (the ask’s reason), the expiry (its reply-by deadline), within()weight() (weightOnAsk) — a pending decision counts towards no threshold; set the weight on the approve() or reject() that answers the ask
cancel()because() (the withdrawal’s reason), within()weight() and the expiry (settingsOnCancel, naming each one set) — a withdrawal records no decision
close()within() (only that round)because() (reasonOnClose) — keep why you closed the round on your own model; then as(), weight() and the expiry (settingsOnClose) — closing records no decision, involves no actor and runs no gate
toggle()on: as approve(); off: because(), as cancel()nothing — toggling off ignores weight() and the expiry, because one call may go either way
use RoundlyConsulting\Approvals\Facades\Approvals;

// Taken: an ask's reason and its reply-by deadline
Approvals::for($post)->as($reviewer)->because('Check the sources')->expiresIn(86400)->ask();

// Refused with InvalidApprovalRequestException — nothing is written
Approvals::for($post)->as($reviewer)->weight(2)->ask();            // weightOnAsk: set the weight on the answer
Approvals::for($post)->as($reviewer)->expiresIn(3600)->reject();   // expiryOnReject: a rejection has no expiry
Approvals::for($post)->as($reviewer)->weight(2)->cancel();         // settingsOnCancel: a withdrawal records no decision
Approvals::for($purchase)->because('Withdrawn')->close();          // reasonOnClose: a round keeps no reason
Approvals::for($purchase)->as($admin)->close();                    // settingsOnClose: closing involves no actor

because() is checked first, so close() with a reason and other settings throws reasonOnClose; the builder’s actor and approvable are checked before either (IncompletePendingApprovalException). because(null) sets no reason, and a bare call works as it always did. These refusals arrived in 1.2.2 (ask()’s weight, close()’s reason) and 1.2.3 (the rest) — before, the settings were dropped silently, so code that set one now gets an exception: drop the setting, which never applied.

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.