Master the Open-Closed Principle in Laravel: A Real-World Guide ๐Ÿš€

Sohag HasanSohag Hasan
4 min read

Open-Closed Principle in Laravel

Introduction

Have you ever found yourself repeatedly modifying existing code to add new features, only to break something else in the process? The Open-Closed Principle (OCP) is here to save you from this maintenance nightmare! In this guide, we'll explore how to implement OCP in Laravel through practical, real-world examples.

What is the Open-Closed Principle?

The Open-Closed Principle states that software entities (classes, modules, functions) should be:

  • Open for extension: You can add new functionality

  • Closed for modification: Existing code shouldn't be changed

Think of it like a smartphone: you can extend its functionality by installing new apps (open for extension) without modifying the phone's core operating system (closed for modification).

Real-World Example: Notification System

Let's build a notification system for an e-commerce platform. Customers need to be notified about their order status through different channels (SMS, Email, WhatsApp).

โŒ Without OCP (Bad Approach)

class OrderNotifier
{
    public function notify($order, $channel)
    {
        if ($channel === 'email') {
            // Send email notification
            Mail::to($order->customer_email)->send(new OrderStatusMail($order));
        } 
        elseif ($channel === 'sms') {
            // Send SMS notification
            SMS::send($order->customer_phone, "Your order #{$order->id} status: {$order->status}");
        }
        // What if we need to add WhatsApp notification? We'd need to modify this class!
    }
}

Problems with this approach:

  1. Adding a new notification channel requires modifying existing code

  2. The class violates Single Responsibility Principle

  3. Testing becomes harder with each new channel

  4. Risk of breaking existing functionality

โœ… With OCP (Better Approach)

First, let's create an interface:

interface NotificationChannel
{
    public function send($order);
}

Now, implement specific notification channels:

class EmailNotification implements NotificationChannel
{
    public function send($order)
    {
        Mail::to($order->customer_email)
            ->send(new OrderStatusMail($order));
    }
}

class SMSNotification implements NotificationChannel
{
    public function send($order)
    {
        SMS::send(
            $order->customer_phone,
            "Your order #{$order->id} status: {$order->status}"
        );
    }
}

class WhatsAppNotification implements NotificationChannel
{
    public function send($order)
    {
        WhatsApp::sendMessage(
            $order->customer_phone,
            "Your order #{$order->id} status: {$order->status}"
        );
    }
}

Create the main notifier class:

class OrderNotifier
{
    private $channel;

    public function __construct(NotificationChannel $channel)
    {
        $this->channel = $channel;
    }

    public function notify($order)
    {
        $this->channel->send($order);
    }
}

Usage in Laravel:

// In your OrderController or Service
public function updateOrderStatus(Order $order)
{
    // Update order status
    $order->update(['status' => 'shipped']);

    // Send notification via email
    $emailNotifier = new OrderNotifier(new EmailNotification());
    $emailNotifier->notify($order);

    // Send notification via SMS
    $smsNotifier = new OrderNotifier(new SMSNotification());
    $smsNotifier->notify($order);
}

Service Provider Implementation

Register your notification channels in Laravel's service container:

// app/Providers/NotificationServiceProvider.php
class NotificationServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind('notification.channels.email', function () {
            return new EmailNotification();
        });

        $this->app->bind('notification.channels.sms', function () {
            return new SMSNotification();
        });
    }
}

Benefits of This Approach

  1. Easy Extension: Add new notification channels without modifying existing code

  2. Better Testing: Test each notification channel independently

  3. Clean Code: Each class has a single responsibility

  4. Maintainable: Changes to one notification channel don't affect others

  5. Dependency Injection: Laravel's IoC container handles dependencies

Real-World Practical Example: Payment Gateway Integration

Let's look at another example with payment gateways:

interface PaymentGateway
{
    public function process(Order $order);
    public function refund(Order $order);
}

class StripePayment implements PaymentGateway
{
    public function process(Order $order)
    {
        // Stripe-specific implementation
        return Stripe::charges()->create([
            'amount' => $order->total * 100,
            'currency' => 'usd',
            'source' => $order->payment_token
        ]);
    }

    public function refund(Order $order)
    {
        // Stripe-specific refund logic
    }
}

class PayPalPayment implements PaymentGateway
{
    public function process(Order $order)
    {
        // PayPal-specific implementation
        return PayPal::payment()
            ->setAmount($order->total)
            ->setOrderId($order->id)
            ->execute();
    }

    public function refund(Order $order)
    {
        // PayPal-specific refund logic
    }
}

Potential Drawbacks and How to Address Them

  1. Increased Initial Development Time

    • Solution: Consider it an investment in maintainability

    • The time saved in future modifications will outweigh initial setup time

  2. More Files and Classes

    • Solution: Use proper namespacing and folder structure

    • Laravel's auto-loading makes handling multiple files efficient

  3. Learning Curve for New Developers

    • Solution: Document your interfaces and implementations

    • Use meaningful names and maintain consistent patterns

Best Practices for OCP in Laravel

  1. Use Laravel's interface binding:
$this->app->bind(PaymentGateway::class, StripePayment::class);
  1. Leverage configuration files:
// config/services.php
return [
    'payment' => [
        'default' => env('PAYMENT_GATEWAY', 'stripe'),
        'gateways' => [
            'stripe' => StripePayment::class,
            'paypal' => PayPalPayment::class,
        ]
    ]
];
  1. Use factory patterns when appropriate:
class PaymentGatewayFactory
{
    public static function create(string $gateway): PaymentGateway
    {
        $gateway = config("services.payment.gateways.{$gateway}");

        if (!$gateway) {
            throw new InvalidArgumentException("Unsupported payment gateway");
        }

        return app($gateway);
    }
}

Conclusion

The Open-Closed Principle might seem like extra work initially, but it's a powerful tool for creating maintainable and scalable Laravel applications. By following OCP:

  • Your code becomes more flexible and adaptable

  • You reduce the risk of breaking existing functionality

  • Testing becomes easier and more focused

  • Your application becomes more modular and professional

Remember: Good architecture is not about solving today's problems, but about making tomorrow's changes easier to implement.

Start implementing OCP in your Laravel projects today, and you'll thank yourself later when new requirements come in!

0
Subscribe to my newsletter

Read articles from Sohag Hasan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sohag Hasan
Sohag Hasan

WhoAmI => notes.sohag.pro/author