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

Decisions & statuses

Every decision is an Approval row between an actor and an approvable. It carries an explicit status — pending, approved, rejected, cancelled or expired — plus an optional reason, an optional expiry, a weight and the time it was decided.

A decision’s lifecycle

use RoundlyConsulting\Approvals\Facades\Approvals;

// 1. Ask for a decision — records a pending row.
Approvals::for($contract)->as($legal)->ask();
Approvals::for($contract)->hasPending();              // true

// 2. The same actor decides — the pending row becomes approved.
Approvals::for($contract)->as($legal)->because('Clauses reviewed')->approve();
Approvals::for($contract)->as($legal)->isApproved();  // true

// 3. Changed their mind — the approval is withdrawn (cancelled), only the rejection counts.
Approvals::for($contract)->as($legal)->because('New revision uploaded')->reject();

// 4. Or withdraw the live decision without replacing it.
Approvals::for($contract)->as($legal)->cancel();      // status: cancelled

One live decision per slot

Each actor holds one live decision per slot — a standalone decision on the approvable, or its decision on one request (or one stage of a staged request). Deciding again changes that decision rather than adding a second one: approving twice is a no-op, and rejecting after approving withdraws the approval (its status becomes cancelled) so only the rejection counts — and vice versa.

A database unique index guards the slot, so two concurrent approvals by the same actor are counted once. A new request, or the next stage, is a fresh slot: approving an earlier request never blocks you from approving the next.

Legal transitions

Statuses move through a guarded state machine. A decided approval can still be cancelled — withdrawn, or superseded when the actor changes their mind — and an approval lapses once its expiry passes. Cancelled and expired are terminal:

FromCan move to
pendingapproved, rejected, cancelled, expired
approvedcancelled (withdrawn or superseded), expired (past its expiry)
rejectedcancelled — withdrawn or superseded
cancelled / expirednothing — terminal
use RoundlyConsulting\Approvals\Enums\ApprovalStatus;

ApprovalStatus::Pending->canTransitionTo(ApprovalStatus::Approved);    // true
ApprovalStatus::Approved->canTransitionTo(ApprovalStatus::Cancelled);  // true — withdrawn or superseded
ApprovalStatus::Approved->canTransitionTo(ApprovalStatus::Expired);    // true — lapses once its expiry passes
ApprovalStatus::Rejected->canTransitionTo(ApprovalStatus::Cancelled);  // true — withdrawn or superseded
ApprovalStatus::Approved->canTransitionTo(ApprovalStatus::Rejected);   // false — a change of mind writes a new decision
ApprovalStatus::Cancelled->canTransitionTo(ApprovalStatus::Approved);  // false — terminal

The transition methods on the Approval model — approve(), reject(), cancel() and markExpired() — validate every move and throw InvalidStatusTransitionException on an illegal one.

What each call does

  • approve() — records an approval in the actor’s slot. A pending ask is decided in place; a held rejection is superseded and a fresh approval written. Approving again returns the same approval and fires no events.
  • reject() — the mirror image: a pending ask is decided in place, and a held approval is withdrawn so only the rejection counts.
  • ask() — records a pending decision for the actor, addressed to that model itself, never to whoever it stands in for. When the actor already holds a live decision in the slot, that decision is returned unchanged.
  • cancel() / cancelApproval() — withdraws the actor’s live decision (pending, approved or rejected), or one it made as a delegate, in the same round a decision would land in. Returns it, or null when there is nothing to withdraw.
  • toggle() / toggleApproval() — the simple thumbs-up, below.

On a closed request they throw ClosedApprovalRequestException — only repeating an approval or rejection the actor already holds there stays a no-op (see Multi-approver requests). The trait methods on GivesApprovals — approve(), reject(), cancelApproval() and toggleApproval() — are shorthand for the same facade calls.

Reading a decision

$approval = Approvals::for($deployment)->as($user)->because('LGTM')->approve();

$approval->status;           // ApprovalStatus::Approved
$approval->reason;           // 'LGTM'
$approval->decided_at;       // CarbonImmutable
$approval->expires_at;       // ?CarbonImmutable
$approval->weight;           // int — 1 unless weighted
$approval->actor;            // the model whose decision this is
$approval->approvable;       // the model decided on
$approval->approvalRequest;  // ?ApprovalRequest it counts toward
$approval->stage;            // ?ApprovalRequestStage, for staged requests
$approval->wasDelegated();   // bool — decided by a delegate

The simple toggle

The one-click on/off form for likes, endorsements and single-reviewer flows:

Approvals::for($deployment)->as($user)->toggle(); // true  — approved (status: approved)
Approvals::for($deployment)->as($user)->toggle(); // false — withdrawn (status: cancelled, soft-deleted)

$user->toggleApproval($deployment);                // the same through the GivesApprovals trait

Toggling on is an approve() — the authorization gate, the request’s named approvers, delegation and the open request all apply, and a rejection you hold is superseded. Toggling off withdraws your live approval and soft-deletes it. Both fire ApprovalToggled and ApprovalStatusChanged.

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.