›
›
›
  1. docs
  2. ›
  3. byrcsc/laravel-approval
1.x
Browse documentationOpenClose

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Workflows and stages
  • Choosing the workflow
  • Approvers and resolvers
  • Requests and state
  • Conditional stages

Approval flow

  • Submitting for approval
  • Recording decisions
  • Attribute drafts
  • Returns and resubmission
  • Bulk decisions

Assignments and deadlines

  • Delegation and reassignment
  • SLAs and escalation

Reading and authorization

  • Eligibility and authorization
  • Queries and timelines
  • Events and listeners
  • Notifications
  • Notification content

Operations

  • Workflow definitions
  • Audit trail and evidence
  • Console commands
  • Testing
  • Troubleshooting

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Workflows and stages
  • Choosing the workflow
  • Approvers and resolvers
  • Requests and state
  • Conditional stages

Approval flow

  • Submitting for approval
  • Recording decisions
  • Attribute drafts
  • Returns and resubmission
  • Bulk decisions

Assignments and deadlines

  • Delegation and reassignment
  • SLAs and escalation

Reading and authorization

  • Eligibility and authorization
  • Queries and timelines
  • Events and listeners
  • Notifications
  • Notification content

Operations

  • Workflow definitions
  • Audit trail and evidence
  • Console commands
  • Testing
  • Troubleshooting

byrcsc/laravel-approval · 1.x

Submitting for approval.

Create a request after resolving its workflow, requester, draft, and approvers.

Submission is explicit. The package does not intercept saves to decide that something needs approval. You call the submit method yourself.

$request = $order->submitForApproval(
    workflow: 'purchase-order',
    newAttributes: ['amount' => 10_000],
);

The facade takes the same call with the model first:

use ByRcsc\LaravelApproval\Facades\Approval;

$request = Approval::submit($order, 'purchase-order', ['amount' => 10_000]);

$model->submitForApproval(...) and the guard escape hatch withoutApprovalGuard() are the only model-side aliases the package ships. Every other command lives on the facade.

Signature

Approval::submit(
    Model $approvable,
    ApprovalWorkflow|WorkflowDefinition|WorkflowBuilder|string|null $workflow = null,
    ?array $newAttributes = null,
    ?Model $requester = null,
    ?string $comment = null,
    array $metadata = [],
): ApprovalRequest;
ArgumentNotes
$approvableMust already be persisted
$workflowA slug, a workflow model, an inline definition, or null
$newAttributesA draft, or null to submit the record itself
$requesterNull to resolve it
$commentRecorded on the submitted audit action
$metadataRecorded on the same action. The engine adds the workflow slug

Record-level versus draft submissions

Passing null for $newAttributes creates a record-level request. The already-saved record is what needs approval, and the package applies nothing when the request completes.

Passing an array creates a draft request. The request holds those values, and the package writes them to the record only after the final approval. See attribute drafts.

An empty array is neither, and raises InvalidDraftException::emptyDraft(). The package treats null and [] differently on purpose. Null means "approve the record". An empty array is almost always a bug.

Workflow resolution

A workflow you name in the $workflow argument always wins over the other sources. The package does not skip it when it is inactive. Instead, submission throws InvalidWorkflowException.

When you name no workflow, the package gathers candidates from the model's approvalWorkflow() method, the approval.workflows config map, and every workflow whose approvable_type column names the model, from most specific to least:

// config/approval.php
'workflows' => [
    App\Models\PurchaseOrder::class => 'purchase-order',
],

The package then filters the candidates. It drops an inactive workflow and a workflow whose condition rules it out for this submission. It orders the rest by source and then by priority, and uses the first. When no candidate remains, submission throws WorkflowNotFoundException.

That is the short version, and it is all you need while a model has one workflow. Choosing the workflow covers registering several against one model, writing conditions, and how ties are refused.

Inline workflows

A one-off approval does not need a stored workflow. Pass a WorkflowBuilder or a WorkflowDefinition, and the request runs under it inline:

use ByRcsc\LaravelApproval\Definitions\StageBuilder;
use ByRcsc\LaravelApproval\Definitions\WorkflowBuilder;

$order->submitForApproval(
    WorkflowBuilder::make('one-off-purchase')
        ->stage('Finance', fn (StageBuilder $stage) => $stage->approver($financeUser)),
    ['amount' => 10_000],
);

The definition uses the same API as workflow definitions. Submission validates and snapshots it like a stored workflow, and the request records no workflow_id. Approval::workflowFor() and Approval::reviseAndResubmit() accept a definition the same way.

An inline definition cannot carry settings. The engine reads settings such as allow_self_approval from the stored workflow when a decision is made, and an inline workflow has no row to read them from. Passing settings throws InvalidWorkflowDefinitionException. Store the workflow with approval:sync when you need them.

Requester resolution

The package looks for the requester in this order:

  1. The $requester argument.
  2. The model's approvalRequester() method.
  3. The authenticated user.

When none of them returns a requester, submission throws MissingRequesterException. No requester could withdraw an unattributed request, and its audit trail would name no one, so the package refuses to create one.

class PurchaseOrder extends Model
{
    use Approvable;

    public function approvalRequester(): ?Model
    {
        return $this->buyer;
    }
}

Returning null falls through to the authenticated user.

What submission validates

Submission runs every check before it opens the transaction, so a misconfigured stage three cannot leave a half-built request behind. The checks run in this order:

CheckFailure
The record is persistedInvalidDraftException::unsavedApprovable()
No unresolved request exists for the recordApprovalInProgressException
The draft is not an empty arrayInvalidDraftException::emptyDraft()
Every drafted key is allowedInvalidDraftException::undraftableAttributes()
The workflow is activeInvalidWorkflowException::inactive()
The workflow has stagesInvalidWorkflowException::hasNoStages()
Each stage's rejection policy is knownInvalidWorkflowException::unknownRejectionPolicy()
Each condition class is a StageConditionInvalidWorkflowException::notACondition()
Each resolver class is an ApproverResolverInvalidWorkflowException::notAResolver()
Resolvers return persisted modelsInvalidWorkflowException::resolverReturnedUnsavedModel()
An applicable stage resolved some approversInvalidWorkflowException::stageHasNoApprovers()
Each threshold is reachableInvalidWorkflowException::approvalRuleUnreachable()

A workflow that cannot produce a decision fails at submit time rather than stalling at stage three weeks later.

What submission writes

Inside the transaction, submission locks the record's row and writes these rows:

  • The request, in pending, with the draft and the original values the draft was made against.
  • One request stage per workflow stage, with the full snapshot and the evaluated condition result.
  • One assignment per resolved approver.
  • A submitted audit action, attributed to the requester.
  • A stage_skipped action for each stage a condition turned off.

Submission then activates the first stage whose condition passed. Activation sets that stage's deadline. ApprovalSubmitted fires with that stage.

Withdrawing

A requester withdraws their own pending request:

Approval::withdraw($request, $requester, 'Raised in error');

Only the request's requester may withdraw it. Check eligibility before you render a withdraw control:

$request->canBeWithdrawnBy($user);                  // bool
Approval::withdrawalEligibility($request, $user);   // with a reason

Withdrawal refuses with WithdrawalIneligibility::RequestClosed or NotRequester.

Administrative cancellation is a separate operation that your application authorizes. See eligibility and authorization.

Attaching evidence

The requester or an approver may attach a file to an open request as evidence:

use ByRcsc\LaravelApproval\Data\AttachmentEvidence;

Approval::attach(new AttachmentEvidence(
    request: $request,
    disk: 's3',
    path: 'quotes/PO-1001.pdf',
    checksum: hash_file('sha256', $localPath),
    name: 'Supplier quote.pdf',
    mimeType: 'application/pdf',
    size: $bytes,
    uploader: $requester,
));

The package records a reference and a digest, never bytes. Storing and serving the file stays with your application. See audit trail and evidence.

What to read next

  • Choosing the workflow for automatic workflow selection.
  • Attribute drafts for proposed attribute rules and stale checks.
  • Recording decisions to move the new request through its stages.
PreviousConditional stagesNextRecording decisions
View source

On this page

  1. Signature
  2. Record-level versus draft submissions
  3. Workflow resolution
  4. Inline workflows
  5. Requester resolution
  6. What submission validates
  7. What submission writes
  8. Withdrawing
  9. Attaching evidence
  10. What to read next