›
›
›
  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

Troubleshooting.

Diagnose stalled requests, missing notifications, conflicts, and audit failures.

Start with the exception class. Every failure the package raises extends ApprovalException, and the subclass names what broke.

use ByRcsc\LaravelApproval\Exceptions\ApprovalException;

try {
    Approval::approve($request, $user);
} catch (ApprovalException $exception) {
    logger()->warning('Approval operation refused', [
        'exception' => $exception::class,
        'request' => $request->ulid,
    ]);
}

For anything about the state of the system rather than one call, start with:

php artisan approval:status

The package throws while booting

InvalidConfigurationException at boot means config/approval.php is wrong. The package checks the configuration at boot instead of on first use. A config mistake found on the day of the first approval has already reached production.

Message namesFix
A blank table nameGive it a non-empty string
The user modelapproval.user_model exists but is not an Eloquent model
A key typeuser_key_type and approvable_key_type must be int, uuid, ulid, or string
A model class keyKeys of model_classes must be keys of table_names
A model classMust extend the package model it replaces
A model modeapproval.models.<class>.mode must be explicit or guard
An escalation caseKeys of escalation.strategies must be known cases
An escalation strategyMust exist and implement EscalationStrategy
A notificationMust exist and extend ApprovalNotification, or be null

A missing key in table_names is not an error. The package uses the default table name.

After changing environment values on a deployed application:

php artisan config:cache
php artisan optimize:clear

Restart long-running workers as well. They keep the old configuration.

Submission throws ApprovalInProgressException

The record already has an unresolved request, pending or conflicted.

$open = Approval::pendingFor($order);
$open?->state;   // pending, or conflicted

A conflicted request is the surprising case. Resolve it, or revise and resubmit, before submitting again. See attribute drafts.

Submission throws InvalidWorkflowException

The workflow cannot produce a decision. The package raises each of these before it writes a row:

MessageCause
inactiveactive is false on the workflow
has no stagesThe workflow row has no stages
stage has no approversAn applicable stage resolved nobody
approval rule unreachablerequired_approvals exceeds the assignments available
unknown rejection policyThe column holds a value the enum does not know
resolver returned unsaved modelA resolver returned a model that is not persisted
not a resolver / not a conditionThe class exists but does not implement the contract

"Stage has no approvers" usually means a resolver returned an empty set. A resolver may return nothing, but not for a stage whose condition passed. That stage would wait forever with no approver.

Submission throws WorkflowNotFoundException

No workflow applied. Check these sources in order: the argument, the model's approvalWorkflow(), the approval.workflows map, and the workflows whose approvable_type names the model. The map accepts a class name or a morph alias as the key. If the model uses a morph map, either form works. A typo in either form matches nothing.

A workflow that exists can still fail to apply. The package filters candidate workflows before it chooses one. Check that the workflow is active and that its condition returns true for this submission. A condition that tests a drafted value can return different results for different drafts.

A slug that names no workflow raises this exception too, instead of falling through to the next source. The package treats a typo as a mistake, not as a reason to fall back.

reviseAndResubmit() raises it when the request has no stored workflow to inherit, because the workflow was deleted or the request ran under an inline workflow. Pass a workflow explicitly.

Submission throws AmbiguousWorkflowException

Two workflows registered against the same model type both applied, and both carry the same priority. The message names them. Give one a higher priority, narrow one with a condition, or name the workflow at the call site. See choosing the workflow.

Submission throws MissingRequesterException

The package found no requester for the submission. This is the usual failure in a console command, a queued job, or a seeder, where there is no authenticated user. Pass the requester explicitly, or implement approvalRequester() on the model.

A draft key is refused

InvalidDraftException naming undraftable attributes means the key is outside the model's approvableAttributes() allow-list. The exception lists both the rejected keys and the allowed ones.

The package throws instead of filtering the draft. If it dropped keys without telling you, approvers would approve changes that the package never applies.

A request is stuck at a stage

Check the approver's eligibility:

Approval::eligibility($request, $user)->reason;
ReasonWhat it means
NotAnApproverThey hold no assignment, often the container-model mistake
AssignmentDelegatedTheir assignment belongs to a delegate now
DecisionAlreadyRecordedThey already decided
SelfApprovalThey submitted it

The container-model mistake is the most common. Assigning a Team or Role model assigns that record itself, so only something acting as the team can approve. Assign the members with an approver resolver.

If nobody at all can approve, the stage may be stranded:

php artisan approval:doctor

A standing delegation did not move an assignment

Check, in order:

  1. The window has opened. A window granted for the future moves nothing until approval:delegations runs. Schedule it.
  2. The assignment is undecided and on an active stage. Routing never moves a decided assignment. Assignments on a stage that is not active yet move when the stage activates.
  3. The approver did not delegate it by hand. A hand delegation stays where the approver put it.
  4. The delegate did not request the approval and holds no assignment on the stage. Routing skips both cases.

The same list applies in reverse to an assignment that did not come back. A window that ends in the future hands assignments back only when approval:delegations runs.

If grantDelegation() throws a DelegationException that names vendor:publish, the delegations migration has not run. See upgrading from 1.0.

A request approved but the record did not change

Check the state:

$request->fresh()->state;
  • conflicted means the record changed while the request was pending, so the package wrote nothing. DraftConflicted names the attributes that changed. Resolve the conflict with Approval::resolve().
  • approved with no change means the request was record-level. Submitting with newAttributes: null approves the record itself and applies nothing.

A conflict fires on a record nobody touched

The stale check compares cast values, so a false conflict should not happen for encrypted attributes, decimals, dates, enums, or JSON. The documented limit is a cast that reads a sibling attribute. The package merges the snapshot over the record's own attributes before casting. That merge handles the sibling case as long as the sibling has not also changed.

If a conflict is still false, check for writes that bypass model events. Query-builder updates, upserts, quiet saves, and raw SQL bypass guard mode. The stale check catches those writes.

Nothing escalates

approval:escalate is not scheduled. The package records deadlines either way, but only the command acts on them.

Schedule::command('approval:escalate')->everyFifteenMinutes();

If the command is scheduled and still nothing happens, check whether the stage uses the record_only default. That strategy records the overdue state, emits StageOverdue, and does nothing else. It sends no message unless notifications are enabled or you listen for the event.

Escalation reports a failure

  Stage 19 could not be escalated: Stage 3 escalates by bringing somebody
  in, but its escalation_config names no resolver to find them.

The stage's strategy threw, so that stage rolled back entirely, including its escalation mark. The next run retries the stage. The other stages in the sweep committed normally. Fix the stage's escalation_config. An escalate strategy needs a resolver key.

No notification arrived

  1. approval.notifications.enabled is false by default.
  2. The event may not be in approval.notifications.map. Unmapped events send nothing. You can still listen for them.
  3. Its entry may be null. A null entry silences an event.
  4. The recipients may be empty. A stage whose approver records were deleted sends nothing, and that is valid.
  5. The listener defers delivery with DB::afterCommit(). A rolled-back transaction sends nothing, by design.
  6. Check the channel: notifications.channels is ['mail'] by default.

approval:verify reports findings

Read the finding's subject and problem. Each finding is one of these:

  • Broken linkage means history was removed, reordered, or inserted. A nulled stage_id from a hard-deleted stage also causes it.
  • Content mismatch means someone edited an action in place.
  • Head mismatch means the final action does not match the request's recorded chain head.
  • Attachment inconsistency means a row no action recorded, a recorded file whose row is gone, or a row whose location or checksum differs from what the package recorded at attach time.

The verifier never repairs. A finding means something wrote to the tables outside the models, because the models refuse updates and deletes. A clean result also does not cover everything. See audit trail and evidence.

A guard-mode write is refused

ApprovalGuardException names the drafted attributes the save touched. Wait for the request to close, or use the audited bypass:

$order->withoutApprovalGuard(
    fn ($order) => $order->update(['notes' => 'Corrected typo']),
    actor: $administrator,
    comment: 'Fixing a data-entry error',
);

The package appends the bypass to the open request's audit trail.

Multi-tenant workflow slugs collide

Workflow slugs use one global namespace. A shared-database multi-tenant application must scope lookups itself or carry the tenant in each slug, for example acme:purchase-order. A database or schema per tenant is already per-tenant and needs nothing extra.

Get help

Report problems at github.com/byrcsc/laravel-approval/issues with a minimal reproduction: the workflow definition, the submission, and the exception or state you observed.

What to read next

  • Requests and state to identify the current request and stage state.
  • Console commands to inspect status, stranded stages, and audit integrity.
  • Testing to reproduce a failure with factories or the fake.
PreviousTesting
View source

On this page

  1. The package throws while booting
  2. Submission throws ApprovalInProgressException
  3. Submission throws InvalidWorkflowException
  4. Submission throws WorkflowNotFoundException
  5. Submission throws AmbiguousWorkflowException
  6. Submission throws MissingRequesterException
  7. A draft key is refused
  8. A request is stuck at a stage
  9. A standing delegation did not move an assignment
  10. A request approved but the record did not change
  11. A conflict fires on a record nobody touched
  12. Nothing escalates
  13. Escalation reports a failure
  14. No notification arrived
  15. approval:verify reports findings
  16. A guard-mode write is refused
  17. Multi-tenant workflow slugs collide
  18. Get help
  19. What to read next