laravel-eloquent
Eloquent ORM best practices: N+1 prevention, scopes, casts, relationships, transactions
Install
Or scan + install everything matching your stack with npx autoskills.
Source
View on GitHubLaravel Eloquent
Eloquent is fast to write and slow to run if you don't understand the query patterns. Most performance bugs in Laravel apps trace back to misusing it.
N+1 โ the universal Laravel bug
// bad: 1 + N queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name; // a new query per post
}
// good: 2 queries total
$posts = Post::with('user')->get();Use the lazy()-eager loading trick for unknown access patterns:
// Throws if any relation is accessed without being eager-loaded
Model::preventLazyLoading(! app()->isProduction());Put preventLazyLoading in AppServiceProvider::boot() during local dev โ it surfaces N+1 as exceptions instead of slow pages.
Eager loading patterns
->with('user')โ single relation->with(['user', 'comments'])โ multiple->with('comments.user')โ nested->with(['comments' => fn ($q) => $q->where('approved', true)])โ constrained->withCount('comments')โ count without loading the rows->withExists('latestComment')โ boolean existence without loading
Scopes for reusable query logic
class Post extends Model
{
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published')
->where('published_at', '<=', now());
}
}
// usage
Post::published()->latest()->paginate(20);Don't repeat where() chains in 5 controllers. Extract a scope. Local scopes go on the model; global scopes apply automatically (use sparingly โ surprising behavior).
Casts for type safety
protected function casts(): array
{
return [
'is_published' => 'boolean',
'published_at' => 'datetime',
'metadata' => 'array',
'config' => AsArrayObject::class,
'price' => MoneyCast::class, // custom
];
}AsArrayObjectfor arrays you'll mutate โ$model->config['x'] = 1; $model->save()works.- Custom casts (
CastsAttributes) for value objects: money, enums, encrypted fields. AsEnumCollection(or singleAsEnum) for PHP 8.1+ enum casts.
Mass assignment โ pick a side
// Option A: fillable allowlist (safer)
protected $fillable = ['title', 'body', 'author_id'];
// Option B: guarded blocklist (use only for trusted-input contexts)
protected $guarded = ['id'];Never use protected $guarded = [] outside of seeders/factories. It's mass_assignment_anywhere = true.
Querying
select(['id', 'title'])when you only need a few columns โ Eloquent fetches*by default.chunk(500, fn ($rows) => ...)for large iterations โ avoids loading 1M rows into memory.lazy()for cursor-based iteration โ even cheaper than chunk for sequential reads.whereHasvswhereRelationโwhereRelation('user', 'is_admin', true)is shorthand for the common case;whereHasfor complex sub-queries.exists()overcount() > 0โ exists short-circuits on first row.
Relationships
| Type | When |
|---|---|
hasMany / belongsTo |
one-to-many, parent-child |
hasOne |
one-to-one (e.g., User โ Profile) |
belongsToMany |
many-to-many with pivot table |
morphTo / morphMany |
polymorphic (e.g., Comment belongs to Post or Video) |
hasManyThrough |
A โ B โ C without B being needed at the call site |
Pivot tables: don't model them with a custom model unless you need pivot columns/scopes. belongsToMany is enough most of the time.
Observers > model events on the class
// app/Observers/PostObserver.php
class PostObserver
{
public function creating(Post $post): void
{
$post->slug ??= Str::slug($post->title);
}
}
// app/Providers/AppServiceProvider.php
Post::observe(PostObserver::class);Keeps the model class clean; one observer per model.
Transactions
DB::transaction(function () {
$order = Order::create([...]);
$order->items()->createMany($items);
OrderCreated::dispatch($order);
});- Events that touch the DB belong inside the transaction.
- Events sent to queues/webhooks should fire after commit โ use
afterCommit()orDB::afterCommit(fn () => ...).
What to avoid
Model::all()in a request handler โ fetches everything. Always paginate or limit.->get()->filter()โ filtering in PHP what you could filter in SQL. Push it into the query.->update()on a collection โ fetches rows then issues an UPDATE per row. Use a query-builder update for bulk:Post::where(...)->update([...]).- Accessor/mutator overuse โ they fire on every access. A computed value used in a list of 1000 rows = 1000 invocations.
- Mutating a model in a view โ views should render, not save.