Back to Blog
Lesson 7 of the Advanced Laravel: Architecture, Scaling & Performance course
LaravelJune 27, 20264 min read

Querying with Strict Eloquent: Preventing N+1 and Data Leaks

Stop N+1 issues and unauthorized data access in their tracks. Learn to configure strict Eloquent modes and global scopes for a rock-solid production architecture.

LaravelEloquentPerformanceN+1Securityphpbackend

Previously in this course, we discussed the Service Layer Pattern to decouple our business logic from the infrastructure. While that pattern protects our domain, the underlying data access layer remains vulnerable to runtime errors like lazy loading and cross-tenant data leakage. This lesson adds a layer of "defensive database programming" to your toolkit, ensuring that your application fails loudly during development rather than silently failing in production.

The Case for Strict Eloquent

In a high-traffic SaaS, performance is often lost in small, silent increments. An N+1 query might be undetectable during local development with a small dataset but can cripple your database under load. Similarly, forgetting a where('tenant_id', ...) clause in a complex query is a catastrophic security vulnerability.

Laravel’s "Strict" modes are designed to turn these silent killers into immediate exceptions. By enabling them, you shift the burden of catching these bugs from your QA team to your test suite.

Enabling Strict Loading

Strict loading prevents the "lazy loading" of relationships, which is the primary driver of the N+1 problem. When enabled, any attempt to access a relationship that hasn't been eager-loaded will throw an Illuminate\Database\LazyLoadingViolationException.

To enable this globally, you typically do this in the boot method of your AppServiceProvider:

PHP
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    #6A9955">// Enable strict mode in local/testing environments
    Model::preventLazyLoading(!app()->isProduction());
    
    #6A9955">// Optional: Log violations in production instead of throwing
    #6A9955">// Model::preventLazyLoading(app()->isProduction());
}

By setting this to !app()->isProduction(), you ensure that your developers are forced to use with() or load() during the development lifecycle. If a developer writes $user->profile->avatar without eager loading profile, the test suite will fail immediately.

Enforcing Data Isolation with Global Scopes

In our SaaS platform project, data leakage between tenants is a non-negotiable failure. While you could manually add a where('tenant_id', ...) to every query, human error makes this approach risky. Global Scopes allow you to inject this constraint automatically into every Eloquent query for a specific model.

Here is how we implement a TenantScope for our Subscription model:

PHP
namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Facades\Auth;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        #6A9955">// Ensure we only retrieve records for the authenticated tenant
        if (Auth::check()) {
            $builder->where('tenant_id', Auth::user()->tenant_id);
        }
    }
}

Now, apply it to the model:

PHP
namespace App\Models;

use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Model;

class Subscription extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new TenantScope);
    }
}

With this in place, Subscription::all() now automatically executes SELECT * FROM subscriptions WHERE tenant_id = ?. You’ve effectively eliminated the possibility of accidentally leaking data from other tenants in your controllers or service classes.

Hands-on Exercise: Implementing the Guard

  1. Enable Strict Mode: Open your AppServiceProvider and add the Model::preventLazyLoading() call as shown above.
  2. Create a Scope: Generate a new scope class for your Project model in our SaaS project to ensure users only ever see their own projects.
  3. Verify: Write a Feature Test that attempts to access a relationship without eager loading. Assert that a LazyLoadingViolationException is thrown.
  4. Test Isolation: Write a test where you attempt to query a Project belonging to a different tenant. Assert that the record is not found, even without an explicit where clause.

Common Pitfalls

  • The "Production Exception" Trap: Do not simply call Model::preventLazyLoading(true) in production if you haven't audited your entire codebase. It will crash your site for users the moment they hit a page with a missing with() call. Use the environment check or a custom log handler.
  • Global Scope Over-restriction: Global scopes are powerful, but they can be problematic when you need to access cross-tenant data (e.g., in a background job that aggregates statistics across all tenants). Use Model::withoutGlobalScope(TenantScope::class) when you intentionally need to bypass these constraints.
  • Performance Overhead of Scopes: While minor, remember that global scopes are applied to every query, including exists() checks or count(). Ensure your tenant_id column has an index to prevent these automated queries from becoming slow.

Summary

By leveraging preventLazyLoading and GlobalScopes, you transform Eloquent from a helpful abstraction into a strict guardian of your data integrity. This approach prevents common N+1 performance bottlenecks and provides a declarative way to handle multi-tenancy. You are now essentially "baking in" security and performance at the framework level.

Up next: We will dive into Advanced Subqueries and Joins to handle complex reporting requirements that go beyond simple model relationships.

Similar Posts