Laravel Eloquent
Data-layer engineering rules: 45 rules across 8 sections, ordered by what actually takes an application down.
Assumes the layering from the laravel-patterns skill: all query construction lives in Query Classes and Repository implementations. These rules describe what goes inside those classes.
When to Apply
- Writing or reviewing a Query Class, Repository implementation or migration
- An endpoint is slow, times out, or exhausts memory
- A column comes back as a string when it should be an enum, date or array
- Writing an import, export, backfill or reporting query
- Adding a scope, global scope or soft deletes to a model
Pick the Rule
| About to write |
Read |
| A query that reads relations |
perf-eager-load-every-touched-relation, perf-prevent-lazy-loading |
| A query returning many rows |
paginate-always-paginate-lists, paginate-cursor-for-deep-pagination |
| A new model |
model-cast-every-column, model-final-and-typed, model-minimal-fillable |
| A write touching more than one table |
tx-wrap-multi-step-writes, tx-keep-transactions-short |
| Raw SQL or a database expression |
raw-never-interpolate-user-input, raw-only-inside-query-classes |
| An update or insert over many rows |
bulk-update-bypasses-events, bulk-upsert-instead-of-loop |
| A filter used by several queries |
scope-query-scopes-for-reusable-filters |
| An existence check |
perf-exists-not-count |
| A pass over a large table |
perf-chunk-large-result-sets, bulk-lazy-by-id-for-huge-sets |
| A slow endpoint to diagnose |
perf-index-filtered-columns, perf-avoid-wherehas-on-hot-paths |
| One value from a has-many |
perf-subquery-select-for-single-values |
| Sorting by a related table's column |
perf-order-by-correlated-subquery |
| A count of related rows |
perf-withcount-not-loaded-relations |
| A migration |
migration-never-edit-a-deployed-migration, migration-constrained-foreign-keys |
| A backfill or default value |
migration-separate-schema-from-data, migration-mirror-defaults-in-the-model |
Before You Write Code
- Every API named in these rules is verified against Laravel
^12.0 || ^13.0 and PHP ^8.3. If you need something these rules do not name, check the docs — never infer an API from its name.
- Version-gated APIs are marked inline ("Laravel 13 only"). Read the project's
composer.json first; on Laravel 12 use the fallback the rule gives.
- Where the project already differs from a rule, follow the project. Name the rule you set aside and why, rather than half-converting the codebase.
- When two rules collide, the higher-impact section wins — sections are ordered by impact.
- One example is not the whole rule. Open
rules/{slug}.md before adapting it to a case the example does not show.
Rule Sections by Priority
| # |
Section |
Impact |
Prefix |
| 1 |
Query Performance |
CRITICAL |
perf- |
| 2 |
Pagination |
HIGH |
paginate- |
| 3 |
Transactions and Consistency |
HIGH |
tx- |
| 4 |
Model Declaration |
HIGH |
model- |
| 5 |
Scopes, Global Scopes and Soft Deletes |
MEDIUM-HIGH |
scope- |
| 6 |
Migrations and Schema |
MEDIUM-HIGH |
migration- |
| 7 |
Raw SQL and Query Expressions |
MEDIUM |
raw- |
| 8 |
Bulk Operations |
MEDIUM |
bulk- |
Quick Reference
1. Query Performance (CRITICAL)
perf-eager-load-every-touched-relation — Every relation the output touches is in with([...])
perf-prevent-lazy-loading — Make lazy loading throw outside production
perf-select-only-needed-columns — Narrow the select on wide tables
perf-exists-not-count — Ask exists() when you only need a boolean
perf-chunk-large-result-sets — Chunk or stream instead of get()
perf-avoid-wherehas-on-hot-paths — Replace whereHas with a join on hot paths
perf-index-filtered-columns — Index every column you filter, join or sort on
perf-withcount-not-loaded-relations — Count with withCount(), never a loaded collection
perf-subquery-select-for-single-values — Pull a single related value with a subquery
perf-order-by-correlated-subquery — Sort by a related value with a subquery, not a join
perf-set-relation-to-close-the-loop — Hand the parent back with setRelation()
2. Pagination (HIGH)
paginate-always-paginate-lists — Never return an unbounded list
paginate-cursor-for-deep-pagination — cursorPaginate() for deep or fast-growing sets
paginate-simple-when-no-total — simplePaginate() when the total is not rendered
paginate-full-only-when-count-required — Reserve paginate() for a required count
3. Transactions and Consistency (HIGH)
tx-wrap-multi-step-writes — Multi-step writes are atomic
tx-keep-transactions-short — No HTTP, mail or file I/O inside a transaction
tx-dispatch-after-commit — Jobs and events fire after commit
tx-lock-for-update-on-contention — Lock rows you read then modify
tx-retry-on-deadlock — Pass attempts: so deadlocks retry
4. Model Declaration (HIGH)
model-cast-every-column — Cast every date, enum, JSON and money column
model-custom-casts-for-value-objects — Value Objects get a CastsAttributes class
model-minimal-fillable — $fillable is a security boundary
model-scope-attribute — Declare scopes with #[Scope] (Laravel 12+)
model-observed-by-attribute — Attach observers with #[ObservedBy]
model-immutable-dates-and-timezones — Store UTC, cast immutable, convert at the edge
model-final-and-typed — final, strict_types, typed relations, @property docblocks
5. Scopes, Global Scopes and Soft Deletes (MEDIUM-HIGH)
scope-query-scopes-for-reusable-filters — Small reusable constraints live on the model
scope-global-scope-for-default-filter — Global scope for a filter that must never be forgotten
scope-global-or-named-not-both — One filter, one mechanism
scope-soft-deletes-for-recoverable — SoftDeletes only for genuinely recoverable records
scope-explicit-trashed-queries — Name the query after what it includes
6. Migrations and Schema (MEDIUM-HIGH)
migration-never-edit-a-deployed-migration — Once it has run in production it is history
migration-separate-schema-from-data — Structure in one migration, data in another
migration-constrained-foreign-keys — constrained() plus an explicit delete behaviour
migration-reversible-down — Write a down() that actually reverses up()
migration-mirror-defaults-in-the-model — The same default in $attributes
7. Raw SQL and Query Expressions (MEDIUM)
raw-only-inside-query-classes — Raw SQL belongs in Query Classes and Repositories
raw-tpetry-instead-of-db-raw — Type-safe expressions instead of DB::raw()
raw-conditional-aggregates-in-one-query — Dashboard counters in a single query
raw-custom-expression-helpers — Wrap driver-specific SQL in an Expression class
raw-never-interpolate-user-input — Bindings for values, allow-lists for identifiers
8. Bulk Operations (MEDIUM)
bulk-upsert-instead-of-loop — Batch inserts and upserts
bulk-update-bypasses-events — Bulk writes skip model events; handle that deliberately
bulk-lazy-by-id-for-huge-sets — chunkById/lazyById, never offset paging while writing
Recommended Packages
Verified against Laravel 13:
| Need |
Package |
Constraint |
Type-safe SQL expressions (replaces DB::raw()) |
tpetry/laravel-query-expressions |
^1.6 |
| Declarative, reusable model filters |
indexzer0/eloquent-filtering |
^2.2.2 |
| Excel import and export |
rap2hpoutre/fast-excel |
^5.14 |
All three are used inside Query Classes and Repositories only. Drop to raw expressions when a declarative filter package hurts performance on a hot query.
Reference Material
references/n-plus-one-playbook.md — finding, fixing and preventing N+1
references/pagination-decision.md — choosing between the three paginators
references/checklist.md — pre-merge self-check for the data layer
How to Use
Load in this order and stop when the answer is clear:
- This file — the Quick Reference names every rule, and usually settles the question.
- One rule file for the reasoning and both examples (~366 tokens each):
rules/perf-eager-load-every-touched-relation.md
rules/tx-dispatch-after-commit.md
- A
references/ file only when a rule points at one.
AGENTS.md is every rule compiled into one document (~12k tokens), for agents that read the AGENTS.md convention. Do not load it when the individual rule files are reachable.
Related Skills
laravel-engineering — trace the full change and read back through the consumer shape
laravel-patterns — where the query goes: Query Class, Repository, Action or inline
laravel-rest-api — pagination and resources at the HTTP edge
laravel-async — caching query results and invalidating on model events
laravel-testing — testing query rules against a real database
1---2name: laravel-eloquent3description: Eloquent and query-layer engineering rules for Laravel — eliminating N+1, choosing a pagination strategy, short atomic transactions, casts and scopes on the model, where raw SQL is allowed, and how migrations declare the schema those queries depend on. Use when writing or reviewing Eloquent models, migrations, query classes, repositories, exports or reporting queries, or when a Laravel endpoint is slow, leaking memory, or returning wrongly-typed columns.4license: MIT5---67# Laravel Eloquent89Data-layer engineering rules: 45 rules across 8 sections, ordered by what actually takes an application down.1011Assumes the layering from the `laravel-patterns` skill: **all query construction lives in Query Classes and Repository implementations.** These rules describe what goes *inside* those classes.1213## When to Apply1415- Writing or reviewing a Query Class, Repository implementation or migration16- An endpoint is slow, times out, or exhausts memory17- A column comes back as a string when it should be an enum, date or array18- Writing an import, export, backfill or reporting query19- Adding a scope, global scope or soft deletes to a model2021## Pick the Rule2223| About to write | Read |24|----------------|------|25| A query that reads relations | `perf-eager-load-every-touched-relation`, `perf-prevent-lazy-loading` |26| A query returning many rows | `paginate-always-paginate-lists`, `paginate-cursor-for-deep-pagination` |27| A new model | `model-cast-every-column`, `model-final-and-typed`, `model-minimal-fillable` |28| A write touching more than one table | `tx-wrap-multi-step-writes`, `tx-keep-transactions-short` |29| Raw SQL or a database expression | `raw-never-interpolate-user-input`, `raw-only-inside-query-classes` |30| An update or insert over many rows | `bulk-update-bypasses-events`, `bulk-upsert-instead-of-loop` |31| A filter used by several queries | `scope-query-scopes-for-reusable-filters` |32| An existence check | `perf-exists-not-count` |33| A pass over a large table | `perf-chunk-large-result-sets`, `bulk-lazy-by-id-for-huge-sets` |34| A slow endpoint to diagnose | `perf-index-filtered-columns`, `perf-avoid-wherehas-on-hot-paths` |35| One value from a has-many | `perf-subquery-select-for-single-values` |36| Sorting by a related table's column | `perf-order-by-correlated-subquery` |37| A count of related rows | `perf-withcount-not-loaded-relations` |38| A migration | `migration-never-edit-a-deployed-migration`, `migration-constrained-foreign-keys` |39| A backfill or default value | `migration-separate-schema-from-data`, `migration-mirror-defaults-in-the-model` |4041## Before You Write Code4243- Every API named in these rules is verified against Laravel `^12.0 || ^13.0` and PHP `^8.3`. If you need something these rules do not name, check the docs — never infer an API from its name.44- Version-gated APIs are marked inline ("Laravel 13 only"). Read the project's `composer.json` first; on Laravel 12 use the fallback the rule gives.45- Where the project already differs from a rule, follow the project. Name the rule you set aside and why, rather than half-converting the codebase.46- When two rules collide, the higher-impact section wins — sections are ordered by impact.47- One example is not the whole rule. Open `rules/{slug}.md` before adapting it to a case the example does not show.4849## Rule Sections by Priority5051| # | Section | Impact | Prefix |52|---|---------|--------|--------|53| 1 | Query Performance | CRITICAL | `perf-` |54| 2 | Pagination | HIGH | `paginate-` |55| 3 | Transactions and Consistency | HIGH | `tx-` |56| 4 | Model Declaration | HIGH | `model-` |57| 5 | Scopes, Global Scopes and Soft Deletes | MEDIUM-HIGH | `scope-` |58| 6 | Migrations and Schema | MEDIUM-HIGH | `migration-` |59| 7 | Raw SQL and Query Expressions | MEDIUM | `raw-` |60| 8 | Bulk Operations | MEDIUM | `bulk-` |6162## Quick Reference6364### 1. Query Performance (CRITICAL)6566- `perf-eager-load-every-touched-relation` — Every relation the output touches is in `with([...])`67- `perf-prevent-lazy-loading` — Make lazy loading throw outside production68- `perf-select-only-needed-columns` — Narrow the select on wide tables69- `perf-exists-not-count` — Ask `exists()` when you only need a boolean70- `perf-chunk-large-result-sets` — Chunk or stream instead of `get()`71- `perf-avoid-wherehas-on-hot-paths` — Replace `whereHas` with a join on hot paths72- `perf-index-filtered-columns` — Index every column you filter, join or sort on73- `perf-withcount-not-loaded-relations` — Count with `withCount()`, never a loaded collection74- `perf-subquery-select-for-single-values` — Pull a single related value with a subquery75- `perf-order-by-correlated-subquery` — Sort by a related value with a subquery, not a join76- `perf-set-relation-to-close-the-loop` — Hand the parent back with `setRelation()`7778### 2. Pagination (HIGH)7980- `paginate-always-paginate-lists` — Never return an unbounded list81- `paginate-cursor-for-deep-pagination` — `cursorPaginate()` for deep or fast-growing sets82- `paginate-simple-when-no-total` — `simplePaginate()` when the total is not rendered83- `paginate-full-only-when-count-required` — Reserve `paginate()` for a required count8485### 3. Transactions and Consistency (HIGH)8687- `tx-wrap-multi-step-writes` — Multi-step writes are atomic88- `tx-keep-transactions-short` — No HTTP, mail or file I/O inside a transaction89- `tx-dispatch-after-commit` — Jobs and events fire after commit90- `tx-lock-for-update-on-contention` — Lock rows you read then modify91- `tx-retry-on-deadlock` — Pass `attempts:` so deadlocks retry9293### 4. Model Declaration (HIGH)9495- `model-cast-every-column` — Cast every date, enum, JSON and money column96- `model-custom-casts-for-value-objects` — Value Objects get a `CastsAttributes` class97- `model-minimal-fillable` — `$fillable` is a security boundary98- `model-scope-attribute` — Declare scopes with `#[Scope]` (Laravel 12+)99- `model-observed-by-attribute` — Attach observers with `#[ObservedBy]`100- `model-immutable-dates-and-timezones` — Store UTC, cast immutable, convert at the edge101- `model-final-and-typed` — `final`, `strict_types`, typed relations, `@property` docblocks102103### 5. Scopes, Global Scopes and Soft Deletes (MEDIUM-HIGH)104105- `scope-query-scopes-for-reusable-filters` — Small reusable constraints live on the model106- `scope-global-scope-for-default-filter` — Global scope for a filter that must never be forgotten107- `scope-global-or-named-not-both` — One filter, one mechanism108- `scope-soft-deletes-for-recoverable` — `SoftDeletes` only for genuinely recoverable records109- `scope-explicit-trashed-queries` — Name the query after what it includes110111### 6. Migrations and Schema (MEDIUM-HIGH)112113- `migration-never-edit-a-deployed-migration` — Once it has run in production it is history114- `migration-separate-schema-from-data` — Structure in one migration, data in another115- `migration-constrained-foreign-keys` — `constrained()` plus an explicit delete behaviour116- `migration-reversible-down` — Write a `down()` that actually reverses `up()`117- `migration-mirror-defaults-in-the-model` — The same default in `$attributes`118119### 7. Raw SQL and Query Expressions (MEDIUM)120121- `raw-only-inside-query-classes` — Raw SQL belongs in Query Classes and Repositories122- `raw-tpetry-instead-of-db-raw` — Type-safe expressions instead of `DB::raw()`123- `raw-conditional-aggregates-in-one-query` — Dashboard counters in a single query124- `raw-custom-expression-helpers` — Wrap driver-specific SQL in an `Expression` class125- `raw-never-interpolate-user-input` — Bindings for values, allow-lists for identifiers126127### 8. Bulk Operations (MEDIUM)128129- `bulk-upsert-instead-of-loop` — Batch inserts and upserts130- `bulk-update-bypasses-events` — Bulk writes skip model events; handle that deliberately131- `bulk-lazy-by-id-for-huge-sets` — `chunkById`/`lazyById`, never offset paging while writing132133## Recommended Packages134135Verified against Laravel 13:136137| Need | Package | Constraint |138|------|---------|------------|139| Type-safe SQL expressions (replaces `DB::raw()`) | `tpetry/laravel-query-expressions` | `^1.6` |140| Declarative, reusable model filters | `indexzer0/eloquent-filtering` | `^2.2.2` |141| Excel import and export | `rap2hpoutre/fast-excel` | `^5.14` |142143All three are used **inside Query Classes and Repositories only**. Drop to raw expressions when a declarative filter package hurts performance on a hot query.144145## Reference Material146147- `references/n-plus-one-playbook.md` — finding, fixing and preventing N+1148- `references/pagination-decision.md` — choosing between the three paginators149- `references/checklist.md` — pre-merge self-check for the data layer150151## How to Use152153Load in this order and stop when the answer is clear:1541551. This file — the Quick Reference names every rule, and usually settles the question.1562. One rule file for the reasoning and both examples (~366 tokens each):157158```159rules/perf-eager-load-every-touched-relation.md160rules/tx-dispatch-after-commit.md161```1621633. A `references/` file only when a rule points at one.164165`AGENTS.md` is every rule compiled into one document (~12k tokens), for agents that read the AGENTS.md convention. Do not load it when the individual rule files are reachable.166167## Related Skills168169- `laravel-engineering` — trace the full change and read back through the consumer shape170- `laravel-patterns` — where the query goes: Query Class, Repository, Action or inline171- `laravel-rest-api` — pagination and resources at the HTTP edge172- `laravel-async` — caching query results and invalidating on model events173- `laravel-testing` — testing query rules against a real database