Database transaction ช่วยให้ข้อมูลไม่ค้างครึ่ง ๆ กลาง ๆ ถ้ามี error ตรงไหน query ทั้งชุดจะ rollback กลับไปหมด แต่ transaction คุมได้แค่ query ใน database ถ้าเรา dispatch job ส่งอีเมล หรือยิง event ระหว่างทาง งานพวกนั้นออกไปแล้วก็ดึงกลับไม่ได้ บทความนี้ไล่ดูว่า Laravel 13 ให้เราสั่ง "รอ commit ก่อน" ได้ที่ไหนบ้าง
Transaction คุมได้แค่ query ใน database
ลองนึกถึงหน้าสมัครสมาชิกที่สร้าง User แล้วสร้าง Team ให้ทันที เราห่อทั้งสองคำสั่งไว้ใน DB::transaction() ถ้าสร้าง Team ไม่ผ่าน Laravel จะ rollback User ที่เพิ่งสร้างไปด้วย ทีนี้ถ้ามีบรรทัดส่งอีเมลต้อนรับอยู่ตรงกลาง
DB::transaction(function () use ($data) {
$user = User::create($data);
Mail::to($user)->send(new WelcomeEmail($user));
Team::create(['owner_id' => $user->id]); // ถ้า error ตรงนี้ User หาย แต่อีเมลออกไปแล้ว
});
transaction rollback ได้แค่ข้อมูลใน database อีเมลที่ส่งไปแล้วเรียกคืนไม่ได้ ลูกค้าจะได้อีเมลต้อนรับเข้าบัญชีที่ไม่มีอยู่จริง
ถ้าโค้ดส่งอีเมลอยู่ใน closure ตรง ๆ แบบนี้ ย้ายไปไว้หลัง DB::transaction() ก็จบ เพราะถ้า transaction ล้ม Laravel จะโยน exception ออกมา บรรทัดถัดไปจึงไม่ได้ทำงาน แต่ในงานจริงโค้ดพวกนี้มักซ่อนอยู่ใน listener ของ event UserRegistered หรือใน Observer ที่ดัก created ของ User ซึ่งทำงานตอน User::create() ยังอยู่ใน transaction ย้ายออกมาแบบนั้นไม่ได้
Worker อาจหยิบ job ไปทำก่อน transaction commit
ปัญหาอีกแบบเกิดกับ job ที่ dispatch ลง queue จากใน transaction แล้วยังมีงานต่ออีกพักใหญ่กว่าจะ commit
worker เป็นอีก process ที่ต่อ database แยกกัน ระหว่างที่ transaction ยังไม่ commit worker จะมองไม่เห็นแถว User ใหม่ ถ้าหยิบ job ส่งอีเมลไปทำในช่วงนั้น ตอนโหลด User กลับขึ้นมาจะเจอ ModelNotFoundException และถ้า transaction rollback ภายหลัง job ก็อยู่ใน queue ไปแล้ว ลูกค้าได้อีเมลอยู่ดี
เลือกวิธีให้รอ commit ตามสิ่งที่ต้องรอ
ทุกวิธีข้างล่างทำงานเหมือนกัน คือเก็บงานไว้ก่อน แล้วปล่อยออกไปเมื่อ transaction ชั้นนอกสุด commit ถ้า transaction rollback Laravel ก็ทิ้งงานนั้นไป และถ้าเรียกนอก transaction งานก็ออกไปทันทีเหมือนเดิม ต่างกันแค่ว่าเราสั่งไว้ที่ไหน
| สิ่งที่ต้องรอ | วิธีสั่ง | เหมาะกับ |
|---|---|---|
| job ทุกตัวใน connection หนึ่ง | 'after_commit' => true ใน config/queue.php |
แอปที่อยากให้รอเป็นค่าเริ่มต้น |
| job บางครั้งที่ dispatch | ->afterCommit() หรือ ->beforeCommit() ตอน dispatch |
เคสเฉพาะจุด |
| job ที่ต้องรอทุกครั้ง | implement ShouldQueueAfterCommit แทน ShouldQueue |
job ส่งอีเมลหรือแจ้งเตือนลูกค้า |
| โค้ดที่ไม่ใช่ job | ห่อด้วย DB::afterCommit() |
เรียก API ภายนอก ส่งอีเมลตรง |
| event ทั้งก้อน | event implement ShouldDispatchAfterCommit |
event ที่ทุก listener ต้องรอ |
| listener หรือ Observer บางตัว | ShouldHandleEventsAfterCommit หรือ ShouldQueueAfterCommit ถ้าเข้า queue |
listener ส่งอีเมลข้าง listener ที่ต้องทำทันที |
| mail กับ notification ใน queue | ->afterCommit() ก่อนส่ง หรือ implement ShouldQueueAfterCommit |
ส่งจากใน service ที่อยู่ใน transaction |
เปิดทั้ง queue connection หรือสั่งเป็นราย job
ใน config/queue.php ทุก connection มี 'after_commit' => false เป็นค่าเริ่มต้น เปลี่ยนเป็น true แล้ว job ทุกตัวที่ dispatch ผ่าน connection นั้นจะรอ commit ก่อน ค่านี้เปิดไว้ได้โดยไม่กระทบโค้ดส่วนอื่น เพราะ job ที่ dispatch นอก transaction ยังเข้า queue ทันที connection sync ก็รับค่านี้เหมือนกัน แค่ใน config ไม่ได้เขียนไว้ให้
'redis' => [
'driver' => 'redis',
// ...
'after_commit' => true,
],
ถ้าไม่อยากเปิดทั้ง connection ก็สั่งตอน dispatch ได้ และกลับกัน job ที่ต้องเข้า queue ทันทีแม้เปิด after_commit ไว้ เช่น job เขียน log ที่ไม่สนผลของ transaction ก็สั่ง beforeCommit()
SendWelcomeEmail::dispatch($user)->afterCommit();
WriteAuditLog::dispatch($user)->beforeCommit();
job ที่ต้องรอเสมอใช้ ShouldQueueAfterCommit
job ที่ไม่ควรออกไปก่อน commit เลย เปลี่ยน implements ShouldQueue เป็น implements ShouldQueueAfterCommit แล้วไม่ต้องจำเรียก afterCommit() ทุกที่ที่ dispatch interface นี้สืบทอดมาจาก ShouldQueue job จึงยังเข้า queue ตามปกติ
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Foundation\Queue\Queueable;
class SendWelcomeEmail implements ShouldQueueAfterCommit
{
use Queueable;
public function __construct(public User $user) {}
public function handle(): void
{
Mail::to($this->user)->send(new WelcomeEmail($this->user));
}
}
ถ้าสั่งหลายที่พร้อมกัน Laravel ตัดสินตามลำดับนี้ beforeCommit() ชนะทุกอย่าง รองลงมาคือ ShouldQueueAfterCommit แล้วค่าที่ตั้งด้วย afterCommit() สุดท้ายค่อยดู after_commit ของ connection
โค้ดที่ไม่ใช่ job ห่อด้วย DB::afterCommit()
ถ้าสิ่งที่ต้องรอเป็นการเรียก API ภายนอก หรือส่งอีเมลตรงด้วย Mail::send() ให้ห่อด้วย DB::afterCommit() Laravel เก็บ closure ไว้ แล้วเรียกหลัง transaction ชั้นนอกสุด commit ไม่ว่าจะซ้อนกันกี่ชั้น
DB::afterCommit(fn () => Mail::to($user)->send(new WelcomeEmail($user)));
event, listener และ Observer ก็สั่งให้รอได้
event ที่ต้องรอทั้งก้อนหน้าตาแบบนี้ ต่างจาก event ปกติแค่ interface ที่ implement
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class UserRegistered implements ShouldDispatchAfterCommit
{
use Dispatchable, SerializesModels;
public function __construct(public User $user) {}
}
- ให้ event รอทั้งก้อน ใส่
ShouldDispatchAfterCommitที่ตัว event แล้วUserRegistered::dispatch($user)ที่เรียกใน transaction จะยังไม่เรียก listener ตัวไหนเลยจนกว่าจะ commit - ให้ listener บางตัวรอ listener ที่ทำงานทันทีใส่
ShouldHandleEventsAfterCommitหรือประกาศpublic $afterCommit = true;ได้ เพราะ listener ไม่ได้ใช้ traitQueueableส่วน listener ที่เข้า queue ใช้ShouldQueueAfterCommitเหมือน job - Observer ของ model ใส่
ShouldHandleEventsAfterCommitได้เหมือน listenercreated()จะรอ commit ก่อน ส่วน method ที่สั่งยกเลิกได้อย่างcreating,savingและdeletingยังทำงานทันที เพราะต้องทำก่อนบันทึก ตัวอย่างอยู่ในหัวข้อ "ให้ทำงานหลัง transaction commit" ของ Laravel Model Event ใช้ยังไง
mail กับ notification ใน queue
mailable กับ notification ที่สร้างจาก make:mail และ make:notification ใช้ trait Queueable เหมือน job จึงเรียก afterCommit() ก่อนส่งได้ หรือ implement ShouldQueueAfterCommit ถ้าอยากให้รอทุกครั้ง
$user->notify((new InvoicePaid($invoice))->afterCommit());
ตอนรัน test ที่ใช้ RefreshDatabase หรือ DatabaseTransactions Laravel ไม่นับ transaction ที่ห่อ test ไว้ งานที่สั่งให้รอจึงออกไปตอน transaction ในโค้ดของเรา commit เหมือนตอนรันจริง
อ่านเพิ่ม
- เอกสาร Laravel: Jobs & Database Transactions, Dispatching Events After Database Transactions, Queued Event Listeners & Database Transactions, Queued Mailables & Database Transactions, Queued Notifications & Database Transactions และ Observers & Database Transactions
- Mohamed Said เป็นคนเขียนฟีเจอร์นี้ เริ่มจาก
DB::afterCommit()ใน Laravel 8.17 ตามด้วย job ใน Laravel 8.19 และ listener อ่านเหตุผลของเขาได้ใน pull request ทั้งสาม






