Một model vừa chạy update(['price' => 2500]), database cũng đã có 2500, nhưng response trả ngay sau đó vẫn hiện 1000. Tình huống nghe giống transaction, cache Redis hoặc API Resource serialize nhầm. Với Laravel 13.36, nguyên nhân còn có thể nằm ngay trong chính model instance: object do Eloquent Attribute accessor trả về đã được giữ lại từ lần đọc trước.
Laravel 13.37.0 phát hành ngày 16/10/2026 có bản sửa cho đường code này. Một fixture cục bộ trên PHP 8.4.24 và SQLite 3.53.2 đã tái hiện được fail-before/pass-after: 13.36 trả accessor 1000 dù raw value và database đều là 2500; 13.37 trả đúng 2500 mà không cần refresh(). Đây là regression test cô lập, không phải sự cố production của Nghĩa hay benchmark hiệu năng.
Mục lục [Hiển thị]
Dấu hiệu nào khiến accessor đáng nghi?
Điểm nhận diện quan trọng không phải chỉ là “dữ liệu cũ”. Case này cần đúng một chuỗi thao tác: accessor trả object, code đã đọc accessor đó, sau đó set hoặc update raw attribute, rồi serialize lại cùng model instance. Nếu request mới query lại model, hoặc accessor chưa từng được đọc trước update, triệu chứng có thể biến mất và làm bug khó bắt.
final class Money
{
public function __construct(public int $amount) {}
}
protected function price(): Attribute
{
return Attribute::get(
fn (int $value) => new Money($value)
);
}
$product->price->amount; // 1000, cache object được tạo
$product->update(['price' => 2500]);
$product->getRawOriginal('price'); // 2500
$product->price->amount; // 1000 trên 13.36
Đừng bắt đầu bằng cache:clear. Cache đang nói tới ở đây là trạng thái nội bộ của Eloquent model, không phải Laravel Cache store, Redis hay file cache. Bài Laravel 13 Cache::touch() xử lý TTL của cache store, còn lỗi này tồn tại ngay trong vòng đời một object PHP.
Vì sao Eloquent lại giữ object?
Tài liệu Eloquent mutators giải thích rằng object do accessor trả về được giữ lại để Laravel có thể trả cùng instance ở những lần đọc tiếp theo. Cơ chế đó hữu ích với value object: sửa thuộc tính của object rồi gọi save(), Eloquent có thể đồng bộ thay đổi trở lại model.
Ở 13.36, đường setAttribute() không loại entry đã cache của get-only accessor. Raw attribute và hàng trong database đổi, nhưng lần đọc tiếp theo vẫn nhận object cũ. PR #62035 mô tả đúng ví dụ 1000 → 2500 và thêm bước xóa cached value ở cuối setAttribute(); accessor có setter không bị cùng lỗi vì đường setter đã cập nhật cache.

Kết quả toArray() trong fixture cũng đi theo accessor: 1000 trên 13.36 và 2500 trên 13.37. Đây là lý do bug có thể lộ ra ở API Resource hoặc JSON response dù câu lệnh SQL đã chạy đúng.
Fixture before/after cho kết quả gì?
Hai môi trường chỉ khác illuminate/database: v13.36.0 và v13.37.0. Cả hai dùng PHP 8.4.24, SQLite 3.53.2, cùng schema một cột integer và cùng thứ tự thao tác. Fixture ghi bốn checkpoint thay vì chỉ assert response cuối: giá trị accessor ban đầu, raw value sau update, giá trị đọc trực tiếp từ database và giá trị accessor/toArray sau update.
| Checkpoint | Laravel 13.36.0 | Laravel 13.37.0 |
|---|---|---|
| Accessor trước update | 1000 | 1000 |
| Raw attribute sau update | 2500 | 2500 |
| Database sau update | 2500 | 2500 |
| Accessor sau update | 1000 | 2500 |
toArray() sau update | 1000 | 2500 |
| Cùng object identity | Có | Không |
Changelog Laravel 13.x liệt kê fix này trong v13.37.0. Changelog cũng có những thay đổi Eloquent khác ở cùng release, nhưng fixture không gộp chúng vào một kết luận “13.37 sửa mọi lỗi cast”. Đặc biệt, lỗi encrypted:object luôn dirty là một PR khác với nguyên nhân và test khác.
Cũng cần phân biệt getRawOriginal() với getOriginal() và giá trị đã cast. Fixture dùng raw original sau update() để nhìn đúng giá trị model vừa đồng bộ, đồng thời query SQLite trực tiếp làm đối chứng. Nếu chỉ log $product->price hai lần, cả hai lần đều đi qua accessor và có thể cùng sai; log dài hơn nhưng không thêm bằng chứng mới. Nếu chỉ query database, ta biết write đã thành công nhưng vẫn chưa biết response của model hiện tại sẽ ra gì.
Bốn control giúp tránh test nhầm
Một case fail-before/pass-after là cần thiết, nhưng chưa đủ để hiểu phạm vi. Fixture chạy thêm ba control và một biến thể primitive. Kết quả cho thấy accessor có setter và accessor gọi withoutObjectCaching() đều trả 2500 ở cả hai bản. Điều này khớp với phạm vi PR: stale value xuất hiện khi cache đã được tạo nhưng đường set không invalidation nó.

Biến thể primitive trả $value * 2 và bật shouldCache() cũng đáng chú ý. Sau raw update từ 1000 lên 2500, 13.36 vẫn trả 2000, còn 13.37 trả 5000. API của Attribute phân biệt shouldCache() với withoutObjectCaching(); kết quả trên chỉ xác nhận hai đường đã chạy trong fixture, không suy rộng sang mọi custom cast.
Nâng patch hay dùng workaround?
Nếu ứng dụng đang ở Laravel 13.36 và có pattern này, ưu tiên hợp lý là cập nhật patch, khóa dependency rồi chạy regression suite. Trước khi đổi framework trong production, nên làm theo cùng tinh thần của checklist test nâng cấp Laravel trên PHP mới: giữ exact version, chạy test nghiệp vụ và kiểm response boundary, không chỉ nhìn Composer hoàn tất.
Khi chưa thể nâng ngay, refresh() hoặc query một model instance mới ở đúng boundary có thể trả dữ liệu mới. Nhưng đó là workaround quanh state cũ, không sửa cơ chế invalidation. Hãy gắn một regression test vào chỗ dùng workaround để đội dự án biết lúc nào có thể bỏ nó.
withoutObjectCaching() cũng không phải công tắc “chữa mọi cache”. Nó thay đổi semantics: mỗi lần đọc có thể tạo object mới, nên identity của object và cách mutation được đồng bộ cần được xem lại. Nếu ứng dụng dựa vào việc sửa value object rồi save(), tắt object caching tùy tiện có thể đổi hành vi mong muốn.
Với dự án còn ở Laravel 12, đừng nâng major chỉ để lấy một patch mà chưa đối chiếu support policy và breaking changes. Có thể tham khảo đường kiểm tra nâng Laravel 12 lên 13, nhưng hãy quay lại exact dependency graph của dự án. Một workaround hẹp kèm regression test đôi khi an toàn hơn nâng vội; ngược lại, giữ workaround vô thời hạn mà không có test sẽ biến nó thành món nợ khó gỡ.
Regression test nên khóa điều gì?
Test tốt nhất tái hiện thứ tự thật của ứng dụng: đọc accessor, update, rồi serialize cùng instance. Chỉ assert database là chưa đủ vì database vốn đã đúng trong case lỗi. Chỉ assert một request mới cũng chưa đủ vì query mới tạo model khác và vô tình né cache cũ.
$product = Product::query()->findOrFail($id);
expect($product->price->amount)->toBe(1000);
$product->update(['price' => 2500]);
expect($product->getRawOriginal('price'))->toBe(2500)
->and($product->price->amount)->toBe(2500)
->and($product->toArray()['price']->amount)->toBe(2500);
Nếu response đi qua Resource, thêm một assertion ở đúng lớp Resource. Nếu accessor có setter, test riêng setter. Nếu dùng shouldCache() cho primitive, thêm case primitive thay vì giả định fix object tự động phủ hết. Mỗi assertion trả lời một câu hỏi khác nhau, nhờ vậy khi test đỏ sẽ biết đang vướng database, model state hay serialization.
Ở application test, nên tạo model mới cho mỗi case để cache của control trước không rò sang control sau. Với SQLite in-memory, schema và seed phải giống nhau ở hai lockfile; với MySQL hoặc PostgreSQL, giữ cùng transaction boundary. Nếu test chạy qua HTTP, ghi rõ response được tạo từ model vừa update hay model đã query lại. Hai đường đó có thể cho cùng JSON trên 13.37 nhưng mang ý nghĩa regression khác nhau.
Đừng đưa timing vào assertion nếu mục tiêu chỉ là correctness. PR không hứa giảm query, giảm memory hay tăng tốc Resource. Fixture này cũng không benchmark. Một test như “response dưới 50 ms” vừa dễ flaky vừa không chứng minh cache đã invalidation; assert raw value, database value và serialized value mới khóa đúng lỗi.
Checklist chẩn đoán ngắn
- Accessor có trả object, hoặc primitive có bật
shouldCache()không? - Cùng model instance đã đọc accessor trước khi chạy
setAttribute(),fill()hayupdate()chưa? - Response có serialize lại chính instance đó ngay sau update không?
- Raw attribute và database đã đúng nhưng accessor vẫn cũ không?
- Exact framework version đang là trước hay từ 13.37.0?
Nếu ba câu đầu đều “có”, hãy viết fixture trước khi clear cache hoặc sửa Resource. Trên Laravel 13.36, kết quả stale có thể tái hiện mà database hoàn toàn đúng. Trên 13.37, cùng fixture đã trả giá trị mới nhờ cache accessor được invalidation khi set attribute.
Nâng patch vẫn cần test đầy đủ, nhưng hướng sửa lần này khá gọn: đừng săn một cache toàn ứng dụng khi dữ liệu cũ đang nằm trong vòng đời của chính model. Xác định đúng instance, đúng thứ tự access → update → serialize sẽ tiết kiệm một vòng chẩn đoán rất dài.