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.
- The problem of passing data as arrays or variables in views — solved by introducing ViewModels as contracts.
- Enable Autocomplete in Blade so your IDE actually helps.
- Annotation to views and partials bound to their own ViewModels.
- Lock down data structures with DTO classes for strict typing.
- 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);
}
}
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
And finally for components.
@foreach ($products as $product)
<x-product-row :name="$product->name" :price ="$product->price" />
@endforeach
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>
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;
}
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]);
}
}
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])
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>
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>
Finally, a component.
// views/components/product.blade.php
@php
/** @var \App\View\Components\Product $model */
@endphp
<span>
{{ $model->name }}
</span>
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.
- TypedViewFactory – A custom view factory that parses
`@var`
declarations at the top of Blade files and enforces them. - 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>
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>
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>
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
Now if wrong type data is passed it will throw the error.
View [products] expects $model of type App\ViewModels\ProductsViewModel,
but got string.
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 asDTO 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()
);
}
}
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;
}
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]);
}
}
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>
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);
}
}
Example implementation.
// For models
$dto = DTOMapper::map($product, SimpleProduct::class);
// for arrays or collection
$dtos = $products->map(fn($p) => DTOMapper::map($p, ProductDTO::class));
Principles of Structured Blade
This approach only holds up if you enforce some ground rules:
- No untyped arrays in views Passing raw arrays to Blade is outlawed. Every view should receive a single, typed contract.
- One ViewModel per Blade file Keep the mental load low. A view gets exactly one entry point: its ViewModel.
- Partials and components need contracts too Even the tiniest include deserves its own ViewModel. Contracts everywhere, no freeloaders.
- Always annotate with
@var
Autocomplete is non-negotiable. Every Blade file starts with a`@var`
docblock. - 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. - Business logic stays out of Blade Views format and render. Data shaping, conditions, and prep all belong in the ViewModel.
- Use DTOs to avoid Eloquent jungle Keep Eloquent in the backend. Views should only see pure, predictable DTOs.
- 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.
Top comments (0)