f in x
Advanced Eloquent ORM: Relationships, Scopes, Accessors, and N+1 Prevention [Hands-On Guide]
> cd .. / HUB_EDITORIALE
Analisi dei dati e metriche

Advanced Eloquent ORM: Relationships, Scopes, Accessors, and N+1 Prevention [Hands-On Guide]

[2026-06-04] Author: Ing. Calogero Bono

Your database has 10,000 orders. A view loading user relationships takes ten seconds. The client thinks the site is broken. That's the N+1 problem: one extra query per record. At Meteora Web, we see it in projects we review: working code, but slow, invisible queries that kill performance. This guide takes you straight to the solutions: well-defined relationships, scopes that encapsulate logic, accessors that transform data, and techniques to kill N+1 before it even appears.

Eloquent Relationships: More Than belongsTo and hasMany

Eloquent relationships are query objects you can chain, constrain, and lazy load. The trick is to treat them as query builders, not simple getters.

Defining Relationships with Custom Keys

By default Eloquent guesses foreign keys. Real projects have different naming conventions. Always specify explicit keys:

// Order model
public function customer(): BelongsTo
{
    return $this->belongsTo(Customer::class, 'customer_id', 'id_customer');
}

public function items(): HasMany
{
    return $this->hasMany(OrderItem::class, 'order_ref', 'reference');
}

Constrained and Nested Eager Loading

Plain with('items') loads all items. When you need only a subset, use constrained eager loading:

$orders = Order::with(['items' => function ($query) {
    $query->where('quantity', '>', 0)->orderBy('price', 'desc');
}])->get();

You can nest as deep as needed. Using with avoids N+1 even with internal filters.

Polymorphic Relationships: One Table for Many Entities

When a model belongs to multiple models (e.g., comments on Post and Video), use MorphMany. Ensure indexes on morph_type and morph_id:

// Post and Video models
public function comments(): MorphMany
{
    return $this->morphMany(Comment::class, 'commentable');
}

// Comment model
public function commentable(): MorphTo
{
    return $this->morphTo();
}

Action right now: Review your model relationships. Add explicit foreignKey and localKey. Use with for nested relationships only on branches you need.

Global and Local Scopes: Reusable Query Logic

Scopes are pre-packaged filters. Don't repeat where('active', 1) in every controller.

Local Scopes: On-Demand Filters

// Product model
public function scopeActive($query): Builder
{
    return $query->where('active', true)->whereNull('deleted_at');
}

public function scopeByCategory($query, $slug): Builder
{
    return $query->whereHas('category', fn($q) => $q->where('slug', $slug));
}

// Usage
$products = Product::active()->byCategory('clothing')->get();

Encapsulate complex conditions: joins, subqueries, raw SQL. Local scopes are callable as methods on the builder.

Global Scopes: Always-Applied Filters

If every query on a model needs a filter (multi-tenancy, soft delete), define a global scope. We use them to isolate clients in multi-company platforms:

// App\Scopes\TenantScope
class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model)
    {
        $builder->where('tenant_id', auth()->user()->tenant_id);
    }
}

// In User model
protected static function booted(): void
{
    static::addGlobalScope(new TenantScope());
}

To bypass a scope in specific queries, use withoutGlobalScope().

Action right now: Find repeated queries in your code. Turn them into local scopes. Consider a global scope for context filters (language, company).

Accessors and Mutators: Transform Data Without Performance Cost

Accessors (getters) and mutators (setters) let you manipulate attributes on the fly. Slow accessors can multiply queries.

Accessors with Internal Cache

// User model
use Illuminate\Database\Eloquent\Casts\Attribute;

protected function fullName(): Attribute
{
    return Attribute::make(
        get: fn ($value, $attributes) => trim($attributes['first_name'] . ' ' . $attributes['last_name'])
    );
}

Accessors that read relationships (e.g., $user->avatar_url) can trigger N+1. Solution: load the relationship with with before accessing the accessor. Or guard with relationLoaded:

protected function avatarUrl(): Attribute
{
    return Attribute::make(
        get: function ($value) {
            if (!$this->relationLoaded('media')) {
                return $this->defaultAvatarUrl();
            }
            return $this->media->first()?->url ?? $this->defaultAvatarUrl();
        }
    );
}

Mutators to Normalize Input

protected function email(): Attribute
{
    return Attribute::make(
        set: fn ($value) => strtolower(trim($value))
    );
}

Action right now: Check accessors that use relationships. Add a relationLoaded guard. For simple transformations, prefer model casts (like array, json) over accessors.

N+1 Prevention: Tools and Techniques

The N+1 problem is Laravel's silent performance killer. It happens when you call an unloaded relationship inside a loop. An e-commerce client with 500 products and an unloaded category relation executes 501 queries. We measured it: response time jumps from 200ms to 3 seconds.

1. Debug with Query Log (Local)

Before optimizing, see the queries. Enable logging in AppServiceProvider (local only):

// In boot()
if (app()->environment('local')) {
    DB::listen(function ($query) {
        logger($query->sql, $query->bindings);
    });
}

Alternative: Laravel Debugbar (barryvdh/laravel-debugbar). Shows query count, duplicates, missing eager loads.

2. Use withCount to Avoid Unnecessary Relationships

If you only need a count (number of orders per customer), don't load the entire relation. Use withCount:

$customers = Customer::withCount('orders')->get();
foreach ($customers as $customer) {
    echo $customer->orders_count; // No extra query
}

3. Lazy Eager Loading After the Query

Not sure if you'll need a relationship? Load it after the initial get() with load():

$orders = Order::all();
if (request()->boolean('include_items')) {
    $orders->load('items.discounts');
}

4. Avoid Loop Relationships with Caching

Sometimes the relationship is unavoidable in a loop (e.g., calculating a total). Load the relationship once and use remember on the collection:

$customers = Customer::with('orders')->get()->remember(60);

Note: remember on models is a macro we added for quick caching. Alternatively use Cache::remember with an explicit key.

5. Automatic Tool: Laravel N+1 Detector

During development, use the package beyondcode/laravel-query-detector (or pyaesoneaung/laravel-n-plus-one-detector). It warns you when you're about to commit N+1. We enable it on every project we consult.

6. Inverse Relationships with HasOne

Sometimes N+1 comes from undefined inverse relationships. Example: an order has an address, but you want the latest order per address. Create a latestOrder relationship on the Address model:

public function latestOrder(): HasOne
{
    return $this->hasOne(Order::class)->latestOfMany();
}

Then load Address::with('latestOrder')->get() – only one extra query.

Action right now: Install Debugbar. Open a view that loads lists. Check the query count. Every query beyond models+1 is an N+1 to eliminate. Replace lazy loads in loops with with. Use withCount for counts.

In Summary — What to Do

  1. Review all relationship methods: explicit keys, constrained eager loading, morphs.
  2. Turn every repeated filter into a local scope. Consider global scopes for global context (tenant, language).
  3. Check accessors and mutators: do not auto-load relationships. Use casts where possible.
  4. Enable query logging in local. Identify N+1 and fix them with with, load, withCount.
  5. Integrate an automatic detector in your development workflow.
  6. Monitor performance in production using tools like Laravel Telescope.

At Meteora Web, we use these techniques in every Laravel project. Result: responsive APIs, fast admin panels, happy clients. Want us to analyze your queries? Contact us. Meanwhile, open your model and start there.

Sponsored Protocol

Ing. Calogero Bono

> AUTHOR_EXTRACTED

Ing. Calogero Bono

Co-founder di Meteora Web. Ingegnere informatico, sviluppo ecosistemi digitali ad alte prestazioni. AI, automazione, SEO tecnica e infrastrutture web. Scrivo di tecnologia per rendere complesso… semplice.

[ Read Full Dossier ]

Hai bisogno di applicare questa strategia?

Esegui il protocollo di contatto per iniziare un progetto con noi.

> INIZIA_PROGETTO

Sponsored

> MW_JOURNAL

> READ_ALL()