›
byrcsc/laravel-approval · 1.x
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:statusInvalidConfigurationException 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.
ApprovalInProgressExceptionThe 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.
InvalidWorkflowExceptionThe 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.
WorkflowNotFoundExceptionNo 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.
AmbiguousWorkflowExceptionTwo 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.
MissingRequesterExceptionNobody 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.
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.
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:doctorCheck 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.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, and raw SQL bypass the guard. Guard mode does not see those, and the stale check is exactly the backstop that catches them.
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.
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.
approval.notifications.enabled is false by default.DB::afterCommit(). A rolled-back transaction
sends nothing, deliberately.notifications.channels is ['mail'] by default.approval:verify reports findingsRead the finding's subject and problem. Four kinds:
stage_id, from a stage that was hard-deleted, does this too.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.
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.
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.
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.