portrait photo Eoghan O'Brien web developer

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.