The open-closed principle (OCP) states that software entities should be open for extension, but closed for modification; that is, such an entity can allow its behaviour to be extended without modifying its source code.
In simple terms: You should be able to add new features to your code without changing or breaking the existing code.
Importance:
- Extensibility: New features can be added without modifying existing code.
- Stability: Reduces the risk of introducing bugs when making changes.
– Flexibility: Adapts to changing requirements more easily.
The Real-World Example: Think of the electrical wall outlet in your home.
- Closed for Modification: The outlet is wired permanently behind your drywall. You do not tear down the wall, change the house wiring, or rebuild the electrical grid every time you buy a new device.
- Open for Extension: The outlet provides a standard interface (the plug holes). You can plug in a phone charger, a vacuum cleaner, a toaster, or a television.
The outlet does not need to know what appliance you are plugging into it ahead of time. It is open to powering any device, as long as that device fits the standard plug shape.
Imagine you run an online store, and you need to calculate shipping costs for different shipping methods.
❌ The Wrong Way: Modifying the existing class (Violates OCP)
Every time your company decides to offer a new shipping carrier (like DHL or FedEx), you have to open this existing file and add another if/else block.
class ShippingCostCalculator {
public function calculate($weight, $carrier) {
if ($carrier === 'FedEx') {
return $weight * 1.5;
} elseif ($carrier === 'UPS') {
return $weight * 1.2;
}
// ❌ If you want to add DHL tomorrow, you must modify this file.
// If you make a typo, you break FedEx and UPS shipping for everyone.
}
}
✅The Right Way: Extending without modifying (Adheres to OCP)
Instead of one giant class making all the decisions, we create a standard contract (Interface). The main calculator stays closed to modification, but the system is open to extension by simply adding new files.
Step 1: Create the standard “Outlet” (Interface)
interface ShippingRate {
public function getRate(float $weight): float;
}
Step 2: Create the “Appliances” (Plugging in new features)
To add a new carrier, you just create a completely separate, isolated class file.
class FedExShipping implements ShippingRate {
public function getRate(float $weight): float {
return $weight * 1.5;
}
}
class UpsShipping implements ShippingRate {
public function getRate(float $weight): float {
return $weight * 1.2;
}
}
// Want to add DHL? Just create a new file! No need to touch FedEx or UPS.
class DhlShipping implements ShippingRate {
public function getRate(float $weight): float {
return $weight * 2.0;
}
}
Step 3: Run the system safely
The core logic never changes, no matter how many carriers you add to your company.
class ShippingCostCalculator {
// This method is CLOSED to modification. It doesn't care about specific brands.
public function calculate(float $weight, ShippingRate $shippingMethod) {
return $shippingMethod->getRate($weight);
}
}
Why this helps you
- Extensibility: New features can be added without modifying existing code.
- Stability: Reduces the risk of introducing bugs when making changes.
- Flexibility: Adapts to changing requirements more easily.
- Team Collaboration: Ten different developers can work on ten different shipping methods at the same time without editing the same file and causing code conflicts.
The 3 Main Confusions Cleared Up
1. Confusion: “Closed for modification means I can never touch this file again?”
- Reality: No. “Closed” means closed for behavioral changes to existing features, or changes driven by new requirements. If you find a bug in the class, you absolutely must open and modify the file to fix it.
2. Confusion: “Should I create an Interface for every single class?”
- Reality: This is the #1 mistake that kills performance and readability. If a class has exactly one implementation that will never change (e.g., a PdfGenerator that only generates PDFs), do not create an interface. Only use interfaces where variance is expected.
3. Confusion: “Does OCP mean I must predict the future?”
- Reality: You cannot guess every feature your client will ask for in two years. OCP does not mean predicting the future; it means structuring code so that when the future happens, it is easy to plug in.
The Anti-Pattern vs. The OCP Solution
❌ The 90% Approach: Modifying the existing class (Violates OCP)
Every time a manager asks for a new payment gateway, the engineer opens this class and adds another elseif. One typo can crash the entire checkout system.
class PaymentProcessor {
public function process($amount, $type) {
if ($type === 'paypal') {
// PayPal logic
} elseif ($type === 'stripe') {
// Stripe logic
} elseif ($type === 'crypto') { // ❌ Had to modify this file to add this!
// Crypto logic
}
}
}
The 10% Approach: Using Polymorphism (Adheres to OCP)
We create a contract (Interface). The main processor never changes, no matter how many gateways you add.
interface PaymentGateway {
public function pay($amount);
}
// The core logic is now CLOSED to modification
class PaymentProcessor {
public function process($amount, PaymentGateway $gateway) {
$gateway->pay($amount); // Dynamic execution
}
}
// OPEN to extension: Just add a new class file!
class StripeGateway implements PaymentGateway {
public function pay($amount) { /* Stripe logic */ }
}
class CryptoGateway implements PaymentGateway {
public function pay($amount) { /* Crypto logic */ }
}
The 3-Step Decision Matrix (How to Choose)
When writing or reviewing a PHP class, use this mental map to decide if you need to apply OCP:
START OCP_Design_Check
IF Is_Writing_Conditional_Statement IS True THEN
IF Conditions_Likely_To_Grow_In_Future IS True THEN
EXECUTE Apply_OCP_Use_An_Interface
ELSE
EXECUTE Simple_If_Else_Is_Fine
END IF
ELSE
EXECUTE Leave_It_Alone
END IF
END OCP_Design_Check
- Is it a variation of a type? If you are writing conditions based on “types” (e.g., user_type, payment_method, report_format, export_destination), it is a prime candidate for OCP. Turn those types into separate classes implementing an interface.
- Is it a simple binary state? If you are checking if ($user->isActive), leave it alone. Do not create an ActiveUser and InactiveUser class. That is over-engineering.
- The “Three Strikes” Rule: If you don’t know if a feature will expand, don’t guess. Write the simple if/else first. The second time you modify it, let it go. The third time you have to open that file to add another condition, refactor it to OCP immediately.
Summary for Decision Making
- Modify code only to fix bugs or optimize existing logic.
- Extend code (by adding new files/classes) to add completely new business rules or features.
The Laravel Blueprint: Mastering OCP
In Laravel, the “wall outlet” is an Interface (often called a Contract), and the “appliances” are your classes. Imagine your Laravel app needs to send marketing alerts to users. Initially, your boss asks for SMS alerts via Twilio.
❌ The Junior Approach (Violates OCP)
A junior developer hardcodes Twilio directly into the service.
namespace AppServices;
use TwilioRestClient;
class OrderNotificationService
{
public function sendAlert($user, $message)
{
// Hardcoded dependency on Twilio
$twilio = new Client(config('twilio.sid'), config('twilio.token'));
$twilio->messages->create($user->phone, ['body' => $message]);
}
}
The Problem: Two months later, the boss says, “Twilio is too expensive, let’s switch to Vonage for SMS, and also add WhatsApp alerts!” You now have to open this class, delete code, and rewrite it. This risks breaking your existing deployment.
The Laravel Mastery Approach (Adheres to OCP)
To write this like a Laravel senior architect, you use a Contract and Laravel’s Service Container.
Step 1: Create the “Outlet” (The Contract)
namespace AppContracts;
interface SmsSender
{
public function send(string $to, string $message): bool;
}
Step 2: Create the “Appliances” (The Providers)
Now, create separate classes for each service. If you need to add a new one later, you just create a new file.
namespace AppServices;
use AppContractsSmsSender;
class TwilioSender implements SmsSender
{
public function send(string $to, string $message): bool {
// Twilio specific API code here
return true;
}
}
class VonageSender implements SmsSender
{
public function send(string $to, string $message): bool {
// Vonage specific API code here
return true;
}
}
Step 3: Inject the Contract into your Service
Your service is now closed for modification. It only knows about the interface, not the concrete brands.
namespace AppServices;
use AppContractsSmsSender;
class OrderNotificationService
{
// Laravel automatically injects the correct sender here!
public function __construct(
protected SmsSender $smsSender
) {}
public function sendAlert($user, $message)
{
$this->smsSender->send($user->phone, $message);
}
}
Step 4: The Magic Switch (AppServiceProvider)
How does Laravel know which appliance is plugged into the outlet? You tell it in your AppServiceProvider.php
namespace AppProviders;
use IlluminateSupportServiceProvider;
use AppContractsSmsSender;
use AppServicesTwilioSender;
use AppServicesVonageSender;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// To switch from Twilio to Vonage globally, change exactly ONE line here:
$this->app->bind(SmsSender::class, TwilioSender::class);
}
}
Why this makes you a Laravel Master
- Zero-Risk Feature Additions: When the boss wants to add WhatsApp tomorrow, you don’t touch OrderNotificationService. You just write a WhatsAppSender file and swap it in the Service Provider.
- Mocking & Testing: In your feature tests, you can swap the implementation with a fake one (SmsSender::fake()) so you don’t accidentally send real text messages during testing.