Pass, flag, skip, stop
Every step ends in one of four ways, and the receipt shows which. This page is about choosing the right one.
The four endings
| Ending | Symbol | In a rule | Use it when |
|---|---|---|---|
| pass | ✓ | return self::SUCCESS; |
the check is fine |
| flag | ✗ | $this->warning('why'); return self::FAIL; or $this->error('why'); |
something is off, but the flow should carry on |
| skip | ↷ | return $this->skip('why'); |
there's nothing for this rule to do |
| stop | ■ | $this->fatal('why'); |
carrying on would be wrong |
The checkout uses all four. ConfirmItemsInStock stops: shipping half an order is worse than not shipping. The fraud rules flag: one signal isn't enough to refuse an order, so the hub's then() decides once it has seen all of them. ApplyDiscountCodes skips when there's no code.
Flag, then decide
The pattern behind the fraud screen is worth copying: rules flag, and the stage decides.
->addPipes([MatchBillingToShippingCountry::class, CheckCardVelocity::class, ScoreDeviceFingerprint::class])
->then(fn ($order) => $order->riskSignals() >= 2 ? $order->holdForReview('Two or more fraud signals') : null)Each rule stays small and honest about one signal, and the policy ("two signals means a hold") lives in one readable line. The later stages then skip themselves with the reason.
Skipping
Four ways, from the smallest to the biggest:
| How | What runs | On the receipt |
|---|---|---|
a rule returns $this->skip('why') |
the rule ran and decided it had nothing to do | that rule ↷, with its reason |
->skipIf(fn ($order) => ...) on a hub |
none of the hub's rules | every rule ↷; the reason once on the hub's line |
->skip() on a hub |
nothing in the hub, and not its then() |
one "Skipping hub" record |
$envelope->skipAll() from a rule or callback |
nothing after that point, in any hub | every remaining rule ↷ |
What a skipIf() closure returns decides whether the hub runs and what the receipt says:
| The closure returns | The hub | The reason on the receipt |
|---|---|---|
| a non-empty string | is skipped | that string |
true |
is skipped | the $reason argument, if you gave one |
false, null or '' |
runs |
The checkout uses both styles: skipIf(fn ($order) => $order->customerIsTrusted(), 'Trusted customer') has one reason, and skipIf(fn ($order) => $order->isOnHold() ? "Held for review: {$order->holdReason}" : null) builds its reason from the order. A closure can return a different string for each reason it might skip.
Stopping
A fatal stops the whole flow by default. Two hub settings change how far a failure reaches:
| You want | In the rule | On the hub |
|---|---|---|
| note it and keep going | $this->error('why') |
nothing |
| end this stage and move on to the next | $this->fatal('why') |
->fatalsStopThisHubOnly() |
| stop everything | $this->fatal('why') |
nothing: this is the default |
| make every failure in a stage stop everything | $this->error('why'), or an exception |
->errorsAreFatal() |
The checkout's Charge stage uses errorsAreFatal(): if authorizing the card fails for any reason, the order must not go on to Fulfil. A stopped flow's later stages never run, so they aren't on the receipt, and the flow's then() doesn't run either:
Checkout · stopped (0.22 ms)
Validate the order — Is this a complete order for items we can actually ship?
✓ RequireShippingAddress (0.02 ms)
■ ConfirmItemsInStock (0.13 ms) Out of stock: Limited-run posterEvery combination, including what then() and the hooks do in each case, is on Outcome tables.
Next: Reacting to failures.