Finding Eloquent records where the count of all children for a parent equals a database-stored value
Whew.
That title is a lot to take in. Let's break it down, cause this is actually a simple concept.
Example Scenario
Let's imagine we have 2 Eloquent models - a Question
and an Answer
. Those models are linked by a simple BelongsTo
and HasMany
relationship (a Question->hasMany(Answer)
and an Answer->belongsTo(Question)
).
Now let's imagine our business logic requires that someone can set a maximum number of answers permitted for each question. Ok, so our Question
model will get an attribute called max_answers
. If a particular Question
has a count of Answers
that matches the integer stored in the max_answers
attribute, we will consider that Question
"full". Perhaps in our imaginary app, we would only reveal answers to "full" questions and so we need to account for this in our API responses for the /answers
endpoint.
There are other scenarios where this could prove useful: polls with special logic around a user-configurable number of votes, an ecommerce product that requires a user-configurable number of orders before it goes into production, etc. The key here is "user-configurable". At the very least, this exercise will help you explore some advanced Eloquent concepts.
Here's where the title of this article comes in...
Rephrasing the question
What if you wanted to find a list of Answers
that belong to a "full" Question
?
You may know, from the Laravel docs, that we can query for records based on the existence of a relation and include a count in that query. It looks like:
Model::query()->has('relation', '=', :integer:)->...;
Or, if you need to specify a constraint on the relation:
Model::query()->whereHas(
'relation',
function(Builder $relationQuery) {
$relationQuery->where('foo', 'bar');
...
}
'=',
:integer:
)->...;
Using our Question
and Answer
example, we could find a list of questions that have exactly 5
answers like so:
Question::query()->has('answers', '=', 5)->get();
Don't forget the operator (
=
above) could be any database compliant operator (ex.<
,>
,<>
, etc).
However, all of these examples use a hard-coded count value (:integer:
).
How do we do this with a count value that is stored in the database so that we can look for "full" Questions
?
Unlocking additional functionality
It turns out it's possible to leverage the has()
or whereHas()
methods to accomplish this. At first, this will seem impossible because the Laravel has()
method specifies the third parameter must be of integer
type via docBlocks. Nonetheless, the following does work, but it's important to wrap our special count in a Query\Expression
, otherwise Eloquent will use a literal string in the final query to the database (like 'questions.max_answers'
vs. questions.max_answers
), which would cause a useless integer to string comparison by the database engine.
Let's first describe the query for a list of "full" Questions
directly, since that's conceptually easier to grasp.
Question::query()
->has('answers', '=', new \Illuminate\Database\Query\Expression('questions.max_answers'))
->get();
// or, using the DB Facade...
Question::query()
->has('answers', '=', \Illuminate\Support\Facades\DB::raw('questions.max_answers'))
->get();
Side note: you can use
(new Question)->qualifyColumn('max_answers')
vs.'questions.max_answers'
if you want Laravel to qualify the columns for you. You will see a version of this shortly, just using an existing query Builder instance instead of a new Model instance...
Technically, one could perform a relation load on the Questions
there to resolve the Answers
to our original question (Question::query()->with('answers')->has(...)->get()
), but the point of this article is to show you how to accomplish the Eloquent setup by looking up the Answers
directly.
Final Solution
Ok. We're ready to take this one step further. Let's find Answers
that belong to a "full" Question
.
The final solution conceptually uses a combination of what we discussed above:
Answer::query()
->whereHas(
'question',
fn (Builder $whereQuestion) => $whereQuestion
->has('answers', '=', $whereQuestion->qualifyColumn('max_answers'))
)
->get();
As you can see, it's possible to use the whereHas()
method to easily say "give me answers that belong to a 'full' question as saved in the db."
Hint: under the hood, the
whereHas()
method on the\Illuminate\Database\Eloquent\Concerns\QueriesRelationships
trait is nothing more than an alias to thehas()
method. ThewhereHas()
definition simply reorders the parameters so that the sub query Closure is in the second slot.
Bonus Round
Want to see two other ways of doing this? See if you can figure out what's going on and how the database may handle each query (use of indexes, etc).
Answer::query()
->whereIn('question_id', fn($questionIdQuery) => $questionIdQuery
->select('id')
->from('questions')
->where('questions.max_answers', '=', fn($subQuery) => $subQuery
->selectRaw('count(*)')
->from('answers')
->whereColumn('questions.id', 'answers.question_id')
)
)
...
The query above is essentially an alternate version of a WHERE EXISTS (...) query and will perform similarly due to foreign key indexes.
Answer::query()
->whereHas('question', fn($query) => $query
->withCount('answers')
->groupBy('id')
->havingRaw('answers_count = questions.max_answers')
)
...
Just in case you want to confuse someone by using a HAVING clause... 😅