4 ways to deal with complexity in your eloquent models

I think everyone loves to work on completely greenfield applications. You get to plan your own course, chose your current favourite technologies, structures, and patterns to follow. There is no legacy code, no technical debt, nothing that stands in your way. You can do whatever you want and building features is a breeze.

But you know the story. You know what happens next.

Your application grows. New requirements come in, and old features need to be changed.

You do your best to keep customers happy, but after a while complexity creeps in and you find yourself in a position where you start doing every possible hack and take every crappy decision to fight your own code into submission.

One of the places our application tends to grow is in our model layer. The usual suspects are the User class and whatever models are part of the core of our application. If you’re building a content management system, Post would be one of the suspects. Selling stuff? Take a look at the Order class.

We’re going to look at ways to deal with complexity in our eloquent models.

Use traits

Traits are the easiest way to slim down an eloquent model. Create a new file, copy and paste a bunch of methods, and BAM! – your model is 100 lines thiner.

The problem with traits is that most of the time you end up sweeping dirt under the rug. You want to cleanup the model but instead of thinking really hard at the problem, you take the trash and sweep it under a trait.

The biggest problem is not the accumulated dirt. Everyone has dirt on their floor at some point. The problem is that, with traits, you won’t really notice it. You’ll look at the room, thinking it’s clean and tidy.

Having your model spread over multiple files makes it a lot harder to identify new concepts and behaviour that can be given representations in your system. You clean and organise things you can see. Code that is not seen is not refactored.

Nevertheless, especially in programming, things are never black and white, and traits are sometimes a good option.

Query builders

One of Eloquent’s nicest features are query scopes. They allow you to pick a common set of constraints, name it, and re-use it through out your application. Let’s take the following example of a Video model:

class Video extends Model
{
    public function scopeDraft($query)
    {
        return $query->where('published', false);
    }

    public function scopePublished($query)
    {
        return $query->where('published', true);
    }

    public function scopeLive($query)
    {
        return $query->published()
            ->where('published_at', '<=', Carbon::now());
    }
}

Once the list of scope methods starts to get in our way we can move them into a dedicated query builder class like bellow. Notice that we no longer need the scope prefix, nor to pass in the $query parameter as we are now in a query builder context.

class VideoQueryBuilder extends Builder
{
    public function draft()
    {
        return $this->where('published', false);
    }

    public function published()
    {
        return $this->where('published', true);
    }

    public function live()
    {
        return $this->published()
            ->where('published_at', '<=', Carbon::now());
    }
}

To replace the default query builder with our enhanced one, we override the newEloquentBuilder method in our Video model like bellow:

class Video extends Model
{
    public function newEloquentBuilder($query)
    {
        return new VideoQueryBuilder($query);
    }
}

Move event listeners to Observer classes

When models are short and easy to go through, I like to keep the event listeners right in the boot method so I don’t need to switch to a second file to figure out what happens when. But, when the model starts growing and growing, moving the event listeners into their own observer class is a good trade-off.

protected static function boot()
{
    parent::boot();

    self::saved(function(BodyState $bodyState) {
        $bodyState->update([
            'macros_id' => $this->user->latestMacros->id
        ]);
    });

    self::created(function(BodyState $bodyState) {
        if ($bodyState->user->logBodyStateNotification) {
            $bodyState->user->logBodyStateNotification->markAsRead();
        }
    });
}

Bonus tip 1: Instead of a having a bunch of CRUD calls, when possible, make your code as expressive as possible.

class BodyStateObserver
{
    public function saved(BodyState $bodyState)
    {
        $bodyState->associateWithLatestMacros();
    }

    public function created(BodyState $bodyState)
    {
        $bodyState->user->markLogBodyStateNotificationsAsRead();
    }
}

Bonus tip 2: Instead of hiding your observers in a provider class, register them in the model’s boot method. Not only you’ll know that observer exists, but you’ll also be able to quickly navigate to it from your model.

class BodyState extends Model
{
    protected static function boot()
    {
        parent::boot();
        self::observe(BodyStateObserver::class);
    }
}

Value objects

When you notice two or more things that seem to always go together, for example street name and street number, start date and end date, you have an opportunity to extract and represent them into a single concept. In our case Address and DateRange.

Another way to detect this kind of objects is to look for methods that “play” a lot with one of your model’s attributes. In the example bellow it seems there are quite a few things we do with price (in cents) of the product.

class Product
{
    public function getPrice()
    {
       return $this->price;
    }

    public function getPriceInDollars()
    {
       return $this->price / 100;
    }

    public function getPriceDisplay()
    {
       return (new NumberFormatter( 'en_US', NumberFormatter::CURRENCY ))
          ->formatCurrency($this->getPriceInDollars(), "USD");
    }
}

We can extract these methods into a Price class.

class Price
{
    public function __constructor($cents)
    {
        $this->cents = $cents;
    }

    public function inDollars()
    {
        $this->cents / 100;
    }

    public function getDisplay()
    {
       return (new NumberFormatter('en_US', NumberFormatter::CURRENCY))
          ->formatCurrency($this->getDollars(), "USD");
    }
}

Remove the previous methods and return the Price class.

class Product
{
    public function getPrice()
    {
        return new Price($this->price);
    }
}

There are many other ways of putting your models on a diet (service objects, form objects, decorators, view objects, policies and others), but the ones I shared above are the ones I tend to reach out the most when my models need to lose some weight. I hope it makes a good diet for your models too 🙂

PS: You should also check Actions in Laravel beyond CRUD

Photo by Mandy Choi