VS Vikesh Singh

Courses PHP & Laravel Lesson 11

PHP & LARAVEL · LESSON 11 OF 18

Model Relationships: hasMany, belongsTo, and Beyond

· 5 min read

Lesson 9 mentioned relationships in passing while explaining the N+1 query problem.

Lesson 9 mentioned relationships in passing while explaining the N+1 query problem. This lesson covers them properly, because relationships are what let Eloquent model real-world data, where almost nothing exists in true isolation: a trade belongs to a user, a user has many trades, a post has many comments, and so on.

HasMany And BelongsTo

The two foundational relationship types are hasMany and belongsTo, and they are always a matched pair describing the two directions of the same one-to-many relationship. One user has many trades; each trade belongs to exactly one user:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class User extends Model
{
    public function trades(): HasMany
    {
        return $this->hasMany(Trade::class);
    }
}

class Trade extends Model
{
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

With both sides declared, you can now navigate the relationship in either direction, and this is where Eloquent's design genuinely earns the "it feels like plain PHP objects" description from this lesson's title:

$tonyStark = User::find(1);
$allHisTrades = $tonyStark->trades;

foreach ($tonyStark->trades as $trade) {
    echo $trade->symbol . "\n";
}

$trade = Trade::find(42);
echo $trade->user->name; // Tony Stark

Notice $tonyStark->trades, accessed without parentheses, as a property, even though trades() is defined as a method. This is intentional Eloquent magic: calling the method directly, $tonyStark->trades(), returns the relationship query builder itself (useful when you want to add more conditions, like $tonyStark->trades()->where('symbol', 'NIFTY')->get()), while accessing it as a property, $tonyStark->trades, actually executes the query and gives you the loaded collection of results. Knowing this distinction matters, because it is the exact mechanism behind the N+1 problem from lesson 9: each time you access ->trades as a property on a different user inside a loop, triggers its own fresh query, unless you eager-load it first with User::with('trades')->get().

Many To Many Relationships

belongsToMany handles many-to-many relationships, where both sides can have multiple matches: a user can take many courses, and a course can have many enrolled users. This requires a third table in the database, called a pivot table, purely to record which pairs are connected:

class User extends Model
{
    public function courses(): BelongsToMany
    {
        return $this->belongsToMany(Course::class);
    }
}

class Course extends Model
{
    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class);
    }
}

$tonyStark->courses()->attach($courseId);
$tonyStark->courses; // collection of Course models

By Laravel convention, the pivot table name is the two model names, singular, alphabetically ordered, joined with an underscore: course_user. attach() inserts a new row into that pivot table connecting the two records; detach() removes it. This pivot table is itself created through an ordinary migration (lesson 10), typically containing just the two foreign key columns.

HasManyThrough Relationships

A relationship type that trips up almost everyone the first time they need it is hasManyThrough, used when you want to reach a relationship that is one more hop away than hasMany alone can reach. Say a User has many Trades, and each Trade has many TradeNotes (from lesson 10's exercise); if you want every note belonging to a given user, across all their trades, without writing a manual join:

class User extends Model
{
    public function tradeNotes(): HasManyThrough
    {
        return $this->hasManyThrough(TradeNote::class, Trade::class);
    }
}

$tonyStark->tradeNotes; // every TradeNote across every one of Tony's trades

Eloquent figures out the two-step join (users to trades to trade_notes) from the relationship definitions already in place on User and Trade, and gives you a single flat collection, exactly as if tradeNotes were a direct relationship.

Eager Loading To Avoid N Plus One

Now the practical lesson that matters most day to day: eager loading, properly. The fix for N+1 queries is not just "remember to call with()"; it is understanding which relationships a given piece of code is actually going to touch, up front, and loading exactly those, no more and no less:

$trades = Trade::with(['user', 'notes'])->get();

foreach ($trades as $trade) {
    echo $trade->user->name . ': ' . $trade->notes->count() . " notes\n";
}

This runs exactly three queries total (one for trades, one for all their users, one for all their notes), no matter whether there are 5 trades or 5,000. Loading relationships you never actually use in that request, though, wastes database work for nothing, so with() is not a thing to sprinkle everywhere defensively; it is a thing to add deliberately once you know, from the actual view or logic that follows, which relationships will be accessed.

Common Relationship Mistakes

One correctness mistake specific to relationships, worth calling out because it is easy to miss in a code review: forgetting that $trade->user can be null if the relationship is optional (nullable foreign key) or if the related row was deleted without cascading. Calling $trade->user->name in that situation throws a fatal error ("Attempt to read property 'name' on null"), not a gentle warning. If a relationship can legitimately be missing, check for it ($trade->user?->name, using PHP 8's nullsafe operator) rather than assuming it always exists.

A final practical note: when deciding between hasMany and belongsToMany, let the real-world question decide, not convenience. Ask whether one side of the relationship can genuinely have more than one of the other at once, in both directions. A trade belonging to exactly one user, but a user having many trades, is a one-to-many, hasMany/belongsTo pair, full stop; forcing it through a pivot table because belongsToMany "feels more flexible" only adds an unnecessary extra table and an unnecessary extra join to every single query that touches it.

Try it yourself: add a comments() hasMany relationship and matching post() belongsTo relationship between a Post and Comment model, then write a query using Post::with('comments')->get() and loop over each post printing its comment count, confirming (with Laravel Debugbar or by counting manually with DB::enableQueryLog()) that it runs a fixed, small number of queries regardless of how many posts or comments exist.