462 lines
12 KiB
Markdown
Executable File
462 lines
12 KiB
Markdown
Executable File
# 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:
|
||
|
||
```php
|
||
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:
|
||
|
||
```php
|
||
return view('products.index', [
|
||
'title' => 'Products',
|
||
'products' => Product::all(),
|
||
'categories' => Category::all(),
|
||
'brand' => Brand::all(),
|
||
]);
|
||
```
|
||
|
||
Or a slightly cleaner:
|
||
|
||
```php
|
||
$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:
|
||
|
||
```blade
|
||
@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:
|
||
|
||
```blade
|
||
@foreach ($products as $product)
|
||
<x-product-row :name="$product->name" :price ="$product->price" />
|
||
@endforeach
|
||
```
|
||
|
||
### The Old Lovely Blade
|
||
|
||
Here’s how it works well:
|
||
|
||
```blade
|
||
<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.
|
||
|
||
```php
|
||
namespace App\ViewModels;
|
||
|
||
class ProductsViewModel
|
||
{
|
||
public string $title;
|
||
public array $products;
|
||
public array $categories;
|
||
public array $brand;
|
||
}
|
||
```
|
||
|
||
And in your controller:
|
||
|
||
```php
|
||
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:
|
||
|
||
```blade
|
||
<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:
|
||
|
||
```blade
|
||
{{-- 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:
|
||
|
||
```blade
|
||
{{-- views/products/partials/product.blade.php --}}
|
||
@php
|
||
/** @var \App\Models\Product $model */
|
||
@endphp
|
||
|
||
<span>
|
||
{{ $model->name }}
|
||
</span>
|
||
```
|
||
|
||
And in components:
|
||
|
||
```blade
|
||
{{-- 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:
|
||
|
||
```blade
|
||
{{-- 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:
|
||
|
||
```blade
|
||
@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
|
||
|
||
```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()
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
### SimpleProduct DTO
|
||
|
||
```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;
|
||
}
|
||
```
|
||
|
||
In the controller:
|
||
|
||
```php
|
||
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:
|
||
|
||
```blade
|
||
{{-- 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:
|
||
|
||
```php
|
||
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:
|
||
|
||
```php
|
||
$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.
|
||
|
||
---
|