›
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.
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 names | Fix |
|---|---|
| A blank table name | Give it a non-empty string |
| The user model | approval.user_model exists but is not an Eloquent model |
| A key type | user_key_type and approvable_key_type must be int, uuid, ulid, or string |
| A model class key | Keys of model_classes must be keys of table_names |
| A model class | Must extend the package model it replaces |
| 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 |
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:clearRestart long-running workers as well. They keep 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. The package raises each of these before it writes a row:
| 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 return nothing, but not for a stage whose condition passed. That stage would wait forever with no approver.
WorkflowNotFoundExceptionNo 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.
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.
MissingRequesterExceptionThe 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.
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.
Check the approver's eligibility:
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 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:doctorCheck, in order:
approval:delegations runs. Schedule it.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.
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.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.
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.
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.
approval.notifications.enabled is false by default.approval.notifications.map. Unmapped events send
nothing. You can still listen for them.DB::afterCommit(). A rolled-back
transaction sends nothing, by design.notifications.channels is ['mail'] by default.approval:verify reports findingsRead the finding's subject and problem. Each finding is one of these:
stage_id from a hard-deleted stage also causes it.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.
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.
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.