Find N+1 problems instantly by disabling lazy loading
Published on by Paul Redmond
In the next release of Laravel 8, you can strictly disable lazy loading entirely, resulting in an exception:
Strict about preventing N+1 query problems? @themsaid's recent contribution to the framework allows you to disable lazy loading entirely (exception will be thrown)...
— Taylor Otwell 🪐 (@taylorotwell) May 19, 2021
Can disable it only on non-production so production doesn't crash if one slips through! 💅
Ships next week! pic.twitter.com/5Q9YpCLRze
Preventing lazy loading in development can help you catch N+1 bugs earlier on in the development process. The Laravel ecosystem has various tools to identify N+1 queries. However, this approach brings the issue front-and-center by throwing an exception.
Demo
Let’s walk through this feature real quick by spinning up a development version of the framework 8.x
branch since this feature is not out yet at the time of writing. Once released, you will have this feature without switching to the latest 8.x
branch.
Setup
First, create a new application:
laravel new strict-lazy-demo
Next, we’ll update the laravel/framework
version in composer.json
to make sure we have this feature (if you’re trying it out before the next release) by adjusting the version to 8.x-dev
:
{ "require": { "laravel/framework": "8.x-dev" }}
Next, run composer update
to make sure you get the latest version of the code for this branch:
composer update laravel/framework
At this point, you should set up your preferred database. I like running a local MySQL instance using Laravel’s defaults of using the root
user without a password. I find it convenient to use the default .env
values locally to get started quickly without any configuration.
mysql -uroot -e"create database strict_lazy_demo"
Once you configure your database of choice, make sure you can migrate:
php artisan migrate:fresh
Demo Data
We’ll create a Post
model and define a one-to-many
relationship from the User
model to demonstrate this feature. We’ll start by creating the Post
model and accompanying files:
# Create a model with migration and factoryphp artisan make:model -mf Post
First, let’s define our Post
migration and factory configuration:
// Your filename will differ based on when you create the file.// 2021_05_21_000013_create_posts_table.php Schema::create('posts', function (Blueprint $table) { $table->id(); $table->foreignIdFor(\App\Models\User::class); $table->string('title'); $table->longText('body'); $table->timestamps();});
Next, define your PostFactory
definition method based on the above schema:
/** * Define the model's default state. * * @return array */public function definition(){ return [ 'user_id' => \App\Models\User::factory(), 'title' => $this->faker->sentence(), 'body' => implode("\n\n", $this->faker->paragraphs(rand(2,5))), ];}
Finally, open up the DatabaseSeeder
file and add the following in the run()
method:
/** * Seed the application's database. * * @return void */public function run(){ \App\Models\User::factory() ->has(\App\Models\Post::factory()->count(3)) ->create() ;}
Associating Models and Prevent Lazy Loading
Now that we have the migration, seeder, and model created, we are ready to associate a User with the Post model to demo this feature.
Add the following method to the User
model to give the user an association with Posts:
// app/Models/User.php/** * @return \Illuminate\Database\Eloquent\Relations\HasMany */public function posts(){ return $this->hasMany(Post::class);}
With that in place, we can migrate and seed the database:
php artisan migrate:fresh --seed
If all went well, we should see something like the following in the console:
We can now using tinker to inspect our seeded data and relationship:
php artisan tinker >>> $user = User::first()=> App\Models\User {#4091 id: 1, name: "Nedra Hayes", email: "bruen.marc@example.com", email_verified_at: "2021-05-21 00:35:59", created_at: "2021-05-21 00:35:59", updated_at: "2021-05-21 00:35:59", }>>> $user->posts=> Illuminate\Database\Eloquent\Collection {#3686 all: [ App\Models\Post {#3369 id: 1,...
The $user->posts
property actually calls the database, thus is "lazy" but is not optimized. The convenience of lazy-loading is nice, but it can come with heavy performance burdens in the long-term.
Disabling Lazy Loading
Now that we have the models set up, we can disable lazy loading across our application. You’d likely want to only disable in non-production environments, which is easy to achieve! Open up the AppServiceProvider
class and add the following to the boot()
method:
// app/Providers/AppServiceProvider.php public function boot(){ Model::preventLazyLoading(! app()->isProduction());}
If you run a php artisan tinker
session again, this time you should get an exception for a lazy loading violation:
php artisan tinker >>> $user = \App\Models\User::first()=> App\Models\User {#3685 id: 1, name: "Nedra Hayes", email: "bruen.marc@example.com", email_verified_at: "2021-05-21 00:35:59", #password: "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi", #remember_token: "jHSxFGKOdw", created_at: "2021-05-21 00:35:59", updated_at: "2021-05-21 00:35:59", }>>> $user->postsIlluminate\Database\LazyLoadingViolationException with message'Attempted to lazy load [posts] on model [App\Models\User] but lazy loading is disabled.'
If you want to visualize what happens if you use lazy loading in a view file, modify the default route as follows:
Route::get('/', function () { return view('welcome', [ 'user' => \App\Models\User::first() ]);});
Next, add the following somewhere in the welcome.blade.php
file:
<h2>Posts</h2>@foreach($user->posts as $post) <h3>{{ $post->title }}</h3> <p> {{ $post->body }} </p>@endforeach
If you load up your application through Valet or artisan serve
, you should see something like the following error page:
Though you’ll get exceptions during development, accidentally deploying code that triggers lazy-loading will continue to work as long as you set environment checking correctly in the service provider.
Learn More
You can learn how this feature was implemented: 8.x Add eloquent strict loading mode - Pull Request #37363. Huge thanks to Mohamed Said, contributors, and of course Taylor Otwell for adding the polish to disable lazy loading conditionally.