---
title: "Decisions & statuses — Approvals for Laravel | Roundly"
description: "Ask, approve, reject, cancel or toggle — the five statuses, one live decision per slot and what each call records."
url: https://roundly-consulting.com/open-source/docs/approvals-for-laravel/decisions
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Approvals for Laravel](https://roundly-consulting.com/open-source/docs/approvals-for-laravel.md)

# 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

```php
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:

| From | Can move to |
| --- | --- |
| `pending` | approved, rejected, cancelled, expired |
| `approved` | cancelled (withdrawn or superseded), expired (past its expiry) |
| `rejected` | cancelled — withdrawn or superseded |
| `cancelled / expired` | nothing — terminal |

```php
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. A rejection has no expiry, so setting one before reject() throws InvalidApprovalRequestException.
- ask() — records a pending decision for the actor, addressed to that model itself, never to whoever it stands in for, with because() as the ask’s reason and the expiry as its reply-by deadline. When the actor already holds a live decision in the slot, that decision is returned unchanged. weight() is refused — the answer carries its own.
- cancel() / cancelApproval() — withdraws the actor’s live decision (pending, approved or rejected), or one it made as a delegate while that delegation is still in force, in the same round a decision would land in — on a staged request, only in the open stage. because() becomes the withdrawal’s reason; weight() or an expiry is refused. Returns the decision, 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. A setting a call has no place for is refused rather than dropped — the full list is under Fluent builders.

## Reasons

An answer to an ask decides the ask’s row in place and records only its own reason: approve(), reject() or toggling on without because() store none, and one with because() stores that one. The ask’s reason stays on the ApprovalRequested event and on the row until the ask is answered. A withdrawal is not a decision of its own, so it keeps the decision’s reason when it gives none — cancel() or toggling off without because(), a decision superseded when the actor changes their mind, and an ask retired when its round closes. Repeating a decision the actor already holds changes nothing, reason included.

The Approval model’s transition methods follow the same rule: approve($reason) and reject($reason) write $reason as given (null clears the ask’s), cancel($reason) replaces it only when one is given, and markExpired() leaves it alone. Before 1.2.3 an answer without a reason kept the ask’s, so an approval could read “Please review…”.

## Reading a decision

```php
$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:

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

// Pinned to one round, with a reason — the approval's when it turns on, the withdrawal's when it turns off
Approvals::for($release)->as($lead)->within($request)->because('LGTM')->toggle();

$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 round it lands in all apply, along with because(), weight() and the expiry, and a rejection you hold is superseded. Toggling off withdraws your live approval and soft-deletes it, firing ApprovalCancelled as cancel() does, with because() as the withdrawal’s reason; weight() and the expiry have nothing to apply to. within() pins the toggle to one round: a round of another subject throws InvalidApprovalRequestException, and a closed one ClosedApprovalRequestException — even while a later round is open. Both directions fire ApprovalToggled and ApprovalStatusChanged — if you listen to ApprovalCancelled and ApprovalToggled alike, make sure a toggle-off isn’t handled twice.

## 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/approvals-for-laravel.md)
