Index

Object Design

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. They look similar enough that “algorithm versus action” sounds like a sufficient answer. It is not, at least not for application architecture.

Wait for execution to fail and the difference becomes clearer. A strategy is a collaborator used while another operation is already in progress. A command represents work that can acquire its own lifecycle. Once that work must be queued, retried, deduplicated, authorized, or audited independently, calling it an implementation choice misses most of the problem.

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. One container binding per customer or order turns application wiring into a fairly well-hidden business rules engine:

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 keeps the deciding input visible, while the strategies stay 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. Application code often separates the request from the code that handles it instead. Symfony Messenger’s message and handler model makes that split explicit: the command carries data and 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 requires more than adding ShouldQueue and congratulating ourselves. 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 is also why “every action should be a command class” produces so much 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. Verbs are cheap. A command earns its object boundary through actual lifecycle needs.

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.

Do not choose from the method signature. Choose from the promise created at the boundary. A strategy makes one calculation replaceable; a command makes a piece of work identifiable. The two PHP types can still look almost identical. That is the least interesting thing about them.