Stop Treating Your Blade Files Like Trash Bins. Give Them Contracts And Structure

Are you a Laravel developer? Do you love Blade? It’s simple, expressive, and fits naturally with the framework. But let’s be honest: most projects eventually turn their Blade files into dumping grounds for random variables. The Controllers pass loose data, views end up receiving unstructured data, partials rely on implicit assumptions, and the whole setup becomes fragile and error-prone.

The Result?

  • No autocomplete.
  • Runtime errors from typos.
  • Partial views with zero contracts.
  • A mess that only grows as the app scales.

It doesn’t have to be this way. Blade files deserve the same contracts and structure we give to APIs, database models, or services. With a few clear practices, you can bring discipline, contracts, and structure to Blade.

This article will show you the complete approach:

  1. The problem of passing data as arrays or variables in views — solved by introducing ViewModels as contracts.
  2. Enable Autocomplete in Blade so your IDE actually helps.
  3. Annotation to views and partials bound to their own ViewModels.
  4. Lock down data structures with DTO classes for strict typing.
  5. Establish Principles for sustainable Blade development.

Remember, we are only targeting Blade for now. Not Twig or Antlr.
Grab a coffee — this is a long read. But by the end, you’ll never look at Blade the same way again.


The Crime Scene: What’s Wrong With “Array Soup” in Blade

Here’s the standard Laravel approach we’ve all seen in controllers:

class ProductController extends Controller
{
    public function index()
    {
        $products = Product::all();
        $categories = Category::all();
        $brand = Brand::all();
        $title = 'Products';

        return view('products.index', compact('products','categories','brands','title') );
    }
}

Or maybe:

return view('products.index', [
    'title' => 'Products',
    'products' => Product::all(),
    'categories' => Category::all(),
    'brand' => Brand::all(),
]);

Or a slightly cleaner:

$data = [
    'title' => 'Products',
    'products' => Product::all(),
    'categories' => Category::all(),
    'brand' => Brand::all(),
];

return view('products.index', $data);

And here is how we do it for partials:

@foreach ($products as $product)
    @include('products.partials.product')
@endforeach

// or pass some additional data
@foreach ($products as $product)
    @include('products.partials.product', ['editable' => $editable])
@endforeach

And finally for components:

@foreach ($products as $product)
    <x-product-row :name="$product->name" :price ="$product->price" />
@endforeach

The Old Lovely Blade

Here’s how it works well:

<h1>{{ $title }}</h1>

<ul>
    @foreach ($products as $product)
        <li>{{ $product->name }} ({{ $product->price }})</li>
        {{-- or partial --}}
        @include('products.partials.product')
        {{-- or component --}}
        <x-product-row :name="$product->name" :price ="$product->price" />
    @endforeach
</ul>

But ask yourself:

  • Do you remember all the variables passed to your views?
  • Do you get autocomplete in Blade to help you catch typos?
  • What about partials and includes — are you sure the data they rely on is really there?
  • If a teammate edits the controller, how do you even know the view won’t silently break?
  • How many times have you opened a Blade file and had no clue what’s in scope?

1. Introducing ViewModels: Contracts for Your Views

Instead of throwing arrays around, define a ViewModel — a simple DTO class. It declares exactly what data is expected.

namespace App\ViewModels;

class ProductsViewModel
{
    public string $title;
    public array $products;
    public array $categories;
    public array $brand;
}

And in your controller:

class ProductController extends Controller
{
    public function index()
    {
        $viewModel = new ProductsViewModel();

        $viewModel->title = 'Products';
        $viewModel->products = Product::all();
        $viewModel->categories = Category::all();
        $viewModel->brands = Brand::all();

        return view('products.index', ['model' => $viewModel]);
    }
}

Suggestion: Always pass view‑model with name model for consistency. This will help in autocomplete.

Now the Blade file might look like:

<h1>{{ $model->title }}</h1>
<ul>
    @foreach ($model->products as $product)
        <li>{{ $product->name }} ({{ $product->price }})</li>
        {{-- or partial --}}
        @include('products.partials.product',['model' => $product])
        {{-- or component --}}
        <x-product-row :model="$product" />
    @endforeach
</ul>

@include('partials.brands', ['model' => $model->brands])
@include('partials.categories', ['model' => $model->categories])

Again, the suggestion: for partials and components, always pass data with name $model for consistency to aid autocomplete.

Benefits:

  • The contract is explicit.
  • Adding or removing data requires updating the ViewModel.
  • Each view, partial, or component has a single data access point $model.

2. Installing Extension for Autocomplete

This does not require code setup, but you’ll need to install a plugin/extension in your IDE. The author uses VS Code and recommends PHP Tools by DevSense (also available for other editors).

If you use a different editor, find a similar extension — support for Blade autocomplete is key.


3. Enabling Autocomplete in Blade

Even with ViewModels, Blade doesn’t automatically know the type of $model. The trick is to annotate it in Blade views, partials, and components:

{{-- views/products/index.blade.php --}}
@php
    /** @var \App\ViewModels\ProductsViewModel $model */
@endphp
<ul>
    @foreach ($model->products as $product)
        <li>{{ $product->name }} ({{ $product->price }})</li>
        {{-- or partial --}}
        @include('products.partials.product')
        {{-- or component --}}
        <x-product-row :name="$product->name" :price="$product->price" />
    @endforeach
</ul>

In partials or components:

{{-- views/products/partials/product.blade.php --}}
@php
    /** @var \App\Models\Product $model */
@endphp

<span>
    {{ $model->name }}
</span>

And in components:

{{-- views/components/product.blade.php --}}
@php
    /** @var \App\View\Components\Product $model */
@endphp

<span>
    {{ $model->name }}
</span>

Now, in an IDE like VS Code, typing $model-> will give you real autocomplete suggestions.

This is a game-changer:

  • No more guessing property names.
  • Mistyped variables get caught instantly.
  • New developers know exactly what’s available in the view.

4. Enforce Types at Runtime

Since now we pass ViewModels into views, partials, or components and define annotations, let’s enforce Blade to accept only specific types. The author adds:

  1. TypedViewFactory – A custom view factory that parses @var declarations at the top of Blade files and enforces them.
  2. TypedViewServiceProvider – Wires everything into Laravel automatically.

If you declare a type in a Blade file, Laravel must pass the correct class, array, or collection; otherwise, it throws an exception.

Example:

{{-- products/index.blade.php --}}
@php /** @var App\ViewModels\ProductsViewModel $model */ @endphp
<h1>{{ $model->title }}</h1>

Only ProductsViewModel works—anything else throws an exception.

Similarly for partials and components, the wrong type is no longer silently accepted; it fails loudly.

You can do the same for arrays and collections:

@php
    /** @var App\Models\Product[] $model */
@endphp

{{-- or --}}
@php
    /** @var \Illuminate\Support\Collection<App\Models\Product> $model */
@endphp

Wrong types cause errors rather than silent bugs.


5. Going Fully Typed With DTO Classes

So far, we’ve wrapped data in ViewModels. But what about the data itself? Passing raw Eloquent models to views can be risky — they carry too much baggage and autocomplete may expose a jungle of methods and properties.

Let’s create DTO classes to define exactly what your views need. (Note: components themselves can act as DTOs, so sometimes you don’t need separate DTOs.)

BaseDTO

namespace App\DTO;

use InvalidArgumentException;

abstract class BaseDTO
{
    public function __construct(array $data = [])
    {
        foreach ($data as $key => $value) {
            if (!property_exists($this, $key)) {
                throw new InvalidArgumentException(
                    "Property '{$key}' is not defined in " . static::class
                );
            }
            $this->$key = $value;
        }
    }
    public function __get($name)
    {
        throw new InvalidArgumentException(
            "Tried to access undefined property '{$name}' on " . static::class
        );
    }
    public function __set($name, $value)
    {
        throw new InvalidArgumentException(
            "Tried to set undefined property '{$name}' on " . static::class
        );
    }
    public static function columns(): array
    {
        return array_map(
            fn($prop) => $prop->getName(),
            (new \ReflectionClass(static::class))->getProperties()
        );
    }
}

SimpleProduct DTO

namespace App\DTO\Product;

use App\DTO\BaseDTO;

class SimpleProduct extends BaseDTO
{
    public string $name;
    public string $image;
    public float $price;
    public int $stock;
}

In the controller:

class ProductController extends Controller
{
    public function index()
    {
        $viewModel = new ProductsViewModel();

        $viewModel->products = Product::select(SimpleProduct::columns())->get();
        // other assignments...
        return view('products.index', ['model' => $viewModel]);
    }
}

In the partial:

{{-- views/products/partials/product.blade.php --}}
@php
    /** @var \App\DTO\Product\SimpleProduct $model */
@endphp

<span>
    {{ $model->name }}
</span>

Autocomplete now shows only DTO properties.

Caveats & Tips

  • If you only select DTO::columns() in your query and still pass raw models to Blade, IDE autocomplete works via annotations—but at runtime Blade can still access everything.
  • For stricter discipline, wrap results into DTO instances.
  • Schema Drift Protection: if a DTO includes a property not in the table, MySQL will error, keeping your DTOs in sync.
  • You lose Eloquent helpers, relations, and accessors. Do transformations before mapping to DTOs.
  • Mapping overhead is minimal: a base DTO + mapper makes it clean and efficient.

Example mapper:

class DTOMapper
{
    public static function map(object $source, string $dtoClass): object
    {
        $dtoReflection = new ReflectionClass($dtoClass);
        $properties = $dtoReflection->getProperties();

        $args = [];

        foreach ($properties as $property) {
            $name = $property->getName();
            if (isset($source->$name)) {
                $args[$name] = $source->$name;
            }
        }

        return new $dtoClass(...$args);
    }
}

Usage:

$dto = DTOMapper::map($product, SimpleProduct::class);
$dtos = $products->map(fn($p) => DTOMapper::map($p, ProductDTO::class));

Principles of Structured Blade

This approach holds only if you enforce ground rules:

  1. No untyped arrays in views — every view receives a single typed contract.
  2. One ViewModel per Blade file — keep mental load low.
  3. Partials and components need contracts too — no freeloaders.
  4. Always annotate with @var — autocomplete is non‑negotiable.
  5. Always pass data as model for consistency — every blade view has a single entry point.
  6. Business logic stays out of Blade views — data shaping and conditions belong in ViewModels.
  7. Use DTOs to avoid Eloquent jungle — views see pure, predictable DTOs.
  8. Strict access, fail loudly — undefined properties in ViewModel should throw, not silently fail.

Final Word

Blade isn’t broken. The way most developers use it is.

Treating Blade files as unstructured trash bins creates brittle, unmaintainable code. Adding ViewModels, autocomplete, contracts for partials, and DTOs gives your views the same level of professionalism as the rest of your codebase.

This isn’t overkill. It’s discipline. And discipline is what separates fragile projects from sustainable ones.

So stop passing arrays. Start passing contracts. Give your Blade files the structure they deserve.