›
byrcsc/laravel-approval · 1.x
Read the state, stages, and relationships stored on an approval request.
An approval request runs one workflow for one record. It stores its own stages, assignments, and history. A closed request is never reopened. Revision creates a new request linked to the previous one.
Every request has a public ulid alongside its numeric key. The ULID is
sortable and safe to put in a URL. The console commands print it.
use ByRcsc\LaravelApproval\Enums\RequestState;| State | Meaning |
|---|---|
pending | Waiting on an approver |
approved | Every applicable stage was satisfied, and any draft was applied |
rejected | The rejection policy ended the request |
returned | An approver sent it back to the requester for revision |
conflicted | Fully approved, but the record had moved, so nothing was applied |
withdrawn | The requester withdrew it |
cancelled | An administrator cancelled it, or the system closed it |
superseded | A conflicted request that was replaced by a revision |
pending and conflicted are the unresolved states. Everything else is
closed.
A record has at most one unresolved request. Submitting a second one throws
ApprovalInProgressException. The exception stops two people from drafting
conflicting edits. To submit again, cancel the open request or wait for it to
close.
The package checks before opening the transaction and again while holding a row lock on the record. Two concurrent submissions therefore cannot both create an unresolved request.
use ByRcsc\LaravelApproval\Enums\StageStatus;| Status | Meaning |
|---|---|
queued | Not yet reached |
active | Currently waiting on its approvers |
approved | Its approval rule was satisfied |
rejected | Its rejection policy ended the request here |
returned | An approver returned the request from this stage |
skipped | Its condition did not pass, or the request ended before it ran |
Every stage starts as queued. Activation is a separate transition, and
activation sets the stage's deadline.
The timeline read model distinguishes the two ways a stage can be skipped, with
a condition_skipped state for stages a condition turned off. See
queries and timelines.
submit ──▶ pending ──▶ stage 1 ──▶ stage 2 ──▶ …
│ │
│ ├─▶ rejected
│ └─▶ returned ──▶ revise ──▶ new request
│
└─▶ withdrawn / cancelled
all stages satisfied ──┬─▶ approved (draft applied, or none held)
└─▶ conflicted (the record moved; nothing applied)A conflicted request has no completion timestamp and sends no approved notification. It waits for an administrator to reapply or discard the draft. See attribute drafts.
| Model | Table | Role |
|---|---|---|
ApprovalWorkflow | approval_workflows | Editable definition, soft-deletes |
ApprovalWorkflowStage | approval_workflow_stages | Ordered stage of a definition |
ApprovalWorkflowStageApprover | approval_workflow_stage_approvers | A concrete approver or resolver rule |
ApprovalRequest | approval_requests | One execution, with the public ULID |
ApprovalRequestStage | approval_request_stages | A stage snapshot belonging to a request |
ApprovalRequestAssignment | approval_request_assignments | One approver's slot on a request stage |
ApprovalAction | approval_actions | Append-only, hash-chained history |
ApprovalAttachment | approval_attachments | Append-only evidence references |
ApprovalDelegation | approval_delegations | A standing delegation's window |
Each model reads its table name from approval.table_names, so renaming a
table needs no code change.
To add scopes, casts, or relations to a package model, extend it and name the
subclass in approval.model_classes. The keys are the same as
approval.table_names:
// app/Models/ApprovalRequest.php
namespace App\Models;
use ByRcsc\LaravelApproval\Models\ApprovalRequest as BaseApprovalRequest;
use DateTimeInterface;
use Illuminate\Database\Eloquent\Builder;
class ApprovalRequest extends BaseApprovalRequest
{
public function scopeSubmittedSince(Builder $query, DateTimeInterface $since): void
{
$query->where('created_at', '>=', $since);
}
}
// config/approval.php
'model_classes' => [
'requests' => App\Models\ApprovalRequest::class,
],The package uses the configured class for every query, relation, and write, so
Approval::awaiting($user) returns your subclass. Each class must extend the
package model it replaces, because the engine relies on behavior the base class
defines, such as the append-only guard on actions and attachments. The package
checks the inheritance at boot.
Every model ships a factory under
ByRcsc\LaravelApproval\Database\Factories, for building fixtures in
application tests.
$request->approvable; // the record under approval (morph)
$request->requester; // who submitted it (morph)
$request->workflow; // reporting reference, nullable
$request->stages; // ordered by sequence
$request->actions; // ordered by id
$request->attachments;
$request->resubmittedFrom; // the request this one replaces
$request->resubmissions; // requests created by revising this oneOn the approvable model, the Approvable concern adds:
$order->approvalRequests; // every request, newest first
$order->latestApprovalRequest; // the newest oneThe package keeps closed requests as approval history.
$request->workflowSlug(); // the slug it is running under
$request->wasRequestedBy($user); // did this user submit itworkflowSlug() always returns a string. The workflow relationship is
nullable, so $request->workflow->slug needs a null check every time you use
it. When the workflow row is gone, workflowSlug() returns a synthetic
request-{id} instead of the original slug. Use the value as a stable label for
display and grouping. Do not use it to look up a workflow.
wasRequestedBy() compares identity across morph types. Use it before you
show a withdraw button. It does not check authorization. To find out whether the
withdrawal is allowed, see eligibility.
The concern listens for model deletion:
closeApprovalOnSoftDelete() to return true if your model should close its
requests instead.Actions and attachments use restrictOnDelete against the requests table. The
database refuses to hard-delete a request while its history exists.
Creating a record can also create its request:
class PurchaseOrder extends Model
{
use Approvable;
public function shouldAutoSubmitForApproval(): bool
{
return true;
}
}Implementing the AutoSubmitsForApproval contract has the same effect.
Override approvalAutoSubmission() to control the workflow, requester,
comment, draft, and metadata used:
use ByRcsc\LaravelApproval\Data\AutoSubmission;
public function approvalAutoSubmission(): AutoSubmission
{
return new AutoSubmission(
workflow: 'purchase-order',
requester: $this->buyer,
comment: 'Raised automatically on creation.',
);
}The package creates the request after the record is saved. The request does not block the record from being created. The package skips auto-submission when the record already has an unresolved request.