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:statusThe 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 names | Fix |
|---|---|
| Missing table names | Every key in table_names must be present, even unchanged ones |
| A blank table name | Give it a non-empty string |
| The user model | approval.user_model exists but is not an Eloquent model |
| The user key type | Must be int, uuid, ulid, or string |
| A model mode | approval.models.<class>.mode must be explicit or guard |
| An escalation case | Keys of escalation.strategies must be known cases |
| An escalation strategy | Must exist and implement EscalationStrategy |
| A notification | Must exist and extend ApprovalNotification, or be null |
After changing environment values on a deployed application:
php artisan config:cache
php artisan optimize:clearRestart 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 conflictedA 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:
| Message | Cause |
|---|---|
| inactive | active is false on the workflow |
| has no stages | The workflow row has no stages |
| stage has no approvers | An applicable stage resolved nobody |
| approval rule unreachable | required_approvals exceeds the assignments available |
| unknown rejection policy | The column holds a value the enum does not know |
| resolver returned unsaved model | A resolver returned a model that is not persisted |
| not a resolver / not a condition | The 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;| Reason | What it means |
|---|---|
NotAnApprover | They hold no assignment — often the container-model mistake |
AssignmentDelegated | Their assignment belongs to a delegate now |
DecisionAlreadyRecorded | They already decided |
SelfApproval | They 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:doctorA 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.DraftConflictednames the attributes that drifted. Resolve it withApproval::resolve().approvedwith no change — the request was record-level. Submitting withnewAttributes: nullapproves 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
approval.notifications.enabledis false by default.- The event may not be mapped. Only twelve events are; the rest are listenable but send nothing.
- Its entry may be null, which is how an event is silenced.
- The recipients may be empty — a stage whose approver records were deleted sends nothing, and that is valid.
- Delivery is deferred with
DB::afterCommit(). A rolled-back transaction sends nothing, deliberately. - Check the channel:
notifications.channelsis['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.