Blade nhận gì — Model, array hay Collection?
Một câu hỏi nhỏ nhưng hỏi rất nhiều trong phỏng vấn Laravel, vì nó lộ ra người trả lời có hiểu cơ chế bên dưới hay chỉ thuộc lòng "nên tách tầng cho sạch". Tài liệu này gộp hai câu hỏi liên quan: Model vs array/DTO, và Collection vs array.
Trả lời ngắn: mặc định truyền Eloquent Model và Collection — đó là thứ Eloquent trả về sẵn, convert đi là việc thừa. Chỉ đổi sang array/DTO khi có một ranh giới thật: cache, queue payload, JSON response, Livewire prop, view dùng lại cho nhiều nguồn, hoặc dữ liệu quá lớn. Dựng tầng DTO cho mọi view là over-engineering.
Bổ sung cho tối ưu Laravel (phần N+1 và chunk/cursor/lazy) và canary Laravel + Livewire (phần snapshot Livewire).
Từ điển nhanh
| Thuật ngữ | Nghĩa ngắn gọn |
|---|---|
| lazy loading | Relation chưa nạp thì Eloquent tự bắn query lúc bạn đọc nó — kể cả khi đang render HTML |
| eager loading | Nạp trước relation bằng with(), gộp thành 1 query thay vì N query |
| N+1 query | 1 query lấy danh sách, rồi mỗi dòng lại bắn thêm 1 query để lấy quan hệ |
| DTO (Data Transfer Object) | Một class chỉ chứa dữ liệu, không có logic, không biết gì về DB |
| ViewModel | DTO dựng riêng cho đúng một view — chứa đúng những field view cần |
| hydrate / rehydrate | Dựng lại object từ dữ liệu thô (dòng DB, hoặc snapshot Livewire) |
| snapshot (Livewire) | Ảnh chụp state component, gửi kèm HTML về browser, ký bằng HMAC để chống sửa |
| higher order message | Lời gọi rút gọn trên Collection: $users->sum->votes thay cho $users->sum(fn($u) => $u->votes) |
| generator | Hàm PHP dùng yield — sinh từng phần tử một, không giữ cả tập trong RAM |
| trust boundary | Ranh giới dữ liệu đi ra khỏi tầm kiểm soát: ra mạng, vào cache, vào queue |
1. Nó là gì / chạy thế nào
1.1. Blade không phải template engine chạy runtime
Blade compile ra file PHP thuần trong storage/framework/views/, rồi
extract() mảng data vào scope của file đó. Không có sandbox, không có lớp bảo vệ nào cả —
trong view bạn gọi được mọi thứ PHP gọi được.
flowchart LR
A["view('user.show', compact('user'))"] --> B["Compile 1 lần
storage/framework/views/abc123.php"]
B --> C["extract($__data)"]
C --> D["include file PHP đã compile"]
D --> E["HTML"]
php artisan view:cachechỉ cache bước compile, không cache dữ liệu. Hai chuyện khác nhau hoàn toàn, rất hay bị nhầm khi trả lời phỏng vấn.
1.2. Khác biệt thật: cái gì xảy ra khi Blade đọc một property
Toàn bộ cuộc tranh luận Model vs array gói gọn trong một nhánh if:
flowchart TD
A["Blade đọc: user → profile → avatar"] --> B{"$user là kiểu gì?"}
B -->|"Eloquent Model"| C["Gọi __get('profile')"]
C --> D{"Relation đã nạp chưa?"}
D -->|"Rồi"| E["Trả từ bộ nhớ — không tốn gì"]
D -->|"Chưa"| F["LAZY LOAD
SELECT ... WHERE user_id = ?
Query DB ngay giữa lúc render HTML"]
B -->|"Array / DTO"| G["Đọc key trong bộ nhớ
Không thể chạm DB, dù muốn"]
Model cho phép view tự đi query DB. Array thì không. Mọi lợi ích và tác hại của hai hướng đều bắt nguồn từ đúng một câu này.
1.3. Collection là gì — chỉ là một object bọc quanh array
class Collection implements ArrayAccess, IteratorAggregate, Countable, JsonSerializable
{
protected $items = []; // ← toàn bộ bí mật nằm ở đây
}
Điểm cần nắm: đa số method biến đổi trả về một Collection MỚI, không sửa cái cũ. Nghĩa là mỗi bước trong chain tạo thêm một bản array trung gian:
$users (10.000 model)
->filter(...) → array mới (8.000) + object mới
->map(...) → array mới (8.000) + object mới
->sortBy(...) → array mới (8.000) + object mới
->values() → array mới (8.000) + object mới
Đỉnh bộ nhớ = các bản trung gian còn sống cùng lúc, cho tới khi GC dọn.
Với 10k dòng không ai thấy gì. Với 500k dòng thì hết RAM. Đây không phải "Collection chậm" — array thuần copy y hệt; vấn đề là nạp toàn bộ vào RAM một lúc.
1.4. Ba lớp Collection phải phân biệt được
| Lớp | Là gì | Khi nào gặp |
|---|---|---|
Support\Collection | Bọc array, khoảng 100 method | collect([...]) |
Eloquent\Collection | Kế thừa lớp trên, thêm load(), loadMissing(), modelKeys(), find($id), fresh() | Model::get(), Model::all() |
LazyCollection | Dùng generator — không giữ toàn bộ trong RAM | Model::cursor(), ->lazy() |
Bẫy ít người biết: vài method làm Eloquent Collection tụt hạng về base collection —
collapse,flatten,flip,keys,pluck,zip. Và nếumap()trả về thứ không phải model, kết quả cũng thành base collection. Sau đó gọi->load('relation')sẽ chết vì method đó không còn tồn tại.
1.5. __get trên Collection — cái bẫy hay gặp nhất trong Blade
{{ $users->name }}
{{-- Property [name] does not exist on this collection instance. --}}
Không trả null — nó ném exception. Lý do: __get trên Collection
được dành cho higher order message, và chỉ nhận một danh sách tên cố định
(map, filter, sum, each, groupBy,
keyBy, sortBy, unique, first, every...):
$users->sum->votes; // = $users->sum(fn ($u) => $u->votes)
$users->each->markAsVip(); // = $users->each(fn ($u) => $u->markAsVip())
Tên nào ngoài danh sách đó → exception. Cách tránh rẻ nhất: đặt tên số nhiều cho collection, số ít cho model. Một quy ước nhỏ chặn được cả một lớp lỗi.
2. Lợi ích
2.1. Truyền Eloquent Model
| Được gì | Vì sao |
|---|---|
| Ít code nhất | Controller không phải map field. Thêm cột mới → view dùng được ngay. |
| Giữ accessor, cast, relation | $user->full_name, $order->created_at->diffForHumans(), $post->author->name chạy tự nhiên. Array phải map tay từng cái. |
| Type-safe khi dùng class component | public function __construct(public User $user) — IDE và static analysis (PHPStan/Larastan) hiểu được. |
| Policy / gate hoạt động | @can('update', $post) cần object, không nhận array. |
| Route model binding trả sẵn model | Không phải convert gì cả. |
| Vá được N+1 sau khi đã query | $users->load('posts') — sửa mà không phải đụng query gốc. |
2.2. Truyền array / DTO
| Được gì | Vì sao |
|---|---|
| View không thể query DB | N+1 trở thành bất khả thi, không còn phụ thuộc vào việc "nhớ đừng quên with()". |
| Contract rõ ràng | Nhìn DTO là biết view cần đúng những field nào. Đổi schema DB không làm vỡ view. |
| View dùng lại được | Cùng một partial render được data từ Eloquent, từ API bên thứ ba, từ cache. |
| Serialize an toàn | Cache, queue, Livewire snapshot — array là dữ liệu thuần, không kéo theo cả object graph. |
| Không rò rỉ field nhạy cảm | Model có password, internal_note... DTO chỉ chứa cái view cần. |
| Test view rẻ | Không cần DB, không cần factory. |
2.3. Collection so với array thuần
| Collection | Array thuần | |
|---|---|---|
| Đọc code | ->filter()->groupBy()->map() đọc theo đúng thứ tự xảy ra | Phải đọc ngược từ trong ra ngoài |
| Thứ tự tham số | Luôn $c->method($cb) | array_map($cb, $arr) nhưng array_filter($arr, $cb) — PHP không nhất quán |
| Truy cập an toàn | ->first() trên rỗng → null; có firstOrFail() khi cần nổ | $arr[0] → warning |
| Hiệu năng | Có object wrapper, tạo instance mới mỗi bước | Nhanh và nhẹ hơn, chạy ở tầng C |
| Serialize | Kèm tên class, kèm object graph | Gọn, ổn định qua các lần deploy |
| Bất ngờ | __get ném exception, có chuyện tụt hạng class | Không có gì bất ngờ |
3. Tác hại / hạn chế
3.1. Model — chỗ dễ hỏng
N+1 ẩn trong Blade. Đây là lỗi số 1.
@foreach ($orders as $order)
{{ $order->customer->name }} {{-- 1 query mỗi vòng lặp --}}
@endforeach
Controller trông sạch sẽ. Query log thì 500 dòng. Tệ hơn: nó thường nằm trong
@include lồng 3 tầng, nên sửa một view khác cũng có thể làm hỏng trang này.
- View phụ thuộc schema DB. Đổi tên cột
name→display_namephải grep toàn bộresources/views. - View có thể sửa dữ liệu. Không có gì chặn
{{ $user->posts()->delete() }}trong Blade. Hiếm, nhưng bề mặt tấn công là có thật. - Khó test view riêng lẻ. Muốn test một partial phải dựng model và DB.
3.2. Array / DTO — chỗ dễ hỏng
- Boilerplate. 40 field → 40 dòng map. Thêm một cột phải sửa 3 chỗ: model, DTO, controller.
- Mất chuỗi gọi tự nhiên.
$user['profile']['avatar']kèm@if (isset(...))khắp nơi. - Mất
load()/loadMissing(). Đã convert là hết đường vá relation. - Tầng DTO dễ phình. Mỗi view một DTO, rồi DTO factory, rồi mapper interface... Đây là chỗ over-engineering hay xảy ra nhất.
toArray()là cái bẫy lớn nhất — nó trông có vẻ là giải pháp nhanh:
Carbonbiến thành string → mất->format(),->diffForHumans()- Relation chưa nạp thì im lặng biến mất khỏi array — view render ra khoảng trắng, không lỗi
$hiddenbị áp dụng,$appendsđược thêm vào — không phải lúc nào cũng như bạn tưởng- Không có type, không có IDE autocomplete
Muốn ranh giới thì làm DTO thật. Không thì cứ truyền model.
3.3. Collection — chỗ dễ hỏng
- Bộ nhớ trên dataset lớn.
User::all()->filter(...)kéo cả bảng vào RAM rồi mới lọc. - Chain dài không debug được. 8 bước liên tiếp, sai ở bước 5, không có chỗ đặt breakpoint.
- Không đồng nhất về tính bất biến. Đa số method trả bản mới, nhưng
push,put,forget,transform,shift,popsửa tại chỗ. collect()rồi->toArray()ngay là lãng phí thuần túy — hai lượt đi qua dữ liệu, không được gì.
4. Trade-off
4.1. Bảng chọn
| Ngữ cảnh | Chọn | Vì sao |
|---|---|---|
| CRUD admin, form, list nội bộ | Model + Collection | Ranh giới không đáng tiền. with() + preventLazyLoading là đủ. |
Blade component tái dùng (<x-user-card>) | Model qua class component | Constructor type-hint chính là contract rồi. |
| View render dữ liệu từ nhiều nguồn | DTO | Ranh giới thật: 2 nguồn phải hội tụ về 1 hình dạng. |
| Trang public, traffic cao, cần cache | array / DTO | Phải serialize được. Cache model là mùi code xấu. |
| Livewire public property | array (xem case 4) | Model → re-query mỗi request. Collection → payload phình. |
| Queue job payload | array hoặc chỉ id | Model bị serialize rồi rehydrate lúc chạy — có thể đã bị xoá hoặc đã đổi. |
| Email / PDF render trong queue | DTO | Nội dung phải khớp thời điểm dispatch, không phải thời điểm chạy. |
| Vòng lặp nóng, hơn ~50k phần tử | array / generator | Object wrapper và bản copy trung gian bắt đầu có giá. |
| Stream file lớn, export | LazyCollection | Bộ nhớ không đổi theo số dòng. |
| API response | API Resource | Không phải cái nào ở trên — dùng JsonResource. |
| Config, hằng số, key-value nhỏ | array | Bọc lại chẳng để làm gì. |
4.2. Tiêu chí quyết định
flowchart TD
A["Cần truyền dữ liệu vào view"] --> B{"View này có được phép
chạm DB không?"}
B -->|"Không, tuyệt đối không"| C["DTO / array"]
B -->|"Được, chấp nhận rủi ro"| D{"Dữ liệu có đi qua
ranh giới nào không?
cache / queue / JSON / Livewire"}
D -->|"Có"| C
D -->|"Không"| E{"Bao nhiêu bản ghi?"}
E -->|"Rất lớn, hơn ~50k"| F["Generator / LazyCollection"]
E -->|"Bình thường"| G["Model + Collection
← mặc định"]
Hai câu để nhớ:
- Model vs array: câu hỏi không phải "cái nào đẹp hơn", mà là "view này có được phép chạm DB không?"
- Collection vs array: Collection cho biến đổi dữ liệu bên trong app; array cho ranh giới ra ngoài và số lượng lớn.
4.3. Đường giữa — không cần dựng tầng DTO
Trước khi nghĩ tới DTO, hãy đặt ranh giới ở query. Rẻ hơn rất nhiều và bắt được phần lớn vấn đề:
$posts = Post::with('author:id,name')
->select('id', 'title', 'author_id', 'published_at')
->latest()
->paginate(20);
return view('posts.index', compact('posts'));
Model vẫn được truyền vào, nhưng bạn đã nói rõ view được dùng cái gì.
4.4. Quy tắc quan trọng hơn cả hai lựa chọn trên
Đẩy việc xuống DB trước đã. ->where() trong query luôn thắng ->filter()
trên collection — không phải vì Collection chậm, mà vì bạn tránh được việc kéo dữ liệu về:
// ✗ kéo 500k dòng về RAM rồi mới lọc
User::all()->filter(fn ($u) => $u->votes > 500);
// ✓ DB lọc, chỉ trả về đúng cái cần
User::where('votes', '>', 500)->get();
5. Khó khăn khi vận hành thực tế
5.1. N+1 không lộ ở local
Local có 10 dòng dữ liệu, view chạy 20ms. Production 100k dòng → 20 giây. Bắt buộc bật:
// AppServiceProvider::boot()
Model::preventLazyLoading(! $this->app->isProduction());
Ở production đừng để nó ném exception làm sập trang — chỉ log lại:
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
report(new LazyLoadingViolationException($model, $relation));
});
5.2. Debug lúc 3h sáng
Trang chậm. Với model, thủ phạm nằm trong Blade — APM chỉ báo "view render 8s",
không chỉ ra dòng nào. Phải bật Telescope/Debugbar hoặc DB::listen() mới thấy.
Với DTO, mọi query đều nằm trong controller/service — đọc là thấy ngay.
Debug chain Collection: ->dd() dừng luôn, ->dump() in rồi chạy tiếp,
->tap(fn ($c) => dump($c->count())) chèn giữa chain mà không phá chain.
5.3. OOM là vách đá, không phải dốc thoải
Job export chạy tốt 6 tháng, dữ liệu tăng dần, rồi một đêm
Allowed memory size exhausted. Không có cảnh báo trước. Nên log
memory_get_peak_usage(true) trong các job xử lý theo lô.
5.4. chunk() có bẫy khi vừa duyệt vừa sửa
Nếu trong vòng lặp bạn update/delete chính điều kiện đang lọc, các trang sau sẽ
nhảy cóc qua dòng — vì OFFSET bị lệch khi tập kết quả co lại.
Dùng chunkById() / lazyById(): chúng phân trang theo khoá chính,
không dùng OFFSET.
5.5. cursor() giữ kết nối DB mở suốt quá trình
Vòng lặp chậm (có gọi API bên ngoài trong đó) sẽ giữ connection hàng phút, dễ cạn pool.
Trường hợp đó dùng chunkById() thay vì cursor().
5.6. LazyCollection chỉ đi được một lượt
Gọi count() là đã tiêu thụ hết; muốn duyệt lần nữa phải query lại.
Nguồn bug khó hiểu cho người mới.
5.7. Chi phí con người
DTO cần cả team giữ kỷ luật. Một dev mới thêm {{ $model->relation->x }} là
ranh giới thủng. Team đông và luân chuyển nhiều → ranh giới cứng có giá trị.
Team 3 người, review kỹ → không cần.
Monitoring nên có: cảnh báo khi số query mỗi request vượt ngưỡng (ví dụ hơn 50). Rẻ, và bắt được N+1 trước khi người dùng kêu.
6. Case thực tế và cách xử lý
Case 1 — Trang list đơn hàng chậm 15 giây sau khi thêm một cột
Ai đó thêm {{ $order->customer->company->name }} vào partial. Controller không đổi.
DB::listen(fn ($q) => Log::debug($q->sql))trên staging → thấy 2×N query lặp.- Sửa nhanh:
Order::with('customer.company')->paginate(20). - Sửa gốc: bật
preventLazyLoadingở local/staging để lần sau vỡ ngay lúc dev. - Nếu trang này nóng và cứ tái diễn: đây là lúc DTO xứng đáng. Partial nhận array → view mất khả năng query, vấn đề không quay lại được nữa.
Điểm mấu chốt khi trả lời phỏng vấn: DTO ở đây không phải "kiến trúc đẹp hơn", nó là cái phanh cho một chỗ đã hỏng hai lần. Kiến trúc nên là phản ứng với vấn đề thật, không phải dự đoán.
Case 2 — Export CSV 800k dòng, job chết vì hết bộ nhớ
$users = User::with('orders')->get(); // ← OOM ngay đây
- Xác nhận: log
memory_get_peak_usage(true)→ thấy chạmmemory_limit. - Sửa nhanh — duyệt theo lô, ghi thẳng ra file:
User::select('id', 'name', 'email') ->lazyById(1000) // phân trang theo id, không dùng OFFSET ->each(fn ($u) => fputcsv($handle, [$u->id, $u->name, $u->email])); - Bỏ
with('orders')nếu CSV không cần — eager load nhân bộ nhớ lên nhiều lần. - Chỉ
select()cột thật sự dùng. Model 40 cột × 800k dòng chính là phần lớn dung lượng.
Vấn đề không phải "Collection nặng". Array cũng chết y hệt. Thứ cứu bạn là generator, không phải kiểu dữ liệu.
Case 3 — Cache một Collection model, deploy xong site trắng
Cache::put('top_products', Product::with('category')->take(50)->get(), 3600);
Deploy đổi tên một cột trong Product → unserialize bản cache cũ tạo ra
model có thuộc tính không khớp → lỗi lung tung, khó lần ra vì cache còn sống 1 tiếng.
- Chữa cháy:
php artisan cache:clear(hoặc xoá đúng key). - Sửa gốc — cache array thuần, không cache object:
Cache::put('top_products', Product::with('category')->take(50)->get() ->map(fn ($p) => [ 'id' => $p->id, 'name' => $p->name, 'category' => $p->category->name, ])->all(), // ← all() trả array PHP thường 3600 ); - Nếu cần model thật: cache danh sách id, rồi
Product::findMany($ids). Payload nhỏ, dữ liệu luôn tươi, không bao giờ vỡ khi deploy.
Case 4 — Livewire: Model và Collection làm public property
Đây là chỗ khác Blade thường nhất, interviewer rất hay hỏi. Livewire lưu state component vào snapshot, gửi kèm HTML về browser:
| Kiểu public prop | Trong snapshot | Mỗi request | Rủi ro |
|---|---|---|---|
Model | Chỉ class + khoá chính | Re-query DB để rehydrate | Model bị xoá → ModelNotFoundException |
Eloquent\Collection | Danh sách khoá chính | Re-query DB toàn bộ | N model = 1 query nặng mỗi lần tương tác |
Support\Collection | Toàn bộ dữ liệu | Không query | Payload phình |
array | Toàn bộ dữ liệu | Không query | Payload to; nhưng HMAC chặn sửa |
Tình huống: bảng 200 dòng, mỗi dòng là component có public Order $order
→ 200 query mỗi lần user click phân trang. Hoặc public Collection $rows (Eloquent)
→ mỗi lần gõ vào ô tìm kiếm là nạp lại 200 model.
Cách xử lý:
- Giữ array cho dữ liệu chỉ để hiển thị.
- Hoặc chỉ giữ
public int $orderIdkèm#[Locked], còn model lấy qua computed property. - Hoặc để component cha query một lần rồi truyền xuống con qua parameter thường, không phải public prop.
Case 5 — Cùng một <x-product-card> cho catalog nội bộ và feed đối tác
Catalog trả Product model. Đối tác trả JSON qua API. Cùng một card.
Đây là ranh giới thật — hai nguồn phải hội tụ:
final readonly class ProductCardData
{
public function __construct(
public string $title,
public string $imageUrl,
public string $priceFormatted,
) {}
public static function fromModel(Product $p): self { /* ... */ }
public static function fromPartnerFeed(array $raw): self { /* ... */ }
}
3 field, 2 named constructor. Không interface, không mapper class, không factory. Thêm cái nào khi có nguồn thứ ba.
Case 6 — Nhầm collection với model trong Blade
{{ $users->name }}
{{-- Property [name] does not exist on this collection instance. --}}
Người viết tưởng $users là một user. Cách tránh: quy ước tên số nhiều / số ít.
Nếu thật sự muốn lấy tên đầu tiên: $users->first()?->name.
Bẫy anh em của nó: paginate() trả về LengthAwarePaginator,
không phải Collection. @foreach chạy được (nó là Traversable),
->links() cũng có, nhưng ->filter() thì không —
phải ->getCollection()->filter(...).
7. Câu hỏi phụ interviewer hay hỏi tiếp
$model->toArray() truyền vào view có ổn không?
Không nên. Nó cho cảm giác an toàn giả: mất Carbon, relation chưa nạp thì biến mất im lặng, không có type. Muốn ranh giới thì làm DTO thật; không thì cứ truyền model.
all() và toArray() khác nhau gì?
Rất khác, và hay bị nhầm. ->all() trả array thô — model bên trong
vẫn là object. ->toArray() chuyển đệ quy — model cũng thành array,
Carbon thành string, $hidden bị ẩn, $appends được thêm.
Muốn array của model → all(). Muốn dữ liệu phẳng để JSON/cache → toArray().
Blade component nên dùng class-based hay anonymous?
Anonymous + @props(['user']) cho component chỉ có markup. Class-based khi có logic
hoặc cần type-hint. Class-based cho bạn contract miễn phí —
public function __construct(public User $user) gần như là DTO rồi, không cần thêm tầng.
View composer thì sao?
Dùng cho dữ liệu chung mọi trang (menu, số thông báo), để controller không phải lặp lại.
Nhưng nó giấu nguồn data — mở view lên không biết $notificationCount
từ đâu ra. Dùng ít thôi, và chỉ với dữ liệu thật sự toàn cục.
collect() một array 100k phần tử có tốn không?
Bản thân việc bọc thì rẻ — chỉ gán mảng vào một property. Tốn kém nằm ở các bước chain sau,
mỗi bước copy một lần. Nếu chỉ định foreach một lượt thì đừng bọc.
each() và foreach khác gì?
each() gọi closure cho từng phần tử, và return false bên trong sẽ dừng vòng lặp.
foreach nhanh hơn một chút và dùng break được. Vòng lặp lớn thì foreach.
map() và transform()?
map() trả collection mới. transform() sửa tại chỗ.
Trên dataset lớn transform() tiết kiệm một bản copy — một trong số ít chỗ tối ưu
bộ nhớ có ý nghĩa mà không phải viết lại gì.
Khi nào dùng LazyCollection?
Khi nguồn dữ liệu lớn hơn RAM: cursor(), đọc file log dòng-theo-dòng, stream API
phân trang. Hạn chế phải nói rõ: chỉ đi được một lượt, và các method cần biết toàn bộ
(sort, count, reverse) sẽ tiêu thụ hết hoặc phá mất lợi ích.
Có nên tạo custom Collection cho model không?
// Order.php
public function newCollection(array $models = []): OrderCollection
{
return new OrderCollection($models);
}
rồi $orders->totalRevenue(). Có, khi cùng một logic lặp lại trên tập model đó.
Nhưng đừng tạo trước khi có ít nhất 2–3 chỗ dùng lại thật — một class chỉ để chứa một method
là chi phí thừa.
Repository pattern có liên quan không?
Có người dùng repository để ép ranh giới này. Nhưng repository bọc Eloquent thường chỉ là một tầng chuyển tiếp vô ích — Eloquent đã là repository rồi. Nếu mục tiêu là chặn view query DB thì DTO giải quyết trực tiếp hơn, ít code hơn.
Có nên áp ViewModel/DTO cho cả project ngay từ đầu?
Không. Đó là chi phí trả trước cho một vấn đề chưa chắc có. Bắt đầu bằng
model + preventLazyLoading. Thêm DTO tại đúng chỗ đã đau: view tái dùng nhiều nguồn,
view cần cache/serialize, hoặc view đã sinh N+1 lặp lại.
Vậy tóm lại trong Blade thì sao?
Model và Collection, không convert. Nhưng đừng biến đổi dữ liệu trong Blade —
{{ $users->filter(...)->sortBy(...)->take(5) }} chạy được, và đó chính là vấn đề:
logic trốn vào view, không test được, không thấy khi đọc controller. Biến đổi ở
controller/service; view chỉ lặp và in.