Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 4

Refactoring for SOLID Principles

Recommended resources

Welcome back. In our last lesson, we focused on identifying violations of the SOLID principles by spotting "code smells" that signal potential design flaws. You learned to recognize the symptoms of a class doing too much, a system resistant to change, or a fragile inheritance hierarchy.

Today, we move from diagnosis to treatment. This lesson is all about the "how": how to take code that violates SOLID principles and refactor it into a design that is clean, robust, and maintainable. Your goal is to apply these principles to transform problematic code into a solid structure. As a seasoned Laravel developer, you'll find that many of the "correct" solutions leverage patterns and tools you use daily, such as service classes, form requests, and the service container. This lesson will help formalize the why behind those practices.

We will work through each of the five principles, starting with a piece of code that violates it and then refactoring it step-by-step into a better solution.

Refactoring for the Single Responsibility Principle (SRP)

As a reminder, the Single Responsibility Principle states that a class should have only one reason to change. The most common violation in web applications, especially within frameworks like Laravel, is the "fat controller"—a controller method that handles validation, business logic, data persistence, and side effects like sending notifications.

Let's look at a classic example of a store method in a controller that does far too much:

// Before Refactoring
class OrderController extends Controller
{
    public function store(Request $request)
    {
        // 1. Validation
        $request->validate([
            'customer_name' => 'required|string|max:255',
            'items' => 'required|array',
        ]);

        // 2. Business Logic
        $orderTotal = $this->calculateTotal($request->input('items'));

        // 3. Persistence
        $order = Order::create([
            'customer_name' => $request->input('customer_name'),
            'total' => $orderTotal,
        ]);

        // 4. Side Effect (Notification)
        Mail::to($request->user())->send(new OrderConfirmationEmail($order));

        return redirect()->route('orders.show', $order);
    }
    
    private function calculateTotal(array $items): float
    {
        // ... calculation logic
    }
}

This method has four distinct responsibilities. A change to validation rules, calculation logic, the database schema, or the notification email would all require modifying this controller. To fix this, we need to separate these concerns.

The following video provides an excellent walkthrough of refactoring a controller just like this one. Please watch the first part, which focuses on SRP.

Achieve Flexible, Robust Code: A Practical Look at SOLID Principles — with Laravel Examples

Watch the section on the Single Responsibility Principle from Glenn Raya's video, "Achieve Flexible, Robust Code".

Watch from the beginning until the end of the SRP section. The author takes a controller very similar to our example and refactors it by moving validation into a Form Request and the email logic into a Trait. This directly demonstrates how to break down a "fat" method into more focused components.

As demonstrated in the video, the Laravel ecosystem provides elegant solutions for this exact problem:

  1. Validation: Move validation rules into a dedicated StoreOrderRequest Form Request class. The controller method then type-hints this request, and validation happens automatically before your method is even executed.
  2. Business Logic & Persistence: The core logic of creating the order can be extracted into a dedicated service class, for example, OrderCreationService. This service would be responsible for calculating the total and creating the Order record.
  3. Side Effects: Sending an email is a side effect of an order being placed. This is a perfect use case for Laravel's events. You can fire an OrderPlaced event, and a dedicated SendOrderConfirmation listener can handle sending the email. This decouples the notification logic from the order creation process.

The refactored controller becomes beautifully simple and clear:

// After Refactoring
class OrderController extends Controller
{
    public function store(StoreOrderRequest $request, OrderCreationService $orderService)
    {
        $order = $orderService->createOrder($request->validated());

        event(new OrderPlaced($order));

        return redirect()->route('orders.show', $order);
    }
}

Now, the controller's single responsibility is clear: to handle the HTTP request and delegate the work to the appropriate components.


Refactoring for the Open/Closed Principle (OCP)

The Open/Closed Principle dictates that software entities should be open for extension but closed for modification. A common violation is using if/else or switch statements to perform different actions based on a type.

Imagine a ReportGenerator class that exports data to different formats.

// Before Refactoring
class ReportGenerator
{
    public function generate(Report $report, string $format)
    {
        if ($format === 'pdf') {
            // Logic to generate PDF
            return $this->generatePdf($report);
        } elseif ($format === 'csv') {
            // Logic to generate CSV
            return $this->generateCsv($report);
        }
        
        throw new \Exception('Unsupported format');
    }

    private function generatePdf(Report $report) { /* ... */ }
    private function generateCsv(Report $report) { /* ... */ }
}

To add a new format like 'Excel', you must modify this class. Let's see how to refactor this using an interface-based approach, which you'll be very familiar with from concepts like Laravel's caching or filesystem drivers.

SOLID Principles: From Paw Patrol to the Neighborhood

Please read the section on the Open/Closed Principle from the "DEV Community" article.

Focus on the AreaCalculator example. The "Common Mistake Example" shows a class that must be modified to add new shapes. The "The Fix" section refactors this using the Strategy Pattern, where an interface defines a contract and separate classes provide the implementation for each shape. This is the core idea we'll apply.

Following the pattern from the article, we can refactor our ReportGenerator.

  1. Define an Interface (the "Contract"):
    interface Reportable
    {
        public function generate(Report $report): string; // Returns file path or content
    }
    
  2. Create Concrete Implementations:
    class PdfReport implements Reportable
    {
        public function generate(Report $report): string { /* PDF generation logic */ }
    }
    
    class CsvReport implements Reportable
    {
        public function generate(Report $report): string { /* CSV generation logic */ }
    }
    
  3. Use a Factory or Service Container to select the implementation:
    // In a ReportFactory or a Controller
    class ReportController extends Controller
    {
        public function download(Report $report, string $format, ReportFactory $factory)
        {
            $generator = $factory->make($format);
            return $generator->generate($report);
        }
    }
    

Now, to add an 'Excel' format, we simply create a new ExcelReport class that implements Reportable and update the factory. The ReportGenerator and ReportController remain untouched. The system is open to adding new formats but closed to modifying the existing logic.


Refactoring for the Liskov Substitution Principle (LSP)

LSP requires that objects of a subclass be substitutable for objects of their superclass without breaking the application. In short, a subclass must honor its parent's contract.

This image humorously depicts an LSP violation. While a hammerhead shark shares part of a name with a hammer, it cannot be substituted for a hammer to perform the `nailTheNail()` action. Similarly, subclasses must be behaviorally substitutable, not just syntactically or nominally related.

In the last lesson, we saw how a Bicycle extending Vehicle could violate LSP if the Vehicle class has an startEngine() method. Let's apply this to a more common scenario in your work: data repositories.

SOLID Principles in Laravel: Practical Examples — Hasan Sidawi

Hasan Sidawi's blog post has a great, subtle example of an LSP violation. Please read the section on the Liskov Substitution Principle.

In the article, find the section on LSP. Read about the CachedProductRepository violation. The author points out that if client code expects find() to return the latest data immediately after save(), this cached version breaks that expectation by potentially serving stale data.

The "before" example in the article is subtle but critical.

// Before Refactoring (Violates LSP)
class CachedProductRepository extends ProductRepository
{
    public function find(int $id): ?Product
    {
        return Cache::remember("product.{$id}", 3600, function () use ($id) {
            return parent::find($id);
        });
    }

    public function save(Product $product): void
    {
        parent::save($product);
        // The cache is NOT updated or cleared here!
    }
}

A client using ProductRepository might call save() and then immediately call find(), expecting to see the updated product. But if they are unknowingly given a CachedProductRepository instance, they might get back stale data from the cache. The subclass has changed the behavior of the system in an unexpected way, violating LSP.

The fix is to ensure the subclass upholds the contract.

// After Refactoring (Obeys LSP)
class CachedProductRepository extends ProductRepository
{
    // find() method remains the same...

    public function save(Product $product): void
    {
        parent::save($product);
        // Uphold the contract by ensuring the cache is consistent.
        Cache::forget("product.{$product->id}");
        // A more advanced implementation might even update the cache:
        // Cache::put("product.{$product->id}", $product, 3600);
    }
}

By invalidating the cache upon saving, the CachedProductRepository now behaves substitutably for the ProductRepository. The client can use either class without unexpected side effects.


Refactoring for the Interface Segregation Principle (ISP)

ISP tells us to "make fat interfaces thin." Clients should not be forced to depend on methods they do not use.

Let's imagine a single, large interface for managing any kind of content in a CMS:

// Before Refactoring (Fat Interface)
interface Manageable
{
    public function get();
    public function create(array $data);
    public function update(int $id, array $data);
    public function delete(int $id);
    public function publish(int $id);
    public function archive(int $id);
}

Now, consider a StaticPage class. Static pages can be created, updated, and published, but perhaps they cannot be archived or deleted through the app. A BlogComment might be get-able and deletable, but not updatable or publishable. Forcing them to implement this interface leads to empty or exception-throwing methods.

The video below explains how to refactor this by breaking the interface apart.

SOLID Principles in Laravel: 5 Examples (+ New Course!)

Watch the section on the Interface Segregation Principle from the "Laravel Daily" video.

In this clip, from the start of the ISP section, the presenter refactors a single ReportInterface with getData() and download() methods into two separate, smaller interfaces. This allows a class to implement only the behavior it needs.

Following the video's logic, we can break our Manageable interface into smaller, role-specific interfaces:

// After Refactoring (Segregated Interfaces)
interface Readable { public function get(); }
interface Writable {
    public function create(array $data);
    public function update(int $id, array $data);
}
interface Deletable { public function delete(int $id); }
interface Publishable {
    public function publish(int $id);
    public function archive(int $id);
}

// Classes now implement only what they need
class StaticPageController implements Readable, Writable, Publishable { /* ... */ }
class BlogCommentController implements Readable, Deletable { /* ... */ }

This design is much more flexible and avoids forcing classes to implement methods that are irrelevant to them, leading to cleaner and more intention-revealing code.


Refactoring for the Dependency Inversion Principle (DIP)

Finally, DIP states that high-level modules should not depend on low-level modules; both should depend on abstractions. You live and breathe this principle every day when using Laravel's service container, but let's make it explicit.

The violation is instantiating a concrete dependency directly inside a class.

// Before Refactoring
class ProcessInvoice
{
    private StripeService $paymentGateway;

    public function __construct()
    {
        // Direct dependency on a concrete, low-level implementation.
        $this->paymentGateway = new StripeService();
    }

    public function handle(Invoice $invoice)
    {
        $this->paymentGateway->charge($invoice->amount, ...);
    }
}

This ProcessInvoice class is tightly coupled to StripeService. Testing this class requires a live Stripe connection, and switching to another payment provider (like PayPal) would require modifying this class's source code.

The solution is to "invert" the dependency by depending on an interface and letting the service container inject the concrete class.

SOLID Principles in Laravel: Practical Examples — Hasan Sidawi

Please read the final principle section in Hasan Sidawi's article on Dependency Inversion.

This section clearly shows a tightly coupled OrderService and then refactors it to depend on a Mailer interface. It then shows the crucial final step: 'binding' the interface to a concrete implementation in a service provider.

Applying this pattern to our example:

  1. Define the Abstraction (Interface):
    interface PaymentGateway
    {
        public function charge(float $amount, ...): bool;
    }
    
  2. Ensure the Low-Level Module Implements It:
    class StripeService implements PaymentGateway
    {
        public function charge(float $amount, ...): bool { /* ... */ }
    }
    
  3. Depend on the Abstraction in the High-Level Module:
    // After Refactoring
    class ProcessInvoice
    {
        public function __construct(private PaymentGateway $paymentGateway) {}
    
        public function handle(Invoice $invoice)
        {
            $this->paymentGateway->charge($invoice->amount, ...);
        }
    }
    
  4. Bind the Implementation in a Service Provider:
    // In AppServiceProvider.php
    public function register(): void
    {
        $this->app->bind(PaymentGateway::class, StripeService::class);
    }
    

Now, ProcessInvoice is completely decoupled from StripeService. To switch to PayPalService, you only need to change one line in the AppServiceProvider. For testing, you can bind the interface to a FakePaymentGateway. This is the power of dependency inversion and a cornerstone of modern framework architecture.


Conclusion

In this lesson, we've put theory into action by refactoring code to adhere to the five SOLID principles. You've seen how these principles are not just abstract academic concepts but practical tools for building flexible, testable, and maintainable applications in a real-world framework like Laravel.

Key Takeaways:

  • SRP: Refactor large classes or methods by extracting responsibilities into dedicated components like Form Requests, Service Classes, and Event Listeners.
  • OCP: Replace conditional logic based on type with an interface and multiple concrete implementations (the Strategy pattern).
  • LSP: Ensure subclasses honor the contract of their parent class by maintaining consistent behavior, parameters, and return types.
  • ISP: Break "fat" interfaces into smaller, more focused ones that better represent specific capabilities.
  • DIP: Invert dependencies by having high-level modules depend on interfaces, and use a service container to inject the concrete implementations.

In our next lesson, we will begin our exploration of specific Design Patterns. We will start by classifying them into creational, structural, and behavioral categories, and you'll see how many of these patterns are already at work within the Laravel framework you use every day.