DEV Community

Cover image for Stop Treating Your Blade Files Like Trash Bins. Give Them Contracts And Structure
Raheel Shan
Raheel Shan

Posted on • Originally published at raheelshan.com

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 may be

class ProductController extends Controller
{
    public function index()
    {
        return view('products.index', [
            'title' => 'Products',
            'products' => Product::all(),
            'categories' => Category::all(),
            'brand' => Brand::all(),    
        ]);
    }
}

// Or a little cleaner approach

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

        return view('products.index', $data);
    }
}
Enter fullscreen mode Exit fullscreen mode

And here is how we do it for partials.

// pass no data, access product from current blade context inside partials/product.blade.php
@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
Enter fullscreen mode Exit fullscreen mode

And finally for components.

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

The Old Lovely Blade

Here is 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>
Enter fullscreen mode Exit fullscreen mode

This works but ask yourself:

  • Do you actually 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.

<?php

namespace App\ViewModels;


class ProductsViewModel
{
    public string $title;
    public array $products;
    public array $categories;
    public array $brand;    
}
Enter fullscreen mode Exit fullscreen mode

And 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]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Suggestion: Always pass view-model with name model for consistancy. This will help in autocomplete.  

Now blade file will look like this.

<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])
Enter fullscreen mode Exit fullscreen mode

Suggestion: For partials and components, always pass data with name model for  consistancy. This will help in 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 to start with.

2. Installing Extension for Autocomplete

This step does not require any code setup but requires installing plugin/extension in your IDE. I use VS Code for Laravel development. I found an excellent extension PHP Tools by DevSense for VS Code which I used to use in Visual Studio when working with .Net framework. This extension is available for Cursor, Zed ,Visual Studio and VS Code. But if you are using a different editor you need to find similar extension.

Once installed we need to move to next step. Take a look at the feature list to find what's available.

Note: When installing PHP Tools I had to ditch Intellisense and Intelliphence but I still get all what they provide with PHP Tools.

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>
Enter fullscreen mode Exit fullscreen mode

You need to do same in partial or component. Here is partial.

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

<span>
    {{ $model->name }}
</span>
Enter fullscreen mode Exit fullscreen mode

Finally, a component.

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

<span>
    {{ $model->name }}
</span>
Enter fullscreen mode Exit fullscreen mode

Now, in VS Code, typing `$model->` will bring up 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 are passing ViewModels into views, partials or components and defining annonations in each blade file, lets enforce blade to accept specific type of class. To do this i added two files.

  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.

Checkout the code here.

The Result: if you declare a type in a Blade file, Laravel must pass the correct class, array, or collection. Otherwise, it throws an exception.

Declaring Types in Blade makes views predictable and strict.

Main View

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

Enter fullscreen mode Exit fullscreen mode

Only `ProductsViewModel` works — anything else throws an exception.

Partial

{{-- products/partials/product.blade.php --}}
@php 
    /** @var App\Models\Product $model */ 
@endphp
<div>{{ $model->title }}</div>
Enter fullscreen mode Exit fullscreen mode

The partial now demands a `Post` instance, not random data.

Component

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

<span>
    {{ $model->name }}
</span>
Enter fullscreen mode Exit fullscreen mode

The component demands Product class instance now.

You can do the same with arrays and collection or other primitive types.

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

// Or collection
@php 
    /** @var \Illuminate\Support\Collection<App\Models\Product> $model */ 
@endphp
Enter fullscreen mode Exit fullscreen mode

Now if wrong type data is passed it will throw the error.

View [products] expects $model of type App\ViewModels\ProductsViewModel, 
but got string.
Enter fullscreen mode Exit fullscreen mode

Similiarly, partials and components will throw the error if passed wrong type of data.

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 will present you a jungle of eloquent methods along with model properties and we will have to stuggle finding properties again . To counter this issue, let's create DTO Classes to describe exactly what's needed. 

Note: Since component itself can act as DTO class and carry specific properties, it does not need additional DTO class.

Lets create a BaseDTO class to define some common methods.

<?php

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()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Now our actual SimpleProduct.php class to define only specific properties required for blade view or partial.

<?php

namespace App\DTO\Product;

use App\DTO\BaseDTO;

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

And here's our controller now.

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

        $viewModel->products = Product::select(SimpleProduct::columns())->get();
        // other code      

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

And in partial instead of defining Eloquent model as annotation define DTO class as annotation.

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

<span>
    {{ $model->name }}
</span>
Enter fullscreen mode Exit fullscreen mode

Now autcomplete will present you only DTO properties.

But wait. There's some issue. We passed Eloquent model but annotating DTO class. How's that supposed to work?

Pay attention on what we selected. Only sepecific columns right. So even though we passed eloquent, due to annotation we will be able to see only DTO properties. But we have to follow one condition.

Suggestion: DTO class properties must be same as Eloquent model columns.


Now here are some points to consider when working this approach.

Strict Enforcement

If you only select `DTO::columns()` in your query and pass raw models to Blade, IDE autocomplete will still work (thanks to the DTO annotation). But at runtime, Blade can still access any hidden column, relation, or accessor. To enforce discipline beyond autocomplete, wrap results into DTOs.  

Schema Drift Protection

If a DTO includes a property that doesn’t exist in the table, MySQL will fail loudly with “Unknown column error”. This is actually a feature — it ensures your DTOs always stay in sync with the schema instead of drifting silently.

Losing Eloquent Helpers

DTOs are deliberately "dumb" carriers. Any accessors, relations, or helpers from your Eloquent models are gone. Prepare and transform data in the controller or service layer before converting to DTOs. Views should only see the minimal contract they need.

Mapping Overhead

Mapping into DTOs isn’t as heavy as it sounds. With a `BaseDTO` base and a mapper helper, it’s just one extra line in the query pipeline. Think of it as casting: you do it once, and the rest of your view logic becomes predictable and safe.  

A Generic DTO 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Example implementation.

// For models
$dto = DTOMapper::map($product, SimpleProduct::class);

// for arrays or collection
$dtos = $products->map(fn($p) => DTOMapper::map($p, ProductDTO::class));
Enter fullscreen mode Exit fullscreen mode

Principles of Structured Blade

This approach only holds up if you enforce some ground rules:

  1. No untyped arrays in views Passing raw arrays to Blade is outlawed. Every view should receive a single, typed contract.
  2. One ViewModel per Blade file Keep the mental load low. A view gets exactly one entry point: its ViewModel.
  3. Partials and components need contracts too Even the tiniest include deserves its own ViewModel. Contracts everywhere, no freeloaders.
  4. Always annotate with @var Autocomplete is non-negotiable. Every Blade file starts with a `@var` docblock.
  5. Always pass data as model for consistancy Every view, partial or component must be passed viewmodel as $model so that all blade views has single entry point to start with.
  6. Business logic stays out of Blade Views format and render. Data shaping, conditions, and prep all belong in the ViewModel.
  7. Use DTOs to avoid Eloquent jungle Keep Eloquent in the backend. Views should only see pure, predictable DTOs.
  8. Strict access, fail loudly If a property isn’t defined in the ViewModel, the view should throw—no guessing, no silent nulls.

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, partial contracts, 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.


If you found this post helpful, consider supporting my work — it means a lot.

Support my work

Top comments (0)