artis-auxilium/laravel-lazy-view-models

Lazy load data in blade view

Maintainers

Package info

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

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

Transparency log

Statistics

Installs: 0

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-08-01 18:18:21 UTC


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 itself Stringable.
  • Implements ArrayAccess — usable as $value['key'] if the resolved value is an array or ArrayAccess.
  • Implements Countable — usable with count($value) if the resolved value is an array or Countable.
  • Implements IteratorAggregate — usable in @foreach if the resolved value is Traversable or an array.
  • Implements DeferringDisplayableValue — Blade's {{ }} echoing correctly handles Htmlable results 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 Generator throws an IterableException — generators can only be consumed once, which is incompatible with property-style, potentially-repeated access. Return a LazyCollection (or any other Traversable/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 InvoiceViewModel links the template back to the view model class, so your IDE can jump to it.
  • Public properties (like customerName if 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 as ViewValue<T> instances, not as a raw T — document them as @var ViewValue<T> $name, with T being the method's real return type. ViewValue proxies string casting, array access, iteration, etc. to the resolved value, so it behaves like T at runtime, but typing it precisely as ViewValue<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 src and tests.
  • Mutation testing: Infection is configured with minMsi: 100 and minCoveredMsi: 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.