norialabs / approvals
Maker-checker approvals for Laravel. Holds Eloquent creates, updates and deletes, pivot writes and custom actions until the right people approve, with multi-step and grouped approvers, rule-based policies, delegation, expiry, escalation, an audit trail, tenancy, and configurable keys and tables.
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Maker-checker approvals for Laravel. A change to an Eloquent model, a many-to-many relationship or any action you define is held until the right people approve it. The person who made the change can never approve it.
- Holds creates, updates, deletes, restores and permanent deletes automatically through model events.
- Supports one approver, several approvers per step, groups within a step ("2 finance and 1 manager", or "the CFO alone or 2 directors"), chains of steps, and rules that pick the steps from the change itself, for example a second approver above a set amount.
- Has delegation, expiry, reminders and escalation, with an append-only audit trail.
- Lets you choose every key type (int, ULID, UUID) and every table name, including your actors and tenant tables, and replace every decision with your own class.
- Is safe under Octane: no state outlives a request.
Requires PHP 8.3+ and Laravel 12 or 13. Tested on SQLite and PostgreSQL 18.
Installation
composer require norialabs/approvals
php artisan vendor:publish --tag=approvals-config
php artisan migrate
Set the config before you migrate: the migration reads it to choose key types, table names and the
tenant column. To own the migration instead, publish it with --tag=approvals-migrations and set
run_migrations to false.
Schedule the sweep that expires, escalates and sends reminders:
Schedule::command('approvals:sweep')->everyFiveMinutes();
Holding changes on a model
use Noria\Approvals\RequiresApproval; class Invoice extends Model { use RequiresApproval; protected array $approvalIgnored = ['viewed_at']; protected array $approvalEncrypted = ['bank_account']; protected array $approvalHidden = ['internal_notes']; protected array $approvalOperations = ['update', 'delete']; }
Every property is optional:
approvalIgnored: fields that can change without review. The model's own created and updated timestamps and its remember token are always ignored, under whatever names the model gives them, sotouch()never needs approval. Business dates such aspaid_atare real data and are not ignored. List fields to ignore on every model in theignoredconfig key.approvalEncrypted: fields encrypted inside the stored request, and shown as[hidden].approvalHidden: fields shown as[hidden]to reviewers, on top of the model's$hiddenand any field with an encrypted or hashed cast.approvalOperations: which operations need approval. Leave it out to cover all of them.
A restore that needs no approval saves its deleted_at change without review. Setting the soft
delete column by hand is still held, so it can't be used to delete without approval.
A held write returns false and changes nothing:
$invoice->withApprovalNotes('Supplier raised the price')->update(['amount' => 2500]); $invoice->wasHeldForApproval(); // true $invoice->heldApproval(); // the Approval waiting for review $invoice->pendingApproval(); // the open approval for this record, from the database
Set intercept to throw to get an ApprovalRequired exception instead of false.
A record can have only one open request at a time. A second write throws ApprovalException with
reason pending_exists.
Bypassing review
$invoice->saveWithoutApproval(); $invoice->updateWithoutApproval([...]); $invoice->deleteWithoutApproval(); $invoice->restoreWithoutApproval(); $invoice->forceDeleteWithoutApproval(); app(Approvals::class)->withoutApproval(fn () => Invoice::query()->create([...]));
APPROVALS_ENFORCED=false turns review off everywhere, for seeders and local work.
Records saved before approval
Some records must exist while they wait, such as a draft with attachments. Save it, then submit it:
class Drawing extends Model { use RequiresApproval; protected bool $hidesUnapprovedCreations = true; } $drawing->saveWithoutApproval(); $drawing->submitForApproval('Ready for sign-off');
Extras set with withApprovalExtras() travel with the submission. Queries hide the record until it
is approved. Use Drawing::query()->withUnapproved() to see it.
When the request is declined, cancelled or expires, the record is deleted, or permanently deleted
if the model uses soft deletes. Define discardUnapprovedCreation(Approval $approval) on the model
to handle that yourself. When changes are requested, the record stays hidden for the maker to
rework and submit again.
Many-to-many writes
Pivot writes fire no model events, so send them through approvalRelation:
$invoice->approvalRelation('tags')->withNotes('Flag for site')->attach($tagIds, ['note' => 'urgent']);
attach, detach, sync, syncWithoutDetaching, syncWithPivotValues, toggle and
updateExistingPivot are supported. They return the Approval, or null when they wrote
straight through.
Actions
To approve something that is not a model write, such as a payout or an export, implement
ApprovableAction:
final class Payout implements ApprovableAction { public function __construct(public readonly string $account, public readonly int $amount) {} public function payload(): array { return ['account' => $this->account, 'amount' => $this->amount]; } public static function fromPayload(array $payload): static { return new self($payload['account'], $payload['amount']); } public function handle(?Approval $approval): void { // runs once approved, or at once when no approval is needed } } $approvals->action(new Payout('0712345678', 2500), 'Weekly wages');
Pass subject: $transaction to tie the action to the record it's about. The approval is then
filed against that record, and the record can have only one open request at a time, just as with a
model write.
Dependent data
A request often carries data stored elsewhere, such as an order's lines or a user's roles. Attach it as extras, and apply it in the model:
$order->withApprovalExtras(['lines' => $lines])->save(); class Order extends Model { use RequiresApproval; public function applyApprovalExtras(array $extras, ?Approval $approval): void { // write the lines; runs in the same transaction as the approved change } }
Extras are applied together with the change: when it's approved, or straight after the write when no
approval is needed. Policies can read them as payload.extras.*. When only the extras change,
nothing on the model is dirty and Eloquent skips the save, so request the change explicitly:
$user->withApprovalExtras(['roles' => [3, 4]])->requestApproval(notes: 'Promote to finance'); $invoice->requestApproval(['amount' => 2500]);
After a change is applied
Define approvalApplied(Approval $approval) on a model to run follow-up work, such as notifications
or derived records, once an approved change has been applied. It also runs after an automatic
approval.
Define approvalApplying(Approval $approval) to check the record just before an approved change
lands on it. Throw to refuse: nothing is written, the request stays open, and the refusal is
recorded as a failed event.
Reviewing
$approvals = app(Approvals::class); $approvals->approve($approval, notes: 'Matches the quote'); $approvals->decline($approval, notes: 'Too high'); $approvals->requestChanges($approval, notes: 'Attach the quote'); $approvals->cancel($approval); $approvals->approveMany($ids); // ['done' => [...], 'failed' => [id => reason]] $approvals->declineMany($ids); $approvals->canReview($approval, $userId); $approvals->reviewersFor($approval); // ids still able to act, or null when unknown $approvals->progress($approval); // [['group' => 'finance', 'approvals' => 2, 'given' => 1], ...]
The reviewer is the signed-in user unless you pass an id. Approving runs in a transaction and locks the request, so two reviewers acting at once cannot double-count or apply a change twice.
Only the person who made a request can cancel it. With distinct_reviewers on (the default), one
person approves once per request, across all its steps.
An approved update writes only the fields it changed. If someone changed one of those fields after
the request was made, approval fails with reason stale, and the request stays open. Set
on_conflict to overwrite to let the request win instead. A failed apply leaves the request open,
records a failed event and fires ApprovalFailed.
Query requests with the open(), forSubject($model), madeBy($id) and forTenant($id) scopes.
$approval->changes() lists each field's old and new value, with hidden fields concealed.
Policies
A policy decides which steps a change needs. It is looked up in this order:
approvalRequirement(PendingChange $change): ?Requirementon the model, when it returns one.- A stored policy in the
approval_policiestable, whendatabase_policiesis on. A tenant's own row beats a shared one. subject_policiesin config, keyed by model or action class.defaultin config: one approval from anyone but the maker.
The policy is copied onto each request when it is made, so editing a policy never changes requests
already waiting. Say why a policy chose a requirement with ->because('over threshold'), or reason
in config; $approval->reason() returns it, or the reason of an automatic approval.
'subject_policies' => [ App\Models\PurchaseOrder::class => [ 'operations' => ['create', 'update'], 'steps' => [['name' => 'manager', 'roles' => ['manager']]], 'expires_after' => 4320, 'rules' => [ ['when' => [['total', '>=', 500000]], 'steps' => [ ['name' => 'manager', 'roles' => ['manager']], ['name' => 'director', 'approvals' => 2, 'ability' => 'approve-large-orders', 'escalate_after' => 1440, 'escalate_to' => ['name' => 'owner', 'users' => [1]]], ]], ['when' => [['status', '=', 'draft']], 'approve' => false], ], ], ],
A step takes:
nameapprovals: how many approvals it needs. Defaults to 1.- Who may approve:
users(ids),ability(a Gate ability, checked with the approval), androles(checked with the actor'shasAnyRoleorhasRole). Every constraint you set must pass. A step with none is open to anyone but the maker. escalate_afterandescalate_to: hand the step to another step when it is overdue.
Groups
To need approvals from different kinds of people in the same step, split the step into groups.
Each group has its own name, approvals, users, ability and roles. A step sets its approvers
either on the step or in groups, never both.
['name' => 'sign-off', 'groups' => [ ['name' => 'finance', 'approvals' => 2, 'roles' => ['finance']], ['name' => 'management', 'roles' => ['manager']], ]] ['name' => 'release', 'mode' => 'any', 'groups' => [ ['name' => 'cfo', 'roles' => ['cfo']], ['name' => 'directors', 'approvals' => 2, 'roles' => ['director']], ]]
With mode set to all (the default), the step closes once every group has its approvals, in any
order. With any, it closes as soon as one group does. Someone who belongs to two groups is counted
wherever the step still needs them. Once a person's groups are full, their approval is refused, but
they can still decline or request changes.
In code: new Step('sign-off', groups: [new Group('finance', 2, roles: ['finance']), new Group('management', roles: ['manager'])]).
The first rule whose conditions all match is merged over the base definition. A condition is
[field, operator, value]. The operators are =, !=, >, >=, <, <=, in, not_in,
between, null, not_null, changed, contains, starts_with and ends_with. Group
conditions with ['any' => [...]] or ['all' => [...]].
A field is read from the new state. Prefix it with old. to read the current state, or payload.
to read an action's payload. operation, actor and tenant are also available.
Build requirements in code with Requirement::of(new Step('finance', approvals: 2)).
Automatic approvals
A policy can approve a change automatically and still keep a record of it:
return Requirement::automatic('system operator acted directly'); // or in config: ['automatic' => 'trusted supplier']
The write goes ahead as if no approval were needed. Once it lands, a closed approved request is
written for it, with an auto_approved event carrying the reason, so every change stays answerable.
$approval->isAutomatic() tells these apart.
Confirmations
Some decisions depend on something the maker confirmed, such as acting alone when nobody else can approve. Pass the confirmation with the change, and read it in your policy:
$transfer->withApprovalConfirmations('sole_approval')->save(); $approvals->action($payout, subject: $transaction, confirmations: ['sole_approval']); // in a Policy return $change->confirmed('sole_approval') ? Requirement::automatic('sole approver confirmed') : Requirement::of(new Step('finance', approvals: 2));
Naming the maker of an action
An action takes its maker from the signed-in user. When it is raised for someone else, such as from a queued job, name them so they still cannot approve it themselves:
$approvals->action($payout, subject: $transaction, actorId: $transaction->author_id);
Escalating by hand
$approvals->escalate($approval, 'Needs head office', $userId); $approvals->escalate($approval, 'Over my limit', $userId, new Step('board', roles: ['director']));
This moves the current step to its escalate_to step, or to the step you pass, and starts its count
again. The reason and the person who escalated are recorded on an escalated event. Decide in your
own code who may escalate.
Veto
Set veto_ability to a Gate ability. Anyone who passes it can decline any open request, even when
they can't approve it, for example a compliance team. A veto is recorded on the declined event as
{"veto": true}. Nobody can veto their own request. Check it with $approvals->canVeto($approval).
Delegation
$approvals->delegate($reviewerId, $coverId, startsAt: now(), endsAt: now()->addWeek(), subjectType: 'invoice'); $approvals->revokeDelegation($delegation);
A delegate may act for anyone who could have reviewed the step. The audit trail records both: the
delegate as actor_id and the absent reviewer as principal_id. Nobody can act for the maker, and
a reviewer cannot approve twice by also acting through a delegate. Delegations belong to a tenant.
Expiry, escalation and reminders
approvals:sweep runs three tasks. Use --only=expire to run one, and --remind-after=60 to
change the reminder interval.
- Expire closes requests past
expires_afterasexpired. - Escalate (
$approvals->escalateDue()) moves an overdue step to itsescalate_tostep and starts its count again. It does the same at once for a step nobody left can approve, when the reviewers are known. A step with nowhere to go firesApprovalStuck, once. - Remind fires
ApprovalReminderDuewith the reviewer ids, once per interval (reminders.after, in minutes).
Events
Each event fires after the transaction commits:
ApprovalRequestedApprovalAdvanced: hasreason(step_completedorescalated) andfromStep.ApprovalClosed: read$approval->statusfor the outcome.ApprovalReminderDueApprovalStuckApprovalFailed
Send notifications from listeners.
Every action also lands in approval_events, which refuses updates and deletes: submitted,
auto_approved, approved, advanced, escalated, stuck, declined, changes_requested,
cancelled, expired, applied and failed.
Configuration
| key | default | |
|---|---|---|
enforced |
true (APPROVALS_ENFORCED) |
Off skips review everywhere. |
intercept |
halt |
halt returns false from the held write; throw throws ApprovalRequired. |
without_actor |
hold |
With nobody signed in: hold for review, allow the write through, or reject it. |
ignored |
[] |
Fields that change without review on every model, beyond its own timestamps. |
distinct_reviewers |
true |
One person approves once per request, across steps. |
on_conflict |
fail |
overwrite lets an approved update win over later edits. |
run_migrations |
true |
Off when you publish and own the migration. |
keys |
ulid |
Primary keys of the package tables: int, ulid or uuid. |
tables.* |
approvals, approval_events, approval_delegations, approval_policies |
Table names. |
models.* |
package models | Your subclasses of the package models. |
subjects.key |
string |
subject_id column type: int, ulid, uuid, or string for mixed models. |
actors.model |
App\Models\User |
The model behind maker, closer and event actors. |
actors.table, actors.references |
users, id |
Where actor foreign keys point. |
actors.key |
int |
Actor key type: int, ulid, uuid or string. |
actors.constrained |
true |
Off leaves actor columns without foreign keys. |
actors.resolver |
AuthActors |
Who is acting, and how to load them. |
tenancy.enabled |
false |
Adds a tenant column to every package table. |
tenancy.column, tenancy.table, tenancy.references |
tenant_id, tenants, id |
Your tenant column and table. |
tenancy.key |
int |
Tenant key type. |
tenancy.constrained |
true |
Off leaves the tenant column without a foreign key. |
tenancy.scope_queries |
true |
Scopes package queries to the current tenant. |
tenancy.resolver |
ConfiguredTenancy |
Reads the tenant from the record, then the signed-in user. |
policy |
ConfiguredPolicy |
Your own Policy class. |
veto_ability |
null |
A Gate ability whose holders may decline any open request. |
reviewers |
GateReviewers |
Your own Reviewers class. |
database_policies |
false |
Reads policies from approval_policies. |
default |
one open step | The policy for anything without its own. |
subject_policies |
[] |
Policies keyed by model or action class. |
reminders.after |
1440 |
Minutes before reviewers are reminded. |
Extending
Bind your own implementation of any contract, or name it in config:
Contracts\Policy: which steps a change needs.Contracts\Reviewers: who may approve for a group, who could (candidates, used for reminders and deadlock detection), and who may veto (mayVeto). A step without groups is one group named after the step.Contracts\Tenancy: the tenant of a record, the current tenant, andwithin($tenant, $callback). Every decision and sweep runs insidewithin, so you can set the database session there (for row-level security, for example).Contracts\Actors: the acting user's id, and how to load an actor.
Testing
composer check
This runs Pint, PHPStan at max level and Pest on SQLite. To run on PostgreSQL as well:
psql -d postgres -f tests/setup-postgres.sql
APPROVALS_TEST_PG=pgsql://noria_approvals_test:approvals_test_only@127.0.0.1:5432/noria_approvals_test composer test
License
MIT