6 min read

Laravel 12's Automatic Eager Loading: Finally Solving N+1 Without Thinking About It

Laravel 12.8 introduces automatic relationship eager loading that silently eliminates N+1 query problems — no more forgetting `with()` on every query.

Featured image for "Laravel 12's Automatic Eager Loading: Finally Solving N+1 Without Thinking About It"

The N+1 query problem is one of those bugs that every PHP developer encounters, understands perfectly once diagnosed, and then promptly forgets to prevent on the next feature. It is not a complex issue — it is a workflow issue. You write a loop, you access a relationship inside it, and suddenly a page that should fire three queries fires 303. You find it in Telescope or Debugbar, you add with(), it goes away, and two weeks later someone else on the team writes the same loop.

Laravel 12.8, released in late 2025, ships a feature that changes this dynamic: automatic relationship eager loading. The framework now detects which relationships you actually access during a request and loads them in batch, without you having to declare them upfront. Here is what it is, how it works, and when you should still reach for explicit with().

The Problem in Concrete Terms

Consider a typical list endpoint. You retrieve a collection of orders and render each order’s client name and that client’s company:

$orders = Order::all();

foreach ($orders as $order) {
    echo $order->client->company->name;
}

Without eager loading, this fires one query for Order::all(), then one query per order for $order->client, then one query per unique client for $order->client->company. On a page with 100 orders, that is 201+ queries. Laravel has always had tools to fix this — with() and load() — but they require you to know in advance which relationships you will touch.

The Old Way: Explicit Eager Loading

The classic solution is to declare your relationships up front:

$orders = Order::with(['client.company'])->get();

This fires three queries regardless of how many orders you have: one for orders, one for their clients, and one for the companies associated with those clients. Efficient and correct — but you have to remember to do it, you have to know the relationship tree before you write the loop, and when a template or component accesses a relationship you did not anticipate, you are back to N+1 without any immediate feedback in production.

To catch the problem during development, Laravel has offered preventLazyLoading() since Laravel 8:

// In AppServiceProvider::boot()
Model::preventLazyLoading(!app()->isProduction());

This throws an exception whenever a relationship is accessed without being eager loaded, forcing you to fix it before it ships. It is the right approach for catching the problem early. The downside is that it is loud — every missed with() becomes an exception — and it requires you to fix each one manually by updating your query.

Laravel 12.8: Automatic Relationship Loading

Laravel 12.8 adds a quieter alternative. Instead of blowing up when a lazy-loaded relationship is accessed, the framework detects the access and loads that relationship (and all others accessed on the same collection) via a batch query. It is lazy loading under the hood, but batched across the entire collection rather than row by row.

Enable it globally in AppServiceProvider:

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Model::automaticallyEagerLoadRelationships();
    }
}

With this in place, the original loop just works:

$orders = Order::all();

foreach ($orders as $order) {
    echo $order->client->company->name; // no N+1
}

The first time any $order->client is accessed, Laravel loads client for the entire $orders collection. The first time any client->company is accessed, it loads company for the entire clients collection. The number of queries scales with the depth of the relationship tree, not the number of rows.

You can also apply it per-query rather than globally, using withRelationshipAutoloading() on a collection or model:

$orders = Order::all()->withRelationshipAutoloading();

Or chain it directly on the query builder result when you want opt-in behavior without changing the global default:

$orders = Order::where('status', 'pending')->get()->withRelationshipAutoloading();

How It Actually Works

The implementation tracks which relationships are accessed on a given model instance during its lifecycle. When you first touch $order->client in a loop, Laravel checks whether client has been loaded for the other models in the same collection. If it has not, it calls loadMissing('client') on the full collection — a single query that hydrates the relationship for all models at once.

Polymorphic relationships are handled correctly. If you have a morphable commentable relationship on a collection of mixed types, Laravel only loads the relationship for the types that actually appear in the collection. You do not pay for morph types that are present in the schema but absent from the current result set.

Relationships you have already explicitly loaded via with() or load() are not re-queried. The automatic loading only fills gaps.

When You Should Still Use Explicit with()

Automatic eager loading is not a reason to stop thinking about query efficiency. There are cases where explicit with() remains the better tool.

When you know the relationship tree upfront and you want the queries to fire before the loop begins, with() gives you that guarantee. This matters for APIs where you want predictable, measurable query counts, or where the relationship data influences how you structure the response before you touch any individual model.

Automatic loading also fires its batch queries on first access, which means the query happens inside the loop rather than before it. In most web request scenarios this distinction does not matter. In long-running queue workers or console commands where you are processing large datasets in batches, you want full control over when queries fire and with() plus chunking is still the right pattern.

For new applications, a reasonable approach is to enable automaticallyEagerLoadRelationships() globally and continue using preventLazyLoading(!app()->isProduction()) alongside it. The automatic loading silently fixes access patterns that would otherwise be N+1 in production, while the exception still fires in local development to prompt you to add proper with() on the critical paths where query predictability matters.

Practical Example With API Resources

API Resources are a common place N+1 queries slip through, because the resource definition does not know what the controller loaded. With automatic eager loading enabled:

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'      => $this->id,
            'total'   => $this->total,
            'client'  => $this->whenLoaded('client', fn () => [
                'name'    => $this->client->name,
                'company' => $this->client->company->name,
            ]),
        ];
    }
}

And in the controller:

public function index(): ResourceCollection
{
    $orders = Order::paginate(25)->withRelationshipAutoloading();
    return OrderResource::collection($orders);
}

The withRelationshipAutoloading() call on the paginator result ensures that when the resource accesses $this->client->company->name for each order, those relationships load in batch rather than one row at a time.

Upgrading and Compatibility

Automatic eager loading is available as of Laravel 12.8. If you are on an earlier 12.x release, composer update to the latest patch brings it in. The feature is additive — enabling it does not change any query behavior for relationships you have already explicitly loaded.

For applications running Laravel 10 or 11, the explicit with() and preventLazyLoading() combination remains your toolkit. Automatic eager loading is a 12.x feature only.

Resources