Originally published on phpdesignpatterns.dev — part of the PHP design patterns course. View this lesson on the original site.

Strategy in a second domain: shipping

The discount calculator is the textbook case. To stop the pattern feeling like a one-trick, here's a different domain with the same shape.

Before — shipping cost as a switch

<?php

final class ShippingCalculator
{
    public function calculateShippingCost(string $method, int $weightGrams, string $country): int
    {
        if ($method === 'standard' && $country === 'GB') {
            return $weightGrams < 2000 ? 295 : 595;
        }

        if ($method === 'standard' && $country !== 'GB') {
            return $weightGrams < 2000 ? 995 : 1995;
        }

        if ($method === 'next-day') {
            if ($country !== 'GB') {
                throw new RuntimeException('Next-day not available outside GB');
            }
            return 695;
        }

        if ($method === 'collection') {
            return 0;
        }

        throw new RuntimeException("Unknown method: {$method}");
    }
}

The same smell as the discount calculator: each method is a different rule, glued together by if/elseif, with the country flag complicating things further.

After — each method is a strategy

<?php

final class ShippingInput
{
    public function __construct(
        public readonly int $weightGrams,
        public readonly string $country,
    ) {}
}

interface CalculatesShippingCost
{
    public function calculateShippingCost(ShippingInput $input): int;
}

final class StandardShipping implements CalculatesShippingCost
{
    public function calculateShippingCost(ShippingInput $input): int
    {
        if ($input->country === 'GB') {
            return $input->weightGrams < 2000 ? 295 : 595;
        }
        return $input->weightGrams < 2000 ? 995 : 1995;
    }
}

final class NextDayShipping implements CalculatesShippingCost
{
    public function calculateShippingCost(ShippingInput $input): int
    {
        if ($input->country !== 'GB') {
            throw new DomainException('Next-day not available outside GB');
        }
        return 695;
    }
}

final class CollectionShipping implements CalculatesShippingCost
{
    public function calculateShippingCost(ShippingInput $input): int
    {
        return 0;
    }
}

What's different from the discount example

  • The strategy needs more than one input, so we used a small ShippingInput value object instead of widening the method signature.
  • One strategy can throw. NextDayShipping rejects non-GB orders rather than silently returning a default. The contract is honest about what each strategy supports.
  • The "country" branching has moved inside the strategies that care about it. CollectionShipping doesn't know about countries — and shouldn't have to.

The pattern is the same shape; the parameters and the return are tuned to the domain. That's the part teams usually get wrong: copying a Strategy implementation from one place to another verbatim instead of fitting the contract to this domain's question.

From PHP design patterns — Strategy: replacing branching with swappable behaviour