artis-auxilium / laravel-lazy-view-models
Lazy load data in blade view
Package info
github.com/artis-auxilium/laravel-lazy-view-models
pkg:composer/artis-auxilium/laravel-lazy-view-models
Requires
- php: ^8.2
- illuminate/support: ^8.0|^9.0|^10.0|^11.0|^12.0|^13
Requires (Dev)
- infection/infection: ^0.32.0
- larastan/larastan: ^3.8
- laravel/framework: ^13|^12|^11
- orchestra/testbench: ^v10.9
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5.55
- slevomat/coding-standard: ^8.31
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
View models for Blade where every value is computed on first read, and at most once, no matter how many times, or from how many places, it's used.
The problem
A view model built as a plain array has to guard expensive values by hand, and that guard tends to end up duplicated wherever the value matters:
final class InvoiceController extends Controller { public function show(Invoice $invoice): View { $customerLifetimeValue = null; $loyaltyDiscount = null; // guard the aggregate query by hand, it's only worth running // for customers who'll actually see the VIP block if ($invoice->customer->isVip) { $customerLifetimeValue = $invoice->customer->orders()->sum('total'); $rate = $customerLifetimeValue > 5000 ? 0.10 : 0.05; $loyaltyDiscount = number_format($invoice->total * $rate, 2) . ' €'; } $lineItemsBreakdown = $invoice->lineItems() ->selectRaw('category, sum(amount) as total') ->groupBy('category') ->get(); $total = number_format($invoice->total, 2) . ' €'; $customerName = $invoice->customer->name; return view('invoices.show', compact( 'customerLifetimeValue', 'loyaltyDiscount', 'lineItemsBreakdown', 'total', 'customerName', )); } }
And that same set of variables feeds a template like this:
<h1>{{ $customerName }}</h1> <p>Total: {{ $total }}</p> @if($invoice->customer->isVip) <p>Lifetime value: {{ $customerLifetimeValue }} €</p> <p>Loyalty discount: {{ $loyaltyDiscount }}</p> @endif @foreach($lineItemsBreakdown as $line) <tr><td>{{ $line->category }}</td><td>{{ $line->total }}</td></tr> @endforeach
$invoice->customer->isVip is now checked twice, independently: once in
the controller to decide whether the query is worth running, once in Blade
to decide whether to render the block. Nothing ties the two together
if the VIP rule changes and only one of the two checks gets updated, you either
run the query for nobody to see, or the block tries to render values that
were never computed. lineItemsBreakdown, meanwhile, still runs
unconditionally, because there's no cheap local condition to guard it on.
Both problems get easier to miss as a controller action grows past 4-5
values with their own guards.
The fix
What this controller action should look like is this:
public function show(Invoice $invoice): View { return view('invoices.show', new InvoiceViewModel($invoice)); }
The fix that would help most here isn't laziness for its own sake, it's
moving the guards out of the controller entirely. As long as the
controller is the one deciding whether customerLifetimeValue is worth
computing, it has to know the VIP rule, which means Blade has to know it
too, and the two copies drift. This package's actual answer is that the
controller shouldn't contain that decision at all: give it the full
InvoiceViewModel and let whichever code reads a property (Blade, another
method, anything) be the one and only place the condition is checked. See
architecture.md for how it does that internally
(reflection, the ViewValue proxy, and the trade-offs involved).
Installation
composer require artis-auxilium/laravel-lazy-view-models
Requires PHP 8.2+ and Laravel. Tested against PHP 8.2, 8.3, 8.4, and 8.5.
Usage
Extend ViewModel and expose data via public methods (or properties):
use ArtisAuxilium\LaravelLazyViewModels\ViewModel; final class InvoiceViewModel extends ViewModel { public function __construct( public readonly Invoice $invoice, ) {} public function customerLifetimeValue(): float { // no if(isVip) guard needed here, this only runs at all if // something actually reads the property, e.g. Blade's @if return $this->invoice->customer->orders()->sum('total'); } public function loyaltyDiscount(): string { // depends on customerLifetimeValue as a *property* read, not a // method call, the query behind it still only runs once, // whether it's triggered from here or from Blade below $rate = $this->customerLifetimeValue > 5000 ? 0.10 : 0.05; return number_format($this->invoice->total * $rate, 2) . ' €'; } public function lineItemsBreakdown(): Collection { return $this->invoice->lineItems() ->selectRaw('category, sum(amount) as total') ->groupBy('category') ->get(); } public function total(): string { return number_format($this->invoice->total, 2) . ' €'; } public function customerName(): string { return $this->invoice->customer->name; } }
And the controller is now exactly the one-liner teased above
no guard, no local variables to keep straight, no compact().
The Blade template from the problem section above doesn't change at
all. $customerLifetimeValue, $loyaltyDiscount, $lineItemsBreakdown,
$total, and $customerName are read exactly the same way they were with
the plain array. What's gone is the manual guard: the view model has no
if ($invoice->customer->isVip) anywhere in it. The VIP condition now
exists in exactly one place (Blade's @if) instead of two copies
that had to be kept in sync by hand. For a non-VIP customer,
customerLifetimeValue simply never runs, because nothing ever reads the
property. For a VIP customer, it runs once, even though it's read
twice directly in Blade, and again inside loyaltyDiscount().
Other things you can do
- Methods with parameters are exposed as callable, not memoized (since
the result depends on the arguments):
{{ $formattedTotal('€') }}. - Arrays and
Traversablevalues work transparently in@foreach. #[Ignore]excludes a public method/property from being exposed to the view, useful for internal helpers.#[IsHtml]marks a method's result as pre-escaped HTML, so Blade's{{ }}doesn't double-escape it.- Documenting the link back to the view model class in Blade (
@see,@var ViewValue<T>) and the full exception list are covered in architecture.md.
Testing & quality
composer test
100% line/method coverage and 100% Mutation Score Indicator (Infection), PHPStan level 10 via Larastan, PHPUnit in strict mode. CI fails if any of these regress. Tested against PHP 8.2, 8.3, 8.4, and 8.5 in CI.
License
MIT.