Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Eloquent Relationships You Might Be Underusing (and the Queries They Replace)

About Post

Open almost any Laravel codebase and you'll find hasMany and belongsTo everywhere. Then, right next to them, you'll find code like this:

$unitIds = $building->units()->pluck('id');
$contracts = Contract::whereIn('unit_id', $unitIds)->get();

foreach ($contracts as $contract) {
    $contract->last_payment = $contract->payments()->latest()->first();
}

It works. It also runs one query per contract, hides a relationship that deserves a name, and will be copy-pasted into three more controllers by the end of the year.

Eloquent has had answers to these patterns for years. Here are the relationships and helpers I see underused most, each with the hand-written code it replaces. The examples use a simple domain: buildings have units, units have contracts, contracts have payments.

1. hasManyThrough: skip the middle table

You wrote: pluck the unit IDs, then query contracts with whereIn.

There's a relationship for that:

class Building extends Model
{
    public function contracts(): HasManyThrough
    {
        return $this->hasManyThrough(Contract::class, Unit::class);
    }
}

$building->contracts()->where('status', 'active')->count();
Building::withCount('contracts')->get();

It's one join instead of two round trips, and because it's a real relationship, you get eager loading, withCount and whereHas for free. Laravel also offers a fluent style, $this->through('units')->has('contracts'), which some people find easier to read.

The default keys assume units.building_id and contracts.unit_id. If yours differ, pass them as extra arguments.

2. latestOfMany and ofMany: "the one that matters" as a relationship

You wrote: $contract->payments()->latest()->first() inside a loop. One query per row, the classic N+1.

There's a relationship for that:

public function latestPayment(): HasOne
{
    return $this->hasOne(Payment::class)->latestOfMany('paid_at');
}

public function largestPayment(): HasOne
{
    return $this->hasOne(Payment::class)->ofMany('amount', 'max');
}

$contracts = Contract::with('latestPayment')->get(); // no N+1

This is one of my favourite Eloquent features, and one of the least known. A "has one of many" relationship turns "the latest", "the oldest" or "the biggest" into something you can eager load for a whole list in a single query. There's also oldestOfMany(), and ofMany() accepts a closure for extra conditions, such as only counting payments that cleared.

3. withSum, withMax and withExists: aggregates without loading rows

You wrote: $contract->payments->sum('amount'), which loads every payment model into memory just to add up one column.

There's a helper for that:

$contracts = Contract::query()
    ->withSum(['payments as paid_total' => fn ($q) => $q->where('status', 'cleared')], 'amount')
    ->withMax('payments', 'paid_at')        // payments_max_paid_at
    ->withExists('maintenanceRequests')     // maintenance_requests_exists
    ->get();

Each one adds a subquery to the SELECT, so the database does the maths and you get a plain attribute on each model. You can even sort by it: ->orderByDesc('paid_total'). There's also withMin, withAvg, and withAggregate as the general form.

Two small gotchas: a contract with no payments gets null, not 0, and sums may come back as strings depending on your database driver. Cast before doing maths.

4. whereRelation: whereHas without the closure

You wrote:

Contract::whereHas('tenant', function ($query) {
    $query->where('is_verified', true);
})->get();

There's a shortcut for that:

Contract::whereRelation('tenant', 'is_verified', true)->get();
Contract::whereRelation('payments', 'amount', '>', 5000)->get();
Contract::whereRelation('unit.building', 'city', 'Doha')->get();

It's the same query underneath, but for a single condition it reads like a sentence. It supports operators and nested relations with dot notation, and there's orWhereRelation too. Its cousin whereBelongsTo($tenant) replaces where('tenant_id', $tenant->id) and works out the foreign key for you.

The mindset shift: a relationship is just a query with a name. Whenever you write the same join or lookup twice, ask whether it should be a relationship method instead. Named queries can be eager loaded, counted, filtered and tested; scattered ones can't.

5. Polymorphic relations: one table, many owners

You wrote: a contract_documents table, then a tenant_documents table, then a maintenance_documents table, all with the same columns.

There's a relationship for that:

// Migration: adds documentable_type and documentable_id
$table->morphs('documentable');

// On Contract, Tenant, MaintenanceRequest...
public function documents(): MorphMany
{
    return $this->morphMany(Document::class, 'documentable');
}

// On Document
public function documentable(): MorphTo
{
    return $this->morphTo();
}

One documents table, one upload component, one set of policies. Comments, attachments, activity logs and notes are all classic fits.

The gotcha: by default, Eloquent stores the full class name, like App\Models\Contract, in the _type column. Rename or move the class and existing rows point to nothing. Fix it on day one with a morph map in a service provider's boot():

Relation::enforceMorphMap([
    'contract' => Contract::class,
    'tenant' => Tenant::class,
    'maintenance_request' => MaintenanceRequest::class,
]);

The honest trade-off: the database can't enforce a foreign key on a polymorphic column. If referential integrity is critical for a relationship, separate tables with real foreign keys may still be the better choice.

Bonus: chaperone() for the reverse N+1

When you loop over $contract->payments and each payment accesses $payment->contract, Eloquent may query the contract again for every payment, even though you already have it. Adding ->chaperone() to the hasMany definition tells Laravel to set the parent on each child automatically.

Cheat sheet

If you're writing...Try
pluck('id') then whereInhasManyThrough
->latest()->first() in a looplatestOfMany / ofMany
->relation->sum('col')withSum, withMax, withExists
whereHas with one conditionwhereRelation
Identical tables for different ownersmorphMany with a morph map

The Eloquent relationships docs are long, but worth one slow read. Most developers skim them once early on and never return, which is exactly how these gems get missed.

Which Eloquent feature did you discover embarrassingly late, and what code did it let you delete?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close