Browse documentationOpen

byrcsc/laravel-approval · 1.x

Troubleshooting.

Diagnose configuration failures, stalled requests, conflicts, stranded stages, missing notifications, and broken audit chains.

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. It is checked at boot rather than on first use, because a config mistake that surfaces the day of the first approval is a mistake that reached production.

Message namesFix
Missing table namesEvery key in table_names must be present, even unchanged ones
A blank table nameGive it a non-empty string
The user modelapproval.user_model exists but is not an Eloquent model
The user key typeMust be int, uuid, ulid, or string
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

After changing environment values on a deployed application:

php artisan config:cache
php artisan optimize:clear

Restart long-running workers as well; they hold 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. Every one of these is raised before a row is written:

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 legitimately return nothing — but not for a stage whose condition passed, because that stage would stall forever.

Submission throws WorkflowNotFoundException

No workflow applied. Check, in order: the argument, the model's approvalWorkflow(), the approval.workflows map, and the workflows whose approvable_type names the model. The map is keyed by class name or morph alias — if the model uses a morph map, either form works, but a typo in neither will match.

A workflow that exists can still fail to apply. Candidates are filtered before one is chosen, so check that it is active and that its condition returns true for this submission — a condition judging a drafted value will answer differently depending on the draft you pass.

A slug naming nothing raises this too, rather than falling through to the next source. That is deliberate: a typo is a mistake, not a fallback.

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

Nobody could be attributed. 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 fails loudly rather than filtering: a draft that silently drops half its keys gets approved on the strength of changes that were never going to be applied.

A request is stuck at a stage

Ask why, from the approver's point of view:

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 common one: assigning a Team or Role model assigns that record itself, and 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 request approved but the record did not change

Check the state:

$request->fresh()->state;
  • conflicted — the record moved while approval was running, so nothing was written. DraftConflicted names the attributes that drifted. Resolve it with Approval::resolve().
  • approved with no change — 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 this should not happen for encrypted attributes, decimals, dates, enums, or JSON. The documented limit is a cast that reads a sibling attribute: the snapshot is merged over the record's own attributes before casting, which covers it as long as the sibling has not also moved.

If a conflict is still spurious, check for writes that bypass model events — query-builder updates, upserts, quiet saves, raw SQL. Guard mode does not see those, and the stale check is exactly the backstop that catches them.

Nothing escalates

approval:escalate is not scheduled. Deadlines are recorded either way; the command is what acts on them.

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

If it is scheduled and still nothing happens, the stage is probably on the record_only default, which records the overdue state and emits StageOverdue and 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 — escalation mark included — and will be retried on the next run. 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 mapped. Only twelve events are; the rest are listenable but send nothing.
  3. Its entry may be null, which is how an event is silenced.
  4. The recipients may be empty — a stage whose approver records were deleted sends nothing, and that is valid.
  5. Delivery is deferred with DB::afterCommit(). A rolled-back transaction sends nothing, deliberately.
  6. Check the channel: notifications.channels is ['mail'] by default.

approval:verify reports findings

Read the finding's subject and problem. Four kinds:

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

The verifier never repairs. Findings mean something wrote to the tables outside the models, since the models refuse updates and deletes. Note also what a clean result does not cover — see audit trail and evidence.

A guard-mode write is refused

ApprovalGuardException names the drafted attributes the save touched. Either wait for the request to close, or take the audited escape hatch:

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

The bypass is appended 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.