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.