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.
<?phpnamespace 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.