Originally published on phpdesignpatterns.dev — part of the PHP design patterns course. View this lesson on the original site.
Designing a Strategy interface that doesn't leak
The interface in lesson 2.4 looks tiny — one method, one parameter, one return type. That's the point. A Strategy interface is the smallest contract that captures what the variation actually is.
Get the contract wrong and the pattern leaks. Get it right and every new strategy slots in cleanly.
Strategy: context depends on one interchangeable interface.
classDiagram
direction LR
class PricingContext {
-CalculatesDiscount strategy
-array orderMeta
+setStrategy(CalculatesDiscount s) void
+quoteForOrder(Order order) int
-resolveStrategy(Order order) void
}
class CalculatesDiscount {
<<interface>>
+calculateDiscount(int totalInPence) int
}
class PercentDiscount {
+int percentBps
+int maxDiscountPence
+calculateDiscount(int totalInPence) int
}
class FixedDiscount {
+int amountOffPence
+bool stacksWithPromo
+calculateDiscount(int totalInPence) int
}
PricingContext --> CalculatesDiscount : uses at runtime
CalculatesDiscount <|.. PercentDiscount
CalculatesDiscount <|.. FixedDiscount
A weak contract
<?php
interface CalculatesDiscount
{
public function calculate(array $context): mixed;
}
The signature doesn't tell you anything. What's in $context? What's in mixed? Every implementation has to defensively check the array and every caller has to know what to put in.
A strong contract
<?php
interface CalculatesDiscount
{
public function calculateDiscount(int $totalInPence): int;
}
The name names the question. The parameter is the only thing the strategy needs to answer it. The return type is the answer.
Three rules for a good Strategy contract
- One method, named after the question being asked. Not
execute, notrun, nothandle—calculateDiscount,nextStateFor,priceInPence. - Parameters are the data the strategy actually needs. Not "the whole order". Not "the whole request". Just the inputs the rule depends on.
- The return type is the strategy's answer. A primitive, a value object, a small DTO. Not "void". Not "depends on the strategy".
When the rules get bigger
If a strategy genuinely needs more context, pass a small input object rather than widening the parameter list:
<?php
final class DiscountInput
{
public function __construct(
public readonly int $totalInPence,
public readonly int $itemCount,
public readonly bool $hasFirstOrderFlag,
) {}
}
interface CalculatesDiscount
{
public function calculateDiscount(DiscountInput $input): int;
}
That keeps the signature stable when the rules grow without forcing every strategy to accept arguments it doesn't use.
What the contract is not
- It is not where you encode shared behaviour. Strategies are siblings, not subclasses. Shared logic goes in helpers, not in a base class. (We'll come back to this in lesson 2.9.)
- It is not where you wire up dependencies. A strategy receives its inputs through its method signature; long-lived collaborators come in through the constructor.
A weak Strategy contract is the most common reason this pattern goes wrong in real codebases. A strong one makes the rest of the chapter easy.
From PHP design patterns — Strategy: replacing branching with swappable behaviour