« back published by @mmartin_joo on January 8, 2023

SOLID Principles with Laravel

First, let's discuss what SOLID stands for:

  • Single-responsibility principle
  • Open-closed principle
  • Liskov substitution principle
  • Interface segregation principle
  • Dependency inversion principle

Sounds scientific, isn't it? But it's all just marketing. In reality, this is the most simple thing in the universe. It's a set of principles mainly focusing on object-oriented programming made popular by Robert C. Martin. Now let's see what are these principles.

Single-Responsibility Principle

Each class should have only one reason to change.

It's kind of hard to define what a "reason" might be and it causes some confusion but usually, it's related to roles. Users have different roles. For example, let's say we're working on an app that is being used by financial experts. Users want reports. It's obvious that an accountant wants to see completely different reports and charts than a CFO. Both of them are reports, but they are being used in different roles. So it's probably a good idea not to mix the code in one Report class. This one class will change for different reasons.

Another, maybe more obvious example is data and its representation. Usually, they change for different reasons. Hence it's probably a safe bet to decouple the query layer from the representation layer. Which is the defacto industry standard nowadays, and one of the reasons why API+SPA is so popular. But it wasn't always the case. But we can take a step back because there are still countless examples when developers mix these two things in Laravel.

Consider this hypothetical (and oversimplified) example:

class UserResource extends JsonResource
{
  public function toArray($request)
  {
    $mostPopularPosts = $user->posts()
      ->where('like_count', '>', 50)
      ->where('share_count', '>', 25)
      ->orderByDesc('visits')
      ->take(10)
      ->get();

    return [
      'id' => $this->id,
      'full_name' => $this->full_name,
      'most_popular_posts' => $mostPopularPosts,
    ];
  }
}

I couldn't count many times I see something like that. This is a modern example of mixing the data and the representation layer in one class. When we see a legacy project where there is a single PHP file with HTML, PHP, and MySQL in it, we cry in pain. Of course, this resource is much better than that, but in fact, it has similar issues:

  • The array is similar to HTML. It's the representation of the data.
  • The Eloquent query is similar to MySQL. It's the query layer.
  • And the whole class is in PHP.

So after all, we're mixing a lot of things in just 20 lines of code.

"All right, but it's all theory. What's the big deal about this?" Here is some problems that might occur:

  • The PM says: "can we please change the definition of 'most popular posts'?" After a request like this, I'd not think that I need to go to the UserResource class. It's just not logical. This class shouldn't be changed because of that request.
  • You probably need this resource in a lot of places all around your application. But information such as most popular posts is typically shown on only a few pages. So it's wasteful.
  • This simple query can be the origin of N+1 queries and other performance issues.

Fortunately, the fix is pretty easy:

class UserResource extends JsonResource
{
  public function toArray($request)
  {    
    return [
      'id' => $this->id,
      'full_name' => $this->full_name,
      'most_popular_posts' => $this->when(
        $request->most_popular_posts, 
        $this->mostPopularPosts,
      ),
    ];
  }
}

class User extends Posts
{
  /**
   * @return Collection<Post>
   */
  public function mostPopularPosts(): Collection
  {
    return $this->posts()
      ->where('like_count', '>', 50)
      ->where('share_count', '>', 25)
      ->orderByDesc('visits')
      ->take(10)
      ->get();
  }
}

Of course in this example, I don't assume anything about the overall architecture of the project. You can write services, actions, query builders, scopes, repositories, or anything you like. You can argue that this query should be in the Post model.

We can do something like that:

Post::mostPopularBy($user);

Or using a scope:

$user->posts()->mostPopularOnes();

I also think that it's a better solution. If a PM says: "can we please change the definition of 'most popular posts'?" I know I need to go to the Post model. And of course, usually, the User is the first one that is going into "legacy" mode. After six months. So you can see that a simple query like this can cause some confusion and debate. Or even (often) religious wars.

This is why I prefer using single-use-case actions. And I think this is why they are getting more and more popular in the Laravel community. It looks like this:

class UserResource extends JsonResource
{
  public function toArray($request)
  {    
    return [
      'id' => $this->id,
      'full_name' => $this->full_name,
      'most_popular_posts' => $this->when(
        $request->most_popular_posts, 
        GetMostPopularPosts::execute($user),
      ),
    ];
  }
}

class GetMostPopularPosts
{
  /**
   * @return Collection<Post>
   */
  public static function execute(User $user): Collection
  {
    return $user->posts()
      ->where('like_count', '>', 50)
      ->where('share_count', '>', 25)
      ->orderByDesc('visits')
      ->take(10)
      ->get();
  }
}

You can also use non-static functions, or invokable classes the choice is yours. All of them are great and testable so I think it's just a matter of preference. But can you see how much we improved the "architecture" from the single-responsibility point-of-view?

Now we have two well-defined classes:

  • UserResource is responsible only for the representation and it has one reason to change.
  • GetMostPopularPosts is responsible only for the query and it has one reason to change.

Here are some typical indicators that you're breaking SRP:

  • Database queries in simple "data" or representation classes such as requests, responses, DTOs, value objects, mails, and notifications. Anytime you write business logic into these classes it can be a bad practice.
  • Dispatching jobs or commands from models. Usually, you don't want to couple these things together. It's better to use an event or dispatch the job from a controller or use an action. Models should not be orchestrator classes that are starting long-running processes.
  • Incorrect dependencies. It breaks a lot of other principles but is usually also a red flag in terms of SRP. By incorrect dependency, I mean, when a Model uses an HTTP request or response. In this case, you're coupling a transportation layer (HTTP) to a data layer (model). These are some examples I consider "incorrect" dependencies:
This classDepends on these
ModelHTTP, Job, Command, Auth
JobHTTP
CommandHTTP
Mail/NotificationHTTP, Job, Command
ServiceHTTP
RepositoryHTTP, Job, Command

Of course, these are just overgeneralized examples. Usually, it depends on your exact project/class.

Open-Closed Principle

A class should be open for extension but closed for modification

It sounds weird, I know. Please don't check out the Wikipedia page because it gets even weirder. So let me show you an example.

Let's say we're working on a social application. It has users, posts, comments, and likes. Users can like posts, so you implement this feature in the Post model. Easy. But now, users also want to like comments. You have two choices:

  • Copy the like-related features into the Comment model
  • You implement a generic trait that can be used in any model

Of course, we want the second option. It looks something like that:

trait Likeable
{
  public function like(): Like
  {
    // ...
  }
  
  public function dislike(): void
  {
    // ...
  }
  
  public function likes(): MorphMany
  {
    // ...
  }
  
  public function likeCount(): int
  {
        return $this->likes()->count();
  }
}

class Post extends Model
{
  use Likeable;
}

class Comment extends Model
{
  use Likeable;
}

Now let's say we need to add a chat to the app, and of course, users want to like messages. So we do this:

class ChatMessage extends Model
{
  use Likeable;
}

This is pretty standard, right? But think about what happened here. We just added new functionality to multiple classes without changing them! We extended our classes instead of modifying them. And this is a huge win in the long term. This is why traits and polymorphism in general are amazing tools.

Let's look at another example that uses polymorphism and interfaces. Let's say we're working on an app similar to DoorDash. It has different products and variations. For example, users can order a small batch or large batch of chicken soup. Both have different prices. They can also order pizza with different toppings that modify the price. But they can also order a pretty standard food such as a Cheeseburger without any modification.

Here's an oversimplified database structure:

products:

idnamepriceprice_type
1Chicken soup7has_batches
2Margherita pizza15has_toppings
3Cheeseburger12standard

product_batches: this table contains the price modification for different batch sizes

idproduct_idnameprice
11Large2

So a small batch of chicken soup costs $7 but a large one costs $12 because of the product_batches.price column.

When customers order their food we need to create an Order and OrderItems for these products:

orders: this table is not that important for our purpose so we leave it pretty simple

idtotal_pricecreated_at
1382023-01-08 14:42

order_items

idorder_idproduct_idproduct_batch_idprice
11119
21217
31312

toppings: this table contains every toppings users can add to their food:

idnameprice
1Cheese1
2Mushroom1

order_item_topping: this is a pivot table that associates products with toppings:

idorder_item_idtopping_id
121
222

So we can determine the prices using different calculations:

  • Chicken soup: products.price + product_batches.extra_price
  • Margherita pizza: products.price + sum of the toppings' prices based on the order_item_topping table
  • Cheeseburger: products.price

Of course, this is just a hypothetical and oversimplified example, but the technical details are not that important for now so it works for our purpose.

Now let's see how we can calculate prices:

class PriceCalculatorService
{
  public function calculatePrice(Order $order): float
  {
    return $order->items()
      ->reduce((float $sum, OrderItem $item) {
        switch ($item->product->type) {
          case 'standard':
            return $item->prouct->price;

          case 'has_batches':
            return $item->product->price + 
              $item->product_batch->price;

          case 'has_toppings':
            $toppingsSum = $item->toppings
              ->reduce(function ($sum, Topping $topping) {
                  return $sum + $topping->price;
              }, 0);

            return $item->product->price + $toppingsSum;
        }
      }, 0);
  }
}

It's not so bad but it has two essential flaws:

  • If you're building a DoorDash-like app, just imagine how many times you need to repeat this switch statement. If there are other things that depend on the product type (and there are a dozen of them!) it gets even worse.
  • What happens when you need to handle a new type of product? Then you need to modify all of those switch statements. And this is the bare minimum you need to do, usually, it's a much bigger pain in a project that doesn't rely on polymorphism and the open-closed principle.

Or put it in other words: this architecture violates the open-closed principle. It's absolutely not extensible but requires changing existing (and probably nasty) classes every time a new requirement comes in.

So let's refactor it using OCP and polymorphism. First, we need a class hierarchy that represents the different price types:

abstract class PriceType 
{
  public function __construct(
    protected readonly OrderItem $orderItem
  ) {}
  
  abstract public function calculatePrice(): float; 
}

class StandardPriceType extends PriceType
{
  public function calculatePrice(): float
  {
    return $this->orderItem->product->price;
  }
}

class HasBatchesPriceType extends PriceType
{
  public function calculatePrice(): float
  {
    return $this->orderItem->product->price + 
      $this->orderItem->product_batch->price;
  }
}

class HasToppingsPriceType extends PriceType
{
  public function calculatePrice(): float
  {
    $toppingsSum = $this->orderItem->toppings
      ->reduce(function (float $sum, Topping $topping) {
        return $sum + $topping->price;
      }, 0);

    return $this->orderItem->product->price + $toppingsSum;
  }
}

These classes can calculate the price of a single OrderItem that has a Product. We need a way to create these classes easily. This is where the factory "design pattern" can be useful:

class PriceTypeFactory
{
  public function create(OrderItem $orderItem): PriceType
  {
    switch ($orderItem->product->type)
    {
      case 'standard':
        return new StandardPriceType($orderItem->product);

      case 'has_batches':
        return new HasBatchesPriceType($orderItem->product);

      case 'has_toppings':
        return new HasToppingsPriceType($orderItem->product);
    }
  }
}

And now we need a way to create these classes in a model. An attribute accessor is an excellent choice to do so:

class OrderItem extends Model
{
  public function priceType(): Attribute
  {
    return new Attribute(
      get: fn () => (new PriceTypeFactory())
        ->create($this),
    );
  }
}

And finally, we can rewrite the PriceCalculator class:

class PriceCalculatorService
{
  public function calculatePrice(Order $order): float
  {
    return $order->items
      ->reduce(function (float $sum, OrderItem $item) {
        return $sum + $item->price_type->calculatePrice();
      }, 0);
  }
}

Do you see what we did? We just eliminated every switch statement from the entire application and switched them to separate classes and a simple factory. Now, what happens when a new product type comes in?

  • We need to add a new class that extends the abstract PriceType class
  • We need to add a new case to the PriceTypeFactory class

Or put in other words: instead of changing everything, we can extend our existing classes with the new functionality

All we needed was a factory, some strategy classes, and a little bit of polymorphism. Of course, now we're only talking about prices that depend on the product type, but usually, there are other things as well. All we need to do is "repeat" this process and introduce another class hierarchy.

Oh and of course, we are cool kids, so let's modernize the factory:

class PriceTypeFactory
{
  public function create(OrderItem $item): PriceType
  {
    return match ($item->product->type) {
      'standard' => new StandardPriceType($item->product),
      'has_batches' => new HasBatchesPriceType($item->product),
      'has_toppings' => new HasToppingsPriceType($item->product),
    };
  }
}

Or even better we can get rid of the whole thing with the magic strings and we can use an enum that can behave like a factory:

enum PriceTypes: string
{
  case Standard = 'standard';
  case HasBatches = 'has_batches';
  case HasToppings = 'has_toppings';

  public function create(OrderItem $item): PriceType
  {
    return match ($this) {
      self::Standard => new StandardPriceType($item),
      self::HasBatches => new HasBatchesPriceType($item),
      self::HasToppings => new HasToppingsPriceType($item),
    };
  }
}

The attribute accessor looks like this:

class OrderItem extends Model
{
  public function priceType(): Attribute
  {
    return new Attribute(
      get: fn () => PriceTypes::from(
        $this->product->price_type
      )->create($this),
    );
  }
}

Liskov Substitution Principle

Each base class can be replaced by its subclasses

It sounds obvious and I think this is the easiest principle to comply with. However, there are some important things.

The principle says that if you have a base class and some subclasses, you should be able to replace the base class with the subclasses anywhere inside your application without any problem.

Consider this scenario:

abstract class EmailProvider
{
  abstract public function addSubscriber(User $user): array;
  
  /**
   * @throws Exception
   */
  abstract public function sendEmail(User $user): void;
}

class MailChimp extends EmailProvider
{
  public function addSubscriber(User $user): array
  {
    // Using MailChimp API
  }
  
  public function sendEmail(User $user): void
  {
    // Using MailChimp API
  }
}

class ConvertKit extends EmailProvider
{
  public function addSubscriber(User $user): array
  {
    // Using ConvertKit API
  }
  
  public function sendEmail(User $user): void
  {
    // Using ConvertKit API
  }
}

We have an abstract EmailProvider and we use both MailChimp and ConvertKit for some reason. These classes should behave exactly the same way, no matter what.

So if I have a controller that adds a new subscriber:

class AuthController
{
  public function register(
    RegisterRequest $request, 
    EmailProvider $emailProvider
  ) {
    $user = User::create($request->validated());
    
    $subscriber = $emailProvider->addSubscriber($user);
  }
}

I should be able to use any of these classes without any problem. It should not matter if the current EmailProvider is MailChimp or ConvertKit. I also should be able to switch the argument:

public function register(
  RegisterRequest $request, 
  ConvertKit $emailProvider
) {}

This sounds obvious, however, there are some important thing that needs to be satisfied:

  • Same method signatures. In PHP we're not forced to use types so it can happen that the addSubscriber method has different types in MailChimp compared to ConvertKit.
  • It's also true for return types. Of course, we can type-hint these, but what about an array or a Collection? It's not guaranteed that an array contains the same types in multiple classes, right? As you can see, the addSubscriber method returns an array that contains the subscriber's data received from the APIs. Both MailChimp and ConvertKit return a different shape. They are arrays, yes, but they are completely different data structures. So I cannot be 100% sure that RegisterController works correctly with any email provider implementation. This is why it's a good idea to have DTOs when working with 3rd parties.
  • The same exceptions should be thrown from each method. Since exceptions cannot be type-hinted in the signature it's also a source of difference between these classes.

As you can see the principle is quite simple but it's easy to make mistakes.

Interface Segregation Principle

You should have many small interfaces instead of a few huge ones

The original principle sounds like this: no code should be forced to depend on methods it does not use but the practical implication is the definition I gave you. To be honest, this is the easiest principle to follow. In the DashDoor example (see in the open-closed principle chapter), products have a type, such as:

  • Standard
  • Has batches
  • Has toppings
  • etc

We had a separate class to handle price calculations for these types. In a real-world application price is not the only thing that depends on the type. There are other things such as:

  • Reports
  • Data representation
  • Inventory management
  • Tax and VAT calculations
  • Information on the receipt

In the original example, we had these classes:

  • ProductPriceType
  • StandardProductPrice
  • HasBatchesProductPrice
  • HasToppingsProductPrice

Each of them handles the price calculation for a certain type of product. Imagine if we write some generic ProductType class, such as:

  • ProductType
  • StandardProduct
  • HasBatchesProduct
  • HasToppingsProductPrice

And we try to handle everything in these classes. So they have functions like this:

interface ProductType
{
  public function calculatePrice(Product $product): float;
  
  public function decreaseInventory(Product $product): void;
  
  public function calculateTaxes(Product $product): TaxData;
  
  // ...
}

I guess you can see what's the problem. This interface is too big. It handles too many things. Things that are independent of one another. So instead of writing one huge interface to handle everything we separate these responsibilities into smaller ones:

interface ProductPriceType
{
  public function calculatePrice(Product $product): float;
}

interface ProductInventoryHandler
{
  public function decreaseInventory(Product $product): void;
}
  
interface ProductTaxType
{
  public function calculateTaxes(Product $product): TaxData;
}

Another great example of this is PHP traits and how the framework itself, 1st and 3rd party packages and the community uses them:

class Broadcast extends Model implements Sendable
{
  use WithData;
  use HasUser;
  use HasAudience;
  use HasPerformance;
}

Each of those traits has a pretty small and well-defined interface and it adds a small chunk of functionality to the class. The same goes for the Sendable interface.

Dependency Inversion Principle

Depend upon abstraction, not concretions.

Whenever you have a parent class and one or more subclasses you should use the parent class as a dependency. For example:

abstract class MarketDataProvider
{
  abstract public function getPrice(string $ticker): float;
}

class IexCloud extends MarketDataProvider
{
  public function getPrice(string $ticker): float
  {
    // Using IEX API
  }
}

class Finnhub extends MarketDataProvider
{
  public function getPrice(string $ticker): float
  {
    // Using Finnhub API
  }
}

It should be quite straightforward at this point, that we want to do something like this:

class CompanyController
{
  public function show(
    Company $company, 
    MarketDataProvider $marketDataProvider
  ) {
    $price = $marketDataProvider->getPrice();
    
    return view('company.show', compact('company', 'price'));
  }
}

So every class should depend on the abstract MarketDataProvider not on the concrete implementation.

In my opinion, it's important to have these abstractions even if you have only one implementation when it comes to 3rd party providers. The reason is that these services and providers change, and you never know what will happen in the future. Just to name a few examples:

  • I was using IEX cloud in a financial app a long time ago. I thought that IEX API is the only constant thing in that project. It was great, it was stable, etc. Until they switched from a monthly subscription to usage-based pricing (if I remember correctly). They essentially 3x our expenses. In a project that did not yet have income. So we switched to Finnhub. But of course, we didn't have the correct abstractions so it was a pain in the ass.
  • I've been using Gumroad since I began to publish content. I thought I'd never use any other platform. Until this book. This is powered by Paddle. Gumroad 2x'd their prices and Paddle 100% better from an accounting point-of-view.
  • I always thought Stripe was the best payment provider in the entire universe. Until I tried Paddle. Now they are my go-to solution and I'm using them in multiple projects.
  • A long time ago MailChimp was the industry-standard mail service provider for me. Now I'm using ConvertKit almost exclusively.
  • We used Azure at my current workplace until we got free credits from Google that will cover our expenses for the next two years.

Services and providers change and you should be able to handle these changes with minimum effort. Minimum effort means an abstraction above the concrete classes that can be switched without modifying your code.

This whole article comes from my new 250-page book Laravel Concepts. Check it out:

Laravel Concepts