Eoghan O'Brien

husband · father · developer at Brightspot · lead engineer on Ignite

Define a custom collection for your Eloquent model

One of the most common things I see when reviewing code written in Laravel, is over-complicated collection methods repeating functionality in multiple places throughout the codebase. Here's a contrived example from a review I did earlier this month with the real controllers/models substituted out.

<?php
namespace App\Http\Controllers;

use App\Post;

class RelatedPostsController extends Controller
{
    public function create()
    {
        $postOptions = Post::query()
            ->where('published', true)
            ->where('publish_date', '<=', now()->toDateTimeString())
            ->orderBy('publish_date', 'desc')
            ->get()
            ->keyBy('id')
            ->map(function ($post) {
                return $post->title;
            });

        return view('related-posts.create', compact('postOptions'));
    }
}

Logic similar to the above was sprinkled around a couple of controllers. The first thing I look at when reviewing code like above is readability. Laravel ships with a pretty expressive API so, to be honest, it doesn't read terribly. However, we're at the controller level, so the code should be as high-level as possible, implementation details should ideally be offloaded to a service or repository (I'm assuming you're working on a large project, not a blog or something where readability and design patterns are less valuable.)

As a quick aside, the first refactor I suggested was to pull the query filters out to eloquent scopes, for example:

 Post::where('publish_date', '<=', now()->toDateTimeString())->get()

can be written as follows using a scope:

Post::publishedBeforeNow()->get()

In your eloquent model, you would define the scope like so:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;

class Post extends Model
{
    public function scopePublishedBeforeNow(Builder $query)
    {
        $query->where('publish_date', '<=', now()->toDateTimeString())
    }
}

So, with all of the query filters converted to scopes, we now have:

 <?php

namespace App\Http\Controllers;

use App\Post;

class RelatedPostsController extends Controller
{
    public function index()
    {
        $postOptions = Post::query()
            ->published()
            ->publishedBeforeNow()
            ->orderByMostRecent()
            ->get()
            ->keyBy('id')
            ->map(function ($post) {
                return $post->title;
            });

        return view('related-posts.create', compact('postOptions'));
    }
}

Now we can deal with the collection methods keyBy() and map(). If you haven't twigged it yet, we're converting all of the posts into a dropdown friendly key-value array. I would argue that a friendly name like toOptionsArray() or toDropdown() would be much easier understand at this level than keyBy() and map(). The question then becomes, how do I code this method to be available after a call to get() on the query builder?

First of all we need to create a custom collection. I typically create a directory called Collections in /app.

<?php

namespace App\Collections;

use Illuminate\Database\Eloquent\Collection;

class PostsCollection extends Collection
{
    public function toDropdown($key = 'id', $value = 'title')
    {
        return $this->keyBy($key)->map(function ($post) use ($value) {
            return $post->getAttribute($value);
        });
    }
}

I'm basically just re-using the logic to map the id and title from the previous code but I'm wrapping it up so that they $key and $value can be changed dynamically.

Now, we just need to tell the eloquent model that we want to use this collection instead of the default Illuminate\Database\Eloquent\Collection.

<?php

namespace App;

use App\Collections\PostCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;

class Post extends Model
{
    public function newCollection(array $models = [])
    {
        return new PostCollection($models);
    }
}

Once we've added the hook above, we can refactor our controller like so.

<?php

namespace App\Http\Controllers;

use App\Post;

class RelatedPostsController extends Controller
{
    public function index()
    {
        $postOptions = Post::query()
            ->published()
            ->publishedBeforeNow()
            ->orderByMostRecent()
            ->get()
            ->toDropdown('id', 'title');

        return view('related-posts.create', compact('postOptions'));
    }
}

Finally, I typically recommend pulling the logic out into a repository class. A repository class typically holds all of the query functionality to your storage engine of choice. It's a good place to keep re-used queries in your application and they typically look something like the following:

<?php

namespace App\Repositories;

use App\Post;
use App\Contracts\Repositories\PostsRepositoryInterface;

class PostsRepository implements PostsRepositoryInterface
{
    public function getDropdownOptions($key = 'id', $value = 'title')
    {
        return Post::query()
            ->published()
            ->publishedBeforeNow()
            ->orderByMostRecent()
            ->get()
            ->toDropdown($key, $value);
    }
}

Our final refactor should really help illustrate how much simpler the controller could have been initially:

<?php

namespace App\Http\Controllers;

use App\Contracts\Repositories\PostsRepositoryInterface;

class RelatedPostsController extends Controller
{
    public function index(PostsRepositoryInterface $postsRepository)
    {
        $postOptions = $postsRepository->getDropDownOptions();

        return view('related-posts.create', compact('postOptions'));
    }
}

If you disagree with anything in the refactor or you have any questions, feel free to get in touch via twitter @eoghanobrien.