AutoSkills
All items
skillv1.0.0

laravel-eloquent

Eloquent ORM best practices: N+1 prevention, scopes, casts, relationships, transactions

laravelphpeloquentormbackend

Install

$npx @autoskills-dev/autoskills --items laravel-eloquent

Or scan + install everything matching your stack with npx @autoskills-dev/autoskills.

Laravel 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
    ];
}
  • AsArrayObject for arrays you'll mutate — $model->config['x'] = 1; $model->save() works.
  • Custom casts (CastsAttributes) for value objects: money, enums, encrypted fields.
  • AsEnumCollection (or single AsEnum) 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.
  • whereHas vs whereRelation — whereRelation('user', 'is_admin', true) is shorthand for the common case; whereHas for complex sub-queries.
  • exists() over count() > 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() or DB::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.