Relational data is the backbone of most Laravel apps. Eloquent relationships express foreign keys and pivot tables as methods you can chain, eager load, and count.
Defining Core Relationships
Relationships are methods on models returning relation objects. Laravel infers foreign keys from naming conventions but allows overrides.
Always define inverse relationships so eager loading and touch() cascading work predictably.
Step 1: hasMany and belongsTo
A User hasMany Post::class and Post belongsTo User::class. The posts table stores user_id referencing users.id.
Access related data with $user->posts and $post->user. Create related models via $user->posts()->create([…]) to set foreign keys automatically.
public function posts() { return $this->hasMany(Post::class); }
public function user() { return $this->belongsTo(User::class); }
Step 2: hasOne and belongsTo
User hasOne Profile::class when each user owns a single profile row. Profiles table stores user_id.
Use hasOne for settings or billing accounts tied to a parent model. Load with $user->profile without extra queries when eager loaded.
return $this->hasOne(Profile::class);
Step 3: belongsToMany with pivots
Post belongsToMany Tag::class with post_tag pivot table. Attach tags with $post->tags()->attach($tagId) or sync() to replace the full set.
Access pivot columns with withPivot('assigned_at') and timestamps on the pivot when needed.
return $this->belongsToMany(Tag::class)->withTimestamps();
Step 4: hasManyThrough
Country hasManyThrough Post::class via User::class when posts belong to users who belong to countries. Useful for indirect associations without duplicating country_id on posts.
Understand SQL joins generated by hasManyThrough before using it in reporting queries; sometimes explicit joins are clearer.
return $this->hasManyThrough(Post::class, User::class);
Eager Loading and Performance
Lazy loading relationships triggers one query per parent row—the classic N+1 problem visible in debugbar or Telescope.
Eager loading with with() fetches related models in minimal queries. Constrain eager loads with closures.
Step 1: with() and load()
Post::with('user')->get() fetches posts then users in two queries. Call $posts->load('comments') if you add relationships after the initial query.
Nested eager loads use dot notation: Post::with('comments.author')->get(). Keep depth reasonable to avoid huge result sets.
$posts = Post::with(['user', 'tags'])->paginate(20);
Step 2: Count and sum without loading
withCount('comments') adds comments_count column. withSum('orders', 'total') aggregates without hydrating every order model.
Use loadCount on collections already fetched when you realize counts are needed in Blade loops.
Post::withCount('comments')->orderByDesc('comments_count')->get();
Step 3: Lazy eager loading
LazyLoadingViolationException in development catches accidental N+1. Model::preventLazyLoading(! app()->isProduction()) enforces discipline locally.
When you must lazy load, document why. Most list endpoints should eager load visible relationships by default.
Model::preventLazyLoading(! app()->isProduction());
Step 4: Query constraints on relations
Post::whereHas('comments', fn ($q) => $q->where('approved', true))->get() filters parents by related data.
whereDoesntHave finds posts without approved comments. Combine with with() to fetch only matching children using array syntax on with.
Post::whereHas('tags', fn ($q) => $q->where('slug', 'laravel'))->get();
Polymorphic and Advanced Relations
Polymorphic relations let one model belong to multiple parent types using commentable_id and commentable_type columns.
Many-to-many polymorphic relations power tagging or liking across posts, videos, and comments with one comments table.
Step 1: morphMany and morphTo
Comment morphTo commentable and Post morphMany Comment::class, 'commentable'. Store fully qualified class names or map aliases in Relation::enforceMorphMap().
Morph maps shorten stored type strings and survive class renames when you update the map instead of database rows.
return $this->morphMany(Comment::class, 'commentable');
Step 2: morphToMany tags
Post morphToMany Tag::class, 'taggable' uses taggables pivot with taggable_id and taggable_type. Share tags across multiple models cleanly.
Sync tags on update forms with $post->tags()->sync($request->input('tags', [])).
return $this->morphToMany(Tag::class, 'taggable');
Step 3: Touching parent timestamps
protected $touches = ['post'] on Comment updates post.updated_at when comments change—useful for cache keys based on parent freshness.
Avoid touch cascades that cause excessive writes. Sometimes explicit cache forget is cheaper than touching large trees.
protected $touches = ['post'];
Step 4: Custom pivot models
Extend Pivot class when pivot rows carry extra attributes or need their own logic. Use using(CustomPivot::class) on belongsToMany.
Custom pivots enable casting assigned_at or tracking who attached a related record in team permission tables.
return $this->belongsToMany(User::class)->using(Membership::class)->withPivot('role');
Aadyanex profiles Laravel data layers with eager-loading strategies, indexed foreign keys, and query tests that keep APIs fast under real traffic.
