6 min read
Eloquent & Query Patterns
N+1 queries are the default failure mode, not an edge case — eager loading that mirrors the view, and mass assignment as a deliberate whitelist.
Principles
N+1 queries are a design default, not an edge case
Accessing a relationship inside a loop triggers one query per iteration unless it was eager loaded. Eloquent makes this so easy to write that it becomes the default failure mode, not a rare mistake.
Eager load what the view will actually touch
`with()` should mirror exactly what the Blade view or API response reads, no more. Over-eager-loading pulls relationships nobody renders and wastes the same query budget N+1 does, just up front instead of spread out.
Query scopes encode intent, repositories encode structure
A local scope (`scopeActive`) reads at the call site like English and stays where Eloquent already looks for it. Reach for a repository layer only when the query logic itself needs to be swapped or mocked, not by default.
`select()` your columns when the model is wide
Loading every column of a 40-column table to render three of them wastes memory and bandwidth on every single query, and it compounds across a paginated list.
Mass assignment protection is a whitelist decision, not an afterthought
`$fillable` (or `$guarded`) determines what a request body can actually set on the model. Leaving it wide open turns every unexpected input field into a potential privilege escalation.
Patterns
What goes wrong, what to do instead, and why the difference matters.
N+1 inside a loop
Avoid
$posts = Post::all();
foreach ($posts as $post) {
// One extra query per post, every single time.
echo $post->author->name;
}100 posts means 1 query for the posts plus 100 more for their authors — the page gets slower every time content is added.
Prefer
$posts = Post::with('author')->get();
foreach ($posts as $post) {
// Already loaded — zero additional queries.
echo $post->author->name;
}One query for posts, one query for all their authors, regardless of how many posts there are.
Over-eager loading vs matching the view
Avoid
// The index view only shows title and author name, but this
// pulls comments, tags, and revision history for every post.
$posts = Post::with(['comments', 'tags', 'revisions', 'author'])->get();Three relationships load data that never renders — the query budget is wasted before the page even paints.
Prefer
// Matches exactly what the index view reads.
$posts = Post::with('author:id,name')->get();Eager loading mirrors the view's actual needs, including selecting only the author columns actually displayed.
Mass assignment left wide open
Avoid
class Post extends Model
{
protected $guarded = []; // nothing is protected
}
// A request body with an unexpected "is_published" or
// "author_id" field now silently sets it.
Post::create($request->all());Any field in the request, including ones the form never intended to expose, can set model attributes directly.
Prefer
class Post extends Model
{
protected $fillable = ['title', 'body', 'excerpt'];
}
Post::create($request->validated()); // from a Form RequestOnly the whitelisted fields can be set via mass assignment, and the input was validated before it ever reached the model.
Review checklist
What I look for when reviewing a pull request that touches this area.
- No relationship is accessed inside a loop without having been eager loaded first.
- `with()` calls mirror exactly what the view or API response reads — no more, no less.
- Wide models use `select()` to load only the columns actually used.
- Every model has a deliberate `$fillable` or `$guarded`, never left at an unprotected default.
- Query scopes are used for reusable filter logic before reaching for a repository layer.
- Slow queries were checked with `DB::listen()` or a query log, not assumed fast.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/laravel-eloquent-queries/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: laravel-eloquent-queries
description: >-
Laravel Eloquent query patterns: eager loading to avoid N+1, matching
eager loads to what the view needs, query scopes vs repositories, column
selection, and mass assignment protection. Use when writing or reviewing
Eloquent queries.
argument-hint: "[model or query]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Eloquent & Query Patterns
Apply when writing a query that touches relationships or reviewing one for performance. Before adding a loop that accesses a relationship, check whether it was eager loaded — if not, that's the fix, not a repository rewrite.
## Rules
1. **N+1 queries are a design default, not an edge case** — Accessing a relationship inside a loop triggers one query per iteration unless it was eager loaded. Eloquent makes this so easy to write that it becomes the default failure mode, not a rare mistake.
2. **Eager load what the view will actually touch** — `with()` should mirror exactly what the Blade view or API response reads, no more. Over-eager-loading pulls relationships nobody renders and wastes the same query budget N+1 does, just up front instead of spread out.
3. **Query scopes encode intent, repositories encode structure** — A local scope (`scopeActive`) reads at the call site like English and stays where Eloquent already looks for it. Reach for a repository layer only when the query logic itself needs to be swapped or mocked, not by default.
4. **`select()` your columns when the model is wide** — Loading every column of a 40-column table to render three of them wastes memory and bandwidth on every single query, and it compounds across a paginated list.
5. **Mass assignment protection is a whitelist decision, not an afterthought** — `$fillable` (or `$guarded`) determines what a request body can actually set on the model. Leaving it wide open turns every unexpected input field into a potential privilege escalation.
## Patterns
### N+1 inside a loop
**Avoid** — 100 posts means 1 query for the posts plus 100 more for their authors — the page gets slower every time content is added.
```php
$posts = Post::all();
foreach ($posts as $post) {
// One extra query per post, every single time.
echo $post->author->name;
}
```
**Prefer** — One query for posts, one query for all their authors, regardless of how many posts there are.
```php
$posts = Post::with('author')->get();
foreach ($posts as $post) {
// Already loaded — zero additional queries.
echo $post->author->name;
}
```
### Over-eager loading vs matching the view
**Avoid** — Three relationships load data that never renders — the query budget is wasted before the page even paints.
```php
// The index view only shows title and author name, but this
// pulls comments, tags, and revision history for every post.
$posts = Post::with(['comments', 'tags', 'revisions', 'author'])->get();
```
**Prefer** — Eager loading mirrors the view's actual needs, including selecting only the author columns actually displayed.
```php
// Matches exactly what the index view reads.
$posts = Post::with('author:id,name')->get();
```
### Mass assignment left wide open
**Avoid** — Any field in the request, including ones the form never intended to expose, can set model attributes directly.
```php
class Post extends Model
{
protected $guarded = []; // nothing is protected
}
// A request body with an unexpected "is_published" or
// "author_id" field now silently sets it.
Post::create($request->all());
```
**Prefer** — Only the whitelisted fields can be set via mass assignment, and the input was validated before it ever reached the model.
```php
class Post extends Model
{
protected $fillable = ['title', 'body', 'excerpt'];
}
Post::create($request->validated()); // from a Form Request
```
## Review checklist
- [ ] No relationship is accessed inside a loop without having been eager loaded first.
- [ ] `with()` calls mirror exactly what the view or API response reads — no more, no less.
- [ ] Wide models use `select()` to load only the columns actually used.
- [ ] Every model has a deliberate `$fillable` or `$guarded`, never left at an unprotected default.
- [ ] Query scopes are used for reusable filter logic before reaching for a repository layer.
- [ ] Slow queries were checked with `DB::listen()` or a query log, not assumed fast.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim