Ngày mai là mốc Laravel 12 hết bug fix. Cụm này dễ dẫn tới hai phản ứng cực đoan: cuống lên nâng production ngay trong đêm, hoặc cho rằng framework vẫn còn bản vá bảo mật nên có thể để đó thêm vài tháng.

Mình chọn cách ít kịch tính hơn: dựng một ứng dụng Laravel 12 nhỏ, cài một package cố tình chỉ hỗ trợ Illuminate 12, rồi chạy composer why-not, dry-run và nâng thật lên Laravel 13. Kết quả không quá đáng sợ, nhưng có một chi tiết cấu hình cache mà test xanh không tự nhắc mình. Bài này là nhật ký audit đó, không phải lời hô hào mọi dự án phải deploy Laravel 13 ngay ngày 13/8.

Ngày 13/8 thay đổi điều gì với Laravel 12?

Theo support policy chính thức, Laravel 12 phát hành ngày 24/02/2025, nhận bug fix đến 13/08/2026 và nhận security fix đến 24/02/2027. Hai mốc này khác nhau. Sau ngày 13/8, nhánh 12 không còn được hứa hẹn sửa các lỗi chức năng thông thường; các bản vá bảo mật vẫn còn trong cửa sổ hỗ trợ thêm hơn sáu tháng.

Vì vậy, “hết bug fix” không đồng nghĩa ứng dụng tự nhiên mất an toàn vào nửa đêm. Nhưng nó cũng không phải một mốc trang trí. Nếu gặp lỗi framework ảnh hưởng queue, cache, database hay một edge case HTTP sau đó, hướng xử lý lâu dài sẽ nghiêng về nâng major thay vì chờ một patch 12.x.

Ở thời điểm mình kiểm tra ngày 12/08/2026, release page của repository còn liệt kê Laravel 12.66.0 và Laravel 13.25.0. Điều này cho thấy nhánh 12 vẫn đang có release ngay sát mốc hỗ trợ, chứ không phải một codebase bị bỏ quên từ lâu. Việc đầu tiên hợp lý là cập nhật lên 12.x mới nhất, đọc thay đổi rồi đo đường lên 13, không phải nhảy thẳng từ một bản 12.x cũ.

Kiểm kê môi trường trước khi đổi constraint

Fixture chạy trên Windows 11, PHP 8.5.9 NTS x64 và Composer 2.10.2. Mình xác minh SHA-256 của PHP và Composer trước khi giải nén. Ứng dụng dùng Laravel 12.66.0, SQLite local, database cache, database session và database queue. Đây là một lab nhỏ, không đại diện cho Redis cluster, Horizon, Octane hay database production của các bạn.

Mình thêm package giả lập acme/legacy-exporter. Package không có code nghiệp vụ; nhiệm vụ duy nhất của nó là yêu cầu illuminate/support:^12.0, giống một package nội bộ chưa kịp mở compatibility cho Laravel 13. Nhờ vậy blocker có chủ đích và tái lập được, không gán lỗi cho một package thật.

Baseline có ba test: trang chủ trả 200, cache ghi rồi đọc lại được, session giữ giá trị và job được đẩy qua queue fake. Kết quả là 3/3 test, 6 assertion; ba migration users, cache và jobs đều ở trạng thái Ran. Nếu là dự án thật, mình sẽ bổ sung phiên bản database, Redis, driver queue, worker, scheduler và các package first-party vào cùng bản kiểm kê.

composer why-not chỉ đúng blocker nào?

Lệnh mình chạy là composer why-not laravel/framework ^13.0 --tree. Composer chỉ ra ba nhóm cản đường: constraint laravel/framework:^12.0 của root project, package giả lập đang giữ Illuminate 12 và Laravel Tinker 2.11.1 vẫn yêu cầu các component đến nhánh 12. Đây là giá trị lớn nhất của why-not: nó trả lời “ai đang giữ phiên bản cũ” trước khi mình sửa hàng loạt dòng trong composer.json.

Kết quả composer why-not cho thấy package giả lập đang chặn Laravel 13
Output thật trên fixture: root project, package giả lập và Tinker đều giữ các constraint của Laravel 12.

Direct dependency và transitive dependency cần được đọc khác nhau. Root constraint là việc mình chủ động đổi. Package nội bộ phải được maintainer kiểm tra API rồi mở constraint. Package bắc cầu như Tinker cần nâng đúng major được upgrade guide yêu cầu. Nếu chỉ xóa lockfile và thử lại, mình che mất câu trả lời chứ không giải quyết compatibility.

Trên một dự án nhiều package, anh em có thể chạy thêm composer why-not ... --tree cho PHP 8.3 và từng package first-party. Mình cũng hay kiểm tra bài về CPX 2.0 và workflow Composer để tách rõ công cụ chạy package với dependency thật của ứng dụng.

Dry-run cho mình biết gì trước khi nâng thật?

Mình đổi các constraint theo guide: PHP lên ^8.3, framework lên ^13.0, Tinker lên ^3.0 và PHPUnit lên ^12.0. Lượt dry-run đầu thất bại với exit code 2 vì acme/legacy-exporter còn giữ illuminate/support:^12.0. Sau khi giả lập maintainer đã kiểm tra và mở thành ^12.0 || ^13.0, dry-run exit 0 sau khoảng 35,3 giây.

Kế hoạch giải dependency dự kiến 35 package được nâng, 3 package gỡ và 1 package thêm. Trong đó framework đi từ 12.66.0 lên 13.25.0, Tinker từ 2.11.1 lên 3.0.2 và PHPUnit từ 11.5.56 lên 12.5.33. Composer còn báo không lấy được advisory từ Packagist trong một lượt do timeout 10 giây, nên mình không biến dry-run đó thành bằng chứng “không có lỗ hổng”. Dependency resolution thành công và security audit là hai kiểm tra khác nhau.

Dry-run không sửa vendor nhưng cũng không chạy ứng dụng. Nó không biết cookie cũ có còn dùng được, worker đang chạy có đọc được payload mới hay truy vấn MySQL thật có đổi hành vi. Mình vẫn thực hiện update trên fixture. Windows Defender hai lần khóa ZIP tạm của Composer, nên mình dùng --prefer-source cho lượt cài lại; toàn bộ repository chỉ nằm trong thư mục tạm và được xóa sau khi ghi bằng chứng.

Ba thay đổi Laravel 13 dễ lọt qua test xanh

Cache prefix, session cookie và cấu hình serialization

Laravel 13 đổi fallback prefix từ kiểu gạch dưới sang gạch ngang. Fixture của mình đang có giá trị app-level laravel-cache-laravel-session, nên nâng package không làm chúng đổi. Dự án thiếu các key này mới cần đặc biệt chú ý; muốn giữ hành vi cũ thì khai báo rõ CACHE_PREFIX, REDIS_PREFIXSESSION_COOKIE.

Điểm đáng chú ý hơn là config cache mới có serializable_classes mặc định false để siết việc unserialize object. File config/cache.php của app Laravel 12 trong lab không tự nhận key mới sau Composer update; probe trả về null. Nghĩa là đổi framework package chưa đủ, mình phải đối chiếu skeleton/config mới và quyết định allow-list class hay chuyển payload cache sang array. Đây chính là kiểu chênh lệch mà ba test xanh không bắt được.

Request forgery protection

Middleware CSRF được đổi tên từ VerifyCsrfToken sang PreventRequestForgery và bổ sung kiểm tra origin dựa trên header Sec-Fetch-Site. Alias cũ vẫn còn nhưng đã deprecated. Probe trên fixture xác nhận class mới tồn tại; dự án có test dùng withoutMiddleware, route exclude hoặc import trực tiếp class cũ nên đổi tham chiếu, rồi chạy lại form, webhook và SPA request thực tế.

upsert và database thật

Laravel 13 từ chối uniqueBy rỗng khi gọi upsert. Trên fixture SQLite, lời gọi thử ném InvalidArgumentException với thông báo “The unique columns must not be empty.” trước khi phát SQL. Guide lưu ý MySQL và MariaDB vốn dùng primary/unique index để phát hiện record, nhưng validation mới vẫn áp dụng.

Mình chưa chạy MySQL hoặc MariaDB trong lần này, cũng chưa kiểm tra biến thể DELETE JOINORDER BY/LIMIT. Nếu production dùng hai đường code đó, đừng lấy kết quả SQLite của mình làm kết luận. Hãy đưa query thật vào integration test với engine và version thật.

Kết quả test sau nâng cấp nói được tới đâu?

Sau khi cài Laravel 13.25.0, ba test vẫn đạt, 6 assertion vẫn xanh và ba migration vẫn ở trạng thái Ran. Thời gian PHPUnit giảm từ khoảng 0,294 giây xuống 0,200 giây trong đúng một lượt chạy, nhưng mẫu quá nhỏ nên mình không gọi đây là cải thiện hiệu năng. Điều có ích là các đường HTTP, cache, session và queue đơn giản không gãy.

Hạng mục Trước nâng cấp Sau nâng cấp Kết luận
Laravel 12.66.0 13.25.0 Đúng constraint mục tiêu
PHPUnit 11.5.56 12.5.33 Được nâng theo guide
Test và assertion 3/3; 6 assertion 3/3; 6 assertion HTTP, cache, session, queue đều đạt
Migration SQLite 3 Ran 3 Ran Không có migration mới trong fixture
Bảng so sánh Laravel 12 và 13 với kết quả test, migration và dependency
Cùng 3 test và 6 assertion đạt trước/sau; dry-run dự kiến 35 gói nâng, 3 gói gỡ và 1 gói thêm.

Giới hạn cũng khá rõ: queue fake không thay worker thật; SQLite không thay MySQL/MariaDB; test session không chứng minh cookie đang mở trên trình duyệt của người dùng sống qua deploy; và fixture không có Horizon, Reverb hay package thương mại. Bài Laravel Boost: test xanh chưa đủ vẫn đúng trong trường hợp này. Một ứng dụng realtime như ví dụ bảng tỷ số dùng Laravel Reverb còn cần smoke test kết nối, event và worker riêng.

Với repository thật, mình sẽ giữ lại lockfile mới trên branch, chạy test có database thật, khởi động worker bằng code mới, gửi một job canary, kiểm tra cache/session bằng hai phiên trình duyệt và so log trước/sau. Nếu dùng Laravel Installer để dựng fixture mới đối chiếu, phần mình thử Laravel Installer 5.31 có ghi rõ những gì command tạo ra và những gì vẫn phải review.

Nâng ngay, lên lịch hay tạm ở lại?

Mình sẽ nâng ngay khi runtime đã ở PHP 8.3+, package quan trọng đều mở constraint, integration test đủ bao phủ và có canary/rollback. Mình chọn lên lịch khi blocker đã có owner nhưng còn cần test database, queue hay client thật. Tạm ở Laravel 12 vẫn là lựa chọn hợp lệ trong cửa sổ security support nếu ứng dụng ở 12.66.0 mới nhất, vẫn theo dõi security release và đã chốt ngày xử lý blocker.

Ma trận quyết định nâng ngay, lên lịch hoặc tạm ở lại Laravel 12
Mốc hết bug fix cần một quyết định có điều kiện; Laravel 12 vẫn còn security fix đến 24/02/2027.

Rollback cũng phải chuẩn bị trước deploy: lưu artifact và composer.lock cũ, phân loại migration có đảo ngược được hay không, dừng hoặc drain worker trước khi đổi code, tránh xóa cache/session chung khi chưa đo tác động và triển khai canary trước một phần traffic. Nếu migration đã ghi dữ liệu theo schema mới, chỉ checkout code cũ chưa chắc là rollback.

Kết luận của mình khá đơn giản: ngày 13/8 là lúc biến “sẽ nâng sau” thành một công việc có blocker, người phụ trách và cửa sổ deploy. Fixture này đi qua Laravel 13 nhanh, nhưng chính key cache bị thiếu cho thấy nâng major không chỉ là làm Composer xanh. Anh em chưa thể nâng ngay thì cứ ở lại Laravel 12 có kiểm soát; đừng nhầm khoảng thời gian còn security fix với quyền trì hoãn vô hạn.

Nguồn mình đã đối chiếu