Advanced Laravel

Design Patterns: Strategy & Factory

18 min Lesson 23 of 40

Understanding Design Patterns

Design patterns are reusable solutions to common problems in software design. They represent best practices and provide a shared vocabulary for developers. In this lesson, we'll explore the Strategy and Factory patterns in the context of Laravel applications.

Why Design Patterns? They help you write more maintainable, flexible, and testable code by providing proven solutions to recurring design problems.

Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. Strategy lets the algorithm vary independently from clients that use it.

Problem: Payment Processing

You need to support multiple payment methods (credit card, PayPal, Stripe) and want to easily add new methods without modifying existing code.

// ❌ Without Strategy Pattern - Violates OCP
class PaymentController extends Controller
{
    public function process(Request $request)
    {
        $method = $request->input('payment_method');

        if ($method === 'credit_card') {
            // Credit card logic
            $result = $this->processCreditCard($request->all());
        } elseif ($method === 'paypal') {
            // PayPal logic
            $result = $this->processPayPal($request->all());
        } elseif ($method === 'stripe') {
            // Stripe logic
            $result = $this->processStripe($request->all());
        }
        // Adding new methods requires modifying this code

        return response()->json($result);
    }
}

// ✅ With Strategy Pattern - Clean and Extensible
namespace App\PaymentStrategies;

interface PaymentStrategy
{
    public function pay(float $amount, array $details): PaymentResult;
    public function refund(string $transactionId, float $amount): bool;
    public function getName(): string;
}

class CreditCardStrategy implements PaymentStrategy
{
    public function pay(float $amount, array $details): PaymentResult
    {
        // Validate credit card details
        $cardNumber = $details['card_number'];
        $cvv = $details['cvv'];
        $expiryDate = $details['expiry_date'];

        // Process payment via credit card gateway
        $transactionId = 'CC-' . uniqid();

        return new PaymentResult(
            success: true,
            transactionId: $transactionId,
            message: 'Credit card payment successful'
        );
    }

    public function refund(string $transactionId, float $amount): bool
    {
        // Refund logic for credit card
        return true;
    }

    public function getName(): string
    {
        return 'credit_card';
    }
}

class PayPalStrategy implements PaymentStrategy
{
    public function pay(float $amount, array $details): PaymentResult
    {
        // PayPal API integration
        $email = $details['email'];

        $transactionId = 'PP-' . uniqid();

        return new PaymentResult(
            success: true,
            transactionId: $transactionId,
            message: 'PayPal payment successful'
        );
    }

    public function refund(string $transactionId, float $amount): bool
    {
        // PayPal refund logic
        return true;
    }

    public function getName(): string
    {
        return 'paypal';
    }
}

class StripeStrategy implements PaymentStrategy
{
    public function pay(float $amount, array $details): PaymentResult
    {
        // Stripe API integration
        $token = $details['stripe_token'];

        $transactionId = 'ST-' . uniqid();

        return new PaymentResult(
            success: true,
            transactionId: $transactionId,
            message: 'Stripe payment successful'
        );
    }

    public function refund(string $transactionId, float $amount): bool
    {
        // Stripe refund logic
        return true;
    }

    public function getName(): string
    {
        return 'stripe';
    }
}

// Context class that uses the strategy
class PaymentProcessor
{
    private PaymentStrategy $strategy;

    public function setStrategy(PaymentStrategy $strategy): void
    {
        $this->strategy = $strategy;
    }

    public function processPayment(float $amount, array $details): PaymentResult
    {
        if (!isset($this->strategy)) {
            throw new \RuntimeException('Payment strategy not set');
        }

        // Log payment attempt
        Log::info('Processing payment', [
            'method' => $this->strategy->getName(),
            'amount' => $amount,
        ]);

        $result = $this->strategy->pay($amount, $details);

        // Log result
        if ($result->success) {
            Log::info('Payment successful', [
                'transaction_id' => $result->transactionId,
            ]);
        }

        return $result;
    }

    public function processRefund(string $transactionId, float $amount): bool
    {
        return $this->strategy->refund($transactionId, $amount);
    }
}

// Controller using Strategy Pattern
class PaymentController extends Controller
{
    public function __construct(private PaymentProcessor $processor) {}

    public function process(Request $request)
    {
        $method = $request->input('payment_method');

        // Select strategy based on payment method
        $strategy = match ($method) {
            'credit_card' => new CreditCardStrategy(),
            'paypal' => new PayPalStrategy(),
            'stripe' => new StripeStrategy(),
            default => throw new \InvalidArgumentException('Invalid payment method'),
        };

        $this->processor->setStrategy($strategy);

        $result = $this->processor->processPayment(
            $request->input('amount'),
            $request->except(['payment_method', 'amount'])
        );

        return response()->json($result);
    }
}

Factory Pattern

The Factory pattern provides an interface for creating objects in a superclass, but allows subclasses to alter the type of objects that will be created. It encapsulates object creation logic.

When to Use Factory: When object creation is complex, when you want to centralize creation logic, or when the exact type of object isn't known until runtime.

Simple Factory Pattern

// Report generation system with Simple Factory
namespace App\Reports;

interface ReportInterface
{
    public function generate(array $data): string;
    public function getFormat(): string;
}

class PdfReport implements ReportInterface
{
    public function generate(array $data): string
    {
        // Generate PDF using library like DomPDF
        $pdf = PDF::loadView('reports.template', $data);
        return $pdf->output();
    }

    public function getFormat(): string
    {
        return 'pdf';
    }
}

class ExcelReport implements ReportInterface
{
    public function generate(array $data): string
    {
        // Generate Excel using library like PhpSpreadsheet
        return Excel::raw(new ReportExport($data), \Maatwebsite\Excel\Excel::XLSX);
    }

    public function getFormat(): string
    {
        return 'xlsx';
    }
}

class CsvReport implements ReportInterface
{
    public function generate(array $data): string
    {
        $output = fopen('php://temp', 'r+');

        // Write headers
        if (!empty($data)) {
            fputcsv($output, array_keys($data[0]));
        }

        // Write data
        foreach ($data as $row) {
            fputcsv($output, $row);
        }

        rewind($output);
        $csv = stream_get_contents($output);
        fclose($output);

        return $csv;
    }

    public function getFormat(): string
    {
        return 'csv';
    }
}

class HtmlReport implements ReportInterface
{
    public function generate(array $data): string
    {
        return view('reports.html', ['data' => $data])->render();
    }

    public function getFormat(): string
    {
        return 'html';
    }
}

// Simple Factory
class ReportFactory
{
    public static function create(string $format): ReportInterface
    {
        return match (strtolower($format)) {
            'pdf' => new PdfReport(),
            'excel', 'xlsx' => new ExcelReport(),
            'csv' => new CsvReport(),
            'html' => new HtmlReport(),
            default => throw new \InvalidArgumentException("Unsupported format: {$format}"),
        };
    }
}

// Usage in controller
class ReportController extends Controller
{
    public function generate(Request $request)
    {
        $format = $request->input('format', 'pdf');
        $data = $this->getReportData();

        // Factory creates appropriate report object
        $report = ReportFactory::create($format);
        $content = $report->generate($data);

        return response($content)
            ->header('Content-Type', $this->getContentType($format))
            ->header('Content-Disposition', "attachment; filename=report.{$report->getFormat()}");
    }

    private function getReportData(): array
    {
        return User::select('name', 'email', 'created_at')->get()->toArray();
    }

    private function getContentType(string $format): string
    {
        return match ($format) {
            'pdf' => 'application/pdf',
            'excel', 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
            'csv' => 'text/csv',
            'html' => 'text/html',
            default => 'application/octet-stream',
        };
    }
}

Factory Method Pattern

Factory Method defines an interface for creating objects, but lets subclasses decide which class to instantiate. It delegates instantiation to subclasses.

// Notification system with Factory Method Pattern
namespace App\Notifications;

abstract class NotificationFactory
{
    // Factory method (to be implemented by subclasses)
    abstract protected function createNotification(): NotificationChannel;

    // Template method that uses the factory method
    public function send(string $recipient, string $message): bool
    {
        $notification = $this->createNotification();

        // Pre-processing
        $this->logAttempt($recipient, $message);

        // Send via appropriate channel
        $result = $notification->send($recipient, $message);

        // Post-processing
        $this->logResult($result);

        return $result;
    }

    protected function logAttempt(string $recipient, string $message): void
    {
        Log::info('Notification attempt', [
            'channel' => $this->createNotification()->getName(),
            'recipient' => $recipient,
        ]);
    }

    protected function logResult(bool $success): void
    {
        Log::info('Notification result', ['success' => $success]);
    }
}

interface NotificationChannel
{
    public function send(string $recipient, string $message): bool;
    public function getName(): string;
}

class EmailChannel implements NotificationChannel
{
    public function send(string $recipient, string $message): bool
    {
        Mail::raw($message, function ($mail) use ($recipient) {
            $mail->to($recipient);
        });
        return true;
    }

    public function getName(): string
    {
        return 'email';
    }
}

class SmsChannel implements NotificationChannel
{
    public function send(string $recipient, string $message): bool
    {
        // SMS API integration (e.g., Twilio)
        return true;
    }

    public function getName(): string
    {
        return 'sms';
    }
}

class PushChannel implements NotificationChannel
{
    public function send(string $recipient, string $message): bool
    {
        // Push notification service (e.g., Firebase)
        return true;
    }

    public function getName(): string
    {
        return 'push';
    }
}

// Concrete factories
class EmailNotificationFactory extends NotificationFactory
{
    protected function createNotification(): NotificationChannel
    {
        return new EmailChannel();
    }
}

class SmsNotificationFactory extends NotificationFactory
{
    protected function createNotification(): NotificationChannel
    {
        return new SmsChannel();
    }
}

class PushNotificationFactory extends NotificationFactory
{
    protected function createNotification(): NotificationChannel
    {
        return new PushChannel();
    }
}

// Usage
class NotificationService
{
    public function notify(string $channel, string $recipient, string $message): bool
    {
        $factory = match ($channel) {
            'email' => new EmailNotificationFactory(),
            'sms' => new SmsNotificationFactory(),
            'push' => new PushNotificationFactory(),
            default => throw new \InvalidArgumentException("Unknown channel: {$channel}"),
        };

        return $factory->send($recipient, $message);
    }
}

Abstract Factory Pattern

Abstract Factory provides an interface for creating families of related or dependent objects without specifying their concrete classes.

Use Case: When you need to create multiple related objects that work together (e.g., UI components for different themes or platforms).
// UI Component factory for different themes
namespace App\UI;

// Abstract factory interface
interface UIFactory
{
    public function createButton(): Button;
    public function createInput(): Input;
    public function createCard(): Card;
}

// Product interfaces
interface Button
{
    public function render(): string;
}

interface Input
{
    public function render(string $name, string $value = ''): string;
}

interface Card
{
    public function render(string $title, string $content): string;
}

// Concrete products for Material theme
class MaterialButton implements Button
{
    public function render(): string
    {
        return '<button class="mdc-button mdc-button--raised"></button>';
    }
}

class MaterialInput implements Input
{
    public function render(string $name, string $value = ''): string
    {
        return '<div class="mdc-text-field">
                    <input type="text" name="' . $name . '" value="' . $value . '">
                </div>';
    }
}

class MaterialCard implements Card
{
    public function render(string $title, string $content): string
    {
        return '<div class="mdc-card">
                    <h2>' . $title . '</h2>
                    <p>' . $content . '</p>
                </div>';
    }
}

// Concrete products for Bootstrap theme
class BootstrapButton implements Button
{
    public function render(): string
    {
        return '<button class="btn btn-primary"></button>';
    }
}

class BootstrapInput implements Input
{
    public function render(string $name, string $value = ''): string
    {
        return '<input type="text" name="' . $name . '" value="' . $value . '" class="form-control">';
    }
}

class BootstrapCard implements Card
{
    public function render(string $title, string $content): string
    {
        return '<div class="card">
                    <div class="card-body">
                        <h5 class="card-title">' . $title . '</h5>
                        <p class="card-text">' . $content . '</p>
                    </div>
                </div>';
    }
}

// Concrete factories
class MaterialUIFactory implements UIFactory
{
    public function createButton(): Button
    {
        return new MaterialButton();
    }

    public function createInput(): Input
    {
        return new MaterialInput();
    }

    public function createCard(): Card
    {
        return new MaterialCard();
    }
}

class BootstrapUIFactory implements UIFactory
{
    public function createButton(): Button
    {
        return new BootstrapButton();
    }

    public function createInput(): Input
    {
        return new BootstrapInput();
    }

    public function createCard(): Card
    {
        return new BootstrapCard();
    }
}

// Client code
class FormBuilder
{
    public function __construct(private UIFactory $factory) {}

    public function buildLoginForm(): string
    {
        $html = '<form>';
        $html .= $this->factory->createInput()->render('email');
        $html .= $this->factory->createInput()->render('password');
        $html .= $this->factory->createButton()->render();
        $html .= '</form>';
        return $html;
    }
}

// Usage - easily switch themes
$theme = config('app.ui_theme', 'bootstrap');

$factory = match ($theme) {
    'material' => new MaterialUIFactory(),
    'bootstrap' => new BootstrapUIFactory(),
    default => new BootstrapUIFactory(),
};

$formBuilder = new FormBuilder($factory);
echo $formBuilder->buildLoginForm();
Exercise 1: Implement a Strategy pattern for image processing:
  • Create ImageProcessorStrategy interface with process() method
  • Implement ResizeStrategy, CropStrategy, WatermarkStrategy
  • Create ImageProcessor context class
  • Allow chaining multiple strategies
Exercise 2: Create a Factory for database exporters:
  • Support MySQL, PostgreSQL, SQLite exports
  • Each exporter should have backup() and restore() methods
  • Use Factory Method pattern
  • Add compression option to all exporters
Exercise 3: Build an Abstract Factory for API response formatters:
  • Create families for JSON API and XML API responses
  • Each family should create: SuccessResponse, ErrorResponse, PaginatedResponse
  • Implement JsonApiFactory and XmlApiFactory
  • Use in a controller to format responses
Pattern Selection Guide: Use Strategy when you have multiple algorithms for the same task. Use Factory when object creation is complex or varies. Use Abstract Factory when you need to create families of related objects.