Index

Strategy Chooses How; Command Records What to Do

Two PHP types can have almost identical shapes and still solve different problems:

interface ShippingRate
{
    public function quote(Shipment $shipment): Money;
}

interface ApplicationCommand
{
    public function execute(): void;
}

Both hide behavior behind a method. Both can be injected and tested. Their surface similarity makes “algorithm versus action” an easy first distinction, but it is not enough for application architecture.

The more useful distinction appears after execution fails. A strategy is a collaborator used while another operation is already in progress. A command represents work that can acquire its own lifecycle. Once work must be queued, retried, deduplicated, authorized, or audited independently, it is no longer merely a choice of implementation.

A strategy varies one decision inside a use case

Suppose checkout needs a shipping quote. The surrounding workflow stays the same, but the calculation varies by service:

interface ShippingRate
{
    public function quote(Shipment $shipment): Money;
}

final readonly class Checkout
{
    public function __construct(private ShippingRate $shipping) {}

    public function total(Cart $cart, Shipment $shipment): Money
    {
        return $cart->subtotal()->add(
            $this->shipping->quote($shipment),
        );
    }
}

FlatRate, CarrierApiRate, and FreeShipping are interchangeable ways to answer the same question. Checkout owns when the question is asked and what happens with the answer. The selected strategy does not represent a request that makes sense on its own.

Laravel can select the implementation through an ordinary or contextual container binding. That is useful when the choice follows deployment configuration or the consuming class:

$this->app->when(WholesaleCheckout::class)
    ->needs(ShippingRate::class)
    ->give(ContractShippingRate::class);

Runtime business data belongs in a resolver instead. Creating one container binding per customer or order hides domain selection inside application wiring:

final readonly class ShippingRates
{
    /** @param iterable<ShippingRate> $rates */
    public function __construct(private iterable $rates) {}

    public function for(ServiceCode $service): ShippingRate
    {
        foreach ($this->rates as $rate) {
            if ($rate->supports($service)) {
                return $rate;
            }
        }

        throw UnsupportedShippingService::for($service);
    }
}

The resolver makes the deciding input visible. The strategies remain focused on calculation.

An application command separates the request from its handler

Now consider capturing payment:

final readonly class CapturePayment
{
    public function __construct(
        public OrderId $orderId,
        public Money $amount,
        public IdempotencyKey $idempotencyKey,
    ) {}
}
final readonly class CapturePaymentHandler
{
    public function __construct(
        private Orders $orders,
        private PaymentGateway $payments,
    ) {}

    public function handle(CapturePayment $command): void
    {
        $order = $this->orders->get($command->orderId);

        $transaction = $this->payments->capture(
            $command->amount,
            $command->idempotencyKey,
        );

        $order->recordPayment($transaction);
        $this->orders->save($order);
    }
}

The opening showed a self-executing Command object. At an application boundary, the request is often separated from the code that handles it. Symfony Messenger’s message and handler model makes that split explicit; the command carries data while the handler performs the work.

Here, CapturePayment contains the stable inputs needed to perform one application operation. It can be validated and authorized before dispatch, logged by type, or routed to a synchronous or asynchronous handler. The handler owns the use case and may use several strategies internally.

Laravel jobs often combine message and handler in one serializable class. The queue system can delay and retry that work. It also re-retrieves serialized models when a worker starts and records jobs that exhaust their attempts. Those features introduce obligations that a strategy does not have:

  • serialized properties must remain meaningful when a worker reads them;
  • retries must not duplicate an irreversible side effect;
  • a worker may execute against state that changed after dispatch; and
  • failure needs an operational destination rather than a return value to the original caller.

Turning CapturePayment into a queued job therefore requires more than adding ShouldQueue. The idempotency key and state checks are part of the command’s contract.

One use case can contain both patterns

The payment handler may select a fraud-scoring strategy before it captures:

final readonly class CapturePaymentHandler
{
    public function __construct(
        private FraudScore $fraud,
        private PaymentGateway $payments,
        private Orders $orders,
    ) {}

    public function handle(CapturePayment $command): void
    {
        $order = $this->orders->get($command->orderId);

        if ($this->fraud->for($order)->requiresReview()) {
            $order->holdForReview();
            $this->orders->save($order);

            return;
        }

        // Capture through an idempotent gateway call, then record the result.
    }
}

CapturePayment says what application work was requested. FraudScore says how one decision inside that work is calculated. Replacing the scoring model does not change the command’s identity. Retrying the command does repeat the use case and must be safe.

This also explains why “every action should be a command class” produces thin ceremony. A private method call that is never queued, logged, composed, authorized, or handled independently may already be the clearest expression of the work. A command earns its object boundary through lifecycle needs, not through the presence of a verb.

Tests should follow the different promises

A strategy test is a table of inputs and outputs:

it('quotes the contract rate', function (int $weight, int $expected) {
    $rate = new ContractShippingRate(Money::EUR(500));

    expect($rate->quote(Shipment::grams($weight)))
        ->toEqual(Money::EUR($expected));
})->with([
    'small parcel' => [500, 500],
    'large parcel' => [5_000, 500],
]);

A command-handler test cares about state transitions and side effects:

it('does not capture an order twice', function () {
    $gateway = new RecordingPaymentGateway();
    $orders = OrdersInMemory::containing(Order::alreadyPaid('order-1'));
    $handler = new CapturePaymentHandler($orders, $gateway);

    $handler->handle(capturePayment('order-1'));

    expect($gateway->captures())->toBeEmpty();
});

If a supposed strategy needs retry identity and durable status, it is carrying a command lifecycle under another name. If a supposed command is only chosen to calculate a value inside one call, it is probably a strategy—or simply a collaborator.

Choose by the promise created at the boundary. Strategy makes an algorithm replaceable. Command makes a piece of work identifiable. The code shape is the least interesting part.