1. Hook & Problem Statement
You've seen it. You've written it. We all have.
public function processPayment(Order $order)
{
if ($order->payment_method === 'stripe') {
// 50 lines of Stripe-specific code
} elseif ($order->payment_method === 'paypal') {
// 50 lines of PayPal-specific code
} elseif ($order->payment_method === 'bank_transfer') {
// 50 lines of bank transfer code
} elseif ($order->payment_method === 'crypto') {
// 50 lines of cryptocurrency code
} else {
throw new Exception('Unknown payment method');
}
}
It starts small. Two payment methods. Then three. Then five. Before you know it, your method is 500 lines long. Adding a new payment method means touching this file, understanding all the existing logic, and hoping you don't break anything.
The pain compounds:
- Readability is dead: Nobody can understand this method.
- Testing is a nightmare: You need to test every branch.
- Changing one method affects all: A bug in Stripe logic requires redeploying the entire controller.
- Open/Closed Principle violation: To add a new payment method, you must modify existing code.
This is the Conditional Apocalypse. And it's all because developers don't understand one of the fundamental pillars of object-oriented programming: Polymorphism.
2. Why This Pattern/Concept Exists
The Software Engineering Problem It Solves
Polymorphism solves the problem of variability in behavior. When you have multiple implementations of the same concept (e.g., payment processing, notification sending, file storage), you need a way to treat them uniformly while allowing each to behave differently.
The Pain That Existed Before
Before polymorphism (or in codebases that don't use it), developers used:
-
Massive Conditionals:
if-elseif-elseblocks that grew endlessly. - Switch Statements: The same problem, different syntax.
-
Case-Based Logic: "Type checking" with
instanceofor string comparisons.
These approaches had severe problems:
- Duplication: Similar logic was copy-pasted across conditional branches.
- Fragility: Adding a new type meant modifying many files.
- Single Responsibility Violation: A single class handled many variations.
- Testing Complexity: Every conditional branch needed testing.
Why Large Applications Need It
As applications grow, the number of variations increases. A simple sendNotification() method might need to handle email, SMS, push notifications, Slack, webhooks, and more.
With polymorphism:
- New variations are additive, not disruptive.
- Each variation is isolated, reducing bugs.
- Testing is focused, not combinatorial.
- Changes are localized, not global.
The core insight: Instead of asking "what type is this?" and then deciding what to do, you simply tell the object to do it. The object knows its own type and behaves accordingly.
3. Real World Analogy
The Universal Remote Control
Imagine a universal remote that controls different devices: TV, sound system, streaming box, DVD player.
The Non-Polymorphic Remote:
- It has a giant button panel with different buttons for each device.
- To turn on the TV, you press "Power" but you have to remember which mode you're in.
- To change volume, you press different buttons depending on the device.
- Adding a new device requires redesigning the entire remote.
The Polymorphic Remote:
- It has simple, universal buttons: Power, Volume Up, Volume Down, Play, Pause, Stop.
- You point it at a device and press "Power." The device turns on.
- You press "Volume Up." The device adjusts its volume.
- Adding a new device (a projector) doesn't require changing the remote—just making a "Projector" that understands the same button presses.
Analogy Mapping:
-
Remote Control: The calling code (e.g.,
PaymentProcessor). -
Universal Buttons: The interface methods (
pay(),refund()). -
Devices: The concrete implementations (
StripePayment,PayPalPayment). - Pointing at the Device: Dependency injection (giving the remote the device).
- Adding a new device: Creating a new class that implements the interface.
Why it works: The remote doesn't need to know about different device types. It just sends the same signal, and the device responds appropriately. This is polymorphism in action.
4. The Pain (Bad Design)
Let's look at a classic example of conditional hell in a Laravel application.
namespace App\Services;
use App\Models\Order;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class PaymentService
{
public function processPayment(Order $order): array
{
$result = ['status' => 'failed', 'message' => ''];
// Check payment method
if ($order->payment_method === 'stripe') {
// Stripe-specific setup
$stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
try {
$intent = $stripe->paymentIntents->create([
'amount' => $order->total * 100,
'currency' => $order->currency,
'payment_method' => $order->payment_method_id,
'confirmation_method' => 'manual',
'confirm' => true,
]);
if ($intent->status === 'requires_action') {
$result['status'] = 'requires_action';
$result['client_secret'] = $intent->client_secret;
} elseif ($intent->status === 'succeeded') {
$result['status'] = 'succeeded';
$order->status = 'paid';
$order->payment_intent_id = $intent->id;
$order->save();
// Send confirmation email
Mail::to($order->user->email)->send(new OrderConfirmation($order));
Log::info('Payment succeeded', ['order' => $order->id]);
} else {
$result['message'] = 'Payment failed: ' . $intent->status;
}
} catch (\Exception $e) {
$result['message'] = 'Stripe error: ' . $e->getMessage();
Log::error('Stripe payment failed', ['error' => $e->getMessage()]);
}
} elseif ($order->payment_method === 'paypal') {
// PayPal-specific setup
$clientId = config('services.paypal.client_id');
$clientSecret = config('services.paypal.secret');
$apiContext = new \PayPal\Rest\ApiContext(
new \PayPal\Auth\OAuthTokenCredential($clientId, $clientSecret)
);
try {
$payment = new \PayPal\Api\Payment();
$payment->setIntent('sale')
->setPayer(['payment_method' => 'paypal'])
->setTransactions([[
'amount' => [
'total' => $order->total,
'currency' => $order->currency,
],
'description' => "Order #{$order->id}",
]])
->setRedirectUrls([
'return_url' => route('paypal.success', ['order' => $order->id]),
'cancel_url' => route('paypal.cancel', ['order' => $order->id]),
]);
$payment->create($apiContext);
// Get approval URL
foreach ($payment->getLinks() as $link) {
if ($link->getRel() === 'approval_url') {
$result['status'] = 'redirect';
$result['url'] = $link->getHref();
break;
}
}
$order->payment_intent_id = $payment->getId();
$order->save();
} catch (\Exception $e) {
$result['message'] = 'PayPal error: ' . $e->getMessage();
Log::error('PayPal payment failed', ['error' => $e->getMessage()]);
}
} elseif ($order->payment_method === 'bank_transfer') {
// Bank transfer specific logic
$reference = 'ORD-' . $order->id . '-' . time();
$result['status'] = 'pending';
$result['reference'] = $reference;
$result['instructions'] = [
'bank_name' => 'Example Bank',
'account_name' => 'Company Name',
'account_number' => '12345678',
'reference' => $reference,
'amount' => $order->total,
];
$order->payment_reference = $reference;
$order->status = 'pending_payment';
$order->save();
// Send instructions email
Mail::to($order->user->email)->send(new BankTransferInstructions($order));
} elseif ($order->payment_method === 'crypto') {
// Crypto-specific logic
$cryptoService = new CryptoPaymentService();
$address = $cryptoService->generateAddress($order->id);
$result['status'] = 'pending';
$result['address'] = $address;
$result['amount'] = $order->total / $cryptoService->getCurrentRate();
$order->crypto_address = $address;
$order->status = 'pending_payment';
$order->save();
} else {
throw new \Exception('Unsupported payment method: ' . $order->payment_method);
}
return $result;
}
}
Why This Is Terrible
- 500+ Lines: The method is unreadable and untestable.
- Violates SRP: The class handles Stripe, PayPal, bank transfers, AND crypto.
-
Violates OCP: Adding a new method requires modifying
PaymentService. - Violates DIP: The class depends on concrete implementations (StripeClient, PayPal API).
- Difficult Testing: Testing Stripe requires mocking PayPal, bank transfer, and crypto logic.
- Duplication: Similar concerns (logging, email sending) are repeated.
- Cognitive Overhead: Understanding the entire method is nearly impossible.
Why Developers Write Code Like This
We write code like this because:
- It's the path of least resistance.
- We're not thinking about extensibility.
- We think "just one more method" won't hurt.
- We haven't internalized the power of polymorphism.
- We don't design for the future.
5. Solution Overview
Polymorphism (from Greek: "many forms") is the ability of objects of different types to respond to the same interface (method) in different ways.
Core Idea
Instead of conditionally checking what type of object you have and then performing different logic, you define a common interface, implement it in multiple classes, and let the object itself decide how to behave.
Main Participants
- The Interface (Contract): Defines the methods that all variations must implement.
- The Concrete Implementations: Each class implements the interface in its own way.
- The Client: The code that uses the interface, not the concrete implementations.
- The Factory/Resolver: Determines which implementation to use (often via the Service Container).
How Objects Collaborate
Instead of:
if ($type === 'stripe') {
// Stripe logic
} elseif ($type === 'paypal') {
// PayPal logic
}
You write:
$paymentProcessor = app(PaymentProcessorInterface::class);
$result = $paymentProcessor->process($order);
The PaymentProcessor is resolved based on the order's payment method. The client code doesn't know which implementation it's using.
Mental Model
Think of polymorphism as a shape-shifter.
- Without Polymorphism: You have to ask "what are you?" before deciding how to interact.
- With Polymorphism: You don't ask. You just interact. The object responds appropriately based on its true nature.
You don't ask a dog "are you a cat?" before telling it to bark. You just tell it to bark. If it's a dog, it barks. If it's a cat, it meows. The speak() method is polymorphic.
Benefits
- Extensibility: Add new variations without changing existing code.
- Isolation: Each variation is in its own class.
- Testability: Test each implementation in isolation.
- Readability: The client code is clean and focused.
- Single Responsibility: Each class handles one variation.
Trade-offs
- More Classes: You'll create many small classes instead of one large one.
- Indirection: It's harder to see "all the code" in one place.
- Learning Curve: Developers need to understand interface-based design.
6. UML Diagram
Laravel Service Container Architecture
Diagram Explanation
- PaymentProcessorInterface defines the contract.
- Concrete implementations each handle a specific payment method.
- PaymentService depends on the interface, not the concrete classes.
- PaymentProcessorFactory determines which implementation to use.
- The client code calls
PaymentService, which delegates to the appropriate processor.
7. Vanilla PHP Example
Let's refactor the payment system using polymorphism.
Before Refactoring (Massive Conditionals)
// The terrible 500-line PaymentService (shown above)
After Refactoring (Polymorphism)
Step 1: Define the Interface
interface PaymentProcessorInterface
{
public function process(Order $order): array;
public function refund(Order $order, float $amount): array;
public function cancel(Order $order): bool;
public function supports(Order $order): bool;
}
Step 2: Implement Each Variation
// StripePaymentProcessor.php
class StripePaymentProcessor implements PaymentProcessorInterface
{
private \Stripe\StripeClient $stripe;
public function __construct()
{
$this->stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
}
public function process(Order $order): array
{
try {
$intent = $this->stripe->paymentIntents->create([
'amount' => $order->total * 100,
'currency' => $order->currency,
'payment_method' => $order->payment_method_id,
'confirmation_method' => 'manual',
'confirm' => true,
]);
$result = ['status' => 'failed'];
if ($intent->status === 'requires_action') {
$result = [
'status' => 'requires_action',
'client_secret' => $intent->client_secret,
];
} elseif ($intent->status === 'succeeded') {
$result = [
'status' => 'succeeded',
'payment_intent_id' => $intent->id,
];
}
return $result;
} catch (\Exception $e) {
throw new \Exception('Stripe error: ' . $e->getMessage());
}
}
public function refund(Order $order, float $amount): array
{
$refund = $this->stripe->refunds->create([
'payment_intent' => $order->payment_intent_id,
'amount' => (int)($amount * 100),
]);
return ['status' => $refund->status, 'refund_id' => $refund->id];
}
public function cancel(Order $order): bool
{
if (!$order->payment_intent_id) {
return false;
}
$intent = $this->stripe->paymentIntents->cancel($order->payment_intent_id);
return $intent->status === 'canceled';
}
public function supports(Order $order): bool
{
return $order->payment_method === 'stripe';
}
}
// PayPalPaymentProcessor.php
class PayPalPaymentProcessor implements PaymentProcessorInterface
{
private \PayPal\Rest\ApiContext $apiContext;
public function __construct()
{
$clientId = config('services.paypal.client_id');
$clientSecret = config('services.paypal.secret');
$this->apiContext = new \PayPal\Rest\ApiContext(
new \PayPal\Auth\OAuthTokenCredential($clientId, $clientSecret)
);
}
public function process(Order $order): array
{
try {
$payment = new \PayPal\Api\Payment();
$payment->setIntent('sale')
->setPayer(['payment_method' => 'paypal'])
->setTransactions([[
'amount' => [
'total' => $order->total,
'currency' => $order->currency,
],
'description' => "Order #{$order->id}",
]])
->setRedirectUrls([
'return_url' => route('paypal.success', ['order' => $order->id]),
'cancel_url' => route('paypal.cancel', ['order' => $order->id]),
]);
$payment->create($this->apiContext);
$result = ['status' => 'failed'];
foreach ($payment->getLinks() as $link) {
if ($link->getRel() === 'approval_url') {
$result = [
'status' => 'redirect',
'url' => $link->getHref(),
'payment_intent_id' => $payment->getId(),
];
break;
}
}
return $result;
} catch (\Exception $e) {
throw new \Exception('PayPal error: ' . $e->getMessage());
}
}
public function refund(Order $order, float $amount): array
{
// PayPal refund logic
$refund = new \PayPal\Api\Refund();
$refund->setAmount([
'total' => $amount,
'currency' => $order->currency,
]);
// Execute refund
$sale = new \PayPal\Api\Sale();
$sale->setId($order->payment_intent_id);
$refunded = $sale->refund($refund, $this->apiContext);
return ['status' => $refunded->getState(), 'refund_id' => $refunded->getId()];
}
public function cancel(Order $order): bool
{
// PayPal cancellation via voiding authorization
return true; // Simplified
}
public function supports(Order $order): bool
{
return $order->payment_method === 'paypal';
}
}
// BankTransferProcessor.php
class BankTransferProcessor implements PaymentProcessorInterface
{
public function process(Order $order): array
{
$reference = 'ORD-' . $order->id . '-' . time();
return [
'status' => 'pending',
'reference' => $reference,
'instructions' => [
'bank_name' => 'Example Bank',
'account_name' => 'Company Name',
'account_number' => '12345678',
'reference' => $reference,
'amount' => $order->total,
],
];
}
public function refund(Order $order, float $amount): array
{
// Bank transfers are usually manual refunds
return ['status' => 'manual_refund_required'];
}
public function cancel(Order $order): bool
{
return true; // Bank transfers can always be cancelled
}
public function supports(Order $order): bool
{
return $order->payment_method === 'bank_transfer';
}
}
// CryptoPaymentProcessor.php
class CryptoPaymentProcessor implements PaymentProcessorInterface
{
private CryptoService $cryptoService;
public function __construct(CryptoService $cryptoService)
{
$this->cryptoService = $cryptoService;
}
public function process(Order $order): array
{
$address = $this->cryptoService->generateAddress($order->id);
$rate = $this->cryptoService->getCurrentRate();
return [
'status' => 'pending',
'address' => $address,
'amount' => $order->total / $rate,
'currency' => 'BTC',
];
}
public function refund(Order $order, float $amount): array
{
// Crypto refund via address lookup
return ['status' => 'manual_refund_required'];
}
public function cancel(Order $order): bool
{
return true;
}
public function supports(Order $order): bool
{
return $order->payment_method === 'crypto';
}
}
Step 3: The Factory (Resolver)
class PaymentProcessorFactory
{
private array $processors;
public function __construct(array $processors)
{
$this->processors = $processors;
}
public function make(Order $order): PaymentProcessorInterface
{
foreach ($this->processors as $processor) {
if ($processor->supports($order)) {
return $processor;
}
}
throw new \Exception('No payment processor found for order');
}
}
// Or with dependency injection in Laravel:
class PaymentProcessorResolver
{
private array $processors;
public function registerProcessor(PaymentProcessorInterface $processor): void
{
$this->processors[] = $processor;
}
public function resolve(Order $order): PaymentProcessorInterface
{
foreach ($this->processors as $processor) {
if ($processor->supports($order)) {
return $processor;
}
}
throw new \Exception("Unsupported payment method: {$order->payment_method}");
}
}
Step 4: The Client Code
class PaymentService
{
private PaymentProcessorResolver $resolver;
private MailService $mailService;
private LoggerInterface $logger;
public function __construct(
PaymentProcessorResolver $resolver,
MailService $mailService,
LoggerInterface $logger
) {
$this->resolver = $resolver;
$this->mailService = $mailService;
$this->logger = $logger;
}
public function processPayment(Order $order): array
{
$processor = $this->resolver->resolve($order);
try {
$result = $processor->process($order);
// Handle successful payment logic
if ($result['status'] === 'succeeded') {
$order->status = 'paid';
$order->payment_intent_id = $result['payment_intent_id'] ?? null;
$order->save();
$this->mailService->sendConfirmation($order);
$this->logger->info('Payment succeeded', ['order' => $order->id]);
} elseif ($result['status'] === 'requires_action') {
$order->status = 'pending_action';
$order->save();
} elseif ($result['status'] === 'pending') {
$order->status = 'pending_payment';
$order->save();
$this->mailService->sendInstructions($order, $result);
} elseif ($result['status'] === 'redirect') {
$order->payment_intent_id = $result['payment_intent_id'] ?? null;
$order->save();
}
return $result;
} catch (\Exception $e) {
$this->logger->error('Payment processing failed', [
'order' => $order->id,
'error' => $e->getMessage(),
]);
return ['status' => 'failed', 'message' => $e->getMessage()];
}
}
}
Step 5: Service Provider (Laravel-Specific)
namespace App\Providers;
use App\Services\Payment\PaymentProcessorResolver;
use App\Services\Payment\PaymentProcessorInterface;
use App\Services\Payment\StripePaymentProcessor;
use App\Services\Payment\PayPalPaymentProcessor;
use App\Services\Payment\BankTransferProcessor;
use App\Services\Payment\CryptoPaymentProcessor;
use Illuminate\Support\ServiceProvider;
class PaymentServiceProvider extends ServiceProvider
{
public function register(): void
{
// Register all processors
$this->app->bind(StripePaymentProcessor::class);
$this->app->bind(PayPalPaymentProcessor::class);
$this->app->bind(BankTransferProcessor::class);
$this->app->bind(CryptoPaymentProcessor::class);
// Register the resolver
$this->app->singleton(PaymentProcessorResolver::class, function ($app) {
$resolver = new PaymentProcessorResolver();
// Register processors (order matters for priority)
$resolver->registerProcessor($app->make(StripePaymentProcessor::class));
$resolver->registerProcessor($app->make(PayPalPaymentProcessor::class));
$resolver->registerProcessor($app->make(BankTransferProcessor::class));
$resolver->registerProcessor($app->make(CryptoPaymentProcessor::class));
return $resolver;
});
}
}
What We Improved
-
Zero Conditionals: The client code has no
if-elseifblocks. - Open/Closed: Add a new processor by creating a class, no modifications needed.
- Single Responsibility: Each processor handles one payment method.
- Testability: Test StripeProcessor without PayPal logic.
- Readability: The client code is clean and focused.
-
Extensibility: New methods (e.g.,
getStatus()) can be added to the interface.
8. Laravel Internal Example
Laravel uses polymorphism everywhere. Let's look at some key examples.
Authentication Guards (Multiple Auth Methods)
Laravel's authentication system uses polymorphism to support different guard types (session, token, JWT, etc.).
// Illuminate\Contracts\Auth\Guard
interface Guard
{
public function check(): bool;
public function guest(): bool;
public function user(): ?Authenticatable;
public function id(): ?int;
public function validate(array $credentials = []): bool;
public function setUser(Authenticatable $user): static;
}
// SessionGuard (one implementation)
class SessionGuard implements Guard
{
// Session-based authentication
}
// TokenGuard (another implementation)
class TokenGuard implements Guard
{
// Token-based authentication (API)
}
// The AuthManager creates the right guard
class AuthManager
{
public function guard(?string $name = null)
{
$name = $name ?: $this->getDefaultDriver();
if (isset($this->guards[$name])) {
return $this->guards[$name];
}
return $this->guards[$name] = $this->resolve($name);
}
}
Why This Is Elegant:
- Your code uses
Auth::guard()->user()regardless of the guard type. - Adding a new guard (e.g., JWT) doesn't affect existing code.
- Each guard is isolated and testable.
Filesystem Drivers (Multiple Storage Services)
Laravel's Filesystem uses polymorphism to support local, S3, FTP, and other storage providers.
// Illuminate\Contracts\Filesystem\Filesystem
interface Filesystem
{
public function exists(string $path): bool;
public function get(string $path): string;
public function put(string $path, $contents, $options = []): bool;
public function delete(string|array $paths): bool;
// ... more methods
}
// LocalFilesystem (one implementation)
class LocalFilesystem implements Filesystem { /* ... */ }
// S3Filesystem (another implementation)
class S3Filesystem implements Filesystem { /* ... */ }
// Usage: Same interface, different implementations
Storage::disk('local')->put('file.txt', 'content');
Storage::disk('s3')->put('file.txt', 'content');
Why This Is Elegant:
- Your code uses
Storage::disk('s3')->put()regardless of the driver. - Adding a new driver (e.g., Google Cloud Storage) is just a new class.
- The interface ensures consistency.
Queue Drivers (Multiple Queue Systems)
Laravel's Queue system uses polymorphism to support database, Redis, SQS, and beanstalkd.
// Illuminate\Contracts\Queue\Queue
interface Queue
{
public function push($job, $data = '', $queue = null);
public function later($delay, $job, $data = '', $queue = null);
public function pop($queue = null);
}
// DatabaseQueue
class DatabaseQueue implements Queue { /* ... */ }
// RedisQueue
class RedisQueue implements Queue { /* ... */ }
// SqsQueue
class SqsQueue implements Queue { /* ... */ }
Why This Is Elegant:
-
Queue::push()works the same regardless of the driver. - Switching from Redis to SQS is a configuration change.
- Each driver handles its own specific logic.
Middleware Pipeline (Chain of Responsibility with Polymorphism)
The middleware pipeline is a perfect example of polymorphic behavior.
// Each middleware implements the same interface
interface Middleware
{
public function handle($request, Closure $next);
}
// Different middleware implementations
class Authenticate implements Middleware { /* ... */ }
class TrimStrings implements Middleware { /* ... */ }
class EncryptCookies implements Middleware { /* ... */ }
// The pipeline treats them uniformly
$response = (new Pipeline($container))
->send($request)
->through([Authenticate::class, TrimStrings::class])
->then(function ($request) {
return $controller->handle($request);
});
Why This Is Elegant:
- The pipeline doesn't care what each middleware does.
- Adding a new middleware is just adding a new class.
- Each middleware is independent.
Notifications System (Multiple Channels)
Laravel's notifications use polymorphism to support different channels.
class OrderShipped extends Notification
{
public function via($notifiable): array
{
return ['mail', 'database', 'broadcast', 'slack'];
}
public function toMail($notifiable): MailMessage
{
// Returns a mail message
}
public function toDatabase($notifiable): array
{
// Returns database data
}
public function toSlack($notifiable): SlackMessage
{
// Returns a Slack message
}
}
Why This Is Elegant:
- You define channel-specific logic in separate methods.
- The notification system decides which methods to call.
- Adding a new channel doesn't affect existing notifications.
9. Real Laravel Application Example
Let's build a Document Export System that handles different export formats (PDF, Excel, CSV, XML) using polymorphism.
Scenario
Your application needs to export reports, invoices, and user data in multiple formats. Each format has different rendering requirements, file structures, and constraints.
Implementation
Step 1: Define the Interface
// app/Contracts/Export/ExportInterface.php
namespace App\Contracts\Export;
use App\Models\Exportable;
interface ExportInterface
{
public function export(Exportable $data, array $options = []): ExportResult;
public function getMimeType(): string;
public function getFileExtension(): string;
public function supports(string $format): bool;
public function getChunkSize(): int;
}
Step 2: Create a Result DTO
// app/DTO/ExportResult.php
namespace App\DTO;
class ExportResult
{
public function __construct(
public readonly string $content,
public readonly string $filename,
public readonly string $mimeType,
public readonly int $size,
public readonly ?string $tempPath = null,
public readonly array $metadata = []
) {}
}
Step 3: Implement Each Format
// app/Services/Export/PdfExporter.php
namespace App\Services\Export;
use App\Contracts\Export\ExportInterface;
use App\DTO\ExportResult;
use App\Models\Exportable;
use Dompdf\Dompdf;
class PdfExporter implements ExportInterface
{
private Dompdf $dompdf;
public function __construct(Dompdf $dompdf)
{
$this->dompdf = $dompdf;
}
public function export(Exportable $data, array $options = []): ExportResult
{
// Generate HTML from the data
$html = $this->generateHtml($data, $options);
$this->dompdf->loadHtml($html);
$this->dompdf->setPaper('A4', 'portrait');
$this->dompdf->render();
$content = $this->dompdf->output();
return new ExportResult(
content: $content,
filename: $this->generateFilename($data) . '.pdf',
mimeType: $this->getMimeType(),
size: strlen($content),
metadata: ['pages' => $this->dompdf->getPaper()],
);
}
public function getMimeType(): string
{
return 'application/pdf';
}
public function getFileExtension(): string
{
return 'pdf';
}
public function supports(string $format): bool
{
return in_array($format, ['pdf', 'PDF', 'application/pdf']);
}
public function getChunkSize(): int
{
return 50; // Process 50 items at a time
}
private function generateHtml(Exportable $data, array $options): string
{
// Generate HTML for PDF rendering
$view = view($data->getExportView(), ['data' => $data, 'options' => $options]);
return $view->render();
}
private function generateFilename(Exportable $data): string
{
return 'export_' . $data->getExportId() . '_' . date('Ymd_His');
}
}
// app/Services/Export/ExcelExporter.php
namespace App\Services\Export;
use App\Contracts\Export\ExportInterface;
use App\DTO\ExportResult;
use App\Models\Exportable;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class ExcelExporter implements ExportInterface
{
public function export(Exportable $data, array $options = []): ExportResult
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
// Headers
$headers = $data->getExportHeaders();
$column = 'A';
foreach ($headers as $header) {
$sheet->setCellValue($column . '1', $header);
$column++;
}
// Data rows
$row = 2;
foreach ($data->getExportData() as $item) {
$column = 'A';
foreach ($headers as $key => $header) {
$value = $item[$key] ?? '';
$sheet->setCellValue($column . $row, $value);
$column++;
}
$row++;
}
// Auto-size columns
foreach (range('A', $column) as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
// Save to temp file
$tempPath = tempnam(sys_get_temp_dir(), 'export_');
$writer = new Xlsx($spreadsheet);
$writer->save($tempPath);
$content = file_get_contents($tempPath);
return new ExportResult(
content: $content,
filename: $this->generateFilename($data) . '.xlsx',
mimeType: $this->getMimeType(),
size: filesize($tempPath),
tempPath: $tempPath,
);
}
public function getMimeType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
public function getFileExtension(): string
{
return 'xlsx';
}
public function supports(string $format): bool
{
return in_array($format, ['excel', 'xlsx', 'xls']);
}
public function getChunkSize(): int
{
return 1000;
}
private function generateFilename(Exportable $data): string
{
return 'export_' . $data->getExportId() . '_' . date('Ymd_His');
}
}
// app/Services/Export/CsvExporter.php
namespace App\Services\Export;
use App\Contracts\Export\ExportInterface;
use App\DTO\ExportResult;
use App\Models\Exportable;
class CsvExporter implements ExportInterface
{
public function export(Exportable $data, array $options = []): ExportResult
{
$output = fopen('php://temp', 'r+');
// Write headers
$headers = $data->getExportHeaders();
fputcsv($output, $headers);
// Write data
foreach ($data->getExportData() as $item) {
$row = [];
foreach ($headers as $key => $header) {
$row[] = $item[$key] ?? '';
}
fputcsv($output, $row);
}
rewind($output);
$content = stream_get_contents($output);
fclose($output);
return new ExportResult(
content: $content,
filename: $this->generateFilename($data) . '.csv',
mimeType: $this->getMimeType(),
size: strlen($content),
metadata: ['rows' => count($data->getExportData())],
);
}
public function getMimeType(): string
{
return 'text/csv';
}
public function getFileExtension(): string
{
return 'csv';
}
public function supports(string $format): bool
{
return in_array($format, ['csv', 'CSV']);
}
public function getChunkSize(): int
{
return 10000;
}
private function generateFilename(Exportable $data): string
{
return 'export_' . $data->getExportId() . '_' . date('Ymd_His');
}
}
Step 4: The Export Service (Client Code)
// app/Services/ExportService.php
namespace App\Services;
use App\Contracts\Export\ExportInterface;
use App\DTO\ExportResult;
use App\Models\Exportable;
use App\Services\Export\ExportResolver;
use Illuminate\Support\Facades\Log;
class ExportService
{
private ExportResolver $resolver;
public function __construct(ExportResolver $resolver)
{
$this->resolver = $resolver;
}
public function export(Exportable $data, string $format, array $options = []): ExportResult
{
$exporter = $this->resolver->resolve($format);
try {
$result = $exporter->export($data, $options);
Log::info('Export completed', [
'format' => $format,
'filename' => $result->filename,
'size' => $result->size,
]);
return $result;
} catch (\Exception $e) {
Log::error('Export failed', [
'format' => $format,
'error' => $e->getMessage(),
]);
throw $e;
}
}
public function isSupported(string $format): bool
{
try {
$this->resolver->resolve($format);
return true;
} catch (\Exception $e) {
return false;
}
}
}
// app/Services/Export/ExportResolver.php
namespace App\Services\Export;
use App\Contracts\Export\ExportInterface;
class ExportResolver
{
private array $exporters;
public function __construct(array $exporters)
{
$this->exporters = $exporters;
}
public function resolve(string $format): ExportInterface
{
foreach ($this->exporters as $exporter) {
if ($exporter->supports($format)) {
return $exporter;
}
}
throw new \Exception("No exporter found for format: {$format}");
}
}
Step 5: Controller
// app/Http/Controllers/ExportController.php
namespace App\Http\Controllers;
use App\Models\Report;
use App\Services\ExportService;
use Illuminate\Http\Request;
class ExportController extends Controller
{
public function __construct(
private ExportService $exportService
) {}
public function export(Request $request, Report $report)
{
$format = $request->input('format', 'pdf');
if (!$this->exportService->isSupported($format)) {
return response()->json([
'error' => "Format '{$format}' is not supported",
'supported' => ['pdf', 'excel', 'csv'],
], 400);
}
try {
$result = $this->exportService->export(
$report,
$format,
$request->input('options', [])
);
// Clean up temp file if needed
if ($result->tempPath && file_exists($result->tempPath)) {
register_shutdown_function(fn() => unlink($result->tempPath));
}
return response($result->content)
->header('Content-Type', $result->mimeType)
->header('Content-Disposition', 'attachment; filename="' . $result->filename . '"')
->header('Content-Length', $result->size);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function supportedFormats()
{
return response()->json([
'formats' => ['pdf', 'excel', 'csv'],
]);
}
}
Step 6: Service Provider
// app/Providers/ExportServiceProvider.php
namespace App\Providers;
use App\Contracts\Export\ExportInterface;
use App\Services\Export\CsvExporter;
use App\Services\Export\ExcelExporter;
use App\Services\Export\ExportResolver;
use App\Services\Export\PdfExporter;
use App\Services\ExportService;
use Illuminate\Support\ServiceProvider;
class ExportServiceProvider extends ServiceProvider
{
public function register(): void
{
// Register exporters
$this->app->tag([
PdfExporter::class,
ExcelExporter::class,
CsvExporter::class,
], 'exporters');
$this->app->singleton(ExportResolver::class, function ($app) {
$exporters = $app->tagged('exporters');
return new ExportResolver($exporters);
});
$this->app->singleton(ExportService::class);
}
}
Why Polymorphism Wins Here
- Extensibility: Adding a new format (XML, JSON, HTML) requires only a new class.
- Isolation: Each exporter is independent. A bug in PDF doesn't affect Excel.
- Testability: Test each exporter separately with known data.
- Configuration: The resolver can prioritize exporters based on context.
-
Open/Closed: The
ExportServicenever changes when adding a new format.
10. SOLID Principles Mapping
L - Liskov Substitution Principle (LSP)
The Most Direct Mapping
LSP states that objects of a superclass should be replaceable with objects of its subclasses without affecting the program's correctness.
In our payment example:
-
StripePaymentProcessor,PayPalPaymentProcessor, etc. are all substitutable forPaymentProcessorInterface. - The
PaymentServiceworks correctly regardless of which processor is used. - Each processor fulfills the contract of the interface.
// LSP in action
function processPayment(PaymentProcessorInterface $processor, Order $order) {
return $processor->process($order); // Works with any processor
}
O - Open/Closed Principle (OCP)
OCP states that classes should be open for extension but closed for modification.
-
Open for extension: Add a new
CryptoPaymentProcessorclass. -
Closed for modification: The
PaymentServicenever changes.
This is the primary benefit of polymorphism. You can add new behavior without touching existing code.
S - Single Responsibility Principle (SRP)
Each payment processor has one responsibility: handling a specific payment method.
-
StripePaymentProcessor: Only Stripe logic. -
PayPalPaymentProcessor: Only PayPal logic. -
PaymentService: Only orchestrates the flow.
D - Dependency Inversion Principle (DIP)
High-level modules depend on abstractions, not concretions.
-
High-level:
PaymentServicedepends onPaymentProcessorInterface. -
Low-level:
StripePaymentProcessordepends on the same interface (via implementation).
I - Interface Segregation Principle (ISP)
The interface is focused and minimal. It only contains methods that all processors need.
interface PaymentProcessorInterface {
public function process(Order $order): array;
public function refund(Order $order, float $amount): array;
public function cancel(Order $order): bool;
public function supports(Order $order): bool;
}
If a processor doesn't support refunds, it can still implement the interface (perhaps returning an error). The interface is not bloated with methods irrelevant to some implementations.
11. Trade-offs
Benefits
- Extensibility: Add new behaviors without modifying existing code.
- Isolation: Changes to one implementation don't affect others.
- Testability: Test each implementation independently.
- Readability: Client code is clean and focused.
- Maintainability: Each variation is in its own file.
- Scalability: Teams can work on different implementations simultaneously.
Costs
- More Classes: Each variation needs its own class.
- Indirection: It's harder to see "all the code" in one place.
- Abstraction Overhead: You need to design interfaces carefully.
- Learning Curve: Developers must understand interface-based design.
- Over-Engineering Risk: Not every conditional needs polymorphism.
When Is Complexity Justified?
Use polymorphism when:
- The number of variations is likely to grow.
- Variations are independent and unrelated.
- You want to adhere to the Open/Closed Principle.
- You need to test variations in isolation.
- Different teams will work on different variations.
Avoid polymorphism when:
- The variations are trivial (e.g., a single-line difference).
- The number of variations is small and stable.
- The logic is tightly coupled across variations.
- Performance is critical (method dispatch overhead).
12. When NOT To Use It
3 Green Flags (USE POLYMORPHISM)
- Multiple Implementations: You have 3+ variations of the same concept.
- Likely to Grow: The number of variations will increase over time.
- Independent Logic: Each variation has significantly different logic.
3 Red Flags (AVOID POLYMORPHISM)
- Trivial Variations: The difference is a single configuration value.
// BAD: Over-engineered polymorphism
interface GreetingInterface { public function greet(): string; }
class EnglishGreeting implements GreetingInterface { public function greet(): string { return 'Hello'; } }
class SpanishGreeting implements GreetingInterface { public function greet(): string { return 'Hola'; } }
// GOOD: Simple config
$greeting = match($locale) {
'en' => 'Hello',
'es' => 'Hola',
default => 'Hello',
};
Tightly Coupled Variations: Variations share most logic with small differences. A strategy with config might be better.
Stable Requirements: The number of variations is known and fixed. The extra complexity isn't justified.
13. Common Mistakes
1. Creating Unnecessary Interfaces
// BAD: Interface with only one implementation
interface UserRepositoryInterface { public function find(int $id): User; }
class EloquentUserRepository implements UserRepositoryInterface { /* ... */ }
// If there's only one implementation, why have the interface?
// Wait until you have a second implementation.
Fix: Use the concrete class directly until you need the abstraction.
2. Interface Bloat (Violating ISP)
// BAD: Bloated interface
interface PaymentProcessorInterface
{
public function process(): array;
public function refund(): array;
public function cancel(): bool;
public function getTransactionHistory(): array;
public function getBalance(): float;
public function getStatus(): string;
public function retry(): bool;
public function void(): bool;
public function capture(): bool;
}
// Some processors can't implement all these methods
class BankTransferProcessor implements PaymentProcessorInterface
{
// Must implement getBalance() even though it doesn't apply
}
Fix: Split into smaller interfaces.
interface PaymentProcessorInterface { /* core */ }
interface RefundableInterface { public function refund(): array; }
interface BalanceCheckInterface { public function getBalance(): float; }
3. Type Checking Instead of Letting Polymorphism Work
// BAD: Type checking
function process($processor, Order $order)
{
if ($processor instanceof StripeProcessor) {
// Stripe-specific logic
} elseif ($processor instanceof PayPalProcessor) {
// PayPal-specific logic
}
}
// GOOD: Let polymorphism do its job
function process(PaymentProcessorInterface $processor, Order $order)
{
return $processor->process($order);
}
Fix: Trust the interface. Don't check types.
4. Hardcoding Dependencies
// BAD: Hardcoding
class PaymentController
{
public function process(Request $request, Order $order)
{
if ($order->payment_method === 'stripe') {
$processor = new StripePaymentProcessor();
} else {
$processor = new PayPalPaymentProcessor();
}
// ... use processor
}
}
Fix: Use dependency injection and the resolver pattern.
5. Over-Engineering Simple Logic
// BAD: Polymorphism for a simple switch
// (See "Trivial Variations" above)
Fix: Use a simple conditional or configuration.
14. Frequently Asked Interview Questions
Beginner/Intermediate
Q: What is polymorphism in object-oriented programming?
A: The ability of objects of different types to respond to the same interface in different ways.Q: What are the two types of polymorphism?
A: Compile-time polymorphism (method overloading, not supported in PHP) and runtime polymorphism (method overriding via interfaces and inheritance).Q: How does polymorphism differ from inheritance?
A: Inheritance is the "is-a" relationship. Polymorphism is the ability to treat objects of different types uniformly through a common interface.Q: What is an interface in PHP?
A: A contract that defines which methods a class must implement. Interfaces enable polymorphism by allowing different classes to implement the same methods.Q: How does Laravel use polymorphism?
A: In authentication guards, filesystem drivers, queue drivers, middleware, and notification channels—all use polymorphism to support multiple implementations.
Senior/Architect
Q: Explain the difference between ad-hoc polymorphism and parametric polymorphism. Which does PHP support?
A: Ad-hoc polymorphism (overloading) allows different behavior based on type. PHP supports this via interfaces and inheritance (runtime polymorphism). Parametric polymorphism (generics) allows types to vary. PHP 8+ supports generics through docblocks but not natively.Q: How do you decide between polymorphism and a simple conditional?
A: Use polymorphism when the number of variations is likely to grow, the logic is significantly different, and you want to adhere to OCP. Use conditionals for simple, stable variations.Q: How does the Strategy pattern relate to polymorphism?
A: The Strategy pattern is an implementation of polymorphism. It defines a family of algorithms (strategies), encapsulates each one, and makes them interchangeable.Q: How would you handle adding a new payment method in the described system?
A: Create a new class implementingPaymentProcessorInterface. Register it in the service provider. No other changes are needed. This demonstrates OCP in action.Q: What's the difference between polymorphism and the Factory pattern?
A: Polymorphism is the principle of different objects responding to the same interface. The Factory pattern is a creational pattern that creates objects. They often work together: a factory creates polymorphic objects.
15. Interactive Practice Challenge
The Requirement
You're building a Notification Delivery System for your SaaS application. Notifications need to be sent through multiple channels: email, SMS, push notifications, Slack, and webhook.
The current code is a mess of conditionals and duplicated logic. It's becoming unmaintainable.
The Code (POOR DESIGN)
namespace App\Services;
use App\Models\Notification;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class NotificationService
{
public function send(Notification $notification, User $user): array
{
$result = ['success' => false, 'channel' => 'none'];
// Determine which channels to use
$channels = $user->notification_preferences ?? ['email'];
foreach ($channels as $channel) {
if ($channel === 'email') {
try {
Mail::to($user->email)->send(new \App\Mail\NotificationMessage($notification));
$result['email'] = ['success' => true];
Log::info('Email sent', ['user' => $user->id, 'notification' => $notification->id]);
} catch (\Exception $e) {
$result['email'] = ['success' => false, 'error' => $e->getMessage()];
Log::error('Email failed', ['error' => $e->getMessage()]);
}
} elseif ($channel === 'sms') {
try {
$twilio = new \Twilio\Rest\Client(
config('services.twilio.sid'),
config('services.twilio.token')
);
$twilio->messages->create(
$user->phone,
[
'from' => config('services.twilio.from'),
'body' => $notification->content,
]
);
$result['sms'] = ['success' => true];
Log::info('SMS sent', ['user' => $user->id, 'notification' => $notification->id]);
} catch (\Exception $e) {
$result['sms'] = ['success' => false, 'error' => $e->getMessage()];
Log::error('SMS failed', ['error' => $e->getMessage()]);
}
} elseif ($channel === 'slack') {
try {
Http::post(config('services.slack.webhook_url'), [
'text' => $notification->content,
'channel' => $user->slack_channel,
]);
$result['slack'] = ['success' => true];
Log::info('Slack sent', ['user' => $user->id, 'notification' => $notification->id]);
} catch (\Exception $e) {
$result['slack'] = ['success' => false, 'error' => $e->getMessage()];
Log::error('Slack failed', ['error' => $e->getMessage()]);
}
} elseif ($channel === 'push') {
try {
// Push notification logic
$pushService = new \App\Services\PushService();
$pushService->send($user->device_token, $notification->title, $notification->content);
$result['push'] = ['success' => true];
} catch (\Exception $e) {
$result['push'] = ['success' => false, 'error' => $e->getMessage()];
}
} elseif ($channel === 'webhook') {
try {
Http::post($user->webhook_url, [
'event' => 'notification',
'data' => [
'id' => $notification->id,
'title' => $notification->title,
'content' => $notification->content,
'user_id' => $user->id,
],
]);
$result['webhook'] = ['success' => true];
} catch (\Exception $e) {
$result['webhook'] = ['success' => false, 'error' => $e->getMessage()];
}
} else {
Log::warning('Unknown channel: ' . $channel);
}
}
// Check if any channel succeeded
foreach ($result as $key => $value) {
if ($key !== 'success' && $key !== 'channel' && ($value['success'] ?? false)) {
$result['success'] = true;
$result['channel'] = $key;
break;
}
}
return $result;
}
}
The Challenges
New requirements are coming fast:
- "We need to add Telegram channel."
- "We need to add Microsoft Teams channel."
- "We need to add WebSocket push notifications."
- "We need to retry failed notifications with exponential backoff."
- "We need to log all notification attempts to a database table."
- "We need to add rate limiting per channel."
The current code is already a nightmare. Adding more channels will make it impossible to maintain.
Your Task
Refactor this system using polymorphism. Specifically:
-
Define a
NotificationChannelInterfacewith methods likesend(),supports(),getRetryDelay(), etc. - Create channel implementations for each notification type (Email, SMS, Slack, Push, Webhook, Telegram, Teams).
-
Build a
ChannelResolverthat resolves the right channel based on the user's preference. -
Implement a
NotificationServicethat uses the resolver and handles the flow. - Add retry logic with exponential backoff.
- Implement logging and tracking without duplicating logic.
Questions to Consider
- What should the
NotificationChannelInterfacelook like? - How should you handle channel preferences?
- How do you add retry logic without duplicating it in every channel?
- How do you track notification status and history?
- How do you add rate limiting per channel?
- How do you test each channel independently?
(We won't provide the solution—refactor this code and experience the power of polymorphism!)
16. Final Mental Model
To keep it simple, memorize these three sentences:
- One-sentence definition: Polymorphism is the ability of objects of different types to respond to the same interface in their own unique way.
- One-sentence intuition: You don't need to ask "what are you?" before interacting. Just interact, and the object will respond appropriately based on its true nature.
-
One-sentence decision rule: If you find yourself writing
if-elseif-elsebased on type, that's polymorphism screaming to be used. Extract each branch into its own class.
17. Related Concepts
SOLID Principles
- Liskov Substitution: Polymorphism is the practical implementation of LSP.
- Open/Closed: Polymorphism enables extension without modification.
- Dependency Inversion: High-level modules depend on abstractions (interfaces), which polymorphism provides.
Design Patterns
- Strategy Pattern: Encapsulates interchangeable algorithms via polymorphism.
- Factory Pattern: Creates polymorphic objects.
- Command Pattern: Encapsulates requests as objects via polymorphism.
- Decorator Pattern: Wraps objects with polymorphic behavior.
- Chain of Responsibility: Uses polymorphism to handle requests through a chain.
Laravel Internals
- Authentication Guards: Different guard implementations via polymorphism.
- Filesystem Drivers: Different storage providers via polymorphism.
- Queue Drivers: Different queue systems via polymorphism.
- Notification Channels: Different delivery methods via polymorphism.
- Middleware: Different processing steps via polymorphism.
Enterprise Patterns
- Strategy: Business logic variations via polymorphism.
- Factory Method: Creation of polymorphic objects.
- Abstract Factory: Families of polymorphic objects.
- Chain of Responsibility: Processing chains via polymorphism.
- Composite: Tree structures with polymorphic behavior.
Final Thoughts
Polymorphism is the most powerful tool in your OOP toolbox for managing variation. It's the antidote to the Conditional Apocalypse.
Every time you write if ($type === 'something'), you're making a decision that could be handled by polymorphism. Every time you add a new branch to an existing conditional, you're violating the Open/Closed Principle.
The next time you find yourself writing a long if-elseif block, stop. Ask yourself: "Could each branch be its own class?"
Remember: Polymorphism isn't just about replacing conditionals. It's about designing systems that are open for extension and closed for modification. It's about making your code future-proof.

Top comments (0)