Browse documentationOpen

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;
StateMeaning
pendingWaiting on an approver
approvedEvery applicable stage was satisfied, and any draft was applied
rejectedThe rejection policy ended the request
returnedAn approver sent it back to the requester for revision
conflictedFully approved, but the record had moved, so nothing was applied
withdrawnThe requester withdrew it
cancelledAn administrator cancelled it, or the system closed it
supersededA 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;
StatusMeaning
queuedNot yet reached
activeCurrently waiting on its approvers
approvedIts approval rule was satisfied
rejectedIts rejection policy ended the request here
returnedAn approver returned the request from this stage
skippedIts 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

ModelTableRole
ApprovalWorkflowapproval_workflowsEditable definition, soft-deletes
ApprovalWorkflowStageapproval_workflow_stagesOrdered stage of a definition
ApprovalWorkflowStageApproverapproval_workflow_stage_approversA concrete approver or resolver rule
ApprovalRequestapproval_requestsOne execution, with the public ULID
ApprovalRequestStageapproval_request_stagesA stage snapshot belonging to a request
ApprovalRequestAssignmentapproval_request_assignmentsOne approver's slot on a request stage
ApprovalActionapproval_actionsAppend-only, hash-chained history
ApprovalAttachmentapproval_attachmentsAppend-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 one

On the approvable model, the Approvable concern adds:

$order->approvalRequests;        // every request, newest first
$order->latestApprovalRequest;   // the newest one

Closed 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 it

workflowSlug() 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.