byrcsc/laravel-approval · 1.x
Choosing the workflow.
How the package picks a workflow when the call site does not name one, and how to register several workflows against a single model.
Approval::submit() takes a workflow, but the argument is optional. Leave it
out and the package works out which workflow the submission should run under.
That is what lets one model have more than one workflow: a purchase order under
₱10,000 taking the short path, and everything above it going to the executive
committee.
Approval::submit($order); // work it out
Approval::submit($order, 'purchase-order-executive'); // use this oneA named workflow is used as named
The argument wins outright. A workflow named at the call site skips selection entirely — its condition is never consulted, and priority plays no part. Nothing below this page applies to it.
Being active is not waived. Submission refuses an inactive workflow with
InvalidWorkflowException::inactive() however the workflow was reached:
naming one skips selection, not validation. A disabled workflow must be
reactivated before anything can run under it.
That split is deliberate. Naming a workflow is an instruction, and an instruction the package silently reinterpreted would be worse than one that fails loudly — so a named workflow is never swapped for another, and a named workflow that cannot run says so with an exception.
Where candidates come from
With nothing named, the package gathers candidates from three places, in this order:
- the model's
approvalWorkflow()method, returning anApprovalWorkflowor a slug; - the
approval.workflowsconfig map, keyed by class name or morph alias; - every workflow whose
approvable_typecolumn names the model.
// 1. On the model
public function approvalWorkflow(): string
{
return 'purchase-order';
}
// 2. In config/approval.php
'workflows' => [
App\Models\PurchaseOrder::class => 'purchase-order',
],
// 3. On the workflow row
ApprovalWorkflow::create([
'slug' => 'purchase-order',
'approvable_type' => PurchaseOrder::class,
]);Use the config map for models you do not own, or to keep every mapping in one
file. Use approvable_type when a model has more than one workflow, since it is
the only source that can name several.
A slug that names no workflow raises WorkflowNotFoundException rather than
being quietly skipped. A typo in a model or in configuration is a mistake, not a
fallback to the next source.
Naming several is a preference order
The config map accepts a list, and each entry is its own step down the order:
'workflows' => [
App\Models\PurchaseOrder::class => [
'purchase-order-capital',
'purchase-order',
],
],The first that applies wins. The second is reached only when the first is inactive or its condition rules it out. Because each named entry sits at its own level of specificity, a list can never be ambiguous — there is always something separating any two of them.
Workflows registered by approvable_type are different: they share the last
position, which is exactly what makes them a set for conditions and priority to
choose between.
A workflow reachable from more than one source is considered at its most specific position only, so listing a workflow in config and also registering it by type does not give it two chances.
Filtering and ordering
Everything gathered is then filtered. An inactive workflow is out, and so is one
whose condition says it does not apply to the submission in hand. What survives
is ordered by where it came from, and then by priority, highest first.
When nothing survives, submission raises WorkflowNotFoundException.
Registering several against one model
Register them against the model type, give each the condition that selects it, and let priority order whatever is left:
ApprovalWorkflow::create([
'name' => 'Executive purchase orders',
'slug' => 'purchase-order-executive',
'approvable_type' => PurchaseOrder::class,
'condition' => AmountAbove::class,
'condition_config' => ['amount' => 10_000],
'priority' => 10,
]);
ApprovalWorkflow::create([
'name' => 'Standard purchase orders',
'slug' => 'purchase-order-standard',
'approvable_type' => PurchaseOrder::class,
'priority' => 0,
]);A ₱25,000 order matches both, and the executive workflow's higher priority wins. A ₱400 order fails the condition, leaving the standard workflow as the only candidate. The unconditional low-priority workflow is doing real work here: it is the fallback that stops a submission from having nowhere to go.
Writing a condition
A workflow condition implements WorkflowCondition:
use ByRcsc\LaravelApproval\Contracts\WorkflowCondition;
use ByRcsc\LaravelApproval\Models\ApprovalWorkflow;
use Illuminate\Database\Eloquent\Model;
final class AmountAbove implements WorkflowCondition
{
public function applies(
ApprovalWorkflow $workflow,
Model $approvable,
?Model $requester,
array $config,
): bool {
return $approvable->amount > ($config['amount'] ?? 0);
}
}Conditions are resolved from the container, so constructor injection works.
Two arguments deserve attention.
$approvable is the proposal, not the record. When the submission holds an
attribute draft, the drafted values are filled onto a clone before the condition
runs, exactly as they are for conditional stages. A
purchase order persisted at ₱4,000 but submitted with a draft raising it to
₱20,000 is judged on ₱20,000 — which is the only reading that makes sense, since
the approval is for the proposal.
$requester may be null. It is null whenever nobody is submitting yet, which
is the case when an application asks which workflows a record could be
submitted against. A condition that dereferences the requester without checking
will work at submission and fail in your workflow picker.
public function applies(
ApprovalWorkflow $workflow,
Model $approvable,
?Model $requester,
array $config,
): bool {
// Nobody is submitting yet: offer the workflow rather than hiding it.
return $requester?->isContractor() ?? true;
}Ties are refused, not broken
Two workflows that are equally specific and equally prioritized raise
AmbiguousWorkflowException:
More than one approval workflow applies to [App\Models\PurchaseOrder] at
priority 0: [purchase-order-capital], [purchase-order-operating]. Give one of
them a higher priority, narrow them with a condition, or name the workflow at
the call site.Picking one arbitrarily would make which workflow ran a matter of insertion order, so the same record could take a different path tomorrow for no reason anyone configured. The message names the three fixes.
Because only the type-registered position can hold more than one workflow, this is the only place ambiguity can arise.
Asking before you submit
These are the queries a workflow picker is built from:
Approval::workflowsFor($order);
// Collection of applicable workflows, best first. Nobody is submitting yet,
// so conditions receive a null requester.
Approval::workflowsFor($order, $requester, ['amount' => 20_000]);
// Applicable to this specific submission, draft and all.
Approval::workflowFor($order);
// The single workflow a submission would use. Raises on ambiguity.
Approval::workflows();
// Builder over every active workflow, ordered by name. The maintenance-screen
// query, not the one to offer a requester.workflowsFor() and workflowFor() answer the same question — the second is
the first entry of the first. Use workflowsFor() to render a choice, and
workflowFor() to preview what would happen without offering one.
Pass the same draft to
workflowsFor()that you will pass tosubmit(). Conditions judge the proposal, so a choice computed without the draft and then submitted with it can land on a different workflow than the one shown.
In a definition file
The fluent builder registers all of it, so workflow selection stays under version control with the rest of the definition:
WorkflowBuilder::make('purchase-order-executive')
->forApprovable(PurchaseOrder::class)
->when(AmountAbove::class, ['amount' => 10_000])
->priority(10)
->stage('Finance', fn ($stage) => $stage->approver($cfo));forApprovable() accepts a model instance or a class-string. See
workflow definitions for the array equivalent and how
approval:sync reconciles changes.
Workflow conditions versus stage conditions
They answer different questions and run at different times.
A workflow condition decides which workflow runs. It is consulted only while a workflow is being chosen, never for one named at the call site, and it runs before the request exists.
A stage condition decides whether a stage inside the running workflow
applies. It runs during submission, once, and a stage that fails it is created
in the skipped state.
A workflow can use both: a condition selecting it for capital purchases, and stage conditions skipping legal review within it.