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
README
Lazy, reflection-based view models for Laravel Blade templates.
laravel-lazy-view-models lets you write view models where every public
method behaves like a property: it's computed lazily, on first access,
and that computation happens at most once, no matter how many times or
from how many places the value is used.
Why
View models are a great way to keep business logic out of Blade templates, but a naive implementation calls every method up front to build an array of data, even for values the view never touches (conditionally hidden blocks, unused partials, etc.), and recomputes a value every time it's read. This package solves both problems: methods are only invoked when actually needed, and only ever once.
Installation
composer require artis-auxilium/laravel-lazy-view-models
Requires PHP 8.2+ and Laravel (Illuminate Support/Contracts). Tested against PHP 8.2, 8.3, 8.4, and 8.5 in CI.
Basic usage
Extend the abstract ViewModel class and expose data via public
properties or public methods:
use ArtisAuxilium\LaravelLazyViewModels\ViewModel; final class InvoiceViewModel extends ViewModel { public function __construct( public readonly Invoice $invoice, ) {} public function total(): string { return number_format($this->invoice->total, 2) . ' €'; } public function customerName(): string { return $this->invoice->customer->name; } }
The key idea: even though total() and customerName() are declared as
methods, everywhere else in the application (in Blade, in other view model
methods, from outside the class) they behave as properties —
$viewModel->total, not $viewModel->total(). That property-style access is
what triggers the computation, and only the first access does any work; any
later access to the same property, from anywhere, reuses the same result.
Since ViewModel implements Arrayable, pass the instance itself as the
view data — Laravel converts it to an array, and every exposed property
becomes its own top-level Blade variable:
return view('invoices.show', new InvoiceViewModel($invoice));
<h1>{{ $customerName }}</h1> <p>Total: {{ $total }}</p>
total and customerName are only computed if and when the template
actually reaches these lines.
Callables
If a property's value is itself a callable, invoke it in the template:
final class ButtonViewModel extends ViewModel { public function label(): callable { return fn () => strtoupper($this->text); } }
{{ $label() }}
Methods with parameters
A method that declares parameters can't be auto-resolved as a property — there's no way to know what arguments to call it with. Instead, it's exposed as directly invokable, and called in Blade with whatever arguments the template wants to pass:
final class InvoiceViewModel extends ViewModel { public function formattedTotal(string $currency): string { return number_format($this->invoice->total, 2) . " {$currency}"; } }
{{ $formattedTotal('€') }}
Unlike a parameterless property, this isn't memoized — the underlying method
runs on every call, since the result can differ depending on the arguments.
#[IsHtml] still applies normally here: the return value of the call is
escaped or not exactly as it would be for a parameterless property.
Arrays and iteration
Arrays and traversables work transparently with @foreach:
final class ListViewModel extends ViewModel { /** @return object[] */ public function items(): array { return [(object) ['value' => 'a'], (object) ['value' => 'b']]; } }
@foreach($items as $item) {{ $item->value }} @endforeach
Values shared between methods
Because every exposed method behaves like a property from anywhere in the class, one method can depend on another simply by reading it as a property — and the underlying computation is still only ever run once, whether it ends up being triggered by the dependent method, by Blade, or by both:
final class ExampleViewModel extends ViewModel { public function base(): string { return 'result'; // expensive computation, e.g. a DB query } public function dependent(): string { return $this->base . '_dep'; // property access, not base() } }
Whether base gets resolved first because the template reads {{ $base }},
or because dependent reads it internally, the second read of base —
wherever it comes from — reuses the already-computed result instead of
running base() again.
ViewValue: the lazy property wrapper
Every non-ignored public method is wrapped in a ViewValue<T> when the view
model is converted to an array (which Laravel does automatically when a
view model is passed to a view). ViewValue is what makes property-style
access to a method's result work:
- Resolves the underlying method once, on first access, then reuses the result for every later access.
- Implements
Stringable— casts to string via(string) $value, provided the resolved value is a scalar or itselfStringable. - Implements
ArrayAccess— usable as$value['key']if the resolved value is an array orArrayAccess. - Implements
Countable— usable withcount($value)if the resolved value is an array orCountable. - Implements
IteratorAggregate— usable in@foreachif the resolved value isTraversableor an array. - Implements
DeferringDisplayableValue— Blade's{{ }}echoing correctly handlesHtmlableresults without double-escaping. - Is invokable —
$value()calls the resolved value if it's callable.
Additional helper methods on ViewValue:
| Method | Description |
|---|---|
value(): mixed |
Force resolution and return the raw underlying value. |
empty(): bool |
empty($resolved). |
notEmpty(): bool |
Inverse of empty(). |
isset(): bool |
isset($resolved). |
Note: a wrapped method returning a
Generatorthrows anIterableException— generators can only be consumed once, which is incompatible with property-style, potentially-repeated access. Return aLazyCollection(or any otherTraversable/array) instead.
Documenting Blade views
Because a view model's variables reach Blade already resolved by name (not
as $viewModel->property), the IDE loses the link back to the class that
defines them, and can't tell you their real type. A useful convention is to
document that link at the top of the Blade file with a @php block:
@php /** @see InvoiceViewModel */ /** @var string $customerName */ /** @var ViewValue<string> $total */ @endphp
@see InvoiceViewModellinks the template back to the view model class, so your IDE can jump to it.- Public properties (like
customerNameif it were a public property on the view model) reach Blade with their actual type — document them as@var Type $name. - Values coming from methods (like
total) reach Blade asViewValue<T>instances, not as a rawT— document them as@var ViewValue<T> $name, withTbeing the method's real return type.ViewValueproxies string casting, array access, iteration, etc. to the resolved value, so it behaves likeTat runtime, but typing it precisely asViewValue<T>keeps IDE autocompletion accurate.
Attributes
#[Ignore]
Exclude a public property or method from the view model's exposed data — it won't be turned into a property and won't reach the view. Useful for public helper methods but shouldn't be exposed to Blade:
use ArtisAuxilium\LaravelLazyViewModels\Attribute\Ignore; final class InvoiceViewModel extends ViewModel { public function total(): string { return $this->formatAmount($this->invoice->total); } #[Ignore] public function formatAmount(float $amount): string { return number_format($amount, 2) . ' €'; } }
Magic methods (__construct, __toString, __invoke, ...) are always
excluded automatically — there's no need to mark them with #[Ignore].
#[IsHtml]
Mark a method's result as pre-escaped HTML. The resolved value is wrapped in
an Illuminate\Support\HtmlString, so Blade's {{ }} will output it
unescaped:
use ArtisAuxilium\LaravelLazyViewModels\Attribute\IsHtml; final class ArticleViewModel extends ViewModel { #[IsHtml] public function renderedBody(): string { return Str::markdown($this->article->body); } }
{{-- Outputs raw HTML, not escaped --}} {{ $renderedBody }}
If the resolved value isn't a scalar or Stringable, the HTML-wrapped result
falls back to an empty string.
Exceptions
All exceptions live under ArtisAuxilium\LaravelLazyViewModels\Exception and
are thrown when a property's value is used in a way that's incompatible with
what it actually resolves to:
| Exception | Thrown when |
|---|---|
StringException |
Casting to string ((string) $value / {{ $value }}) but the resolved value isn't scalar or Stringable. |
ArrayAccessException |
Using array access ($value['key']) but the resolved value isn't an array or ArrayAccess. |
CountableException |
Calling count($value) but the resolved value isn't an array or Countable. |
IterableException |
Iterating (@foreach) a non-iterable value, or when the resolved value is a Generator. |
Note: these exceptions cover misuse of a resolved value's type. For methods with parameters, calling the exposed closure with missing or invalid arguments raises a native PHP error (
ArgumentCountError,TypeError, ...), not one of the exceptions above.
Testing & quality
composer test
The suite maintains 100% line/method coverage and a 100% Mutation Score Indicator (via Infection) — the CI fails if either drops. A few notes for contributors:
- Static analysis: PHPStan at level 10 (the
strictest), via Larastan, run
against both
srcandtests. - Mutation testing: Infection is configured with
minMsi: 100andminCoveredMsi: 100— any mutant that survives, or any line not covered by a test that kills its mutants, fails the build. - PHPUnit runs in strict mode (
failOnRisky,failOnWarning,requireCoverageMetadata) — tests must explicitly declare what they cover, and any deprecation, warning, or risky test fails the suite.
License
MIT.