- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a Blade template that hasn't been touched in two years. It renders an invoice page. Halfway down the file you see {{ $invoice->customer->billing_address->postal_code }}. The next line conditionally hides a field based on $invoice->status === 'draft'. There's a @foreach ($invoice->items as $item) that calls $item->product->category->parent->name.
The view layer just dictated the shape of your database. A column rename in invoices becomes a template edit. Renaming a status enum breaks the page. Splitting billing_address into its own service makes the postal code disappear from the PDF that finance prints every month.
Eloquent in Blade is the most ergonomic mistake in the Laravel ecosystem. The framework rewards it — compact('invoice') in the controller, magic accessors in the view, and the page renders. The cost lands two years later, when someone tries to refactor the storage layer and discovers the schema is load-bearing for the marketing site.
The thing the template is actually asking for
Look at a Blade template as a function. Its input is data. Its output is HTML. The data it needs has a specific shape: strings, numbers, booleans, lists of small structured things. It does not need ActiveRecord. It does not need lazy-loaded relations. It does not need a method that hits the database when you forget to eager-load.
When you pass an Eloquent model into the template, you give the view access to the entire ORM API and the full schema. The template starts using both, because it can. Then the template becomes the contract.
Here's the typical setup. An invoice with line items and a customer:
// app/Http/Controllers/InvoiceController.php
final class InvoiceController
{
public function show(int $id): View
{
$invoice = Invoice::with(['items.product', 'customer'])
->findOrFail($id);
return view('invoices.show', compact('invoice'));
}
}
{{-- resources/views/invoices/show.blade.php --}}
<h1>Invoice #{{ $invoice->number }}</h1>
<p>Customer: {{ $invoice->customer->name }}</p>
<p>Status: {{ ucfirst($invoice->status) }}</p>
@if ($invoice->status === 'paid')
<span class="badge badge-green">Paid</span>
@elseif ($invoice->status === 'draft')
<span class="badge badge-grey">Draft</span>
@endif
<table>
@foreach ($invoice->items as $item)
<tr>
<td>{{ $item->product->name }}</td>
<td>{{ $item->quantity }}</td>
<td>${{ number_format($item->price_cents / 100, 2) }}</td>
</tr>
@endforeach
</table>
<p>Total: ${{ number_format($invoice->total_cents / 100, 2) }}</p>
Read what the template knows:
- The status column is a string with values like
paid,draft,sent. - Prices are stored as integer cents in a column called
price_cents. - A line item belongs to a product with a name column.
- An invoice has a
numberand a relatedcustomerwith aname. - Currency formatting is done with
/100andnumber_format.
That is five schema facts and one piece of presentation logic, all encoded in a .blade.php file. The day finance asks you to support EUR, you change every template that ever rendered money. The day product asks you to add a partially_paid status, you grep for 'paid' in the views folder. The day someone splits Invoice and Customer into separate services, the page stops working in a way the test suite never warned you about.
The fix: a view model the template owns
The template doesn't need an Invoice. It needs an InvoiceView — an object whose fields match what the page renders, named in the language of the page, with formatting and conditionals already computed.
// app/Http/ViewModels/InvoiceView.php
declare(strict_types=1);
namespace App\Http\ViewModels;
final readonly class InvoiceView
{
/** @param list<InvoiceLineView> $lines */
public function __construct(
public string $number,
public string $customerName,
public string $statusLabel,
public string $statusBadgeClass,
public bool $showPaidBadge,
public bool $showDraftBadge,
public array $lines,
public string $totalFormatted,
) {
}
}
// app/Http/ViewModels/InvoiceLineView.php
declare(strict_types=1);
namespace App\Http\ViewModels;
final readonly class InvoiceLineView
{
public function __construct(
public string $productName,
public int $quantity,
public string $priceFormatted,
) {
}
}
These are dumb data carriers. No database, no relations, no business logic. They name things the way the page names them: statusLabel, priceFormatted, showPaidBadge. The template stops asking what column is this? and starts asking what should I render?
The translation happens once, in a presenter that takes the Eloquent model and returns the view model:
// app/Http/Presenters/InvoicePresenter.php
declare(strict_types=1);
namespace App\Http\Presenters;
use App\Http\ViewModels\InvoiceLineView;
use App\Http\ViewModels\InvoiceView;
use App\Models\Invoice;
final class InvoicePresenter
{
public function present(Invoice $invoice): InvoiceView
{
$lines = $invoice->items
->map(fn ($item) => new InvoiceLineView(
productName: $item->product->name,
quantity: $item->quantity,
priceFormatted: $this->money($item->price_cents),
))
->all();
return new InvoiceView(
number: $invoice->number,
customerName: $invoice->customer->name,
statusLabel: ucfirst($invoice->status),
statusBadgeClass: $this->badgeClass($invoice->status),
showPaidBadge: $invoice->status === 'paid',
showDraftBadge: $invoice->status === 'draft',
lines: $lines,
totalFormatted: $this->money($invoice->total_cents),
);
}
private function money(int $cents): string
{
return '$' . number_format($cents / 100, 2);
}
private function badgeClass(string $status): string
{
return match ($status) {
'paid' => 'badge badge-green',
'draft' => 'badge badge-grey',
default => 'badge badge-blue',
};
}
}
The controller becomes a two-liner:
final class InvoiceController
{
public function __construct(
private readonly InvoicePresenter $presenter,
) {
}
public function show(int $id): View
{
$invoice = Invoice::with(['items.product', 'customer'])
->findOrFail($id);
return view('invoices.show', [
'invoice' => $this->presenter->present($invoice),
]);
}
}
And the template stops knowing anything about the database:
<h1>Invoice #{{ $invoice->number }}</h1>
<p>Customer: {{ $invoice->customerName }}</p>
<p>Status: {{ $invoice->statusLabel }}</p>
@if ($invoice->showPaidBadge)
<span class="{{ $invoice->statusBadgeClass }}">Paid</span>
@elseif ($invoice->showDraftBadge)
<span class="{{ $invoice->statusBadgeClass }}">Draft</span>
@endif
<table>
@foreach ($invoice->lines as $line)
<tr>
<td>{{ $line->productName }}</td>
<td>{{ $line->quantity }}</td>
<td>{{ $line->priceFormatted }}</td>
</tr>
@endforeach
</table>
<p>Total: {{ $invoice->totalFormatted }}</p>
Run a diff against the original. The Blade file lost _cents, ->customer->, number_format, /100, the string 'paid', and every other piece of database vocabulary. What's left is the page.
What you actually get back
This isn't an aesthetic argument. The view model pays for itself in four places.
Refactors stop breaking pages. Rename price_cents to unit_price_cents and run your migration. The Blade template doesn't care, because it never knew the column existed. The presenter is the one file that breaks, and it tells you where to update.
Templates become testable. A Blade template that consumes Eloquent needs the database to render. Or a factory. Or a mock chain of relations. A template that consumes a readonly DTO can be exercised with new InvoiceView(number: 'INV-001', customerName: 'Acme', ...). Snapshot tests, golden-file tests, accessibility tests, all become trivial.
public function test_renders_paid_invoice(): void
{
$view = new InvoiceView(
number: 'INV-001',
customerName: 'Acme',
statusLabel: 'Paid',
statusBadgeClass: 'badge badge-green',
showPaidBadge: true,
showDraftBadge: false,
lines: [
new InvoiceLineView(
productName: 'Widget',
quantity: 2,
priceFormatted: '$15.00',
),
],
totalFormatted: '$30.00',
);
$html = view('invoices.show', ['invoice' => $view])->render();
$this->assertStringContainsString('INV-001', $html);
$this->assertStringContainsString('badge-green', $html);
$this->assertStringContainsString('$30.00', $html);
}
No database. No migrations. No factories. The test runs in milliseconds and tells you whether the page renders what it should.
The same template works for a different data source. A few months in, someone asks for an invoice preview that pulls a draft from Redis instead of MySQL. With Eloquent-in-Blade, you write a fake model class or a hydration trick. With a view model, you write a second presenter that builds the same InvoiceView from a different source, and the template doesn't change.
The presenter is where the unit tests go. Money formatting, badge logic, status labels: all the small decisions that used to live in the template now live in PHP you can call directly. PHPUnit can verify that EUR amounts render with a leading €, that the partially_paid badge gets a yellow class, that an empty line list still produces a valid view model. The template is left to do the one thing templates are good at: structure markup.
When not to bother
Not every page deserves a view model. A throwaway admin CRUD that shows users in a table will be fine with User::all() and a @foreach. The cost of a view model only pays off when:
- The page survives long enough to see a refactor.
- More than one route renders the same shape (list, show, PDF export, email).
- The template starts growing logic: formatting, conditionals, derived fields.
- The model behind it is going to split, move to a service, or get replaced.
For everything else, ship the Eloquent and move on. This isn't dogma. The moment a template starts encoding schema facts, it just signed up to break every time the schema moves. A view model is the cheapest way to draw the line between the database and the page, and once it's drawn, the rest of the refactors get cheaper too.
If this was useful
The view model is one boundary in a stack of them. The same logic shows up everywhere: pass the smallest thing the next layer needs, instead of the largest thing you happen to have. That's what turns a Laravel app from a framework-shaped pile into something that survives migrations, framework upgrades, and the next team that inherits it. Decoupled PHP walks the full stack of those boundaries with PHP 8.3 examples you can run.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)