Search by

artis-auxilium / laravel-lazy-view-models

Dev2a

Lazy load data in blade view

Package info

github.com/artis-auxilium/laravel-lazy-view-models

pkg:composer/artis-auxilium/laravel-lazy-view-models

Statistics

Installs: 53

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-01 17:49 UTC

This package is auto-updated.

Last update: 2026-09-05 16:46:01 UTC


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 Traversable values 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.