Laravel Context คืออะไร และวิธีใส่ request ID ให้ทุกบรรทัดของ log

Context ใน Laravel เก็บค่าอย่าง request ID หรือ user ID ไว้ที่เดียว แล้ว Laravel เติมค่านั้นให้ทุกบรรทัดของ log และส่งต่อให้ queue job ที่ dispatch จาก request นั้นเอง

เผยแพร่
อัปเดต
— เขียนใหม่ให้ครบทุกเมธอด พร้อมตัวอย่าง middleware ใส่ request ID
เวลาอ่าน
5 นาที
แท็ก
#Laravel#Queue

เวลาลูกค้าแจ้งว่า "สั่งซื้อแล้วไม่ได้อีเมล" เราเปิด storage/logs/laravel.log ดู ก็จะเจอ log ของทุก request ปนกันหมด ไม่รู้ว่าบรรทัดไหนมาจาก request ของลูกค้าคนนั้น และ log ของ job ที่ส่งอีเมลก็ไม่มีอะไรโยงกลับไปหา request ต้นทางเลย

Context แก้ตรงนี้ เราเก็บค่าอย่าง request ID, user ID หรือ tenant ID ไว้ที่ facade Context แล้ว Laravel จะเติมค่าพวกนี้ให้ทุกบรรทัดของ log เอง พอ dispatch job ไป queue ค่าใน Context ก็ตามไปด้วย log ใน job จึงมี request ID เดียวกับ request ที่สั่งงาน Context มีมาตั้งแต่ Laravel 11 และยังใช้แบบเดิมใน Laravel 13

Log::withContext ใช้ได้แค่ใน process เดียว

ที่ผ่านมาเราก็มักใส่ request ID ให้ log ด้วย Log::withContext() ใน middleware ก็ใช้ได้นะ แต่ค่านั้นอยู่กับ logger ของ process ที่รับ request เท่านั้น job ที่ dispatch ไป queue รันอยู่ใน worker อีก process หนึ่ง log ใน job จึงไม่มี request ID ให้เห็น

Context เก็บค่าไว้นอก logger และ Laravel ส่งค่าชุดนี้ไปพร้อม payload ของ job ให้ด้วย เราจึงย้ายจาก Log::withContext() มาใช้ Context::add() ได้เลย

เพิ่มค่าเข้า Context

เรียก Context::add() ทีละ key หรือส่งเป็น array ก็ได้ ถ้า key ซ้ำ ค่าใหม่จะทับค่าเดิม

use Illuminate\Support\Facades\Context;

Context::add('request_id', '0f9c2b6e-41d7-4f6a-9a3b-2c8d5e7f1a90');

Context::add([
    'user_id' => 27,
    'tenant_id' => 3,
]);

ถ้าอยากเพิ่มเฉพาะตอนที่ยังไม่มี key นั้น ใช้ addIf() ส่วนตัวนับ เช่นจำนวนแถวที่ import ไปแล้ว ใช้ increment() กับ decrement()

Context::addIf('source', 'web');        // ถ้ามี source อยู่แล้ว จะไม่ทับ

Context::increment('rows_imported');    // +1
Context::increment('rows_imported', 5); // +5
Context::decrement('rows_imported');    // -1

อยากเพิ่มตามเงื่อนไขก็ใช้ when() ได้ closure แรกทำงานเมื่อเงื่อนไขเป็นจริง closure ที่สองทำงานเมื่อไม่จริง และทั้งคู่ได้ตัว Context เข้ามาเป็น argument

Context::when(
    app()->runningInConsole(),
    fn ($context) => $context->add('source', 'cli'),
    fn ($context) => $context->add('source', 'web'),
);

มี helper context() ให้ด้วย context(['user_id' => 27]) เท่ากับ Context::add() และ context('user_id') เท่ากับ Context::get()

อ่านและลบค่าใน Context

Context::get('user_id');                     // 27
Context::get('plan', 'free');                // ไม่มี key นี้ ได้ค่า default
Context::all();                              // ทุกค่า
Context::only(['request_id', 'user_id']);
Context::except(['tenant_id']);

Context::has('user_id');                     // true แม้ค่าจะเป็น null
Context::missing('plan');                    // true

$requestId = Context::pull('request_id');    // อ่านแล้วลบออกทันที

Context::forget('tenant_id');
Context::forget(['user_id', 'source']);

ค่าที่ต้องคำนวณหรือ query มา ใช้ remember() ครั้งแรกจะรัน closure แล้วเก็บผลไว้ ครั้งต่อไปคืนค่าเดิมโดยไม่รัน closure อีก

$plan = Context::remember('plan', fn () => $team->subscription->plan);

เก็บเป็นลำดับด้วย push

ถ้าอยากรู้ว่า request นี้ผ่านขั้นตอนไหนมาบ้าง เก็บเป็น stack ด้วย push() ค่าที่ push จะต่อท้ายตามลำดับ

Context::push('steps', 'ตรวจสต็อก');
Context::push('steps', 'ตัดบัตร', 'สร้างใบเสร็จ');

Context::get('steps');
// ['ตรวจสต็อก', 'ตัดบัตร', 'สร้างใบเสร็จ']

Context::pop('steps');                         // 'สร้างใบเสร็จ'
Context::stackContains('steps', 'ตัดบัตร');    // true

push() ใช้กับ key ที่ยังไม่มี หรือ key ที่เป็น array แบบ list อยู่แล้วเท่านั้น ถ้า key นั้นเก็บค่าอื่นไว้ เช่น string หรือ array ที่มี key เป็นชื่อ Laravel จะโยน RuntimeException ออกมา

Context ไปโผล่ตรงไหนใน log

Laravel ติด processor ตัวหนึ่งไว้กับทุก log channel คอยเติมค่าจาก Context ลงใน extra ของทุกบรรทัด เราจึงไม่ต้องส่งค่าเองทุกครั้งที่เรียก Log::info()

Log::info('สร้างคำสั่งซื้อแล้ว', ['order_id' => 1042]);
[2026-10-11 14:02:11] production.INFO: สร้างคำสั่งซื้อแล้ว {"order_id":1042} {"request_id":"0f9c2b6e-41d7-4f6a-9a3b-2c8d5e7f1a90","user_id":27}

JSON ก้อนแรกคือค่าที่เราส่งให้ log บรรทัดนั้น ก้อนที่สองคือ Context ส่วน error ที่ Laravel report ลง log เองก็ผ่าน logger ตัวเดียวกัน log ของ exception จึงมี request_id ติดมาด้วย

เก็บค่าที่ไม่อยากให้ลง log เป็น hidden

บางค่า job ต้องใช้ แต่เราไม่อยากเห็นซ้ำในทุกบรรทัดของ log เช่น locale ที่ผู้ใช้เลือกไว้ เก็บเป็น hidden context ด้วย addHidden() แล้วอ่านกลับด้วย getHidden() เพราะ get() มองไม่เห็นค่า hidden

Context::addHidden('locale', 'th');

Context::getHidden('locale');  // 'th'
Context::get('locale');        // null

เมธอดฝั่ง hidden มีครบเหมือนฝั่งปกติ ได้แก่ addHiddenIf(), pushHidden(), popHidden(), pullHidden(), onlyHidden(), exceptHidden(), allHidden(), hasHidden(), missingHidden(), forgetHidden(), rememberHidden() และ hiddenStackContains()

ใส่ค่าชั่วคราวด้วย scope

ถ้าอยากให้ค่าบางตัวอยู่ใน Context แค่ช่วงหนึ่ง เช่นตอน import ไฟล์ที่อยากรู้ว่า log แต่ละบรรทัดมาจากแถวไหนของไฟล์ ใช้ Context::scope() ค่าที่ส่งใน data (และ hidden) จะอยู่แค่ใน closure พอ closure จบ Context กลับไปเป็นเหมือนก่อนเรียก แม้ closure จะโยน exception ออกมาก็ตาม

foreach ($rows as $index => $row) {
    Context::scope(
        function () use ($row) {
            Log::info('นำเข้าสินค้า');
            // ...
        },
        data: ['row' => $index + 2, 'sku' => $row['sku']],
    );
}

// นอก scope ไม่มี row กับ sku แล้ว

ค่าที่ add() เพิ่มเข้าไปข้างใน closure ก็หายไปตอนจบเหมือนกัน ยกเว้นค่าที่เป็น object ถ้าเราแก้ property ของ object ข้างใน closure ผลนั้นยังติดออกมาข้างนอก

queue job ได้ Context จาก request ที่ dispatch

ตอน dispatch job Laravel เก็บ Context ทั้งหมด ทั้งค่าปกติและ hidden ใส่ไว้ใน payload ของ job ใต้ key illuminate:log:context พอ worker เริ่มรัน job ก็โหลดค่าชุดนั้นกลับเข้า Context ก่อนเรียก handle() เราไม่ต้องเขียนอะไรเพิ่ม ใช้ได้กับทุกอย่างที่เข้า queue ทั้ง job, listener, mail และ notification

<?php

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

class SendOrderConfirmation implements ShouldQueue
{
    use Queueable;

    public function __construct(public Order $order)
    {
    }

    public function handle(): void
    {
        Log::info('ส่งอีเมลยืนยันคำสั่งซื้อ', ['order_id' => $this->order->id]);

        // ...
    }
}
[2026-10-11 14:02:13] production.INFO: ส่งอีเมลยืนยันคำสั่งซื้อ {"order_id":1042} {"request_id":"0f9c2b6e-41d7-4f6a-9a3b-2c8d5e7f1a90","user_id":27}

worker ล้าง Context ทุกครั้งที่รัน job เสร็จ ค่าที่ job หนึ่ง add() เพิ่มเองจึงไม่ไหลไปปนกับ job ถัดไป และถ้าเก็บ Eloquent model ไว้ใน Context Laravel จะส่งไปแค่ id แล้ว query model ใหม่ในฝั่ง worker แบบเดียวกับ SerializesModels ถ้ามีใครลบ model นั้นไปก่อน job รัน ค่านั้นจะกลายเป็น null และ Laravel report ModelNotFoundException ลง log ให้ เก็บแค่ id ไว้ใน Context จึงง่ายกว่า

นอกจาก queue แล้ว command ที่ scheduler รันแยก process และ Concurrency::run() ที่ใช้ driver process ก็ได้ Context ส่งต่อไปให้ process ลูกเหมือนกัน

ส่งค่าอื่นตามไปด้วย dehydrating กับ hydrated

บางค่าไม่ได้อยู่ใน Context แต่ job ต้องใช้ เช่น app.locale ที่ middleware ตั้งจาก Accept-Language ของ request ถ้าไม่ส่งไป notification ใน queue จะใช้ locale default ของแอป Context มี hook สองตัวให้จัดการเรื่องนี้ ลงทะเบียนไว้ใน boot() ของ AppServiceProvider

  • dehydrating ทำงานตอน dispatch ก่อน Laravel เก็บ Context ลง payload
  • hydrated ทำงานใน worker หลังโหลด Context กลับมาแล้ว
use Illuminate\Log\Context\Repository;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Context;

public function boot(): void
{
    Context::dehydrating(function (Repository $context) {
        $context->addHidden('locale', Config::get('app.locale'));
    });

    Context::hydrated(function (Repository $context) {
        if ($context->hasHidden('locale')) {
            Config::set('app.locale', $context->getHidden('locale'));
        }
    });
}

ข้างใน callback ให้แก้ผ่าน $context ที่ได้มาเท่านั้น ถ้าเรียก facade Context ในนั้น เราจะไปแก้ Context ของ process ปัจจุบันแทนชุดที่กำลังส่งไปกับ job

ทำ middleware ใส่ request ID กับ user ID ให้ทุก log

ลองรวมทั้งหมดเป็นของที่ใช้ได้จริง middleware ตัวนี้สร้าง request ID ใหม่ทุก request ใส่ไว้ใน Context พร้อม user ID แล้วส่ง request ID กลับไปใน header X-Request-Id ของ response ด้วย เวลาผู้ใช้แจ้งปัญหา เราขอ request ID จาก header นี้มาค้นใน log ได้เลย

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class AddRequestContext
{
    public function handle(Request $request, Closure $next): Response
    {
        $requestId = (string) Str::uuid();

        Context::add([
            'request_id' => $requestId,
            'user_id' => $request->user()?->id,
        ]);

        $response = $next($request);

        $response->headers->set('X-Request-Id', $requestId);

        return $response;
    }
}

เราต่อ middleware ไว้ท้ายกลุ่ม web ไม่ใช้ $middleware->append() เพราะ global middleware รันก่อน StartSession ตอนนั้น Laravel ยังไม่ได้อ่าน session $request->user() จึงได้ null เสมอ ถ้าแอปมี route ใน api ด้วย ก็เพิ่ม $middleware->api(append: AddRequestContext::class) อีกบรรทัด

ทีนี้ทุกบรรทัดใน log ของ request เดียวกัน รวมถึง job ที่ request นั้น dispatch ไป จะมี request_id ตรงกัน เราค้นทั้งเส้นทางได้ด้วย grep คำสั่งเดียว

grep 0f9c2b6e-41d7-4f6a-9a3b-2c8d5e7f1a90 storage/logs/laravel.log
[2026-10-11 14:02:11] production.INFO: สร้างคำสั่งซื้อแล้ว {"order_id":1042} {"request_id":"0f9c2b6e-41d7-4f6a-9a3b-2c8d5e7f1a90","user_id":27}
[2026-10-11 14:02:13] production.INFO: ส่งอีเมลยืนยันคำสั่งซื้อ {"order_id":1042} {"request_id":"0f9c2b6e-41d7-4f6a-9a3b-2c8d5e7f1a90","user_id":27}

ถ้าใช้ channel daily ไฟล์ log จะแยกตามวัน เช่น laravel-2026-10-11.log ให้ค้นในไฟล์ของวันนั้นแทน ถ้าเข้า server ด้วย SSH ไม่ได้ ก็วาง request ID ลงช่องค้นหาของ Log Viewer ได้เหมือนกัน วิธีติดตั้งอยู่ใน ดู log ของ Laravel บน production ยังไง ไม่ต้อง SSH เข้า server

อ่านค่าใน Context ผ่าน dependency injection

แอปที่มีหลาย tenant เก็บ tenant_id ไว้ใน Context ตั้งแต่ middleware ได้เหมือนกัน ทีนี้ job หรือ controller ที่อยากได้ค่านี้ ไม่ต้องเรียก Context::get() เอง ใส่ attribute #[Context] ที่ parameter แล้ว container จะดึงค่าจาก Context มาให้

use Illuminate\Container\Attributes\Context;

public function handle(#[Context('tenant_id')] ?int $tenantId): void
{
    // $tenantId มาจาก Context ของ request ที่ dispatch job นี้
}

attribute นี้รับ default เป็น argument ที่สอง และอ่านค่า hidden ได้ด้วย #[Context('locale', hidden: true)]

ดูตัวอย่างเป็นคลิป

Laravel News ทำคลิปสั้นสาธิตตอน Context เปิดตัวใน Laravel 11 ไว้ ตัวอย่างในคลิปยังใช้กับ Laravel 13 ได้

ดูบน YouTube

อ่านต่อ

เมธอดทั้งหมดอยู่ใน เอกสาร Context ของ Laravel 13 ถ้าอยากเห็นว่าข้างในทำงานยังไง ลองเปิด Repository กับ ContextServiceProvider ไฟล์หลังเป็นตัวผูก Context เข้ากับ payload ของ queue ส่วนที่มาของฟีเจอร์นี้ Taylor Otwell โพสต์ประกาศไว้บน X ตอนปล่อย Laravel 11

อ่านต่อ

ถูกอ้างถึงใน

ล่าสุด

ดูทั้งหมด →