Back to blog
Laravel
Advanced

Advanced Laravel Eloquent ORM Techniques for Scalable Applications

Learn advanced Laravel Eloquent ORM techniques including eager loading, scopes, subqueries, repository pattern, and performance tips to build scalable applications.

September 24, 2025

Introduction

Laravel's Eloquent ORM is celebrated for its expressive syntax and developer‑friendly approach to database interactions. While the basics—defining models, creating relationships, and running simple queries—are covered in countless tutorials, real‑world applications demand far more sophistication. As data volumes grow and business logic becomes complex, developers must leverage Eloquent's advanced features to keep queries efficient, code maintainable, and applications scalable. This guide dives deep into those advanced techniques, showing you how to push Eloquent beyond CRUD operations and into the realm of high‑performance, enterprise‑grade solutions. You'll learn how to craft sophisticated query scopes, harness eager loading with constraints, utilize database functions directly, and implement advanced patterns such as repository and service layers. By the end, you'll have a toolbox of patterns that let you build Laravel applications that are both elegant and performant.

Table of Contents

Core Concepts

Before diving into code, it's essential to grasp the advanced concepts that make Eloquent a powerful ORM rather than just a simple query builder. Understanding these ideas will help you decide when to apply each technique and avoid common pitfalls.

Eager Loading vs Lazy Loading

Lazy loading triggers a separate query each time you access a relationship, which can lead to the infamous N+1 problem. Eager loading fetches related models in a single query using the with method, drastically reducing database round‑trips. Advanced eager loading lets you add constraints, select specific columns, or even nest eager loads.

Query Scopes

Scopes encapsulate reusable query logic. Local scopes are defined on the model and applied via ->scopeName(). Global scopes automatically apply to every query for that model, useful for soft deletes or tenant filtering. You can also combine scopes dynamically.

Advanced Where Clauses

Eloquent's where method supports closures for complex nested conditions, subqueries, and raw expressions. You can build dynamic queries that adapt to user input while staying safe from SQL injection.

Subqueries and Unions

Laravel 6+ introduced subquery support within select, whereExists, and whereNotExists. Unions allow you to combine the results of two or more queries into a single result set, which is handy for reporting dashboards.

Database Functions and Expressions

Sometimes you need to call database‑specific functions like COUNT, MAX, or custom stored procedures. Eloquent's selectRaw, whereRaw, and havingRaw let you inject raw SQL safely.

Mutators and Accessors

Accessors format attributes when you retrieve them (e.g., formatting a date). Mutators modify attributes before saving (e.g., hashing a password). Combined with attribute casting, they keep your model's data layer clean.

Model Events and Observers

Eloquent fires events such as creating, updating, saving, and deleted. Observers group listener methods into a single class, keeping your models thin and moving side‑effects like logging or notifications to observers.

Relationship Types Beyond the Basics

Beyond belongsTo and hasMany, Eloquent supports belongsToMany (many‑to‑many), hasOneThrough, hasManyThrough, and polymorphic relations (morphTo, morphOne, morphMany). Advanced techniques include constraining polymorphic relationships and using intermediate table pivots with extra columns.

Architecture Overview

A well‑structured Laravel application separates concerns across layers: routes, controllers, services, repositories, and models. Keeping Eloquent logic out of controllers improves testability and reusability.

Service Layer

Services encapsulate business logic that may involve multiple models or external APIs. By injecting repositories into services, you decouple the controller from data access details.

Repository Pattern

A repository provides a collection‑like interface for accessing models. It centralizes query building, making it easier to swap implementations (e.g., moving from Eloquent to a external API) without changing dependent code.

Use of Laravel's Container

Laravel's service container resolves dependencies automatically. Binding an interface to an Eloquent repository implementation allows you to change the data source with a single binding change.

DTOs and Transformers

When exposing data via APIs, transform resources (using Laravel Resources or Fractal) to shape the output. This prevents over‑exposing model attributes and lets you compute derived fields.

Queue Jobs for Heavy Operations

Operations like generating large reports or processing thousands of records should be dispatched to queues. Eloquent models can be serialized and reconstituted in queue workers, ensuring long‑running tasks don't block HTTP requests.

Testing Strategies

Unit tests can mock repositories, while feature tests use an in‑memory SQLite database to verify Eloquent interactions. Using Laravel's RefreshDatabase trait keeps each test isolated.

Step‑by‑Step Guide

Now we'll walk through a practical scenario: building a product catalog for an e‑commerce platform that supports filtering, sorting, pagination, and tenant isolation.

  1. Define the Model with Relationships

    Start with a Product model that belongs to a Category, has many Reviews, and belongs to a Tenant via a polymorphic relationship.

    class Product extends Model{    use SoftDeletes;    protected $casts = [        'price' => 'decimal:2',        'is_active' => 'boolean',    ];    public function category()    {        return $this->belongsTo(Category::class);    }    public function reviews()    {        return $this->hasMany(Review::class);    }    public function tenant()    {        return $this->morphTo();    }    public function scopeActive($query)    {        return $query->where('is_active', true);    }    public function scopeTenanted($query, $tenant)    {        return $query->where('tenant_type', get_class($tenant))                     ->where('tenant_id', $tenant->id);    }}
  2. Create Local and Global Scopes

    We already added scopeActive. A global scope can automatically apply tenant filtering for all queries on the Product model.

    namespace App\Scopes;use Illuminate\Database\Eloquent\Builder;use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\Scope;class TenantScope implements Scope{    public function apply(Builder $builder, Model $model)    {        if (auth()->check() && auth()->user()->tenant) {            $tenant = auth()->user()->tenant;            $builder->where('tenant_type', get_class($tenant))                    ->where('tenant_id', $tenant->id);        }    }}

    Register it in the model's booted method:

    protected static function booted(){    static::addGlobalScope(new TenantScope);}
  3. Eager Load with Constraints

    When listing products, you often need the category name and the average rating from reviews. Use eager loading with constraints and aggregates.

    $products = Product::with([    'category:id,name',    'reviews' => function ($query) {        $query->select('product_id', DB::raw('AVG(rating) as avg_rating'))              ->groupBy('product_id');    }])->active()->tenanted(auth()->user()->tenant)->filter(request()->only(['search', 'min_price', 'max_price']))->orderBy(request()->input('sort', 'created_at'), request()->input('dir', 'desc'))->paginate(20);
  4. Build Dynamic Query Builders

    Encapsulate filtering logic in a dedicated query builder class or reuse scopes.

    class ProductFilter{    protected $builder;    public function __construct(Product $model)    {        $this->builder = $model->newQuery();    }    public function apply(array $filters)    {        foreach ($filters as $field => $value) {            switch ($field) {                case 'search':                    $this->builder->where('name', 'like', "%{$value}%")                                  ->orWhere('description', 'like', "%{$value}%");                    break;                case 'min_price':                    $this->builder->where('price', '>=', $value);                    break;                case 'max_price':                    $this->builder->where('price', '<=', $value);                    break;                case 'category_id':                    $this->builder->where('category_id', $value);                    break;            }        }        return $this->builder;    }}

    Then use it in the controller:

    $filter = new ProductFilter(new Product);$query = $filter->apply(request()->only(['search','min_price','max_price','category_id']))                ->active()                ->tenanted(auth()->user()->tenant)                ->with(['category']);
  5. Utilize Subqueries for Advanced Filtering

    Suppose you want to show only products that have at least one review with a rating above 4.

    $products = Product::whereExists(function ($query) {    $query->selectRaw(1)          ->from('reviews')          ->whereColumn('reviews.product_id', 'products.id')          ->where('rating', '>', 4);})->active()->tenanted(auth()->user()->tenant)->get();
  6. Leverage Database Functions

    To compute a price discount directly in the database, use selectRaw.

    $products = Product::select([        'products.*',        DB::raw('ROUND(price * 0.9, 2) AS discounted_price')    ])    ->active()    ->tenanted(auth()->user()->tenant)    ->get();
  7. Chunk Large Datasets

    When exporting all products to CSV, avoid memory issues by processing in chunks.

    Product::tenanted(auth()->user()->tenant)        ->chunk(500, function ($products) {            foreach ($products as $product) {                // generate CSV line            }        });
  8. Cache Expensive Queries

    For rarely changing data like category trees, cache the result.

    $categories = Cache::rememberForever('categories.all', function () {    return Category::with('children')->get();});
  9. Implement a Repository Interface

    Define a contract and an Eloquent implementation.

    interface ProductRepositoryInterface{    public function getPaginated(array $filters): LengthAwarePaginator;    public function findById(int $id): ?Product;    public function create(array $data): Product;    public function update(Product $product, array $data): Product;    public function delete(Product $product): bool;}class EloquentProductRepository implements ProductRepositoryInterface{    public function getPaginated(array $filters)    {        return (new ProductFilter(new Product))            ->apply($filters)            ->active()            ->tenanted(auth()->user()->tenant)            ->with(['category'])            ->paginate(20);    }    // other methods omitted for brevity}
  10. Add Observers for Auditing

    Create an observer that logs every product change.

    namespace App\Observers;use App\Models\Product;use App\Models\ProductLog;class ProductObserver{    public function saved(Product $product)    {        ProductLog::create([            'product_id' => $product->id,            'user_id'    => auth()->id(),            'action'     => $product->wasRecentlyCreated ? 'created' : 'updated',            'changes'    => $product->getDirty(),        ]);    }    public function deleted(Product $product)    {        ProductLog::create([            'product_id' => $product->id,            'user_id'    => auth()->id(),            'action'     => 'deleted',            'changes'    => [],        ]);    }}

    Register the observer in the booted method of the model or in a service provider.

    protected static function booted(){    static::observe(ProductObserver::class);}

Real-World Examples

Example 1: E‑Commerce Product Catalog with Filtering

We already walked through the product catalog. The key takeaways are using scoped tenants, dynamic filters via a filter class, eager loading with aggregates, and pagination. This pattern scales to millions of products while keeping response times under 200 ms.

Example 2: SaaS Multi‑Tenanted Application Using Tenant Scopes

In a multi‑tenant SaaS, each tenant's data must be isolated. A global tenant scope (as shown) automatically adds tenant_id and tenant_type constraints to every query. Combine this with database‑level indexing on those columns for fast look‑ups. For tenant‑specific schemas, you can switch connections dynamically using Laravel's DB::purge and DB::reconnect.

Example 3: Reporting Dashboard with Complex Aggregations

A sales dashboard needs totals, averages, and group‑by periods. Use subqueries and selectRaw to compute running totals, and leverage Eloquent's groupBy with havingRaw for filtered aggregates.

$sales = DB::table('orders')    ->select(        ->whereBetween('created_at', [$start, $end])        ->groupBy('sale_date')        ->havingRaw('SUM(amount) > ?', [$minimum])        ->orderBy('sale_date')        ->get();

Example 4: Activity Feed Using Polymorphic MorphMany

An activity feed can track actions on various models (posts, comments, likes). Use a polymorphic morphMany relation from a User model to an Activity model.

class User extends Authenticatable{    public function activities()    {        return $this->morphMany(Activity::class, 'subject');    }}class Activity extends Model{    public function subject()    {        return $this->morphTo();    }}

To fetch a user's recent activity with the related subject, eager load the morph:

$activities = Auth::user()->activities()    ->with(['subject'])    ->latest()    ->take(20)    ->get();

Production Code Examples

Below are ready‑to‑copy snippets that demonstrate the patterns discussed. They follow Laravel 10.x conventions and PHP 8.2+.

1. Base Model with Casts, Mutators, and Scopes

class Product extends Model{    use SoftDeletes;    protected $fillable = [        'name',        'description',        'price',        'is_active',        'category_id',        'tenant_type',        'tenant_id',    ];    protected $casts = [        'price' => 'decimal:2',        'is_active' => 'boolean',        'tenant_id' => 'integer',    ];    protected $appends = ['discounted_price'];    public function category()    {        return $this->belongsTo(Category::class);    }    public function reviews()    {        return $this->hasMany(Review::class);    }    public function tenant()    {        return $this->morphTo();    }    public function scopeActive($query)    {        return $query->where('is_active', true);    }    public function scopeTenanted($query, $tenant)    {        return $query->where('tenant_type', get_class($tenant))                     ->where('tenant_id', $tenant->id);    }    public function getDiscountedPriceAttribute()    {        return round($this->price * 0.9, 2);    }}

2. Repository Interface and Eloquent Implementation

interface ProductRepositoryInterface{    public function getPaginated(array $filters): LengthAwarePaginator;    public function findById(int $id): ?Product;    public function create(array $data): Product;    public function update(Product $product, array $data): Product;    public function delete(Product $product): bool;}class EloquentProductRepository implements ProductRepositoryInterface{    public function getPaginated(array $filters): LengthAwarePaginator    {        return (new ProductFilter(new Product))            ->apply($filters)            ->active()            ->tenanted(auth()->user()->tenant)            ->with(['category:id,name'])            ->paginate(20);    }    public function findById(int $id): ?Product    {        return Product::tenanted(auth()->user()->tenant)->find($id);    }    public function create(array $data): Product    {        return Product::create($data);    }    public function update(Product $product, array $data): Product    {        $product->update($data);        return $product->fresh();    }    public function delete(Product $product): bool    {        return $product->delete();    }}

3. Service Layer Using the Repository

class ProductService{    protected $repository;    public function __construct(ProductRepositoryInterface $repository)    {        $this->repository = $repository;    }    public function listProducts(Request $request)    {        $filters = $request->only(['search', 'min_price', 'max_price', 'category_id']);        return $this->repository->getPaginated($filters);    }    public function getProduct(int $id)    {        return $this->repository->findById($id);    }    public function createProduct(array $data)    {        return $this->repository->create($data);    }    public function updateProduct(int $id, array $data)    {        $product = $this->repository->findById($id);        if (!$product) {            return null;        }        return $this->repository->update($product, $data);    }    public function deleteProduct(int $id)    {        $product = $this->repository->findById($id);        if (!$product) {            return false;        }        return $this->repository->delete($product);    }}

4. Observer for Auditing

class ProductObserver{    public function saved(Product $product)    {        ProductLog::create([            'product_id' => $product->id,            'user_id'    => auth()->check() ? auth()->id() : null,            'action'     => $product->wasRecentlyCreated ? 'created' : 'updated',            'changes'    => $product->getDirty(),        ]);    }    public function deleted(Product $product)    {        ProductLog::create([            'product_id' => $product->id,            'user_id'    => auth()->check() ? auth()->id() : null,            'action'     => 'deleted',            'changes'    => [],        ]);    }}

5. Controller Leveraging Service and Repository

class ProductController extends Controller{    protected $service;    public function __construct(ProductService $service)    {        $this->service = $service;    }    public function index(Request $request)    {        $products = $this->service->listProducts($request);        return view('products.index', compact('products'));    }    public function show($id)    {        $product = $this->service->getProduct($id);        if (!$product) {            abort(404);        }        return view('products.show', compact('product'));    }    public function store(Request $request)    {        $data = $request->validate([            'name' => 'required|string|max:255',            'description' => 'nullable|string',            'price' => 'required|numeric|min:0',            'is_active' => 'boolean',            'category_id' => 'required|exists:categories,id',        ]);        $product = $this->service->createProduct($data);        return redirect()->route('products.show', $product->id)                         ->with('success', 'Product created.');    }    public function update(Request $request, $id)    {        $data = $request->validate([            'name' => 'sometimes|required|string|max:255',            'description' => 'sometimes|nullable|string',            'price' => 'sometimes|required|numeric|min:0',            'is_active' => 'sometimes|boolean',            'category_id' => 'sometimes|required|exists:categories,id',        ]);        $product = $this->service->updateProduct($id, $data);        if (!$product) {            abort(404);        }        return redirect()->route('products.show', $product->id)                         ->with('success', 'Product updated.');    }    public function destroy($id)    {        if ($this->service->deleteProduct($id)) {            return redirect()->route('products.index')                             ->with('success', 'Product deleted.');        }        abort(404);    }}

Comparison Table

When deciding between Eloquent, the Query Builder, and raw SQL, consider the following trade‑offs.

FeatureEloquent ORMQuery BuilderRaw SQL
ReadabilityHigh – expressive, model‑centricMedium – fluent syntaxLow – plain SQL strings
Development SpeedFast – relationships, scopes, mutatorsModerate – manual joinsSlow – manual handling of results
PerformanceGood with eager loading; can suffer N+1 if misusedGood – full control over queriesBest – you write exactly what you need
Security (SQL Injection)Automatic protection via PDO bindingAutomatic protectionManual – you must bind parameters
Portability Across DatabasesHigh – Laravel's abstractions smooth differencesMedium – still uses Query BuilderLow – vendor‑specific syntax
Best Use CaseStandard application logic, CRUD, complex relationshipsDynamic reports, ad‑hoc queries, when you need fine‑tuned SQLPerformance‑critical sections, database‑specific features, migrations

Best Practices

  • Always define explicit $fillable or $guarded arrays to guard against mass‑assignment vulnerabilities.
  • Use local scopes for reusable query logic; keep them short and focused.
  • Prefer eager loading (with) over lazy loading when you know you'll need the relationship.
  • Constrain eager loads with closure callbacks to select only needed columns and add filters.
  • Leverage database functions via selectRaw, whereRaw, and havingRaw for computations that are faster in SQL.
  • Use model events and observers to keep business logic out of controllers.
  • Encapsulate complex filtering in dedicated filter classes or repository methods.
  • Cache results of infrequently changing queries (e.g., navigation menus, reference data).
  • Chunk large result sets to avoid memory exhaustion.
  • Write unit tests for repositories and feature tests that use an in‑memory SQLite database.
  • Keep models thin; move business logic to services or action classes.
  • Use Laravel's pagination (simplePaginate or paginate) for efficient LIMIT/OFFSET queries.
  • Always run php artisan optimize:clear during deployment to clear compiled views and configuration caches.

Common Mistakes

  • Forgetting to add ->with() and suffering from N+1 query problems in loops.
  • Over‑using ->get() on large tables, causing memory spikes.
  • Placing complex business logic directly in controllers, making them hard to test.
  • Neglecting to define proper model casts, leading to incorrect data types (e.g., storing JSON as strings).
  • Using raw SQL without parameter binding, opening the door to SQL injection.
  • Ignoring database indexes on columns used in where, join, or orderBy clauses.
  • Mutating attributes inside accessors, which can cause unexpected side effects when the model is saved.
  • Forgetting to detach pivot records when deleting a model that has many‑to‑many relationships.
  • Relying on default timestamps (created_at, updated_at) when they are not needed, adding unnecessary overhead.
  • Not using transactions when performing multiple related writes, risking data inconsistency.

Performance Tips

  • Add composite indexes on columns frequently filtered together (e.g., tenant_id and is_active).
  • Use select to retrieve only the columns you need; avoid * when possible.
  • Prefer chunk or cursor for processing thousands of records.
  • Enable query logging (DB::enableQueryLog) only in development to spot inefficient queries.
  • Leverage database‑level caching (e.g., MySQL query cache) for repetitive read‑only queries.
  • Use exists() instead of count() > 0 when you only need to know if a record exists.
  • When aggregating, consider materialized views or summary tables updated via jobs.
  • Use Laravel's remember helper to cache Eloquent results for a configurable time.
  • Optimize eager loading by selecting only needed columns within the closure.
  • Apply database indexing on foreign keys and polymorphic type/id columns.
  • Monitor slow query logs and use EXPLAIN to verify index usage.

Security Considerations

  • Always validate and sanitize user input before using it in queries; rely on Laravel's validation rules.
  • Use Eloquent's automatic parameter binding; never concatenate raw user input into query strings.
  • Protect against mass assignment by defining $fillable or $guarded on every model.
  • Implement authorization policies (Laravel Gates and Policies) to ensure users can only access their own tenant's data.
  • Rate‑limit API endpoints that expose product listings to prevent scraping.
  • Use HTTPS everywhere and set secure cookies.
  • Keep Laravel and dependencies up to date to receive security patches.
  • Log authentication failures and anomalous query patterns for intrusion detection.
  • Consider using database‑level row‑level security (RLS) if your RDBMS supports it for additional defense in depth.

Deployment Notes

  • Run php artisan migrate --force during deployment to apply new migrations.
  • Clear the configuration, route, and view caches: php artisan optimize:clear.
  • If you use Horizon or Supervisor for queues, restart workers after code changes: php artisan horizon:terminate.
  • Ensure the .env file is not committed; use environment‑specific files or secrets management.
  • Run php artisan test in a CI pipeline to catch regressions before merging.
  • Monitor application performance with tools like Laravel Telescope or New Relic.
  • Set appropriate file permissions: directories 755, files 644, and storage/bootstrap cache writable by the web user.
  • Enable OPcache in PHP for better performance.

Debugging Tips

  • Enable Laravel's debug mode (APP_DEBUG=true) only in local environments to see detailed error pages.
  • Use dd($query->toSql(), $query->getBindings()) to inspect the raw SQL and bindings before execution.
  • Leverage Laravel Telescope to watch incoming requests, exceptions, queries, logs, and jobs in real time.
  • When suspecting an N+1 issue, add ->withCount() or ->with(['relation' => function($q){ $q->select('id'); }]) to see the generated queries.
  • Check the queue failed table for jobs that threw exceptions.
  • Use DB::listen callbacks to log every query executed during a request.
  • Examine the laravel.log file for stack traces and context.
  • If a model attribute isn't being set, verify that the column is listed in $fillable or that you're using setAttribute correctly.
  • Run php artisan route:list to ensure your routes are correctly registered.
  • For testing, use RefreshDatabase trait to rollback migrations after each test.

FAQ

What is the difference between a local scope and a global scope?

A local scope is a method you call explicitly on a query builder (e.g., $model->scopeName()) to apply custom constraints. A global scope is automatically applied to every query for that model, useful for cross‑cutting concerns like tenant filtering or soft deletes.

How can I prevent the N+1 problem when loading relationships?

Use eager loading with the with method to load relationships in a single query.If you need to add constraints to the eager load, pass a closure: ->with(['comments' => function($q){ $q->where('approved', true); }]).Alternatively, use lazy eager loading (->load()) after you've already retrieved the models, but only when you know the collection size is small.

When should I use the Query Builder instead of Eloquent?

Choose the Query Builder when you need fine‑grained control over the SQL (e.g., complex unions, database‑specific functions, or when you're building a report that doesn't map neatly to a model). Eloquent excels at CRUD operations and when you benefit from model features like mutators, scopes, and relationships.

Can I use Eloquent with multiple database connections?

Yes. Define additional connections in config/database.php and specify the connection on a model using protected $connection = 'connection_name';. You can also switch connections at runtime with ->on('connection').

How do I implement soft deletes without losing data?

Add the SoftDeletes trait to your model and include a deleted_at timestamp column. Eloquent will automatically add whereNull('deleted_at') to queries. To restore, use restore(); to force delete, use forceDelete().

What is the best way to paginate large datasets efficiently?

Use Laravel's simplePaginate when you only need