All topics
Laravel

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

php
$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

php
$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

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();

Three relationships load data that never renders — the query budget is wasted before the page even paints.

Prefer

php
// 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

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());

Any field in the request, including ones the form never intended to expose, can set model attributes directly.

Prefer

php
class Post extends Model
{
    protected $fillable = ['title', 'body', 'excerpt'];
}

Post::create($request->validated()); // from a Form Request

Only 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.

Download
.claude/skills/laravel-eloquent-queries/SKILL.md

Drop 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