Browse documentationOpen

byrcsc/laravel-approval · 1.x

Conditional stages.

Skip stages that do not apply to a submission by implementing StageCondition, evaluated once against the proposed record.

A stage can decide it does not apply to a particular submission. Board approval above ₱100,000, legal review only for external suppliers, an extra signature only when the change touches a regulated field.

use ByRcsc\LaravelApproval\Contracts\StageCondition;
use ByRcsc\LaravelApproval\Data\StageSnapshot;
use Illuminate\Database\Eloquent\Model;

final class AmountExceeds implements StageCondition
{
    public function passes(
        StageSnapshot $stage,
        Model $approvable,
        Model $requester,
        array $config,
    ): bool {
        return $approvable->amount > ($config['threshold'] ?? 0);
    }
}

Attach it to the stage:

$workflow->stages()->create([
    'sequence' => 2,
    'name' => 'Board',
    'required_approvals' => 2,
    'condition' => AmountExceeds::class,
    'condition_config' => ['threshold' => 100_000],
]);

Conditions are resolved from the container, so constructor injection works.

Evaluated against the proposal

The $approvable a condition receives is the proposed record, not the persisted one. When a request holds an attribute draft, the drafted values are filled onto a clone before the condition runs:

// Persisted amount is 4_000; the draft proposes 150_000.
$order->submitForApproval('purchase-order', ['amount' => 150_000]);

// AmountExceeds sees 150_000 and the board stage applies.

The clone is discarded immediately. The real model is never mutated, and approver resolvers still receive the persisted record.

Evaluated once, then frozen

Conditions run during submission, before the request is written. Each stage stores its result in condition_result, and a stage whose condition did not pass is created directly in the skipped state.

Nothing re-evaluates a condition afterwards. A record edited mid-flight does not switch a skipped stage back on, for the same reason approver snapshots do not update: the request runs the rules it was submitted under.

A skipped stage is recorded in the audit trail as a stage_skipped action naming the condition class, so the history explains why a stage never ran.

Validation

A condition that does not exist, or that exists but does not implement StageCondition, raises InvalidWorkflowException at submission — before any row is written. Workflow definitions check the same thing at definition time.

Every stage skipped

A workflow whose stages all skip has nothing to wait on. The request is submitted, ApprovalCompleted fires immediately, the draft is applied through the usual stale check, and the request reaches approved without any human decision.

This is a legitimate outcome — the rules said no approval was needed — but it is worth designing for deliberately. A workflow whose first stage is unconditional avoids it entirely.

Conditions and required approvals

A skipped stage produces no assignments, but its configuration is still validated. Resolvers run for every stage, and an explicit required_approvals that exceeds what the stage resolved throws InvalidWorkflowException::approvalRuleUnreachable() even when the stage is skipped — a workflow that cannot reach a decision is refused whichever condition happened to pass today.

One check is condition-aware: only a stage whose condition passes must resolve at least one approver. A stage that does not apply is allowed to have nobody; a stage that does apply and has nobody is a workflow that would stall, and submission fails with InvalidWorkflowException::stageHasNoApprovers().

Reading the outcome

foreach ($request->timeline() as $entry) {
    $entry->state; // TimelineState::ConditionSkipped for condition skips
}

TimelineState distinguishes condition_skipped from an ordinary skipped, which is what a stage becomes when the request ended before reaching it. The underlying StageStatus is skipped in both cases; the timeline separates them by reading condition_result.