›
byrcsc/laravel-approval · 1.x
Choose which workflow a model uses when several workflows could apply.
You can name a workflow when submitting a model or let Laravel Approval choose one. Automatic selection lets the same model follow different processes. For example, a small purchase order can use a short workflow while a larger one also needs executive approval.
Approval::submit($order); // work it out
Approval::submit($order, 'purchase-order-executive'); // use this oneThe argument always wins. When you name a workflow, Laravel Approval does not check its selection condition or compare its priority with other workflows.
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.
A named workflow is never replaced with another workflow. If it cannot run, submission throws an exception.
With nothing named, the package gathers candidates from three places, in this order:
approvalWorkflow() method, returning an ApprovalWorkflow or a
slug;approval.workflows config map, keyed by class name or morph alias;approvable_type column 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.
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, the list has a clear order and cannot be ambiguous.
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.
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.
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.
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 because the request is asking for approval of the
proposed value.
$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;
}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.
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() returns all matching workflows in order. workflowFor()
returns the first match. Use workflowsFor() to render a choice and
workflowFor() to preview automatic selection.
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.
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.
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.