›
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. It is sortable and
safe to put in a URL, and it is what the console commands print.
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, which is the engine's answer to two people
drafting conflicting edits: 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 |
Stages are born queued; activating one is a transition of its own, and it is what 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 |
Each model reads its table name from approval.table_names, so renaming a
table needs no code change.
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 oneClosed requests are retained as approval history.
$request->workflowSlug(); // the slug it is running under
$request->wasRequestedBy($user); // did this user submit itworkflowSlug() always returns a string, which is the point: the workflow
relationship is nullable, so $request->workflow->slug needs a guard every time
you reach for it. When the workflow row is gone it returns a synthetic
request-{id} rather than the original slug, so treat it as a stable label for
display and grouping, not as a slug you can look a workflow up by.
wasRequestedBy() compares identity properly across morph types, which is what
you want before offering a withdraw button. It answers a different question from
authorization: see eligibility for whether the withdrawal would
actually be allowed.
The concern hooks model deletion:
closeApprovalOnSoftDelete() to return true when your model should close
them instead.Actions and attachments use restrictOnDelete against the requests table: the
trail outlives everything, and a request cannot be hard-deleted 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 request is created after the record is persisted. It does not gate creation, and it is skipped when the record already has an unresolved request.