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 ModelCollection — đó 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 loadingRelation chưa nạp thì Eloquent tự bắn query lúc bạn đọc nó — kể cả khi đang render HTML
eager loadingNạp trước relation bằng with(), gộp thành 1 query thay vì N query
N+1 query1 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
ViewModelDTO dựng riêng cho đúng một view — chứa đúng những field view cần
hydrate / rehydrateDự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 messageLời gọi rút gọn trên Collection: $users->sum->votes thay cho $users->sum(fn($u) => $u->votes)
generatorHàm PHP dùng yield — sinh từng phần tử một, không giữ cả tập trong RAM
trust boundaryRanh 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:cache chỉ 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ớpLà gìKhi nào gặp
Support\CollectionBọc array, khoảng 100 methodcollect([...])
Eloquent\CollectionKế thừa lớp trên, thêm load(), loadMissing(), modelKeys(), find($id), fresh()Model::get(), Model::all()
LazyCollectionDùng generator — không giữ toàn bộ trong RAMModel::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ếu map() 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ấtController 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 componentpublic 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 modelKhô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 DBN+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àngNhì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 đượcCùng một partial render được data từ Eloquent, từ API bên thứ ba, từ cache.
Serialize an toànCache, 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ảmModel 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

CollectionArray thuần
Đọc code->filter()->groupBy()->map() đọc theo đúng thứ tự xảy raPhả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ăngCó object wrapper, tạo instance mới mỗi bướcNhanh và nhẹ hơn, chạy ở tầng C
SerializeKèm tên class, kèm object graphGọn, ổn định qua các lần deploy
Bất ngờ__get ném exception, có chuyện tụt hạng classKhô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.

3.2. Array / DTO — chỗ dễ hỏng

toArray() là cái bẫy lớn nhất — nó trông có vẻ là giải pháp nhanh:

  • Carbon biế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
  • $hidden bị á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


4. Trade-off

4.1. Bảng chọn

Ngữ cảnhChọnVì sao
CRUD admin, form, list nội bộModel + CollectionRanh giới không đáng tiền. with() + preventLazyLoading là đủ.
Blade component tái dùng (<x-user-card>)Model qua class componentConstructor type-hint chính là contract rồi.
View render dữ liệu từ nhiều nguồnDTORanh giới thật: 2 nguồn phải hội tụ về 1 hình dạng.
Trang public, traffic cao, cần cachearray / DTOPhải serialize được. Cache model là mùi code xấu.
Livewire public propertyarray (xem case 4)Model → re-query mỗi request. Collection → payload phình.
Queue job payloadarray hoặc chỉ idModel bị serialize rồi rehydrate lúc chạy — có thể đã bị xoá hoặc đã đổi.
Email / PDF render trong queueDTONộ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 / generatorObject wrapper và bản copy trung gian bắt đầu có giá.
Stream file lớn, exportLazyCollectionBộ nhớ không đổi theo số dòng.
API responseAPI ResourceKhông phải cái nào ở trên — dùng JsonResource.
Config, hằng số, key-value nhỏarrayBọ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.

  1. DB::listen(fn ($q) => Log::debug($q->sql)) trên staging → thấy 2×N query lặp.
  2. Sửa nhanh: Order::with('customer.company')->paginate(20).
  3. Sửa gốc: bật preventLazyLoading ở local/staging để lần sau vỡ ngay lúc dev.
  4. 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
  1. Xác nhận: log memory_get_peak_usage(true) → thấy chạm memory_limit.
  2. 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]));
  3. Bỏ with('orders') nếu CSV không cần — eager load nhân bộ nhớ lên nhiều lần.
  4. 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 Productunserialize 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.

  1. Chữa cháy: php artisan cache:clear (hoặc xoá đúng key).
  2. 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
    );
  3. 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 propTrong snapshotMỗi requestRủi ro
ModelChỉ class + khoá chínhRe-query DB để rehydrateModel bị xoá → ModelNotFoundException
Eloquent\CollectionDanh sách khoá chínhRe-query DB toàn bộN model = 1 query nặng mỗi lần tương tác
Support\CollectionToàn bộ dữ liệuKhông queryPayload phình
arrayToàn bộ dữ liệuKhông queryPayload 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ý:

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()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()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()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.