---
title: "Multi-approver requests — Approvals for Laravel | Roundly"
description: "Open a request that only its named approvers can decide, under a unanimous, quorum, any-one or weighted rule — it resolves itself."
url: https://roundly-consulting.com/open-source/docs/approvals-for-laravel/multi-approver-requests
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)

# Multi-approver requests

Open a request for a subject with Approvals::request(), name the approvers with from(), pick a rule and call open():

```php
use RoundlyConsulting\Approvals\Facades\Approvals;

// Unanimous (the default): every approver must approve; one rejection rejects the request.
Approvals::request($release)->from([$lead, $qa, $pm])->open();

// Quorum: 2 of 3 approvals resolve it; it rejects once 2 approvals are no longer reachable.
Approvals::request($release)->from([$lead, $qa, $pm])->quorum(2)->open();

// Any: the first approval resolves it.
Approvals::request($release)->from([$lead, $qa, $pm])->any()->open();

// Weighted: resolves once the summed approver weight reaches 3.
Approvals::request($release)->from([$lead, $qa, $pm])->weighted(3)->open();
```

With RequiresApproval on the subject, requestApproval() is shorthand for the same call:

```php
use RoundlyConsulting\Approvals\Enums\ApprovalRule;

// RequiresApproval shorthand — the same request through the same manager
$release->requestApproval([$lead, $qa, $pm], ApprovalRule::Quorum, quorum: 2);
```

Approvers decide on the subject as usual. Each decision flows into the open request, and the request resolves itself the moment the rule is met:

```php
Approvals::request($release)->from([$lead, $qa, $pm])->open();   // Unanimous by default

Approvals::for($release)->as($lead)->approve();   // decisions flow into the open request automatically
Approvals::for($release)->as($qa)->approve();
Approvals::status($release);          // ApprovalStatus::Pending — one approval still missing

Approvals::for($release)->as($pm)->approve();
Approvals::status($release);          // ApprovalStatus::Approved
$release->isApproved();               // true — RequiresApproval shorthand
```

## Only the named approvers decide

The approvers you pass — to from(), requestApproval(), a StageDefinition or a workflow’s open() — are stored on the request, per stage for a staged request. Only they, or a delegate acting for one of them, can decide it. Anyone else gets UnauthorizedApprovalException from approve(), reject(), toggle() and ask(), and nothing is recorded:

```php
use RoundlyConsulting\Approvals\Facades\Approvals;

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

Approvals::for($release)->as($intern)->approve();   // throws UnauthorizedApprovalException — not a named approver
$request->namedApprovers();                         // list<NamedApprover>
```

A request opened without named approvers — from(\[\]), or an ApprovalRequest you create yourself with only required\_approvers — keeps open semantics: any approver’s decision counts, until required\_approvers of them have decided. It needs a rule it can reach without names — any(), quorum($n), or requiredApprovers: $n on ApprovalRequestData or a StageDefinition. Under the default Unanimous rule it would need zero approvals, which never resolves, so opening it throws:

```php
Approvals::request($release)->open();               // InvalidApprovalRequestException
Approvals::request($release)->any()->open();        // any one approval resolves it
Approvals::request($release)->quorum(2)->open();    // two approvals resolve it
```

Each approver is stored once, a request can’t require more approvals than it names, and a quorum or weighted threshold its approvers could never reach is refused when it opens — all with InvalidApprovalRequestException.

## How rules resolve

Unanimous and Any count heads; Quorum and Weighted sum decision weights, which with the default weight of 1 is a plain head-count too. required is the number of approvals the request needs — one per named approver unless set otherwise; the threshold is the quorum, or required when no quorum is given:

| Rule | Approved when | Rejected when |
| --- | --- | --- |
| `Unanimous` | approvals ≥ required (a headcount) | any rejection |
| `Quorum` | approved weight ≥ threshold | the threshold is out of reach: approved weight + outstanding weight < threshold |
| `Any` | the first approval | every approver decided without an approval |
| `Weighted` | approved weight ≥ threshold | as Quorum — without named approvers only once every slot has decided |

## Which request a decision joins

A decision attaches to the approvable’s latest open request. To attach it to one specific request, pin it with within($request); the request must belong to the approvable, otherwise InvalidApprovalRequestException is thrown and nothing is written. A model that never had a request keeps taking standalone decisions.

## Closed requests

Once a request is closed — approved, rejected, cancelled or expired — its round is over and nothing more is recorded on it. A late approve(), reject(), toggle() or ask() on its subject (or pinned to it with within()), and a cancel() that would withdraw one of its decisions, throw ClosedApprovalRequestException — for a delegate too. The exception’s $request property is the closed request. Repeating the decision an actor already holds there stays a harmless no-op that returns it:

```php
$release->requestApproval([$lead]);
$lead->approve($release);           // resolves the request as approved

$lead->approve($release);           // no-op: returns the same approval
$lead->reject($release);            // throws ClosedApprovalRequestException
$lead->cancelApproval($release);    // throws ClosedApprovalRequestException

$release->requestApproval([$lead]); // a new round: decisions count towards it again
```

When a request resolves — approved, rejected or expired — or your app closes it, the asks still outstanding in it are cancelled, each firing ApprovalCancelled and ApprovalStatusChanged, so pendingApprovals(), hasPending() and @pendingApproval stop reporting asks nobody can answer any more.

## Closing a round from your app

When your app cancels or expires the subject itself — a purchase request withdrawn, a booking timed out — close its open round so a late decision cannot resolve it. close() takes only for() and, optionally, within():

```php
use RoundlyConsulting\Approvals\Enums\ApprovalStatus;
use RoundlyConsulting\Approvals\Facades\Approvals;

Approvals::for($purchase)->close();                                         // as cancelled: every open round of $purchase
Approvals::for($purchase)->close(ApprovalStatus::Expired);                  // as expired
Approvals::for($purchase)->within($round)->close(ApprovalStatus::Expired);  // only $round

// int — how many rounds this call closed; 0 when none was open
```

The round closes the way the engine closes one it resolves. A conditional write moves it only while it is still pending and stamps resolved\_at, so a decision that resolved it first keeps its outcome; its outstanding asks are retired (ApprovalCancelled and ApprovalStatusChanged for each); then ApprovalRequestResolved and ApprovalStatusChanged fire for the round. The decision paths lock the request row while they write, so a decision racing the close either lands before it or is refused with ClosedApprovalRequestException. Decisions already made stay as they are.

- Outcomes — only Cancelled (the default) and Expired. Approved and Rejected come only from the approvers’ decisions, so they and Pending throw InvalidStatusTransitionException, even when no round is open.
- Already closed — a round that is already closed, whatever closed it, is left alone and nothing fires; a closed within() round is not an error here. A subject with no open round returns 0.
- Past its expiry — a round past its expires\_at already reads as expired, so it closes as expired whatever outcome you pass.
- Staged rounds — asks in every stage are retired and currentStage() returns null; the stage rows keep their status, as when a round lapses.
- No actor, no authorization, no reason — closing is a lifecycle move your app decides on, like opening the round, so authorize it in your own code. as(), weight(), an expiry or a because() reason throw InvalidApprovalRequestException and the round stays open; keep why you closed it on your own model. because(null) is no reason and closes as usual.
- Fake — Approvals::fake() records it as ApprovalOperation::Close (see Testing). The engine step underneath, ApprovalRequest::close(), is internal: go through the facade or CloseApprovalRequestAction.

## Progress

Approvals::progress() — or approvalProgress() on a RequiresApproval model — returns a read-only snapshot of the latest request:

```php
$progress = Approvals::progress($release);   // null when the subject has no request

$progress->status;         // ApprovalStatus
$progress->approved;       // approvals in — summed weight under Quorum and Weighted
$progress->required;       // approvals the request needs
$progress->threshold;      // quorum / weight threshold, or null
$progress->ratio();        // float 0.0–1.0
$progress->percentage();   // int 0–100
```

| Member | Meaning |
| --- | --- |
| `status` | The request’s ApprovalStatus — Expired once it is past its expiry, as Approvals::status() reports it. |
| `approved / rejected` | Approvals / rejections in — summed weight under Quorum and Weighted, a headcount otherwise; of the open stage, for staged requests. |
| `required` | Approvals the request needs — of the open stage, for staged requests. |
| `threshold` | The quorum / weight threshold, or null. |
| `currentStage` | Position of the open stage, or null — always null for a closed or overdue request. |
| `totalStages` | Number of stages — 0 for a flat request. |
| `clearedStages` | Stages cleared as approved. |
| `ratio()` | 0.0–1.0 — cleared/total stages when staged, approved/threshold otherwise. |
| `percentage()` | ratio() rounded to a whole percent. |

## The request model

```php
$request = Approvals::request($release)->from([$lead, $qa, $pm])->quorum(2)->open();

$request->rule;                 // ApprovalRule::Quorum
$request->quorum;               // 2
$request->required_approvers;   // 3
$request->namedApprovers();     // list<NamedApprover> — empty when opened without names
$request->status;               // ApprovalStatus::Pending
$request->decisions;            // decisions attached to this request
$request->resolved_at;          // ?CarbonImmutable — set when it resolves
$request->isOverdue();          // bool — still pending, but past expires_at

$request->resolve();            // re-evaluate and persist; a final request is returned unchanged
```

Resolution fires ApprovalRequestResolved and the umbrella ApprovalStatusChanged, exactly once — a resolving request is finalized with a conditional update, so concurrent decisions resolve it once. The decision actions call resolve() for you; call it yourself only after attaching decisions out-of-band.

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