When a sequence of conditions became a separate process
I wanted:
each rule to remain a separate class;
the priority order to be visible in one place;
a rule to either return a result or pass processing forward;
every step to be testable independently.
Laravel Pipeline turned out to be a good fit for this.
A shared contract for every step
<?php declare(strict_types=1); namespace App\Services\Commission\Steps; use App\DTO\CommissionContext;use Closure; interface CommissionStepInterface{ public function handle( CommissionContext $context, Closure $next, ): array;}This contract makes every rule look the same from the Pipeline’s perspective. Internally, the steps may check completely different conditions, but they interact with the process in a consistent way.
The rule order is visible in one place
<?php declare(strict_types=1); namespace App\Services\Commission; use App\DTO\CommissionContext;use App\Services\Commission\Steps\CampaignCommissionStep;use App\Services\Commission\Steps\CustomerTierCommissionStep;use App\Services\Commission\Steps\DefaultCommissionStep;use App\Services\Commission\Steps\PreferredPartnerCommissionStep;use App\Services\Commission\Steps\ProductCategoryCommissionStep;use App\Services\Commission\Steps\VolumeCommissionStep;use Illuminate\Pipeline\Pipeline; final readonly class CommissionPipeline{ public function __construct( private Pipeline $pipeline, ) {} public function calculate(CommissionContext $context): array { return $this->pipeline ->send($context) ->through($this->steps()) ->thenReturn(); } private function steps(): array { return [ PreferredPartnerCommissionStep::class, ProductCategoryCommissionStep::class, CustomerTierCommissionStep::class, VolumeCommissionStep::class, CampaignCommissionStep::class, DefaultCommissionStep::class, ]; }}This is the part of the solution I like the most.
To understand the calculation priority, I no longer need to open a large method and inspect nested if statements. I can simply read the list of steps from top to bottom.
Special rules run first. If none of them applies, the final step returns the default commission.
One of the іteps
<?php declare(strict_types=1); namespace App\Services\Commission\Steps; use App\DTO\CommissionContext;use App\Helpers\Percent;use App\Services\Commission\CommissionPolicy;use Closure; final readonly class DefaultCommissionStep implements CommissionStepInterface{ public function __construct( private MinimumCommissionApplier $minimumCommissionApplier, private CommissionPolicy $commissionPolicy, ) {} public function handle( CommissionContext $context, Closure $next, ): array { $percent = $this->commissionPolicy->defaultPercent( $context->subject, $context->amount, ); return [ 'commission' => $this->minimumCommissionApplier->apply( Percent::amount($context->amount, $percent), $context, ), 'original_percent' => $percent, 'percent' => $percent, 'rule' => 'default', ]; }}What changed after this
The calculation did not become smaller in terms of business rules. But its structure became much easier to understand.
Now:
every rule has a separate responsibility;
the execution order is immediately visible;
a new scenario can be added as another step;
individual rules are easier to test;
the main service no longer grows with every new condition.
Separate tests are especially important for calculation logic. In another case, a test was exactly what showed me that the same calculation using round() produced a different result on PHP 8.3 and PHP 8.4, even though the code itself had not changed.
For me, this was one of those cases where a pattern did more than make the code look cleaner. It made the business process visible in the program structure.
Takeaway
Laravel Pipeline is often associated with middleware or sequential data processing. In this case, it worked well for an ordered collection of commission rules.
The biggest benefit was not the removal of conditional statements. They still exist, but now each one lives in the class where it belongs.
The real benefit was that the order became explicit, every step received a clear responsibility, and the calculation stopped looking like one large method.



