Strategy Pattern in Laravel: Flexible B2B Pricing

9 min read

A match statement is often the right way to start. One customer type, two discounts, a rule that fits on a screen: there is no prize for introducing five classes before the second condition exists.

The trouble starts when that statement becomes the place where pricing policy goes to accumulate. Retail customers see the catalogue price. Partners receive a percentage discount. Contract customers have negotiated prices, minimum quantities, and perhaps a validity date. Then somebody adds a marketplace with its own commission rule. A small conditional becomes a change hotspot for a business rule that deserves to be independently understood and tested.

The Strategy pattern gives each pricing algorithm a name and a boundary. Laravel then resolves the right implementation through its container. This article builds that boundary for a B2B catalogue, explains where contextual binding fits, and—just as importantly—where it does not.

The warning sign is not the first match

This service is perfectly reasonable while the application has only two simple rules:

php
<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Product;

final class PriceCalculator
{
    public function quote(Product $product, string $customerType): int
    {
        return match ($customerType) {
            'retail' => $product->price_cents,
            'partner' => (int) round($product->price_cents * 0.85),
            'contract' => $this->contractPriceFor($product),
            default => throw new \InvalidArgumentException("Unknown customer type [{$customerType}]."),
        };
    }

    private function contractPriceFor(Product $product): int
    {
        return $product->price_cents;
    }
}

The problem is not match itself. The problem is that all future pricing behaviour must now change this one class. The contract branch will need a repository. The partner branch may need a promotion. A marketplace rule may need an external tax service. Dependencies arrive, tests need increasingly elaborate setup, and a change for one channel can accidentally break another.

That is the moment to extract strategies—not before.

Give the business operation a small contract

First, make the input and output explicit. Money remains integer cents, so no floating-point value can quietly turn a €19.99 price into €19.98. The quote also carries enough information for an API resource or checkout page to explain the price later.

php
<?php

declare(strict_types=1);

namespace App\Pricing;

use App\Models\Product;

final readonly class PriceRequest
{
    public function __construct(
        public Product $product,
        public int $quantity,
        public ?string $customerId = null,
    ) {
        if ($quantity < 1) {
            throw new \InvalidArgumentException('Quantity must be at least one.');
        }
    }
}
php
<?php

declare(strict_types=1);

namespace App\Pricing;

final readonly class PriceQuote
{
    public function __construct(
        public int $unitPriceCents,
        public string $rule,
    ) {
        if ($unitPriceCents < 0) {
            throw new \InvalidArgumentException('A price cannot be negative.');
        }
    }

    public function totalCents(int $quantity): int
    {
        return $this->unitPriceCents * $quantity;
    }
}
php
<?php

declare(strict_types=1);

namespace App\Pricing;

interface PriceRule
{
    public function quote(PriceRequest $request): PriceQuote;
}

PriceRule deliberately says nothing about controllers, Eloquent queries, or the current HTTP request. A caller gives it a request and receives a quote. That small contract is what makes each algorithm replaceable.

Implement the rules where they belong

The retail rule is intentionally boring. Its value is not clever arithmetic; it is making the default policy an explicit, testable unit.

php
<?php

declare(strict_types=1);

namespace App\Pricing\Rules;

use App\Pricing\PriceQuote;
use App\Pricing\PriceRequest;
use App\Pricing\PriceRule;

final class RetailPriceRule implements PriceRule
{
    public function quote(PriceRequest $request): PriceQuote
    {
        return new PriceQuote(
            unitPriceCents: $request->product->price_cents,
            rule: 'retail',
        );
    }
}

Partners receive a documented rate. Keeping the rate in a constructor makes it configuration, not hidden policy. A service provider can later read it from config/pricing.php without changing the strategy's public API.

php
<?php

declare(strict_types=1);

namespace App\Pricing\Rules;

use App\Pricing\PriceQuote;
use App\Pricing\PriceRequest;
use App\Pricing\PriceRule;

final class PartnerPriceRule implements PriceRule
{
    public function __construct(
        private readonly int $discountBasisPoints,
    ) {
        if ($discountBasisPoints < 0 || $discountBasisPoints > 10_000) {
            throw new \InvalidArgumentException('Discount must be between 0 and 10,000 basis points.');
        }
    }

    public function quote(PriceRequest $request): PriceQuote
    {
        $discountedPrice = intdiv(
            $request->product->price_cents * (10_000 - $this->discountBasisPoints),
            10_000,
        );

        return new PriceQuote(
            unitPriceCents: $discountedPrice,
            rule: 'partner',
        );
    }
}

Contract pricing has a dependency that the other two rules do not: a source of negotiated prices. That dependency belongs in this strategy, not in a generic calculator that every channel must carry around.

php
<?php

declare(strict_types=1);

namespace App\Pricing\Contracts;

use App\Models\Product;

interface ContractPriceRepository
{
    public function unitPriceCentsFor(string $customerId, Product $product): ?int;
}
php
<?php

declare(strict_types=1);

namespace App\Pricing\Rules;

use App\Pricing\Contracts\ContractPriceRepository;
use App\Pricing\PriceQuote;
use App\Pricing\PriceRequest;
use App\Pricing\PriceRule;

final class ContractPriceRule implements PriceRule
{
    public function __construct(
        private readonly ContractPriceRepository $contractPrices,
    ) {}

    public function quote(PriceRequest $request): PriceQuote
    {
        if ($request->customerId === null) {
            throw new \LogicException('A contract quote requires a customer.');
        }

        $unitPriceCents = $this->contractPrices->unitPriceCentsFor(
            customerId: $request->customerId,
            product: $request->product,
        );

        if ($unitPriceCents === null) {
            throw new \DomainException('No active contract price exists for this product.');
        }

        return new PriceQuote(
            unitPriceCents: $unitPriceCents,
            rule: 'contract',
        );
    }
}

The exception is a business decision. Some companies fall back to the retail price when no contract row exists; others must prevent checkout. Make that policy explicit in this rule or in a separate fallback decorator. Do not hide it behind ?? $product->price_cents unless that is truly the agreed commercial behaviour.

Contextual binding is for a known consumer

Laravel's contextual binding answers this question: “when this class needs an interface, which concrete implementation should it receive?” It is excellent when the channel is known from the entry point.

For example, a storefront endpoint always uses retail pricing, while a partner portal always uses the partner rule. Each service depends on the same honest contract:

php
<?php

declare(strict_types=1);

namespace App\Pricing;

final class StorefrontQuoteService
{
    public function __construct(
        private readonly PriceRule $priceRule,
    ) {}

    public function quote(PriceRequest $request): PriceQuote
    {
        return $this->priceRule->quote($request);
    }

    public function rule(): PriceRule
    {
        return $this->priceRule;
    }
}
php
<?php

declare(strict_types=1);

namespace App\Pricing;

final class PartnerPortalQuoteService
{
    public function __construct(
        private readonly PriceRule $priceRule,
    ) {}

    public function quote(PriceRequest $request): PriceQuote
    {
        return $this->priceRule->quote($request);
    }

    public function rule(): PriceRule
    {
        return $this->priceRule;
    }
}

Register the choices once in a provider. The provider contains composition decisions; the strategies contain pricing decisions.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Pricing\PartnerPortalQuoteService;
use App\Pricing\PriceRule;
use App\Pricing\Rules\PartnerPriceRule;
use App\Pricing\Rules\RetailPriceRule;
use App\Pricing\StorefrontQuoteService;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;

final class PricingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->when(StorefrontQuoteService::class)
            ->needs(PriceRule::class)
            ->give(RetailPriceRule::class);

        $this->app->when(PartnerPortalQuoteService::class)
            ->needs(PriceRule::class)
            ->give(fn (Application $app): PartnerPriceRule => new PartnerPriceRule(
                discountBasisPoints: (int) $app['config']->get('pricing.partner_discount_basis_points'),
            ));
    }
}

This works because StorefrontQuoteService and PartnerPortalQuoteService are different consumers. Binding PriceRule globally would make both receive the same implementation. Binding by the authenticated user's type is also not what contextual binding does: the container resolves class dependencies, not a different strategy for every request attribute.

Runtime selection needs a resolver

When a single endpoint can quote for retail, partner, or contract customers, selection is runtime business logic. Use a small resolver with an explicit map instead of hoping the container can infer a rule from an HTTP request.

php
<?php

declare(strict_types=1);

namespace App\Pricing;

use App\Pricing\Rules\ContractPriceRule;
use App\Pricing\Rules\PartnerPriceRule;
use App\Pricing\Rules\RetailPriceRule;

final class PriceRuleResolver
{
    public function __construct(
        private readonly RetailPriceRule $retail,
        private readonly PartnerPriceRule $partner,
        private readonly ContractPriceRule $contract,
    ) {}

    public function forCustomerType(string $customerType): PriceRule
    {
        return match ($customerType) {
            'retail' => $this->retail,
            'partner' => $this->partner,
            'contract' => $this->contract,
            default => throw new \InvalidArgumentException("Unknown customer type [{$customerType}]."),
        };
    }
}

Notice that the match has not disappeared. It moved to the one place whose job is choosing a strategy. The algorithms no longer know about customer-type strings, and adding a new rule changes the resolver rather than every pricing branch in the application.

Test the policy, not the container magic

Each strategy is an ordinary PHP object. Its most valuable tests are fast unit tests that pin down commercial behaviour. This Pest example tests rounding in integer cents without booting Laravel:

php
<?php

declare(strict_types=1);

use App\Models\Product;
use App\Pricing\PriceRequest;
use App\Pricing\Rules\PartnerPriceRule;

test('partner pricing applies the configured discount in integer cents', function (): void {
    $product = new Product(['price_cents' => 19_99]);
    $rule = new PartnerPriceRule(discountBasisPoints: 1_500);

    $quote = $rule->quote(new PriceRequest(product: $product, quantity: 3));

    expect($quote->unitPriceCents)->toBe(16_99)
        ->and($quote->totalCents(3))->toBe(50_97)
        ->and($quote->rule)->toBe('partner');
});

Add one feature test for the container wiring. It should prove that the storefront receives RetailPriceRule and the partner portal receives PartnerPriceRule; it does not need to repeat every pricing assertion.

php
<?php

declare(strict_types=1);

use App\Pricing\PartnerPortalQuoteService;
use App\Pricing\StorefrontQuoteService;
use App\Pricing\Rules\PartnerPriceRule;
use App\Pricing\Rules\RetailPriceRule;

test('the quote services receive their contextual pricing rules', function (): void {
    expect(app(StorefrontQuoteService::class)->rule())
        ->toBeInstanceOf(RetailPriceRule::class);

    expect(app(PartnerPortalQuoteService::class)->rule())
        ->toBeInstanceOf(PartnerPriceRule::class);
});

The rule() method makes the wiring observable for this small example. In a production application, prefer a behaviour-level assertion through the endpoint when exposing the concrete strategy would leak an unnecessary implementation detail. The important boundary is clear: unit tests protect each rule; a single integration test protects the provider.

Do not use Strategy when a conditional is still clearer

Strategy has a cost: more files, more names, and another layer of indirection. Keep a match when there are only a couple of stable, trivial branches and no branch has its own dependencies or lifecycle. A five-line formatter does not become better because it has an interface.

Also do not turn every configurable value into a strategy. A different VAT rate from configuration is data. A different calculation process—with dependencies, edge cases, and a separately meaningful name—is behaviour. The latter earns a strategy; the former usually earns a configuration value or database record.

Finally, Strategy is not a substitute for correct pricing modelling. It does not decide whether prices include tax, how promotions compose, or how a quote is audited after an order is placed. Those are domain decisions that must be made before code can faithfully implement them.

The useful boundary

The result is not “no conditionals.” It is a system where choosing a pricing policy happens once, while every policy is independently readable, testable, and changeable. Laravel's container makes the static choices declarative; a resolver keeps runtime choices honest.

That is the Strategy pattern at its most practical: introduce it when a growing conditional is concealing several business algorithms, then keep the interface small enough that the next rule has an obvious place to live.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.