Laravel Eloquent Associations
Relationships are Eloquent's "superpower"—with just one line of code, you can bridge the gap between tables using foreign keys and say goodbye to manual JOINs.
1. What You'll Learn
- One-to-one: hasOne/belongsTo
- One-to-many: hasMany/belongsTo
- Many-to-many: belongsToMany (including a pivot table)
- HasManyThrough: Remote Associations and Polymorphic Associations
- Preloading and Lazy Loading: The N+1 Problem and Optimization with
with()/load()
2. A True Story of a Data Analytics Team
(1) Pain Point: 50 queries just to display 10 orders
Charlie sees 10 orders on the ShopMetrics dashboard—he runs one query to retrieve the orders, then for each order, he runs one query for the user, one for the store, and one for the product, totaling 1 + 10 x 4 = 41 SQL queries. Database load skyrocketed, and page load time went from 200 ms to 3 s. Bob added more joins (order → product → category → tag), bringing the number of queries to over 200, and Alice complained that the system was "as slow as a snail."
(2) Preloading Solutions
Eloquent's with() preload retrieves all associated data in a single query, reducing 41 SQL statements to 4.
// N+1 problem — 41 queries
$orders = Order::take(10)->get();
foreach ($orders as $order) {
echo $order->user->name; // +1 query each
echo $order->shop->name; // +1 query each
}
// Eager loading — 4 queries total
$orders = Order::with(['user', 'shop', 'items.product'])->take(10)->get();
foreach ($orders as $order) {
echo $order->user->name; // 0 extra queries
echo $order->shop->name; // 0 extra queries
}
(3) Revenue
After Charlie implemented preloading, the number of dashboard queries dropped from 41 to 4, and page load time decreased from 3 seconds to 300 milliseconds.
3. One-to-One Relationship
(1) hasOne / belongsTo
// User has one Profile
class User extends Model
{
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
}
// Profile belongs to User
class Profile extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// Usage
$profile = $user->profile;
$user = $profile->user;
| Direction | Method | Foreign Key Position | Description |
|---|---|---|---|
| User → Profile | hasOne | profiles table | has |
| Profile → User | belongsTo | profiles table | belongs to |
(1) ▶ Example: ShopMetrics Users and Subscription Plans
// User has one active subscription
class User extends Model
{
public function activeSubscription(): HasOne
{
return $this->hasOne(Subscription::class)
->where('status', 'active')
->latestOfMany();
}
}
// Subscription belongs to User
class Subscription extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// Usage
$plan = $user->activeSubscription->plan;
Output:
// Execution Successful
4. One-to-Many Relationship
(1) hasMany / belongsTo
// Tenant has many Shops
class Tenant extends Model
{
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}
// Shop belongs to Tenant
class Shop extends Model
{
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}
(2) UML Diagram of the Seven Major Association Relationships
classDiagram
class Tenant {
+shops() HasMany
+orders() HasMany
+users() HasMany
+subscription() HasOne
}
class User {
+tenant() BelongsTo
+profile() HasOne
+orders() HasMany
}
class Shop {
+tenant() BelongsTo
+products() HasMany
+orders() HasMany
}
class Product {
+shop() BelongsTo
+categories() BelongsToMany
}
class Category {
+products() BelongsToMany
}
class Order {
+shop() BelongsTo
+user() BelongsTo
+items() HasMany
}
class OrderItem {
+order() BelongsTo
+product() BelongsTo
}
Tenant "1" --> "*" Shop : hasMany
Tenant "1" --> "*" User : hasMany
Tenant "1" --> "1" Subscription : hasOne
Shop "1" --> "*" Product : hasMany
Shop "1" --> "*" Order : hasMany
Product "*" --> "*" Category : belongsToMany
Order "1" --> "*" OrderItem : hasMany
(1) ▶ Example: Stores and Orders in a ShopMetrics Tenant
// Get tenant with all shops and their recent orders
$tenant = Tenant::with(['shops' => function ($query) {
$query->withCount(['orders' => function ($q) {
$q->where('created_at', '>=', now()->subDays(30));
}])->orderBy('revenue', 'desc');
}])->findOrFail($tenantId);
foreach ($tenant->shops as $shop) {
echo "{$shop->name}: {$shop->orders_count} recent orders";
}
Output:
// Execution Successful
5. Many-to-Many Relationships
(1) belongsToMany and Pivot Tables
// Product belongs to many Categories (via category_product pivot)
class Product extends Model
{
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class)
->withPivot('is_primary')
->withTimestamps();
}
}
class Category extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class)
->withPivot('is_primary')
->withTimestamps();
}
}
(2) Pivot Table Structure
category_product
├── id
├── category_id (FK)
├── product_id (FK)
├── is_primary (BOOLEAN)
├── created_at
└── updated_at
| Pivot Method | Purpose |
|---|---|
withPivot() |
Additional read of the pivot column |
withTimestamps() |
Maintain the pivot's timestamp |
as('alias') |
Alias for pivot |
wherePivot() |
Filter pivot conditions |
sync() |
Synchronization Association (Set Difference) |
attach() |
Add Association |
detach() |
Remove Association |
(1) ▶ Example: ShopMetrics Product Categories (Many-to-Many)
// Attach categories to a product
$product->categories()->attach([1, 2, 3], ['is_primary' => false]);
$product->categories()->attach(4, ['is_primary' => true]);
// Sync — set exact categories (removes others)
$product->categories()->sync([
1 => ['is_primary' => false],
4 => ['is_primary' => true],
]);
// Sync without detaching — add only, don't remove
$product->categories()->syncWithoutDetaching([5, 6]);
// Query with pivot condition
$primaryCategory = $product->categories()
->wherePivot('is_primary', true)
->first();
// Detach specific categories
$product->categories()->detach([1, 2]);
Output:
// Execution Successful
6. Distant Correlation and Polymorphic Correlation
(1) HasManyThrough
// Tenant has many Products through Shop
class Tenant extends Model
{
public function products(): HasManyThrough
{
return $this->hasManyThrough(
Product::class, // final target
Shop::class, // intermediate
'tenant_id', // FK on shops
'shop_id', // FK on products
'id', // PK on tenants
'id', // PK on shops
);
}
}
// Usage: direct access without loading shops
$products = $tenant->products()->where('is_active', true)->get();
(2) Polymorphic Associations
// Image can belong to Shop or Product (morphable)
class Image extends Model
{
public function imageable(): MorphTo
{
return $this->morphTo();
}
}
class Shop extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
class Product extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
// Migration for polymorphic
Schema::create('images', function (Blueprint $table) {
$table->id();
$table->morphs('imageable'); // imageable_type + imageable_id
$table->string('path');
$table->timestamps();
});
| Association Type | Method | Use Case |
|---|---|---|
| One-to-one | hasOne/belongsTo | User → Profile |
| One-to-many | hasMany/belongsTo | Tenant → Store |
| Many-to-Many | belongsToMany | Product ↔ Category |
| Remote One-to-Many | hasManyThrough | Tenant → Product (via Store) |
| Polymorphic One-to-One | morphOne/morphTo | Image → Product/Store |
| Polymorphic One-to-Many | morphMany/morphTo | Comments → Products/Articles |
| Polymorphic Many-to-Many | morphToMany/morphByMany | Tag → Product/Article |
(1) ▶ Example: ShopMetrics Polymorphic Image System
// Add image to shop
$shop->images()->create(['path' => 'shops/alice-store/banner.jpg']);
// Add image to product
$product->images()->create(['path' => 'products/widget-a/thumb.jpg']);
// Query polymorphic — get image's owner
$image = Image::find(1);
$image->imageable; // Returns Shop or Product instance
// Eager load polymorphic
$images = Image::with('imageable')->get();
foreach ($images as $image) {
echo $image->imageable->name; // Works for both Shop and Product
}
Output:
// Execution Successful
7. Preloading and N+1 Optimization
(1) The N+1 Problem
Without eager loading:
1. SELECT * FROM orders WHERE tenant_id = 1 LIMIT 10 -- 1 query
2. SELECT * FROM users WHERE id = 1 -- +1 per order
3. SELECT * FROM users WHERE id = 2
4. SELECT * FROM shops WHERE id = 5
... (up to 30+ queries for 10 orders)
(2) with() Preloading
// Eager load — 4 queries total
$orders = Order::with(['user', 'shop', 'items.product'])->paginate(15);
// Nested eager loading
$orders = Order::with(['items.product.categories'])->get();
// Conditional eager loading
$orders = Order::with(['items' => function ($query) {
$query->where('quantity', '>', 1);
}])->get();
// Lazy eager loading — load after initial query
$orders = Order::all();
if ($needItems) {
$orders->load('items.product');
}
| Method | Timing | Applicable Scenarios |
|---|---|---|
with() |
Load on query | Known to require a join |
load() |
Load after query | Conditional loading |
loadCount() |
Load count only | Quantity only, no data required |
loadMissing() |
Load when missing | Avoid reloading |
(1) ▶ Example: ShopMetrics Dashboard N+1 Optimization
// BAD — N+1 problem in dashboard
$shops = Shop::where('tenant_id', $tenantId)->get();
foreach ($shops as $shop) {
echo $shop->orders->count(); // +1 query per shop
echo $shop->products->count(); // +1 query per shop
}
// GOOD — withCount + eager loading
$shops = Shop::where('tenant_id', $tenantId)
->withCount(['orders', 'products', 'orders as recent_orders_count' => function ($q) {
$q->where('created_at', '>=', now()->subDays(30));
}])
->with(['latestOrder'])
->get();
foreach ($shops as $shop) {
echo $shop->orders_count; // 0 extra queries
echo $shop->recent_orders_count; // 0 extra queries
echo $shop->latestOrder->total; // 0 extra queries
}
Output:
// Execution Successful
8. Comprehensive Example: ShopMetrics Tenant Data Aggregation
// ============================================
// Comprehensive: ShopMetrics Tenant Data Aggregation
// Covers: all relationship types, eager loading, pivot, polymorphic
// ============================================
// app/Models/Tenant.php
class Tenant extends Model
{
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class)->where('status', 'active');
}
public function products(): HasManyThrough
{
return $this->hasManyThrough(Product::class, Shop::class);
}
public function scopeWithStats(Builder $query): Builder
{
return $query->withCount([
'shops as active_shops_count' => fn ($q) => $q->where('status', 'active'),
'orders as monthly_orders_count' => fn ($q) => $q->whereBetween('created_at', [
now()->startOfMonth(), now()->endOfMonth(),
]),
])->withSum('orders as total_revenue', 'total');
}
}
// Usage — one query with everything
$tenant = Tenant::withStats()
->with(['subscription.plan', 'shops' => fn ($q) => $q->orderBy('revenue', 'desc')->take(5)])
->findOrFail($tenantId);
$tenant->active_shops_count; // 15
$tenant->monthly_orders_count; // 234
$tenant->total_revenue; // 45678.90
$tenant->subscription->plan->name; // Pro
$tenant->shops->first()->name; // Alice Store
❓ FAQ
hasOne or hasMany; if the foreign key is in this table, use belongsTo; if neither table has a foreign key, use belongsToMany (requires a pivot table). A model can belong to multiple types using polymorphic relationships.with() and load()?with() loads data during the query, offering optimal performance; load() loads data on demand after the query, making it suitable for conditional loading (such as when related data is needed only under certain conditions).imageable_type column to distinguish between types, so traditional foreign key constraints cannot be established. Adding an index on (imageable_type, imageable_id) can improve performance. For large datasets, consider using separate tables instead of polymorphic joins.DB::listen() to log the number of queries or use laravel/telescope for monitoring.$shop->products is a dynamic property (returns a Collection), while $shop->products() is an association query constructor (allows for chained queries). The former executes the query automatically, while the latter defers it until get() is manually called.📖 Summary
- For one-to-one relationships, use
hasOneorbelongsTo; the foreign key is on thebelongsToside. - One-to-many relationships (hasMany/belongsTo) are the most common types of associations.
- For many-to-many relationships, use
belongsToMany; a pivot table is required. - HasManyThrough: Accessing a distant association through an intermediate table
- Polymorphic associations allow a single model to belong to multiple types (e.g., image → product/store)
- Preloading with () solves the N+1 problem; withCount() only counts the number of records without loading the data
📝 Exercises
-
Basic Exercise (⭐): Define the Tenant→Shop→Product association for ShopMetrics. Use Tinker to create test data and access the association:
$tenant->shops->first()->products. -
Advanced Exercise (⭐⭐): Implement a many-to-many relationship between
ProductandCategory, create a pivot migration that includes anis_primarycolumn, usesync()to synchronize categories, and query the primary category for a given product. -
Challenge (⭐⭐⭐): Implement a polymorphic comment system (where the
Commentmodel can be associated with both products and stores) and preload all comments and theircommentableassociations in the dashboard, ensuring that only 3 SQL statements are required (1 to retrieve comments + 1 to retrieve products + 1 to retrieve stores).



