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

Status & guarded transitions

Every request is always in exactly one of five statuses. The enum carries lifecycle helpers and the shared enum helpers for selects and validation:

$request->status->isOpen();                          // true only for New
$request->status->isTerminal();                      // Approved, Rejected, Cancelled, Expired
$request->status->canTransitionTo(Status::Approved); // bool, per the lifecycle graph
$request->status->allowedTransitions();              // list<Status>

// Shared helpers from enums-for-laravel:
Status::values();
Status::labels();
Status::options();
Status::validationRule();
$request->status->label();

The lifecycle graph

New        -> Approved | Rejected | Cancelled | Expired
Approved   -> Rejected | New (reopen) | Cancelled
Rejected   -> New (reopen)
Expired    -> New (reopen)
Cancelled  -> (terminal)

Enforcing transitions

Set requests.enforce_transitions to true to enforce the graph. Illegal moves — for example approving a rejected request — then throw InvalidStatusTransition, which carries $from and $to:

// config/requests.php: 'enforce_transitions' => true

use RoundlyConsulting\Requests\Exceptions\InvalidStatusTransition;

try {
    Requests::approve($rejectedRequest, $alice);
} catch (InvalidStatusTransition $e) {
    $e->from; // Status::Rejected
    $e->to;   // Status::Approved
}
  • approve(), reject(), reopen(), cancel() and expire() check the graph before acting, against the stored status — with the flag on, expiring an approved request throws InvalidStatusTransition, even from a copy loaded while it was still New.
  • A move to the current status is always allowed.
  • The sync listener skips an engine resolution that would be illegal instead of throwing.
  • With the flag off (the default) the guard is skipped and decisions resolve directly — except that a closed request stays closed either way.

Closed requests

A cancelled or expired request is closed, whatever enforce_transitions says: a Cancelled request is final, and an Expired one can only be reopened with Requests::reopen(). Any other action on it throws RequestAlreadyResolved. Cancelling an already-cancelled request, or expiring an already-expired one, does nothing.

Concurrent changes

Every status write re-reads the request’s row with lockForUpdate() inside a transaction and runs its checks — already in that status, closed, enforce_transitions — on the stored status, never on the copy you hold. That covers cancel(), expire(), reopen(), approve() / reject() on a request without approvers, the requests:expire sweep and the engine sync listener.

  • A copy loaded before a concurrent decision, cancel or sweep can’t overwrite the newer status — it can’t expire an approved request, or approve, reopen or un-cancel a cancelled one.
  • Events fire after the transaction, and only for a status that really changed, so an approval, a cancellation and a scheduled clean-up arriving together each announce their change once.

Asking the graph

Check a move before you act — for example to show or hide an Approve button:

use RoundlyConsulting\Requests\Enums\Status;
use RoundlyConsulting\Requests\Facades\Requests;

// Answers the same way whether or not enforcement is on
Requests::canTransition($request, Status::Approved); // bool

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.