๐Ÿ”งAutoSkills
All items
skillv1.0.0

laravel-eloquent

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

laravelphpeloquentormbackend

Install

$npx autoskills --items laravel-eloquent

Or scan + install everything matching your stack with npx 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.