One Call Site, Many Behaviors: Refund Logic That Dispatches Itself
The refund endpoint holds a pile of type checks:
function refund(Payment $p): string
{
if ($p instanceof CardPayment) { return $p->refundCard(); }
if ($p instanceof WalletPayment) { return $p->refundToWallet(); }
if ($p instanceof InvoicePayment){ return $p->issueCreditNote(); }
throw new LogicException('unknown payment');
}
Every new payment type edits this function — the exact work polymorphism exists to delete. The fix is to give all payment types one method name and let the object decide:
abstract class Payment
{
public function __construct(private float $amount) {}
abstract public function refund(): string;
}
final class CardPayment extends Payment
{
public function refund(): string
{
return sprintf('Card refund of %.2f queued with the acquirer', $this->amount);
}
}
final class WalletPayment extends Payment
{
public function refund(): string
{
return sprintf('%.2f returned to store credit', $this->amount);
}
}
function refund(Payment $p): string
{
return $p->refund(); // one call site — the object picks the implementation
}
$p->refund() is dynamic dispatch: at call time PHP looks at the object's actual class and runs that class's implementation, even though $p is typed as Payment. That is the whole mechanism — the base class declares the vocabulary (refund), each subclass supplies its meaning, and callers stay ignorant of the list. Adding CryptoPayment tomorrow touches only the new class file; refund() and every caller are untouched.
Two mechanics sit underneath. First, the override must be signature-compatible with the parent declaration — same or wider parameters, same or narrower return — and a child implementing an abstract method is not "overriding" so much as completing a contract the parent refused to finish. Miss one abstract method and the child cannot be instantiated at all: Cannot instantiate abstract class CryptoPayment. Second, dispatch is per-instance, never per-variable-type or per-class-name. There is no static binding of Payment::refund() to the base version for a CardPayment object; the only question PHP asks is "what concrete class is this?" — which is why final on leaf classes both documents intent and blocks a downstream override that would change refund behavior in production.
This example also shows polymorphism's honest scope: it replaces type-name branching with behavior substitution. If the branches do genuinely different things with different data, no hierarchy helps — you have three features, not three implementations of one contract. The contract view is what the next chapter's type hints make enforceable across a whole codebase.
Two dispatch subtleties are worth pinning down while the example is fresh. First, dispatch is decided by the object's class at runtime, but the compiler still type-checks calls against the declared type where one exists — calling a method only some subclasses have is a compile error when called on a Payment-typed variable, even if the actual object has it. The contract, not the object, is what call sites may assume. Second, overriding happens per method, not per hierarchy: if WholesaleOrder redefines refund() and ShippingOrder does not, each runs its own implementation with no coordination between them — which is precisely why the base declaration must be complete enough that any non-overriding child is still correct. Interviewers like the concrete phrasing: the abstract class owns the vocabulary, subclasses own the meanings, and the engine performs a per-instance lookup at call time.
The base-class mechanics this topic builds on — overriding, substitutability, the single-extends rule — come from Inheritance and the extends Keyword, while the class-level state deliberately kept out of dispatch belongs to Static Properties and Methods.
Type Hints That Accept the Whole Family
Polymorphism pays off only if call sites actually accept the family. A parameter typed as the base class or an interface is a standing invitation to every subtype:
function checkout(Payment $payment, LabelPrinter $printer): void
{
$printer->print($payment->refund()); // no instanceof anywhere
}
checkout(new CardPayment(19.99), new ThermalPrinter());
checkout(new WalletPayment(19.99), new ThermalPrinter());
The type hint is the contract's public half: "anything that can refund() may enter here". Inside, the code must only use methods the hint declares — calling refundCard() on a Payment $p would be a fatal error even when the runtime object happens to be a CardPayment, because the static type said Payment. That discipline is what keeps the function valid for every future subtype.
The return side completes the loop. A function may promise a narrower type than its parent (covariance), so a factory typed to return the base can hand back a concrete instance:
final class PaymentFactory
{
public static function fromRequest(array $body): Payment
{
return match ($body['method']) {
'card' => new CardPayment((float) $body['amount']),
'wallet' => new WalletPayment((float) $body['amount']),
default => throw new InvalidArgumentException('unsupported method'),
};
}
}
Callers get a Payment and can only do Payment things; the concrete choice is an implementation detail of the factory. This pattern — hint the abstraction, construct the concrete — is dependency injection's entry point, and it is why test suites can pass a stub implementing the same hint without touching production code.
instanceof is not banned; it is demoted. Legitimate uses check for an optional capability at a boundary — if ($x instanceof JsonSerializable) before handing to a heterogeneous queue, or logging an unexpected runtime type in a catch-all. The smell is a chain of instanceof ConcreteClass inside business logic, because each link is a manual dispatch table waiting to be forgotten. If the branches all share a name and a shape, they wanted polymorphism; if you truly need to ask "what are you?" ten times, the objects probably lack a common contract — and the answer is to design one, or to reach for an interface as described in Contracts with Interfaces, which lets unrelated classes join the family with no shared parent at all. One caveat to carry forward: substitutability is a two-way promise. The hints guarantee shape; they cannot guarantee that an override behaves sanely — that judgment is exactly what the next chapter puts under the microscope.
Return-type polymorphism deserves one more worked example because it composes with the parameter side. A handler that consumes Payment and returns JsonSerializable lets a pipeline of handlers chain without any of them naming another's concrete types — the family flows through the system in both directions. PHP checks these hints at compile time, and the errors are legible: 'must be of type Refundable, ArrayLogger given' names both the contract and the offender, which is why typed codebases fail earlier and cheaper than stringly-typed ones. The related discipline for teams: when a signature hints the concrete class and nothing in the body needs the concrete API, widening the hint to an interface is a near-zero-risk refactor — the compiler then finds every call site that genuinely depended on the extra methods.
Overriding That Breaks Substitutability: The Rules That Prevent It
Polymorphism rests on one promise: a subtype may be used wherever its supertype is expected, without the caller noticing. Violate it and the type hints keep passing while the program lies. The classic PHP specimen:
class ReadOnlyList
{
public function count(): int { return 10; }
public function get(int $i): string { return "item $i"; }
}
final class AppendableList extends ReadOnlyList
{
private array $items = [];
public function append(string $item): void { $this->items[] = $item; }
public function get(int $i): string
{
if ($i >= 10) {
throw new OutOfBoundsException('only 10 items are ever readable');
}
return parent::get($i);
}
}
Syntax is perfect — the signature matches, the return type is identical. Yet every function typed ReadOnlyList that iterates 0..count()-1 now throws when handed an AppendableList, because the child quietly added a precondition the parent never had. That is the Liskov Substitution Principle (LSP) in one bug: preconditions may not strengthen, postconditions may not weaken, and invariants must survive every override.
PHP's own rules handle the mechanical half of LSP. The engine rejects signature drift at compile time — narrowing a parameter, widening a return, adding a required argument — so the most common violations never reach production:
class Handler
{
public function handle(array $job): bool { return true; }
}
final class StrictHandler extends Handler
{
public function handle(array $job): bool
{
if (!isset($job['id'])) {
throw new InvalidArgumentException('job.id required'); // precondition the parent lacked
}
return (bool) $job['id'];
}
}
No fatal here — the violation is behavioral, and only review catches it. The practical tests before shipping an override: does the parent's documented input still work? Does it still return the promised kind of result (a covariant return is fine; a different meaning is not)? Did any exception type appear that callers of the parent never caught? If an override genuinely needs different input, the hierarchy is wrong — extract a sibling subtype under a shared abstract parent, or split into separate contracts rather than poisoning the shared one.
The other lever is final. Marking a method final stops any override, converting "please don't change this step" from a comment into a compile-time error; marking a leaf class final closes the family below it. Used together they bound the polymorphic surface: open where subtypes are expected (abstract methods, interfaces), closed where behavior must stay predictable. That open/closed shape — abstract classes and interface contracts declaring the extension points — is exactly what Abstract Classes and Contracts builds next.
One measurement makes LSP concrete for code reviewers: count the places a subclass is passed where the parent type is hinted. If the answer is 'nowhere, because callers check instanceof first', the hierarchy is decorative — subtypes that nobody substitutes, inheritance as file organization rather than design. Conversely, when new subtypes drop into existing call sites with zero edits — the CryptoPayment scenario from the first chapter — the hierarchy is doing its job. Prepare the interview version too: define LSP in one sentence (a subtype must honor every expectation the supertype established), give one violation (a strengthened precondition inside an override), and name the tooling that catches which half — variance checks for signatures, review and contract tests for behavior.
Quiz
1. Payment $p is typed as the abstract Payment, but $p->refund() runs CardPayment's implementation. What decides which method runs?
2. Which override of `public function handle(array $job): bool` is legal AND substitution-safe?
3. What is the Liskov Substitution Principle, in one operational sentence?
4. Where does a chain of `if ($p instanceof CardPayment) ... elseif ($p instanceof WalletPayment) ...` belong?
Flashcards
Question
What is dynamic dispatch, and what is it NOT based on?
Click to reveal answer
Answer
At call time PHP runs the implementation from the object's actual concrete class, so $p->refund() with $p typed Payment runs CardPayment's refund when the object is a CardPayment. It is based on the instance's class — not the variable's declared type, not load order, not class-name comparisons.
Question
How do parameter type hints create polymorphic call sites?
Click to reveal answer
Answer
A hint typed to the base class or interface accepts every subtype, so the function body may only use members the hint declares — calling a subtype-only method on the hinted variable is a fatal error even when the runtime object has it. Adding a new subtype requires no edit to the function.
Question
State the Liskov Substitution Principle and give a PHP example of breaking it with a legal signature
Click to reveal answer
Answer
A subtype must be usable wherever its supertype is expected: no stronger preconditions, no weaker postconditions, no new exceptions for the caller. Example: overriding get(int $i) to throw OutOfBoundsException for $i >= 10 when the parent accepted any int — signature identical, callers typed to the parent now break.
Question
What variance rules do PHP 8 overrides follow?
Click to reveal answer
Answer
Parameters contravariant — equal or wider types accepted (int|float where parent had int is fine, narrowing is a fatal). Returns covariant — equal or narrower (returning a subclass type is fine). Visibility may widen (protected to public), never narrow. final methods cannot be overridden.
Question
Implementing an abstract method vs overriding a concrete one — what differs?
Click to reveal answer
Answer
Implementing an abstract method completes a contract the parent declared but did not finish; skipping one makes the child non-instantiable ('Cannot instantiate abstract class'). Overriding replaces a working implementation, and the replacement must honor the parent's contract — including calling parent:: when base behavior should be extended, not discarded.
Revision Notes
Key Takeaways
- 1. Polymorphism replaces type-name branching with behavior substitution: one method name, one call site, per-object dispatch
- 2. Type hints on the abstraction are what make call sites family-wide; the body may only touch members the hint declares
- 3. Dynamic dispatch keys off the instance's concrete class — declared types and class-name checks play no part in it
- 4. LSP is the promise under the syntax: legal signatures can still break callers when an override strengthens preconditions
- 5. final converts 'do not override this' from a comment into a compile-time error, bounding the polymorphic surface where behavior must stay fixed
Interview Tips
- • "What is polymorphism in PHP?" — dynamic dispatch on the instance's class, so overriding changes behavior at unchanged call sites; tie it to the abstract-declares-vocabulary, subclass-supplies-meaning framing
- • "instanceof chain in review — what do you say?" — it is a manual dispatch table; give the types a shared contract and dispatch through one method, keeping instanceof for capability checks at boundaries
- • "How do you know an override is safe?" — signature variance handles the shape; then apply LSP: preconditions not stronger, postconditions not weaker, no new exception types — with a concrete breaking example
- • "abstract method vs interface method?" — an abstract method is one unfinished step inside a class that may also ship shared code; an interface is pure contract that any number of unrelated classes can satisfy, previewing the interface topic
Cheat Sheet
Polymorphism and Overriding
One call site, one name, many behaviors:
abstract class Payment
{
public function __construct(private float $amount) {}
abstract public function refund(): string; // the vocabulary
}
final class CardPayment extends Payment
{
public function refund(): string { /* acquirer refund */ }
}
function refund(Payment $p): string { return $p->refund(); } // dynamic dispatch
Dispatch is on the object's actual class at call time — never the variable's declared type.
Variance (compile-time fatal when broken):
| Position | Rule |
|---|---|
| Parameter | equal or wider (contravariant); optional additions OK |
| Return | equal or narrower (covariant) |
| Visibility | may widen, never narrow |
final method |
immune to overriding |
LSP — the behavioral half: no strengthened preconditions, no weakened postconditions, no surprise exception types. PHP cannot check this; ask before shipping an override: does the parent's input still work, does the result keep its meaning, can callers still catch what they were told to catch?
instanceof policy: banned as a business-logic dispatch table; fine as an optional-capability check at boundaries (if ($x instanceof JsonSerializable)). An instanceof chain on concrete classes means a shared contract is missing.