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';
});
Queue::assertPushed(ProcessPodcast::class, [
'podcast' => $podcast,
'user' => $user,
'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;
}
php artisan schedule:interrupt
# สลับไป release ใหม่ รัน migration ฯลฯ
php artisan queue:restart
คำสั่งแบบนี้ต้องหยุดกลางทางได้โดยไม่เสียข้อมูล และรอบถัดไปต้องทำงานที่เหลือต่อได้ ตัวอย่างข้างบน 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 อ่านเป็น markdownlazy_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






