Using subqueries in Eloquent/Laravel
Stefan Bogdanescu
Founder & Senior Architect · 2026-06-29
Mastering Complex Queries: Using Subqueries in Eloquent and Laravel
As developers working with relational databases through an ORM like Eloquent, we often encounter scenarios where the most elegant solution lies not just in defining relationships, but in mastering complex SQL constructs. The example query you provided—involving a derived table (a subquery in the FROM clause) to calculate a maximum date before filtering—is a perfect illustration of this challenge.
While Eloquent is fantastic for standard CRUD operations and simple one-to-many/many-to-many relationships, complex aggregations and multi-layered subqueries often require us to step into the query builder or use raw expressions to achieve optimal performance.
Let's break down how you translate that specific SQL logic into idiomatic Laravel Eloquent.
Understanding Subqueries in the Context of Eloquent
A subquery is essentially a query nested inside another query. In your example, the inner query calculates the refreshDate (the maximum balance creation date for each character), and the outer query filters based on that calculated value.
In raw SQL, this is handled by a Derived Table (a subquery in the FROM clause). Eloquent doesn't always map these complex structural elements directly to simple relationship methods. Therefore, we have two primary strategies: using Eloquent's advanced filtering features or leveraging the power of the Query Builder.
Strategy 1: Using Eloquent with Nested Where Clauses (The Relational Approach)
For simpler tasks, you can sometimes achieve this by defining relationships and chaining whereHas clauses. However, for aggregations like finding the maximum date across joined tables, this often becomes cumbersome or inefficient compared to a direct subquery.
Strategy 2: Leveraging Eloquent's Query Builder (The Direct Translation)
When dealing with complex grouping and aggregation, the most robust approach is to use the where clause combined with a nested subquery, which mirrors your original SQL structure perfectly. This keeps the logic explicit and gives you full control over the database operation.
Here is how we can translate your SQL into a Laravel Eloquent query, assuming you have models for Character and Balance:
use App\Models\Character;
use Illuminate\Support\Facades\DB;
$cutoffDate = '2017-03-29';
$results = Character::where(function ($query) use ($cutoffDate) {
// This is the core of the subquery logic translated into a closure.
$query->select('characters.id')
->join('balances', 'characters.id', '=', 'balances.character')
->whereNotNull('characters.refreshToken')
->groupBy('characters.id')
->selectRaw('MAX(balances.created_at) as refreshDate'); // Note: We select the calculated date
})
// Now, we join back to the main characters table to apply the final filter
->join(DB::raw('(' . DB::raw("SELECT characters.id, MAX(balances.created_at) as refreshDate FROM characters c INNER JOIN balances b ON c.id = b.character WHERE c.refreshToken IS NOT NULL GROUP BY c.id") . ') as t1'), 'characters.id', '=', 't1.id')
->where('t1.refreshDate', '<', $cutoffDate)
->get();
A Cleaner, More Direct Approach using whereExists or whereIn:
While the above demonstrates the complexity, a more readable and often more performant way to handle this in Laravel is to use a subquery within a where clause. This forces the database to execute the aggregation first, which is exactly what your SQL was designed to do:
use App\Models\Character;
use Illuminate\Support\Facades\DB;
$cutoffDate = '2017-03-29';
$characters = Character::whereExists(function ($query) use ($cutoffDate) {
// Subquery: Find characters that meet the criteria defined in the derived table (t1 in your SQL)
$query->select(DB::raw(1))
->from('balances')
->join('characters', 'balances.character', '=', 'characters.id')
->whereNotNull('characters.refreshToken')
->groupBy('characters.id')
->havingRaw('MAX(balances.created_at) < ?', [$cutoffDate]);
})
->select('characters.*') // Select the final character details
->get();
// Note: If you need to select aggregated data from the main table,
// you would typically use a separate join or a more complex CTE approach.
Developer Takeaways
When translating raw SQL subqueries into Eloquent, remember these principles:
- Performance First: Always ensure that your logic translates efficiently to the database. For complex aggregations, using
whereExistsor nestedwhereclauses (as shown above) often results in better execution plans than trying to force Eloquent relationships for highly specific derived table logic. - Use
DB::raw()Judiciously: When the required operation (likeMAX(),GROUP BY, or complex joins) doesn't map cleanly to an Eloquent relationship, useDB::raw()to inject your precise SQL logic directly into the query builder. This is a powerful tool for advanced scenarios, especially when working with database-level features on platforms like Laravel where you can leverage the power of frameworks like Laravel Company. - Readability Matters: While raw queries are powerful, try to structure your Eloquent code so that it remains readable. For extremely complex operations, sometimes defining a dedicated Model query or using Common Table Expressions (CTEs) within the database itself can simplify the PHP layer significantly.
By understanding how subqueries work at a fundamental level and knowing when to bridge the gap between SQL and Eloquent using DB::raw(), you gain the ability to write highly optimized and expressive database interactions in Laravel.