Laravel data relationships best practices
Modeling relationships correctly is one of the most important parts of building a maintainable Laravel app. Get it right and your code stays readable, your queries stay fast, and future changes are simple. Get it wrong and you’ll fight unexpected N+1 queries, confusing pivot logic, and brittle migrations.
This guide covers practical best practices for Eloquent relationships, migration patterns, performance tips, caching strategies, and real examples you can copy into your projects.
Laravel Data Relationships: Best Practices and Practical Examples
Quick overview of relationship types
Laravel supports the usual relationship types. Use the simplest one that models your domain:
hasOne/belongsTo— one-to-onehasMany/belongsTo— one-to-manybelongsToMany— many-to-many (pivot table)morphOne/morphMany/morphTo— polymorphic relationshasManyThrough— access distant relations via an intermediate model
1. Design first: model the domain, then the DB
Start by modelling your domain in plain language. Ask: how do these objects relate in the real world?
Example: A blog domain:
- A
Userwrites manyPosts. - A
Posthas manyComments. - A
Postbelongs to manyCategoryitems via a pivot table. Images can belong to posts or users (polymorphic).
Only after mapping relationships should you create migrations and add foreign keys.
2. Use explicit foreign key names and index them
When creating migrations, name your foreign keys clearly and add indexes. It helps readability and query performance.
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade'); // user => posts
$table->string('title');
$table->text('body');
$table->timestamps();
});
foreignId(...)->constrained() is clean and sets the FK properly. Always add onDelete('cascade') or restrict based on domain rules.
3. Prefer Eloquent conventions, but be explicit when needed
Eloquent conventions reduce boilerplate (e.g., user_id for User). Use them when possible, but if your naming differs, explicitly set keys and related models:
class Post extends Model
{
public function author()
{
// if column is author_id instead of user_id
return $this->belongsTo(User::class, 'author_id');
}
}
Being explicit helps readers immediately understand what’s going on.
4. Avoid N+1 queries – eager load strategically
N+1 is the classic trap. If you plan to iterate posts and show author and categories, eager load them.
Bad:
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // triggers a query per post
}
Good:
$posts = Post::with(['author', 'categories'])->get();
When loading nested relations, use dot notation:
Post::with('author.profile', 'comments.user')->get();
5. Use select to limit columns when appropriate
Eager loading everything is fine, but restrict the fields returned when you don’t need full rows:
$posts = Post::with(['author:id,name', 'categories:id,name'])
->select('id', 'title', 'user_id')
->get();
This reduces memory and network overhead.
6. Pivot tables: model them when they have data
If your many-to-many pivot has extra columns (like role, added_at, approved_by), create a pivot model.
Migration:
Schema::create('category_post', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->onDelete('cascade');
$table->foreignId('category_id')->constrained()->onDelete('cascade');
$table->string('status')->default('active');
$table->timestamps();
});
Eloquent:
class Post extends Model
{
public function categories()
{
return $this->belongsToMany(Category::class)
->withPivot('status')
->withTimestamps();
}
}
If you need logic on that pivot, create a CategoryPost model and use using():
class Post extends Model
{
public function categories()
{
return $this->belongsToMany(Category::class)
->using(CategoryPost::class)
->withPivot('status');
}
}
7. Use polymorphic relations for shared attachments
If multiple models attach images or comments, use polymorphic relations:
Migration:
Schema::create('images', function (Blueprint $table) {
$table->id();
$table->string('url');
$table->morphs('imageable'); // imageable_id, imageable_type
$table->timestamps();
});
Eloquent:
class Image extends Model
{
public function imageable()
{
return $this->morphTo();
}
}
class Post extends Model
{
public function images()
{
return $this->morphMany(Image::class, 'imageable');
}
}
class User extends Model
{
public function images()
{
return $this->morphMany(Image::class, 'imageable');
}
}
Polymorphics simplify schema reuse, but avoid overusing them when a simple join table is clearer.
8. Use hasManyThrough sparingly for convenience
hasManyThrough is great to reach deep relations without custom queries:
// Country -> hasManyThrough -> Post via User
class Country extends Model
{
public function posts()
{
return $this->hasManyThrough(Post::class, User::class);
}
}
It’s readable, but ensure indexes are present on intermediate tables for performance.
9. Transactions and cascading for data integrity
When performing multi-step writes, wrap them in transactions:
DB::transaction(function () use ($data) {
$post = Post::create($data['post']);
$post->categories()->attach($data['category_ids']);
// other operations
});
For deletes, define cascade rules in migrations (onDelete('cascade')) where it makes sense. That keeps data consistent without manual cleanup.
10. Use query scopes and repository-like services for complex logic
Keep your models clean put repeated query logic into local scopes or service classes.
Model scope:
class Post extends Model
{
public function scopePublished($query)
{
return $query->whereNotNull('published_at');
}
}
Usage:
Post::published()->with('author')->get();
If queries involve multiple models or heavy transformations, use a dedicated service or repository class.
11. Indexes, explain plans and optimization
Always index foreign key columns. For complex queries, use EXPLAIN to analyze query plans. Example:
EXPLAIN SELECT p.* FROM posts p
JOIN users u ON u.id = p.user_id
WHERE u.status = 'active';
In Laravel, you can run raw statements in tinker or DB clients. Look for full table scans and add indexes accordingly.
12. Caching strategies for expensive relations
For relations that change rarely (popular posts, top categories), cache the result:
$topPosts = Cache::remember('top_posts', 60 * 10, function () {
return Post::with('author')->orderBy('views', 'desc')->take(10)->get();
});
Invalidate caches when content updates via model events or webhooks.
13. Avoid eager-loading everything — measure and decide
Blindly using with() everywhere can increase payload size and memory usage. Measure requests and tune which relations you load per endpoint.
14. Defensive serialization for API responses
When returning Eloquent models in APIs, use API resources to control which relations and attributes are visible:
class PostResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'author' => new UserResource($this->whenLoaded('author')),
'categories' => CategoryResource::collection($this->whenLoaded('categories')),
];
}
}
Resources avoid accidentally exposing sensitive fields and only include relations when they were explicitly loaded.
15. Testing relationships
Write tests that assert relationships exist and return expected data. Use factories to build records and assert eager loading counts.
Example (PHPUnit/Pest):
it('loads author with post', function () {
$user = User::factory()->create();
$post = Post::factory()->for($user, 'author')->create();
$fetched = Post::with('author')->find($post->id);
expect($fetched->author->id)->toBe($user->id);
});
Testing prevents regressions when refactoring relationships.
Example: Complete small schema
Migrations (simplified):
// users (id)
// posts (id, user_id)
// categories (id)
// category_post (id, post_id, category_id)
// comments (id, post_id, user_id, body)
Models (simplified):
class User extends Model
{
public function posts() { return $this->hasMany(Post::class); }
}
class Post extends Model
{
public function author() { return $this->belongsTo(User::class, 'user_id'); }
public function categories() { return $this->belongsToMany(Category::class)->withTimestamps(); }
public function comments() { return $this->hasMany(Comment::class); }
}
Fetch example (optimized):
$posts = Post::with(['author:id,name', 'categories:id,name', 'comments' => function ($q) {
$q->select('id','post_id','body')->latest();
}])
->select('id','title','user_id')
->paginate(10);
When to reach for raw SQL
If you need highly optimized aggregation or complex joins that Eloquent struggles with, write a raw query or a DB view and consume it in Laravel. But keep raw SQL focused and documented.
Final checklist (short)
- Model domain relationships first, then create migrations.
- Name foreign keys explicitly and add indexes.
- Eager load related models where needed; avoid N+1.
- Use pivot models for meaningful join data.
- Use polymorphic relations only when it simplifies the domain.
- Wrap multi-step writes in transactions.
- Cache expensive relation results and invalidate on updates.
- Use API resources to control serialization.
- Write tests for relationships and eager loading.
- Profile queries and add indexes or raw queries when necessary.
Summary
Good relationship design makes your app faster, easier to test, and simpler to maintain. Favor clarity over cleverness – explicit names, well-placed indexes, and careful eager loading will save you debugging time later. If you follow these practices, your Laravel data layer will stay robust as your app grows.