ORM (Object Relational Mapping) ช่วยให้เราทำงานกับฐานข้อมูลได้ง่ายขึ้นมาก เรากำหนด relationship ระหว่างตารางเป็น object แล้วเขียน query ผ่าน model ได้เลย ไม่ต้องสนใจว่าเบื้องหลังคุยกับฐานข้อมูลยังไง
Eager Loading คืออะไร
Eager Loading คือการบอก Eloquent ไว้ก่อนว่า ตอนดึง model ขึ้นมา เราจะใช้ relation ไหนด้วย framework จะได้รวบไปดึงทีเดียวด้วย query ที่คุ้มที่สุด แทนที่จะยิง query ไปที่ database ทีละแถว ก็จะเหลือแค่หนึ่งหรือสอง query
ในบทความนี้ เราจะสร้าง relation ตัวอย่างขึ้นมา แล้วดูว่าตอนใช้กับไม่ใช้ Eager Loading จะมี query ต่างกันแค่ไหน ลองเขียนหลาย ๆ แบบดูจะได้เห็นว่า Eager Loading ทำงานยังไง และช่วยให้ query ของเราเร็วขึ้นได้ตรงไหน
ปัญหา N+1 query เกิดขึ้นตอนไหน
โดยปกติ ORM จะ "lazy load" relation ของ model คือยังไม่ดึงจนกว่าเราจะเรียกใช้ เพราะ ORM ไม่รู้ว่าเราจะใช้ relation ไหนบ้าง และเราก็คงไม่อยากให้ ORM ดึง relation ที่ไม่ได้ใช้มาด้วย แต่ถ้าเราไม่ระวัง จะเจอปัญหาที่เรียกว่า "N+1" เขียนโค้ดแบบ object ไปเรื่อย ๆ โดยไม่รู้ตัวเลยว่าเบื้องหลังยิง query ไปที่ database กี่ครั้ง
สมมติเราดึง post มาจาก database 100 แถว แต่ละแถวมี model ที่เกี่ยวข้องอีกหนึ่งตัว (เช่น belongsTo) ถ้าใช้ ORM แบบปกติจะได้ query ทั้งหมด 101 ครั้ง คือหนึ่งครั้งสำหรับ post 100 แถวแรก แล้วอีกหนึ่งครั้งต่อ post หนึ่งแถวตอนดึง relation
ตัวอย่างเช่น เราอยากได้ชื่อ author ของทุก post ที่เผยแพร่แล้ว (แต่ละ post มี author หนึ่งคน) อาจเขียนได้แบบนี้
$posts = Post::published()->get(); // หนึ่ง query
$authors = $posts->map(function (Post $post) {
// query ไปที่ตาราง authors ทุกรอบ
return $post->author->name;
});
เราไม่ได้บอก Post ไว้ก่อนว่าจะใช้ author ด้วย เลยมี query เพิ่มขึ้นหนึ่งครั้งทุกครั้งที่เราเรียกชื่อ author ของแต่ละ post
ใช้ Eager Loading ลด query ยังไง
อย่างที่บอกไปว่า ORM จะ lazy load relation ให้เราเป็นค่าเริ่มต้น ถ้าเรารู้อยู่แล้วว่าจะใช้ relation ไหน ก็บอก model ให้ eager load ไว้ก่อน แค่นี้ 101 query ก็เหลือแค่ 2 query
Eloquent ได้แนวคิดมาจาก Active Record ของ Rails ลองเทียบวิธีเขียนดู
posts = Post.includes(:author).limit(100)
$posts = Post::with('author')->limit(100)->get();
ถ้าอยากเข้าใจเบื้องหลังของ Active Record มากขึ้น ลองอ่าน guide ของ Rails ที่ลิงก์ไว้ดู มีตัวอย่างที่ช่วยให้เห็น concept ชัดขึ้น
ลองทำใน Laravel กัน
ORM ของ Laravel ชื่อว่า Eloquent ทำ Eager Loading ได้ง่าย รวมถึง eager load relation ที่ซ้อนกันหลายชั้นด้วย เราจะต่อจากตัวอย่าง Post ข้างบน สร้างโปรเจกต์ขึ้นมาจริง แล้วลองดูว่าใน Laravel เรา eager load ได้แบบไหนบ้าง
สร้างโปรเจกต์
เราจะสร้าง migration, model และ seed ข้อมูล สำหรับลอง Eager Loading ถ้าอยากทำตาม ต้องมี database และติดตั้ง Laravel ได้ก่อน
สร้างโปรเจกต์ด้วย Laravel installer
laravel new blog-example
ค่าเริ่มต้นของโปรเจกต์ใหม่คือ SQLite ถ้าใช้ MySQL หรือ database ตัวอื่น ให้แก้ค่า DB_* ใน .env ให้ตรงกับ database ของเรา
ต่อไปเราจะสร้าง model 3 ตัว เพื่อลอง eager load relation ที่ซ้อนกัน
php artisan make:model Author -mf
php artisan make:model Post -mf
php artisan make:model Profile -mf
flag -m สร้างไฟล์ migration ของ model นั้นมาด้วย ส่วน -f สร้าง factory ไว้ให้เราใช้ตอน seed ข้อมูล เราสร้าง Author ก่อน เพราะตาราง posts กับ profiles มี foreign key ชี้ไปที่ authors ตาราง authors เลยต้องมีก่อน
model ทั้งสามเกี่ยวข้องกันแบบนี้
Post -> belongsTo -> Author
Author -> hasMany -> Post
Author -> hasOne -> Profile
เขียน migration
ทีนี้มาเขียน schema ของแต่ละตารางกัน ไฟล์ migration อยู่ในโฟลเดอร์ database/migrations/ เราแก้แค่ method up() ส่วน down() Laravel เขียนไว้ให้แล้วตอนสร้างไฟล์
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('authors', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('bio');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('authors');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->constrained();
$table->string('title');
$table->text('body');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('posts');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('profiles', function (Blueprint $table) {
$table->id();
$table->foreignId('author_id')->constrained();
$table->date('birthday');
$table->string('city');
$table->string('state');
$table->string('website');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('profiles');
}
};
เขียน model และ relation
ต่อไปเรากำหนด relation ใน model ไฟล์ model มีให้แล้วตั้งแต่ตอนรัน php artisan make:model
model แรก app/Models/Post.php
<?php
namespace App\Models;
use Database\Factories\PostFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
/** @use HasFactory<PostFactory> */
use HasFactory;
public function author(): BelongsTo
{
return $this->belongsTo(Author::class);
}
}
ถัดมา app/Models/Author.php มีสอง relation
<?php
namespace App\Models;
use Database\Factories\AuthorFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Author extends Model
{
/** @use HasFactory<AuthorFactory> */
use HasFactory;
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
พอมี model กับ migration ครบแล้ว รัน php artisan migrate สร้างตาราง
php artisan migrate
INFO Running migrations.
2026_10_10_062001_create_authors_table .............................. 6.21ms DONE
2026_10_10_062002_create_posts_table ................................ 4.87ms DONE
2026_10_10_062003_create_profiles_table ............................. 4.12ms DONE
ลองเปิดดูใน database จะเห็นตารางตามที่เราเขียนไว้ใน migration
สร้างข้อมูลตัวอย่างด้วย factory และ seeder
เราจะใช้ model factory สร้างข้อมูลตัวอย่างลง database ไว้ลอง query ไฟล์ factory มีให้แล้วจาก flag -f ตอนสร้าง model ถ้าตอนนั้นไม่ได้ใส่ ก็สร้างทีหลังได้ด้วย php artisan make:factory PostFactory
เปิดไฟล์ database/factories/PostFactory.php แล้วแก้ method definition() แบบนี้
<?php
namespace Database\Factories;
use App\Models\Author;
use App\Models\Post;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Post>
*/
class PostFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'author_id' => Author::factory(),
'title' => fake()->sentence(),
'body' => fake()->paragraphs(fake()->numberBetween(3, 10), true),
];
}
}
ไฟล์ database/factories/AuthorFactory.php
<?php
namespace Database\Factories;
use App\Models\Author;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Author>
*/
class AuthorFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'bio' => fake()->paragraph(),
];
}
}
ไฟล์ database/factories/ProfileFactory.php
<?php
namespace Database\Factories;
use App\Models\Author;
use App\Models\Profile;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Profile>
*/
class ProfileFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'author_id' => Author::factory(),
'birthday' => fake()->dateTimeBetween('-100 years', '-18 years'),
'city' => fake()->city(),
'state' => fake()->state(),
'website' => fake()->domainName(),
];
}
}
factory ช่วยให้เราสร้างข้อมูล post ไว้ลอง query ได้เยอะ ๆ แบบง่าย ๆ แล้วเราใช้ factory ใน seeder สร้างข้อมูลที่เกี่ยวข้องกันทั้งชุดได้
เปิดไฟล์ database/seeders/DatabaseSeeder.php แล้วแก้ method run() แบบนี้
<?php
namespace Database\Seeders;
use App\Models\Author;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
Author::factory()
->count(5)
->hasProfile()
->hasPosts(10)
->create();
}
}
seeder นี้สร้าง author 5 คน แต่ละคนมี profile หนึ่งอัน และ post อีก 10 post
ตอนนี้เรามี migration, model, factory และ seeder ครบแล้ว รัน migration พร้อม seed ข้อมูลได้ในคำสั่งเดียว ถ้าอยากล้างแล้วลองใหม่ก็รันซ้ำได้
php artisan migrate:fresh --seed
migrate:fresh ลบทุกตารางแล้วสร้างใหม่ ใช้กับโปรเจกต์ทดลองนี้ได้ แต่อย่ารันกับ database ที่มีข้อมูลจริง
ลองเปิด database ดู จะเห็นข้อมูลในตารางตามที่ seeder สร้างไว้
ดู query ที่เกิดขึ้นจริงใน log
ทีนี้ก็พร้อมลอง Eager Loading แล้ว วิธีที่ทำให้เห็นภาพชัดที่สุดคือ log ทุก query ลงไฟล์ storage/logs/laravel.log
ให้ Laravel log query ให้เราได้โดยเพิ่มโค้ดนี้ใน method boot() ของ app/Providers/AppServiceProvider.php
<?php
namespace App\Providers;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
DB::listen(function (QueryExecuted $query) {
Log::info($query->sql, $query->bindings);
});
}
}
DB::listen() รับทุก query ที่ส่งไปที่ database แล้วเราเขียนลงไฟล์ laravel.log อีกทางหนึ่งคือติดตั้ง Laravel Debugbar แล้วดู query บนหน้าเว็บได้เลย
ลองดูก่อนว่าถ้าไม่ใช้ Eager Loading จะเป็นยังไง ล้างไฟล์ storage/logs/laravel.log ให้ว่างก่อน แล้วเปิด tinker
php artisan tinker
แล้วพิมพ์ใน tinker
use App\Models\Post;
$posts = Post::all();
$posts->map(fn ($post) => $post->author);
เปิด laravel.log ดู จะเห็น query ดึง author ออกมาเป็นแถวยาว
[2026-10-10 06:24:26] local.INFO: select * from `posts`
[2026-10-10 06:24:32] local.INFO: select * from `authors` where `authors`.`id` = ? limit 1 [1]
[2026-10-10 06:24:32] local.INFO: select * from `authors` where `authors`.`id` = ? limit 1 [1]
[2026-10-10 06:24:32] local.INFO: select * from `authors` where `authors`.`id` = ? limit 1 [1]
....
seeder สร้าง post ไว้ 50 post ตรงนี้เลยมีทั้งหมด 51 query
ล้าง laravel.log อีกรอบ คราวนี้ใช้ with() เพื่อ eager load author
use App\Models\Post;
$posts = Post::with('author')->get();
$posts->map(fn ($post) => $post->author);
รอบนี้ใน log จะเหลือแค่สอง query คือ query แรกดึง post ทั้งหมด กับอีก query ดึง author ของ post เหล่านั้น
[2026-10-10 06:26:54] local.INFO: select * from `posts`
[2026-10-10 06:26:54] local.INFO: select * from `authors` where `authors`.`id` in (1, 2, 3, 4, 5)
ถ้ามีหลาย relation ก็ eager load พร้อมกันได้ด้วยการส่งเป็น array
// สมมติว่า Post มี relation comments ด้วย
$posts = Post::with(['author', 'comments'])->get();
Eager load relation ที่ซ้อนกันทำยังไง
relation ที่ซ้อนกันก็ eager load ได้คล้าย ๆ กัน ในตัวอย่างของเรา author แต่ละคนมี profile หนึ่งอัน (hasOne) ถ้าเราไม่ได้ eager load profile ไว้ Laravel ก็จะดึง profile ทีละคน
ล้าง laravel.log แล้วลองโค้ดนี้
use App\Models\Post;
$posts = Post::with('author')->get();
$posts->map(fn ($post) => $post->author->profile);
ดูใน laravel.log
[2026-10-10 06:32:35] local.INFO: select * from `posts`
[2026-10-10 06:32:35] local.INFO: select * from `authors` where `authors`.`id` in (1, 2, 3, 4, 5)
[2026-10-10 06:32:45] local.INFO: select * from `profiles` where `profiles`.`author_id` = ? and `profiles`.`author_id` is not null limit 1 [1]
[2026-10-10 06:32:45] local.INFO: select * from `profiles` where `profiles`.`author_id` = ? and `profiles`.`author_id` is not null limit 1 [2]
[2026-10-10 06:32:45] local.INFO: select * from `profiles` where `profiles`.`author_id` = ? and `profiles`.`author_id` is not null limit 1 [3]
[2026-10-10 06:32:45] local.INFO: select * from `profiles` where `profiles`.`author_id` = ? and `profiles`.`author_id` is not null limit 1 [4]
[2026-10-10 06:32:45] local.INFO: select * from `profiles` where `profiles`.`author_id` = ? and `profiles`.`author_id` is not null limit 1 [5]
รวมแล้วมี 7 query สอง query แรกมาจาก eager load ที่เหลือคือ query ดึง profile ของ author ทีละคน
เราลด query ที่ซ้ำกันใน relation ที่ซ้อนกันได้ด้วยการใส่จุดคั่นชื่อ relation ล้าง laravel.log เป็นรอบสุดท้าย แล้วลองใน tinker
$posts = Post::with('author.profile')->get();
$posts->map(fn ($post) => $post->author->profile);
ทีนี้จะเหลือแค่ 3 query
[2026-10-10 06:35:47] local.INFO: select * from `posts`
[2026-10-10 06:35:47] local.INFO: select * from `authors` where `authors`.`id` in (1, 2, 3, 4, 5)
[2026-10-10 06:35:47] local.INFO: select * from `profiles` where `profiles`.`author_id` in (1, 2, 3, 4, 5)
โหลด relation ทีหลังด้วย load()
บางทีเราอยากดึง relation เฉพาะบางเงื่อนไข เช่น ตอนดึง post ขึ้นมายังไม่รู้ว่าจะใช้ author หรือเปล่า แบบนี้เราสั่ง eager load ทีหลังได้ด้วย load() ซึ่งเรียกว่า Lazy Eager Loading
use App\Models\Post;
$posts = Post::all();
// ...
$posts->load('author.profile');
$posts->first()->author->profile;
พอเรียก $posts->load() แล้ว เราจะเห็นใน log แค่ 3 query เท่าเดิม
อ่านต่อ
บทความนี้พาลอง eager load model แบบต่าง ๆ แล้วดูว่าเบื้องหลังเกิด query อะไรขึ้นบ้าง ในคู่มือของ Laravel อธิบายเรื่อง eager loading ไว้ละเอียดมาก ลองทำตามตัวอย่างนี้ดูแล้วจะเข้าใจขึ้นเยอะว่าควร query relation ใน Laravel ยังไงให้คุ้มที่สุด
Laravel 13 มีอะไรช่วยกัน N+1 เพิ่มอีกบ้าง
หลังจากต้นฉบับออกมา Laravel ก็เพิ่มตัวช่วยเรื่องนี้มาอีกสามตัว สองตัวแรกเปิดใน method boot() ของ AppServiceProvider ส่วนตัวที่สามใส่ใน relation ของ model
ให้ Laravel ฟ้องตอนเราลืม eager load
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
พอเปิดไว้ ถ้าเราลอง Post::all()->map(fn ($post) => $post->author) อีกรอบ Laravel จะโยน LazyLoadingViolationException ทันที
Attempted to lazy load [author] on model [App\Models\Post] but lazy loading is disabled.
Laravel เช็คเฉพาะตอนที่ query ได้ model กลับมามากกว่าหนึ่งตัว ถ้าเราดึง post ตัวเดียวแล้วเรียก $post->author ก็ยังทำงานได้ปกติ ส่วนที่ส่ง ! $this->app->isProduction() ไว้ก็เพื่อให้ฟ้องแค่ตอนพัฒนา ผู้ใช้บน production จะได้ไม่เจอหน้า error ถ้าอยากรู้ว่าบน production ยังมีจุดไหนหลุดอยู่ ใช้ Model::handleLazyLoadingViolationUsing() เขียนลง log แทนการโยน exception ได้
ให้ Laravel eager load ให้เองอัตโนมัติ
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::automaticallyEagerLoadRelationships();
}
พอเปิดไว้ ตอนเราเรียก $post->author กับ post ตัวแรกใน collection Laravel จะดึง author ของทุก post ใน collection นั้นมาทีเดียว โค้ดที่ไม่ได้ใส่ with() อย่าง Post::all()->map(fn ($post) => $post->author->profile) จะเหลือ 3 query เท่ากับตอนเขียน with('author.profile') เอง
ถ้าไม่อยากเปิดทั้งแอป เปิดเฉพาะ collection ที่ต้องการได้ด้วย $posts->withRelationshipAutoloading() และถ้าเปิดทั้งตัวนี้และ preventLazyLoading() พร้อมกัน Laravel จะ eager load ให้ก่อน เลยไม่โยน exception
ให้ post รู้จัก author ที่ดึงมาแล้ว ด้วย chaperone()
อีกแบบที่เจอบ่อยคือดึง post ของ author คนหนึ่งมา แล้ววนเรียก $post->author กลับไปหา author คนเดิม Laravel ไม่รู้ว่า author ตัวนั้นเรามีอยู่แล้ว เลยยิง query ไปดึงใหม่ทุก post
public function posts(): HasMany
{
return $this->hasMany(Post::class)->chaperone();
}
ใส่ chaperone() ต่อท้าย hasMany แล้ว ทุกครั้งที่เราดึง $author->posts ไม่ว่าจะ lazy load หรือ eager load ด้วย Author::with('posts') Laravel จะใส่ author ตัวเดิมกลับเข้าไปใน relation author ของแต่ละ post ให้เลย ไม่ต้อง query เพิ่ม Laravel เดาชื่อ relation จาก foreign key author_id ให้ ถ้าชื่อไม่ตรง ให้ส่งชื่อเข้าไปเอง เช่น ->chaperone('writer')
รายละเอียดเพิ่มเติมอยู่ในคู่มือ Laravel หัวข้อ Preventing Lazy Loading, Automatic Eager Loading และ Automatically Hydrating Parent Models on Children





