Polymorphism means different objects can answer the same method call in their own way, so your code can treat them all alike.
🎵A conductor says "play" to the whole orchestra. The violin, the trumpet and the drum each respond differently, yet the conductor gives one instruction and never needs a separate speech for each instrument.
The trick behind Laravel's swappable drivers
Fewer if/else chains
Instead of checking "what type is this?" everywhere, you call one method and let each object handle it.
Add without editing
New behaviour = a new class. Existing, tested code doesn't change. This is the open/closed principle.
Swap with one setting
File cache today, Redis tomorrow. Laravel switches drivers through config because they all share one interface.
One call, many shapes
Checkout only knows the interface. Which pay() actually runs depends on the object it was given, decided at runtime.From type-checking to polymorphism
-
Start with the problem: type-checking chains
Without polymorphism, code ends up asking "which kind is this?" and branching. Every new payment method means editing this function, and similar
matchblocks spread across the codebase.function pay(string $method, int $cents): string { return match ($method) { 'stripe' => "Charged {$cents}¢ via Stripe", 'paypal' => "Charged {$cents}¢ via PayPal", default => throw new InvalidArgumentException("Unknown method {$method}"), }; }matchis great for mapping simple values. But when each branch is real behaviour that keeps growing, it's a sign you want polymorphism. -
Define the shared contract
Pull the common action into an interface (from Abstraction & Interfaces). Each way of paying becomes its own class.
<?php interface PaymentGateway { public function pay(int $cents): string; } final class StripeGateway implements PaymentGateway { public function pay(int $cents): string { return "Charged {$cents}¢ via Stripe"; } } final class PayPalGateway implements PaymentGateway { public function pay(int $cents): string { return "Charged {$cents}¢ via PayPal"; } } -
Write code that works with any implementation
Type-hint the interface.
Checkoutcallspay()and PHP runs the right version for whichever object it holds. That's polymorphism: one call, many forms.final class Checkout { public function __construct(private PaymentGateway $gateway) {} public function complete(int $cents): string { return $this->gateway->pay($cents); // no if, no match } } echo (new Checkout(new StripeGateway()))->complete(1999); // ...via Stripe echo (new Checkout(new PayPalGateway()))->complete(1999); // ...via PayPal -
Add a new behaviour without touching old code
Need a fake gateway for tests that never charges anyone? Write one class.
Checkoutdoesn't change at all.final class FakeGateway implements PaymentGateway { public array $charges = []; public function pay(int $cents): string { $this->charges[] = $cents; return 'Pretended to charge'; } } $fake = new FakeGateway(); (new Checkout($fake))->complete(500); var_dump($fake->charges); // [500]ℹ️ Polymorphism through inheritance, tooIt works with parent classes as well. A function typed
Content $itemaccepts aPostor aPage, and$item->url()runs each child's override (see Inheritance). -
Treat a whole list the same way
Polymorphism shines with collections. Loop over mixed objects and call the same method on each.
/** @param list<Notifier> $channels */ function broadcast(array $channels, string $message): void { foreach ($channels as $channel) { $channel->send('team', $message); // email, SMS, Slack... each in its own way } } broadcast([new EmailNotifier(), new SmsNotifier()], 'Deploy finished ✅');The
/** @param ... */line is a docblock. PHP can't type the contents of an array, so this comment tells your editor what's inside. -
Enums can be polymorphic too
Enums can implement interfaces. Each case behaves differently through the same method, which is handy for small, fixed sets.
interface HasPrice { public function monthlyCents(): int; } enum Plan: string implements HasPrice { case Free = 'free'; case Pro = 'pro'; public function monthlyCents(): int { return match ($this) { Plan::Free => 0, Plan::Pro => 1200, }; } } function invoiceTotal(HasPrice $item): int { return $item->monthlyCents(); } echo invoiceTotal(Plan::Pro); // 1200
Avoid this, do that
if ($gateway instanceof StripeGateway) {
$gateway->chargeStripe($cents);
} elseif ($gateway instanceof PayPalGateway) {
$gateway->sendPayPal($cents);
}
Checking the class and calling differently named methods defeats the whole point.
$gateway->pay($cents);
Give every implementation the same method from a shared interface, then just call it.
class FakeGateway implements PaymentGateway
{
public function pay(int $cents): string
{
throw new Exception('Not supported');
}
}
An implementation that breaks the promise surprises every caller that trusted the interface.
public function pay(int $cents): string
{
$this->charges[] = $cents;
return 'Pretended to charge';
}
Every implementation should honour the contract, so callers never need special cases.
instanceofSeeing the same instanceof or match ($type) check in several places? That's a hint to move the behaviour into the classes themselves.
- Create an interface
Discountwithapply(int $cents): int. - Implement
PercentOff(e.g. 10%),FixedOff(e.g. 500 cents) andNoDiscount. - Write a
Cartclass that receives aDiscountin its constructor and has atotal(array $prices): intmethod. - Loop over an array of all three discounts and print the cart total for each, without a single
if. - Bonus: add a
BuyOneGetOnediscount and confirmCartneeded no changes.
- Drivers: cache (file, database, Redis), queue, mail and filesystem drivers all implement shared contracts. Change
CACHE_STORE=redisin.envand your code keeps callingCache::get()(see Caching). - The Service Container lets you bind an interface to a concrete class, so
PaymentGatewayresolves to Stripe in production and a fake in tests (see The Service Container). - Notification channels: one notification, delivered through
mail,databaseorbroadcastchannels. - Testing fakes:
Mail::fake()andQueue::fake()swap in fake implementations (see Feature Tests). - Polymorphic relationships: a
Commentcan belong to aPostor aVideo(see Relationships).
Key takeaways
- Polymorphism = one method call, different behaviour depending on the object.
- Type-hint an interface (or parent class) and accept any implementation.
- New behaviour means a new class, not edits to working code.
- Repeated
instanceoformatch ($type)checks are a sign to use polymorphism. - Every implementation must honour the contract it signs.
- Laravel's swappable drivers and test fakes are polymorphism in action.