Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 016 / 100

Eloquent relationships explained: lessons from production

hasMany, belongsTo and many-to-many with examples.

Expona Admin6 min read

Introduction

Eloquent relationships are the backbone of Laravel's ORM. Get them wrong in production and you'll face N+1 queries, confusing data structures, and frustrated teammates debugging your models. Eloquent relationships explained: lessons from production means understanding not just that relationships work, but why they're shaped the way they are and how to wield them without shooting yourself in the foot.

Over the past decade, we've built everything from e-commerce platforms to SaaS dashboards. One pattern emerged every time: teams that nail relationship design early save weeks of refactoring later. This guide distills what we've learned.

Understanding relationship types and matching them to your database

The first rule is simple: your Eloquent relationships must reflect the actual shape of your database. Laravel gives you four main relationship types, each solving a different problem.

hasOne and belongsTo model a one-to-one relationship. Use hasOne when the parent owns a single child record, and belongsTo on the child to point back to the parent. A User has one Profile. The Profile belongs to a User. The foreign key (user_id) lives in the profiles table.

php
// User model
public function profile()
{
    return $this->hasOne(Profile::class);
}

// Profile model
public function user()
{
    return $this->belongsTo(User::class);
}

hasMany and belongsTo model one-to-many. An Order has many Items. Each Item belongs to an Order. The foreign key (order_id) lives in the items table.

php
// Order model
public function items()
{
    return $this->hasMany(OrderItem::class);
}

// OrderItem model
public function order()
{
    return $this->belongsTo(Order::class);
}

belongsToMany models many-to-many with a pivot table. A Product belongs to many Categories, and a Category has many Products. The pivot table product_category bridges them with product_id and category_id foreign keys.

php
// Product model
public function categories()
{
    return $this->belongsToMany(Category::class);
}

// Category model
public function products()
{
    return $this->belongsToMany(Product::class);
}

morphMany handles polymorphic relationships. A Comment can belong to a Post or a Video. Instead of two separate foreign key columns, you use a morphable_type and morphable_id.

php
// Post model
public function comments()
{
    return $this->morphMany(Comment::class, 'commentable');
}

// Video model
public function comments()
{
    return $this->morphMany(Comment::class, 'commentable');
}

// Comment model
public function commentable()
{
    return $this->morphTo();
}

The key: pick the relationship type that matches your database schema, not the other way around. Your schema is the source of truth.

Define relationships on both sides

A relationship is only half-useful if you define it one way. In production, you'll query from both directions. An Order needs to fetch Items. Sometimes Items need to fetch their Order. If you only define one side, you're making extra work for yourself later.

Consider a real e-commerce scenario. When you display an order detail page, you load the order and fetch its items. That's hasMany working forward. But later, a background job needs to update inventory based on items from the last 24 hours. It queries items first, then needs the order to check status. Now you need the inverse relationship.

php
// OrderItem model
public function order()
{
    return $this->belongsTo(Order::class);
}

// Later in a queue job:
$recentItems = OrderItem::where('created_at', '>=', now()->subDay())
    ->with('order') // Loads the order for each item
    ->get();

foreach ($recentItems as $item) {
    if ($item->order->status === 'completed') {
        // Update inventory
    }
}

Both directions make sense. Define them both.

Pivot data: storing extra information on many-to-many tables

Many-to-many relationships often need extra columns. A Product assigned to a Category might have a sort order specific to that category. A User assigned to a Role might have that role assigned on a specific date. The pivot table holds this metadata.

Laravel calls this pivot data. Access it with the pivot attribute after using withPivot().

php
// Product model
public function categories()
{
    return $this->belongsToMany(Category::class)
        ->withPivot('sort_order', 'display_name');
}

// Usage:
$product = Product::with('categories')->first();
foreach ($product->categories as $category) {
    echo $category->name; // From categories table
    echo $category->pivot->sort_order; // From pivot table
}

In a real scenario, imagine managing product visibility per category. Some categories show a product, others don't. You'd store is_visible in the pivot.

php
// migration
Schema::create('category_product', function (Blueprint $table) {
    $table->id();
    $table->foreignId('product_id')->constrained()->onDelete('cascade');
    $table->foreignId('category_id')->constrained()->onDelete('cascade');
    $table->boolean('is_visible')->default(true);
    $table->integer('sort_order')->default(0);
    $table->timestamps();
});

// Product model
public function categories()
{
    return $this->belongsToMany(Category::class)
        ->withPivot('is_visible', 'sort_order')
        ->orderByPivot('sort_order');
}

// Query visible categories only:
$visibleCategories = $product->categories()
    ->wherePivot('is_visible', true)
    ->get();

Use withTimestamps() if you need created_at and updated_at on the pivot.

Querying relations with whereHas and eager loading

The most powerful relationship queries filter by related records. whereHas lets you add conditions to a relationship before checking if the relationship exists.

Example: find all orders with at least one item over $100.

php
$expensiveOrders = Order::whereHas('items', function ($query) {
    $query->where('price', '>', 100);
})->get();

This generates a WHERE EXISTS query. It's efficient and readable.

In a SaaS context, imagine finding all customers with an unpaid invoice.

php
$customersWithUnpaid = Customer::whereHas('invoices', function ($query) {
    $query->where('status', 'unpaid');
})->get();

Combine whereHas with eager loading to avoid N+1 queries. Load the relationships you need upfront.

php
// Bad: N+1 queries
$orders = Order::all();
foreach ($orders as $order) {
    echo $order->customer->name; // Query per order
}

// Good: one query per relationship
$orders = Order::with('customer', 'items')->get();
foreach ($orders as $order) {
    echo $order->customer->name; // No additional queries
}

Use with() when you know you'll always need the relationships. Use whereHas() when filtering.

Common mistakes

Forgetting the inverse relationship. You'll eventually query from both directions. Define both sides from the start. Refactoring later is painful.

Ignoring N+1 queries. Load relationships with with(), not in loops. Use lazy eager loading with load() only when absolutely necessary, and only on small result sets.

Overcomplicating pivot tables. If a many-to-many needs too much pivot data, consider a proper model instead. A User assigned to a Project with a role, start_date, end_date, and hourly_rate is better modeled as an explicit Assignment model.

php
// Instead of:
// $user->belongsToMany(Project::class)
//     ->withPivot('role', 'start_date', 'end_date', 'hourly_rate');

// Use:
public function assignments()
{
    return $this->hasMany(Assignment::class);
}

// Assignment has columns: user_id, project_id, role, start_date, end_date, hourly_rate
// Now Assignment is queryable, scalable, and clear.

Not cascading deletes. When you delete a parent, orphaned children cause bugs. Use onDelete('cascade') or handle cleanup in your application logic.

php
$table->foreignId('order_id')
    ->constrained()
    ->onDelete('cascade');

Using the wrong relationship type. Don't force a hasMany relationship when belongsTo is correct. The foreign key placement matters.

Key takeaways

  • Match your relationships to your database schema. hasOne/belongsTo for one-to-one, hasMany/belongsTo for one-to-many, belongsToMany for many-to-many, morphMany for polymorphic.
  • Define relationships on both sides. You'll query both directions in production.
  • Use withPivot() and wherePivot() for many-to-many pivot data. Consider a dedicated model if pivot data gets complex.
  • Eager load with with() to avoid N+1 queries. Filter with whereHas() for relationship-based conditions.
  • Cascade deletes or handle orphaned records explicitly. Your database will thank you.
  • Test relationships early. A misconfigured relationship compounds across your codebase.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles