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

Resolving requests

Resolve through the facade. Each call takes the request, the acting approver and an optional reason:

use RoundlyConsulting\Requests\Facades\Requests;

$request = Requests::make()->requireApprovalsFrom([$alice, $bob])->create();

Requests::approve($request, $alice);                        // 1 of 2 — stays New
Requests::reject($request, $bob, reason: 'Out of policy');  // unanimous: one rejection rejects it
Requests::reopen($request, $alice);                         // back to New — a fresh approval round opens
Requests::approve($request, $alice);
Requests::approve($request, $bob, reason: 'Looks good');    // 2 of 2 — becomes Approved

Every decision is recorded through the approvals engine with its actor, reason and timestamp, so you get a full audit trail for free.

  • A request with no declared approvers resolves immediately on the first approve or reject. Repeating its current outcome records the decision and moves nothing, so no second RequestStatusChanged fires.
  • Otherwise the approval rule decides when the request flips — by default unanimous, so every declared approver must approve.
  • Only the declared approvers — or someone they delegated to — may decide. Anyone else gets RoundlyConsulting\Approvals\Exceptions\UnauthorizedApprovalException and nothing is recorded — straight through the engine too ($mallory->approve($request)).
  • Once the approval round has resolved, a new decision would count towards nothing, so approve() and reject() throw RequestAlreadyResolved until the request is reopened (straight through the engine, $alice->approve($request) throws its ClosedApprovalRequestException).
  • A request past its expires_at can no longer be decided, even before requests:expire runs: approve() or reject() expires it on the spot (RequestExpired fires once) and throws RequestAlreadyResolved with status Expired — with or without approvers.
  • approve() fires ApprovalRecorded, even when the request stays New.
  • reopen() moves the request back to New. While its round is still open (or it has none) the actor’s own decision is withdrawn. If the round is already over, a fresh round opens with the same approvers, rule, stages or preset, and everyone decides again — earlier decisions don’t carry over. A declared approver that no longer exists makes the reopen throw InvalidApprover, and nothing changes (see Reopening below).
  • The returned Request is refreshed, so its status already reflects the engine’s outcome.

Refused decisions

use RoundlyConsulting\Approvals\Exceptions\UnauthorizedApprovalException;
use RoundlyConsulting\Requests\Exceptions\RequestAlreadyResolved;

try {
    Requests::approve($request, $mallory);   // not a declared approver, nor a delegate of one
} catch (UnauthorizedApprovalException $e) {
    // nothing was recorded
}

try {
    Requests::approve($approvedRequest, $alice);   // the round has resolved
} catch (RequestAlreadyResolved $e) {
    $e->status;   // Status::Approved — reopen() first to decide again
}

try {
    Requests::approve($overdueRequest, $alice);    // past expires_at, before requests:expire ran
} catch (RequestAlreadyResolved $e) {
    $e->status;   // Status::Expired — expired on the spot, RequestExpired fired once
}

Reopening

Reopening is a lifecycle move, like cancel() and expire(), not a decision: the actor doesn’t have to be one of the round’s approvers, so your app decides who may reopen. When approvals.authorization.enabled is on, the approvals gate (approvals.authorization.ability, decide-approval by default) is asked as well, whatever state the round is in — a denied actor gets UnauthorizedApprovalException and nothing changes:

use Illuminate\Support\Facades\Gate;
use RoundlyConsulting\Approvals\Exceptions\UnauthorizedApprovalException;

// config/approvals.php — 'authorization' => ['enabled' => true, 'ability' => 'decide-approval']
Gate::define('decide-approval', fn ($user, $approvable) => $user->is_manager);

try {
    Requests::reopen($request, $user);   // the gate is asked whatever state the round is in
} catch (UnauthorizedApprovalException $e) {
    // denied — nothing changed
}

// config/requests.php — 'default_ttl' => 60 * 24 * 7
$expired->status;                        // Status::Expired — its deadline passed
Requests::reopen($expired, $alice);
$expired->expires_at;                    // 7 days from now — null without default_ttl
  • A request moved back to New whose expires_at already passed gets a fresh deadline — default_ttl from now, or none when it isn’t set — so the next sweep doesn’t expire it again at once. A deadline still in the future is kept.
  • The reopen and its fresh round happen together on the request’s locked row, so a round that can’t reopen leaves the request as it was.
  • ApprovalRevoked is the reopen signal: it fires when the actor’s decision was withdrawn or the request was reopened. RequestStatusChanged fires only when the status actually moved. Reopening a New request the actor never decided fires nothing.

The ResolveRequest action

The underlying action accepts a Status and an optional reason directly:

use RoundlyConsulting\Requests\Actions\ResolveRequest;
use RoundlyConsulting\Requests\Enums\Status;

app(ResolveRequest::class)->execute($request, $alice, Status::Approved, reason: 'Signed off');

Decisions made through the engine

Because the sync listener mirrors engine resolutions, a decision recorded straight through the approvals engine moves the request too — as long as the actor is a declared approver or their delegate:

use RoundlyConsulting\Approvals\Facades\Approvals;

// Decide straight through the approvals engine — the request's Status still follows.
Approvals::for($request)->as($alice)->approve();

$request->fresh()->status; // Status::Approved once the rule is met

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.