บทความ

Laravel 13.35 มีอะไรใหม่ Route::query(), defaults() ใน Model และ scheduler หลายเครื่อง

Laravel 13.35 เพิ่ม route แบบ QUERY ค่าเริ่มต้นของ Model ที่คำนวณได้ assertion ของ fake ที่รับ array และตัวช่วยให้ scheduler ทำงานครั้งเดียวเมื่อมีหลาย server

เผยแพร่
เวลาอ่าน
4 นาที
สรุปจาก
Paul Redmond · laravel-news.com

Route::query() รับคำค้นหาใน body

QUERY เป็น HTTP method ใหม่สำหรับคำขอที่แค่อ่านข้อมูล แต่เงื่อนไขยาวเกินจะใส่ใน URL ถ้ายังไม่เคยได้ยิน อ่านHTTP QUERY method คืออะไรก่อนได้ ใน 13.35 เราประกาศ route แบบนี้ได้แล้ว

Route::query('/products/search', SearchProductsController::class);

ก่อนหน้านี้ Laravel 13.19 มี Http::query() กับตัวช่วย test $this->query() และ $this->queryJson() ให้ใช้แล้ว รอบนี้เลยครบทั้งฝั่งเรียกและฝั่งรับ

defaults() ตั้งค่าเริ่มต้นของ Model ที่คำนวณได้

ปกติเราตั้งค่าเริ่มต้นของ attribute ด้วย $attributes แต่ property ของ PHP รับได้แค่ค่าคงที่ อยากได้ค่าจาก config หรือ feature flag ต้องไปเขียนใน event creating เอง ใน 13.35 มี trait HasDefaultAttributes ให้เขียนเป็น method ได้

use Illuminate\Database\Eloquent\Concerns\HasDefaultAttributes;

class Subscription extends Model
{
    use HasDefaultAttributes;

    protected function defaults(): array
    {
        return [
            'cancelled' => false,
            'trial_days' => Feature::active('extended-trials') ? 30 : 14,
        ];
    }
}

ฟีเจอร์นี้ยกมาจาก Laravel 14 ใน 13.x จึงต้องเปิดเองด้วย trait ถ้าตั้งค่า key เดียวกันไว้ทั้งใน $attributes และ defaults() ค่าจาก defaults() จะชนะ

Assertion ของ fake รับ array แทน callback

เวลาเช็คว่าโค้ดส่ง job เข้าคิวพร้อมข้อมูลที่ถูกต้อง เดิมเราต้องเขียน callback แบบนี้

Queue::assertPushed(ProcessPodcast::class, function (ProcessPodcast $job) use ($podcast, $user) {
    return $job->podcast->is($podcast)
        && $job->user->is($user)
        && $job->status === 'pending';
});

ใช้ได้กับ fake ทั้งสี่ตัว คือ Queue, Bus, Event และ Notification รวมถึงแบบกลับด้านอย่าง assertNotPushed() และ assertNotSentTo() ด้วย

Event::assertDispatched(OrderShipped::class, ['order' => $order]);
Notification::assertSentTo($user, InvoicePaid::class, ['invoice' => $invoice]);

Laravel News เขียนเจาะเรื่องนี้แยกไว้ มีข้อควรรู้ที่ควรจำไว้ก่อนเปลี่ยน test ทั้งชุด

  • property ต้องเป็น public fake อ่านค่าจากนอก object ถ้าเป็น protected หรือต้องเรียก getter ให้ใช้ callback เหมือนเดิม constructor promotion แบบ public ใช้ได้
  • เทียบด้วย === ยกเว้น Eloquent model ที่เทียบด้วย $model->is() ให้
  • วันที่เทียบไม่ผ่าน Carbon สองตัวที่เป็นเวลาเดียวกันเป็นคนละ object 'scheduled_at' => now() จึงไม่ผ่าน กรณีนี้ใช้ callback กับ equalTo()
  • เช็คแค่ชั้นบนสุด ใช้ dot notation เข้าไปดูค่าข้างในไม่ได้
  • พิมพ์ชื่อ key ผิดแล้ว test ไม่ผ่าน ไม่ได้ข้ามไปเงียบ ๆ ช่วยจับชื่อ property ที่พิมพ์ผิดได้

Scheduler บนหลาย server

เว็บส่วนใหญ่รันบน server เครื่องเดียว เรื่องนี้ยังไม่ต้องสนใจ แต่พอขยายเป็นสองเครื่องหลัง load balancer และทุกเครื่องรัน schedule:run ทุก task จะรันซ้ำทุกเครื่อง ล้าง cache ในเครื่องซ้ำไม่เป็นไร แต่ส่งรายงานประจำวันซ้ำสองฉบับให้ลูกค้าไม่ดีแน่

เดิมเราต้องต่อ ->onOneServer() ทีละ task (scheduler ใช้ lock ใน cache เครื่องที่จองได้ก่อนเป็นคนรัน เครื่องอื่นข้าม) ใน 13.35 สั่งครั้งเดียวให้มีผลกับทุก task ได้

use Illuminate\Support\Facades\Schedule;

public function boot(): void
{
    Schedule::alwaysOnOneServer();

    // ถ้า CACHE_STORE เป็น file ให้ lock ไปใช้ store ที่ทุกเครื่องเห็นร่วมกัน
    Schedule::useCache('redis');
}

Laravel News เขียนรายละเอียดเรื่อง scheduler หลายเครื่องไว้ จุดที่พลาดง่ายมีสามข้อ

  • closure ต้องมีชื่อ lock ตั้งชื่อตาม task คำสั่ง artisan ได้ชื่อจาก command line เอง แต่ closure ไม่มี และ alwaysOnOneServer() จะข้าม closure ที่ไม่มีชื่อไปเงียบ ๆ ปล่อยให้รันทุกเครื่องเหมือนเดิม ต้องใส่ ->name('send-daily-report') เอง
  • cache ต้องแชร์กัน Redis, Memcached, DynamoDB หรือ database ใช้ได้ ส่วน file กับ array แต่ละเครื่องมี lock ของตัวเอง ทุกเครื่องเลยรันหมด
  • เลือกยกเว้นบาง task ไม่ได้ ถ้ามี task ที่ต้องรันทุกเครื่องจริง ๆ เช่น ลบไฟล์ชั่วคราวบน disk ของแต่ละเครื่อง ให้ปิด alwaysOnOneServer() แล้วต่อ onOneServer() เฉพาะ task ที่ต้องรันครั้งเดียว

คำสั่งที่รันนานหยุดได้ตอน deploy

อีกปัญหาคือคำสั่งที่รันนานด้วย runInBackground() เดิม schedule:interrupt ส่งสัญญาณถึงแค่ schedule:run ส่วนคำสั่งที่รันอยู่ใน process แยกไม่รู้เรื่อง เลยรันต่อด้วยโค้ดเวอร์ชันเก่าจนจบ หรือโดนฆ่ากลางทาง

13.35 เก็บเวลาที่สั่ง interrupt ไว้ใน cache แบบเดียวกับที่ queue:restart ทำกับ queue worker คำสั่งของเราเช็คได้ระหว่างรอบ

public function handle(Schedule $schedule): int
{
    $startedAt = now();

    foreach (Product::lazyById(500) as $product) {
        if ($schedule->hasBeenInterruptedSince($startedAt)) {
            $this->info('Interrupted by a deploy, stopping.');

            return self::SUCCESS;
        }

        $this->syncProduct($product);
    }

    return self::SUCCESS;
}

คำสั่งแบบนี้ต้องหยุดกลางทางได้โดยไม่เสียข้อมูล และรอบถัดไปต้องทำงานที่เหลือต่อได้ ตัวอย่างข้างบน sync สินค้าทุกตัวใหม่ทุกชั่วโมง หยุดกลางทางจึงไม่เสียอะไร อีกข้อคือเวลา interrupt เก็บใน cache store หลัก ไม่ได้ใช้ store จาก useCache() ถ้ามีหลายเครื่อง store หลักก็ต้องแชร์กันด้วย

queue:work จำกัด memory เป็นเปอร์เซ็นต์ได้

php artisan queue:work --memory=60%

ถ้า memory_limit ของ PHP เป็น 512M worker จะหยุดเมื่อใช้ไปราว 307 MB แล้ว Supervisor (หรือตัวที่คุม process อยู่) ก็เปิดตัวใหม่ขึ้นมาแทน ข้อดีคือปรับ memory_limit แล้วไม่ต้องตามแก้ตัวเลขใน config ของ worker ระวังกรณี memory_limit = -1 เปอร์เซ็นต์จะกลายเป็นค่าติดลบ และ worker จะไม่หยุดเพราะ memory เลย

ของเล็ก ๆ อีกสองอย่าง

  • response()->markdown() ตอบเป็น text/markdown ใช้คู่กับ $request->wantsMarkdown() ที่มีอยู่แล้ว เหมาะกับหน้าที่อยากให้ AI agent อ่านเป็น markdown
  • lazy_root_creation ใน disk แบบ local ไม่ต้องสร้างโฟลเดอร์ root ไว้ก่อน รอสร้างตอนเขียนไฟล์แรก
'reports' => [
    'driver' => 'local',
    'root' => storage_path('app/reports'),
    'lazy_root_creation' => true,
],

รอบนี้ยังมี bug fix อีกกว่า 50 รายการ ทั้ง relationship, factory, collection, queue และ scheduler ช่วงเปลี่ยนเวลา DST ดูรายการเต็มและ PR ของแต่ละฟีเจอร์ได้ในบทความต้นฉบับของ Laravel News

อ่านต่อ

ล่าสุด

ดูทั้งหมด →