byrcsc/laravel-approval · 1.x
Requests and state.
The request lifecycle, every state and stage status, the models the package ships, and the one-unresolved-request rule.
An approval request is one execution of a workflow for one record. It carries its own stages, its own assignments, and its own history, and it is never reopened — revising creates a new request linked back to the old 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.
Request states
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.
One unresolved request per record
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 check runs twice — once before the transaction opens, and once inside it under a row lock on the record itself — so two concurrent submissions cannot both slip through.
Stage statuses
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.
Lifecycle
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.
Models
| 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.
Relationships
$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.
Asking a request about itself
$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.
Deleting the record
The concern hooks model deletion:
- A hard delete or force delete closes every unresolved request for the record, recording a system closure with the reason "The approvable model was deleted."
- A soft delete leaves the request open by default, because a soft-deleted
record is recoverable and so its request should be too. Override
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.
Automatic submission
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.