Dedicated, autocompleted and typed Eloquent query builders
April 04, 2020 — 5 min readBefore we start, this article was heavily inspired by Tim MacDonald’s article “Dedicated query builders for Eloquent models”. You should definitely check it out.
Wouldn’t it be cool if Laravel’s query builder had autocomplete? Even on deep relationship queries? We can achieve this with dedicated query builders.
Our first dedicated query builder
Let’s define a new UserBuilder
, which will extend Eloquent’s base query builder:
<?php
declare(strict_types=1);
namespace App\Builders;
use Illuminate\Database\Eloquent\Builder;
final class UserBuilder extends Builder
{
public function whereName(string $name): self
{
return $this->where('name', $name);
}
}
Notice how we’ve defined a custom whereName
method? All the methods that we’re defining in our custom query builder will be basically simple wrappers around Eloquent’s query builder methods.
Linking our custom query builder with the model
Eloquent models have a newEloquentBuilder
method under the hood, which by default returns the base query builder. We can link our custom query builder with the desired model by overriding this method:
<?php
declare(strict_types=1);
namespace App;
use App\Builders\UserBuilder;
use Illuminate\Foundation\Auth\User as Authenticatable;
final class User extends Authenticatable
{
public function newEloquentBuilder($query): UserBuilder
{
return new UserBuilder($query);
}
}
Great! Now we can use the whole thing like this:
User::whereName('Alex')->first();
But unfortunately, our IDE will not provide autocomplete, since it can’t recognize our custom query builder just by overriding the newEloquentBuilder
method.
To get autocomplete working, we’ll have to type hint the return type of the query
method, available in Eloquent models.
<?php
declare(strict_types=1);
namespace App;
use App\Builders\UserBuilder;
use Illuminate\Foundation\Auth\User as Authenticatable;
final class User extends Authenticatable
{
/**
* @return \App\Builders\UserBuilder|\Illuminate\Database\Eloquent\Builder
*/
public static function query()
{
return parent::query();
}
public function newEloquentBuilder($query): UserBuilder
{
return new UserBuilder($query);
}
}
Now, to get static analysis working, we’ll have to start all our queries with the query
method:
User::query()->whereName('Alex')->first();
Static analysis on relationship queries
Let’s define a new Article
model:
<?php
declare(strict_types=1);
namespace App;
use App\Builders\ArticleBuilder;
use Illuminate\Database\Eloquent\Model;
final class Article extends Model
{
/**
* @return \App\Builders\ArticleBuilder|\Illuminate\Database\Eloquent\Builder
*/
public static function query()
{
return parent::query();
}
public function newEloquentBuilder($query): ArticleBuilder
{
return new ArticleBuilder($query);
}
}
And a new ArticleBuilder
for our Article
model:
<?php
declare(strict_types=1);
namespace App\Builders;
use Illuminate\Database\Eloquent\Builder;
final class ArticleBuilder extends Builder
{
public function wherePublished(bool $published): self
{
return $this->where('published', $published);
}
}
Let’s assume that our User
model has many articles, so let’s define a HasMany
relationship:
<?php
declare(strict_types=1);
namespace App;
use App\Builders\UserBuilder;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class User extends Authenticatable
{
/**
* @return \App\Builders\UserBuilder|\Illuminate\Database\Eloquent\Builder
*/
public static function query()
{
return parent::query();
}
public function newEloquentBuilder($query): UserBuilder
{
return new UserBuilder($query);
}
public function articles(): HasMany
{
return $this->hasMany(Article::class);
}
}
Now, by building a query from an user’s articles relationship, we won’t get any autocomplete, since the articles
relationship method returns an Illuminate\Database\Eloquent\Relations\HasMany
instance:
User::articles()->??????->first();
To solve this, we can also type hint the return type of the articles
relationship method:
/**
* @return \App\Builders\ArticleBuilder|\Illuminate\Database\Eloquent\Relations\HasMany
*/
public function articles()
{
return $this->hasMany(Article::class);
}
And we’ve solved the problem:
User::articles()->wherePublished(true)->first();
Static analysis on “whereHas” queries
What if we want to retrieve all the users, which have published articles only? Normally we’d do it like this:
User::whereHas('articles', function ($query) {
$query->where('published', true);
})->get();
However, since we are moving away from the classic query building, we’re going to define a whereHasArticles
method on our UserBuilder
:
public function whereHasArticles(\Closure $callback = null): self
{
return $this->whereHas('articles', $callback);
}
Which will be used like this:
User::query()
->whereHasArticles(fn(ArticleBuilder $query) => $query->wherePublished(true))
->get();
Static analysis on “with” queries
To retrieve all the users with their published articles only, this is what we’d normally do:
User::with([
'articles' => function ($query) {
$query->where('published', true);
},
])->get();
But, we’ll define a custom withArticles
method in our UserBuilder
:
Keep in mind that the
with
query closures will inject instances of relationships, likeHasMany
in our case. Fortunately, relationship instances provide agetQuery
method, which will return our dedicated query builder.
public function withArticles(\Closure $callback = null): self
{
return $this->with([
'articles' => fn(HasMany $query) => $callback ?
$callback($query->getQuery()) :
$query,
]);
}
The method can be used like this:
User::query()
->withArticles(fn(ArticleBuilder $query) => $query->wherePublished(true))
->get();
Caveat: multi-where scopes
Read about this caveat on Tim MacDonald’s article “Dedicated query builders for Eloquent models”.
Wrapping up
I’ve showed how you can type hint your with
/ whereHas
closures, but it can also be applied to basically all types of queries, like whereHasMorph
, and so on.
I do really enjoy this approach, since I’m always trying to get static analysis working everywhere and I believe that this approach is the only one available out there to get it working with Eloquent and to eliminate the magic. It’s extremely helpful once your team grows or projects get larger, at least in my humble opinion.
Thanks for reading this post! Subscribe below and get notified when new posts will be released. Also, let me know on Twitter (@sandulat) what do you think about this approach.
Join the Newsletter
Subscribe to get my latest posts by email.