DEV Community

Cover image for Taming Laravel Blade with Fully Typed Views, Autocomplete, and Type Safety

Taming Laravel Blade with Fully Typed Views, Autocomplete, and Type Safety

Raheel Shan on September 04, 2025

Blade is messy. We all know it. Developers pass associative arrays into views and switch between multiple partials, view and controller to remember...
Collapse
 
xwero profile image
david duymelinck • Edited

I think Blade already has what the library does with components.

// views/page/home.blade.php
<x-base :content="$content"/>

// app/View/Components/Base.php
class Base extends Component
{
   public function __construct(public Content $content) {}
}

// views/components/base.blade.php
<html>
<body>
<x-header :items="$content->header"/>
<x-main :items="$content->main"/>
<x-footer :items="$content->footer">
</body>
</html>

// app/View/Components/Header.php
class Header extends Component
{
   public function __construct(public HeaderItems $items) {}
}

// and so on
Enter fullscreen mode Exit fullscreen mode
Collapse
 
raheelshan profile image
Raheel Shan • Edited

That's totally different. Components definitely help structure Blade, but they don’t give you type safety and autocomplete inside the templates themselves. With my approach, you don’t need to wire up constructors and custom classes for every piece, you get typed variables directly in your Blade files, and your IDE actually understands them. Components are useful for reusability, mine’s about developer experience and type-safety.

Collapse
 
xwero profile image
david duymelinck

Blade components give you type safety because the types of the arguments are checked.
It is not because the data isn't checked when adding it to the view, it can't be checked in the entry template, home.blade.php. That is what my example proves.

I' m sure that checking @var lines in templates is going to be more difficult and is going to require more resources than instantiating the component class.

Components don't improve the developer experience?

Sure autocomplete is a nice to have, but if you know the class it is not that hard to find out the properties you need.
To make it more convenient, the properties can be added to the template as a comment. Then you don't even need to search for the class.

Thread Thread
 
raheelshan profile image
Raheel Shan • Edited

Components check constructor types, but they don’t give inline autocomplete or prevent typos inside Blade.

It is not because the data isn't checked when adding it to the view, it can't be checked in the entry template, home.blade.php. That is what my example proves.

Once checked you are still prone to make mistakes and blade won't prevent you. That's what's solved here.

I' m sure that checking @var lines in templates is going to be more difficult and is going to require more resources than instantiating the component class.

I totally disagree here and I suggest don't be sure unless you have experienced this. A @var line is a single comment. Instantiating components requires scaffolding classes, wiring up constructors, and passing props making things diffcult. One is a hint to the IDE, the other is a whole architecture decision.

Autocomplete is just a nice-to-have.

Sure, until you’re working in a large project with 20 models and dozens of view contexts. Then autocomplete goes from nice to have to “why my sanity is still intact.” because developers need fast feedback in their editor.

I have also gone through the comments by Taylor Otwell. He says "don’t build cathedrals of complexity". My solution is exactly opposite. I add one line of type info and IDE does the heavy lifting.

Components don't improve the developer experience?

Ofcourse they don't improve the developer experience unless you need reusable UI pieces. Otherwise they add additional boilerplate.

Thread Thread
 
xwero profile image
david duymelinck

Once checked you are still prone to make mistakes

Can you give an example, I'm not sure what you mean.

A @var line is a single comment.

it is not about the @var lines in the template but the solution to check them. How are you going to check them when the data shape can be whatever? As far a I know there is no event in Blade to add a method to all partials to make this possible.
The only way I can think of doing this is by creating a custom @include directive, and in that directive read the template, extract the @var lines, and check those against the data that is added. Having a cache would improve the performance, but it still is a lot more effort than the way components work.

until you’re working in a large project with 20 models and dozens of view contexts

Why should you have a lot of models? With one model you should be able to cover most displays, and then a few other models where the data is too different from the common model to handle it. When you need too many models it could mean the project is experiencing feature creep.

I have also gone through the comments by Taylor Otwell

It seems you missed this line; Some problems are genuinely complex, but in general, if a developer finds a "clever solution" which goes beyond the standard documented way in a framework such as Laravel or Ruby on Rails, "that would be like a smell."

Your solution is not only one line of type info. Your solution requires ViewModels and adding a library. The solution is not a cathedral, but it seems like a little church to me.
That is why I'm championing components as a build-in fix for type safety and the developer experience.

they don't improve the developer experience unless you need reusable UI pieces.

I really hope you don't create specific templates for each page of a website. Because that is what that sentence is implying when i read it.

To be clear adding the @var line isn't the main problem. that is something I use often in classes to make autocomplete work.
I'm using a Jetbrains IDE and there it isn't possible to use the comment type in Blade templates. So for your solution to work, I need to change my IDE.
That is not going to happen because the value of this convenience is too small compared to all other features the IDE provides.

Thread Thread
 
raheelshan profile image
Raheel Shan

Okay, you asked for an example. If you type $conten instead of $content in home.blade.php, neither components nor Blade will complain. That’s exactly where inline type checks and autocomplete help.

I don’t need an event in Blade or a custom @include directive. A simple service provider that swaps the default ViewFactory with a typed version is enough. It’s not fancy or hacky, it just inspects the @var lines and validates the data passed in.

class TypedViewServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->extend('view', function ($factory, $app) {
            return new TypedViewFactory(
                $app['view.engine.resolver'],
                $app['view.finder'],
                $app['events']
            );
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

From there, TypedViewFactory checks the expected variables and types (including collections and arrays) against the provided data. No runtime hacks, no events, no cathedral, just a tiny extension on top of Laravel’s internals.

The ViewModel approach isn’t about multiplying models for every small case. It’s about making the data contract for a view explicit in one place. Instead of scattering expectations across includes and constructors, a ViewModel makes it clear what the template requires. That keeps things predictable and maintainable as the project grows.

On Taylor’s quote: I read it. He said “don’t build cathedrals of complexity.” That’s why I avoided wiring up constructors and components for every template. A single @var line is not a church. And just as a side note, not all applications use components; some stick to plain Blade views.

For IDE support: JetBrains not interpreting @var in Blade is an IDE limitation, not a flaw in the approach. PHPStorm and VS Code has plugins for this, and many editors support it out of the box. If your tool doesn’t, fair enough, but that doesn’t make the feature useless to others.

Finally, on developer experience: components are great for reusable UI pieces. But forcing every page-level template into a component just to feel “safe” is boilerplate, not better developer experience, My point isn’t to throw components away, but to keep them in their lane.

Thread Thread
 
xwero profile image
david duymelinck • Edited

If you type $conten instead of $content in home.blade.php, neither components nor Blade will complain.

I agree there will be no errors in the IDE, or only a vocabulary error. But that is why there are tests suites, because at runtime it will error. Never trust your manual testing.

A simple service provider that swaps the default ViewFactory with a typed version is enough

That is a major change to the framework for me. This implies it should also work when the project is using another template engine?
The service provider is simple, but I think the consequences are not.

TypedViewFactory checks the expected variables and types (including collections and arrays) against the provided data.

How does the class know the expected variables and types? By reading the templates and extracting the @var lines, right?
And how does it know the provided data from the ViewModel for a specific template?

not all applications use components

I never mentioned that, so I don't know why that is relevant.

PHPStorm and VS Code has plugins for this, and many editors support it out of the box.

You mention developer experience a lot because of the @var lines, but if not all editors support it is that a good developer experience? That was the point I was trying to get across.

But forcing every page-level template into a component just to feel “safe” is boilerplate, not better developer experience

I'm not the one that is promoting type safety in templates, you are. I'm not forcing anyone, I'm providing a build-in alternative.

And it is perfectly normal to use a page level component. The only thing I added is an attribute to pass the data.

Thread Thread
 
raheelshan profile image
Raheel Shan

Tests are great, but they catch issues after execution, while template type safety prevents them before runtime, both serve different stages.
Swapping the ViewFactory isn’t a major rewrite; it’s exactly what Laravel’s container is meant for and only touches Blade unless extended further. The factory simply reads @var lines and compares them with the $data array, no hidden tricks.
Editor support varies, but where it exists the benefit is immediate, and teams can opt in without overhead.
Components remain valuable for reusable UI, but ViewModels give page templates a clear contract without forcing everything into component boilerplate.

Thread Thread
 
xwero profile image
david duymelinck • Edited

The factory simply reads @var lines

I don't see how this is simple? You do need to know how deep the templates go and there are multiple ways of adding sub-templates.

// page.blade.php
@extends('layout')

@section('main')
   @foreach(mainContent as section)
       <x-dynamic-component :component="$section->componentName">
   @endforeach
@endsection

// layout.blade.php
<html>
<body>
@include('header')
@yield('main')
@include('footer')
<body>
</html>
Enter fullscreen mode Exit fullscreen mode

I don't think this example is too far fetched.

You also ignored my other engines comment. Adding @var lines can be different in another template engine.
I worked with Laravel people who preferred Twig over Blade. And Statamic uses the Antlers template engine.

component boilerplate

If you think the component class doesn't fit your style, there are also anonymus components. The problem there is that you can define the variables, you can't force the type.

While I agree that type safety is important, and developer experience is a nice goal. I feel creating your own pattern made you blind for alternatives.
If you make a pros and cons list of your way of working, how many cons do you have?

Thread Thread
 
raheelshan profile image
Raheel Shan

You said the factory reading @var lines isn’t simple because of sub-templates. For includes, that’s already handled — each partial can have its own typed property in the ViewModel, no matter how deep it goes. For other mechanisms like @extends or @yield, the contract is still checked at the entry template, so the depth doesn’t blow things up.
Take a look at the below example. For layout pieces like header and footer, I treat them as global ViewModel properties so they’re available in every view automatically, not something wired up per controller. That way they’re still typed, and autocomplete works inside their own partials.

class LayoutViewModel
{
    public $header; 
    public $footer;
}

class BaseController extends Controller
{
    public function __construct()
    {
        $this->initialize();
    }

    public function initialize()
    {
        $layoutViewModel = new LayoutViewModel();
        $layout = $layoutViewModel->handle();

        view()->share('header', $layout->header);
        view()->share('footer', $layout->footer);
    }
}

class HomeController extends BaseController 
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

And in layouts/app.blade.php:

@include('partials.header', ['header' => $header])
@include('partials.footer', ['footer' => $footer])
Enter fullscreen mode Exit fullscreen mode

This way, header and footer are globally shared, still type-checked, and their partials get autocomplete like any other view.

On components: in the end, they still render Blade, so adding a @var type definition on top of the component view works the same way. I haven’t dug into applying this directly on component classes yet, but nothing stops that either.

Other engines: I skipped that deliberately. This is Blade-specific, not Twig or Antlers. Different ecosystem, different rules.

Anonymous components: they’re lightweight, yes, but they don’t and won't let you enforce type contracts. You can pass variables, but you can’t guarantee their shape without something like my approach.

Pros and cons: sure, my approach has cons — IDE dependency, focusing only on Blade, adding a small layer. But the pros (inline type checks, autocomplete, explicit contracts, less boilerplate) matter more in practice. It’s not blindness to alternatives, it’s choosing the trade-offs that improve day-to-day developer experience.

Thread Thread
 
xwero profile image
david duymelinck

header and footer are globally shared

Isn't that breaking your own premise of only having one typed data object for the view.
Your example is a fancy way to do view('page', ['model' => $model, 'header' => $model->header, 'footer' => $model->footer])

not Twig or Antlers. Different ecosystem, different rules.

Statamic is a Laravel based CMS. And as I mentioned before Twig is used in Laravel by developers. So it is not a different ecosystem.

sure, my approach has cons

My question was not about the cons themselves, but about your expectations of the way of working.
I'm glad you have a rational way of thinking about the solution.

Thread Thread
 
raheelshan profile image
Raheel Shan

Header/footer being global doesn’t break the premise — they’re still part of a typed LayoutViewModel, just exposed differently, and still benefit from strict typing and autocomplete.

As for Twig/Antlers, I deliberately scoped this to Blade since that’s the ecosystem I’m targeting.

And yes, every approach has cons, but my expectation here is enforcing discipline: one typed object per view context. It’s less about ignoring alternatives and more about keeping things consistent.

Thread Thread
 
xwero profile image
david duymelinck

The idea, as far I understand it, is that the template checks the data with the @var lines. This means if you add data to the view for partials, you should add @var lines for that data too. Otherwise you are not sure that not all data is type checked.
What is the benefit of having the header data in the ViewModel if it is never going to used in the templates as $model->header. It is just ending duplicate data to the same endpoint. If it was a request that would be the first thing you would fix.

My example only shows two levels of inclusions, but I have seen frontends with five levels of inclusions because the frontend developers where using the atomic design principle.
I don't see how your way of working can handle type safety in that situation.

Thread Thread
 
raheelshan profile image
Raheel Shan

You’re right that every partial should declare its own @var lines if it expects typed data. That’s exactly the point of the approach — each piece of the view tree has a contract.

As for “duplicate data,” I see it differently. The LayoutViewModel carries the header/footer data because layouts are part of the page contract too. The header template doesn’t consume $model->header directly; it consumes its own HeaderDTO. That’s not duplication, that’s separation of concerns.

On deep nesting: even if you go five levels down (atomic design style), it’s still just nested ViewModels/DTOs. Each level passes its child type down explicitly. Here’s a simple 3-level example.

class PageViewModel implements BaseViewModel
{
    /** @var HeaderViewModel */
    public $header;

    /** @var FooterViewModel */
    public $footer;
}

class HeaderViewModel implements BaseViewModel
{
    /** @var App\DTO\Menuitem[] */
    public array $menu;
    public string $currency;
}

class FooterViewModel implements BaseViewModel
{
    public array $pages;
    public string $socialLinks;
}

class Menuitem {
    public string $title;
    public string $link;
}

Enter fullscreen mode Exit fullscreen mode

So whether it’s two levels or five, you always know exactly what type flows into each view. Autocomplete will still follow the chain ($page->header->menu[0]->title) and surfaces the correct properties at every depth, keeping type safety intact across the whole tree.

Thread Thread
 
xwero profile image
david duymelinck

Please read my comment again about the @var lines. To make it more clear

// controller
$layout = $layoutViewModel->handle();

view()->share('header', $layout->header);
view()->share('footer', $layout->footer);

return view('page', ['model' => $layout]);
// page.blade.php
@php
     /** @var \App\Model $model */
     /** @var \App\Header $header */
    /** @var \App\Footer $footer */
@endphp
Enter fullscreen mode Exit fullscreen mode

How else are you sure the template and sub templates get the right data?
If you only add the model check it still could go wrong.

A scenario we haven't addressed yet is conditional content. How are you going to check that?

The rigidity of a single model is already showing cracks with simple examples. Which means the more complex the templates become the more you need to fight it. And that is not a good developer experience.

Thread Thread
 
raheelshan profile image
Raheel Shan

Okay, I think you’re missing my point. What I recommend is a single @var line per Blade file. So instead of this.

// page.blade.php
@php
    /** @var \App\Model $model */
    /** @var \App\Header $header */
    /** @var \App\Footer $footer */
@endphp
Enter fullscreen mode Exit fullscreen mode

Do this.

// page.blade.php
@php
    /** @var \App\Model $model */
@endphp

@include('partials.header', ['header' => $model->header])
@include('partials.footer', ['footer' => $model->footer])

// header.blade.php
@php
    /** @var \App\Header $header */
@endphp

// footer.blade.php
@php
    /** @var \App\Footer $footer */
@endphp
Enter fullscreen mode Exit fullscreen mode

That’s how templates and sub-templates get the right data.

If you only add the model check it could go wrong.

It only goes wrong if the ViewModel itself isn’t structured correctly. With a PageViewModel that already defines $header and $footer, a single @var PageViewModel $model covers everything. If the contract is wrong, the type check fails at the source, not scattered across annotations. If contract is fine but some sub-template has wrong type, it will throw the error.

For conditional content, you have two clean options.

// Option 1: nullable ViewModel
class PageViewModel {
    public ?PromoViewModel $promo = null;
}

@if($model->promo)
    @include('partials.promo', ['promo' => $model->promo])
@endif

// partials/promo.blade.php
@php
    /** @var \App\Model\Promo $model */
@endphp
Enter fullscreen mode Exit fullscreen mode
// Option 2: nullable type in annotation
@include('partials.promo', ['promo' => $model->promo])

// partials/promo.blade.php
@php
    /** @var \App\Model\Promo|null $model */
@endphp
Enter fullscreen mode Exit fullscreen mode

If your ViewModel is set up properly and you stick to the pattern, you won’t run into these issues. Problems arise only when the rules are bent — but that’s true for any design practice.

And for context: this pattern is already well-established in the .NET ecosystem. Laravel developers may not be used to it yet, but with consistent use, it can become second nature and even a standard.

Thread Thread
 
xwero profile image
david duymelinck

If you add globals, see your share method example, you suggest to still use one @var line?
In the new example you are not using the share method, which makes it a problem to check the type because the sub template doesn't know the data without needing to read the include code.

Of course it is in .net because they came up with the pattern. But from the wikipedia definition it is an whole other thing than what you call a viewmodel.
It is saying you drive a car but you are actually riding a bicycle.

I like the out of the box thinking, but this is never going to become a standard.

Thread Thread
 
raheelshan profile image
Raheel Shan

The new example was just the one you posted earlier so that you can see how it fits the convention. Either way any blade view or partial should have one @var line at top.

It will become standard or not, let's leave it up to developers to choose. I am just happy to share my experience and ideas.

Collapse
 
avinashzala profile image
Avinash Zala

Very insightful! Type safety in Blade has always been tricky - this approach seems like a game changer for reducing runtime errors.

Collapse
 
raheelshan profile image
Raheel Shan • Edited

Thanks, Avinash! Type safety in Blade has always been overlooked, and I think that’s why so many runtime surprises happen. My goal was to keep Blade simple while still giving us the safety net we’re used to in modern dev. Happy to see it resonates. Here's the complete picture of how devs can achieve this.

dev.to/raheelshan/stop-treating-yo...

Collapse
 
xwero profile image
david duymelinck

As a side note: maybe this gives you food for thought developers.slashdot.org/story/25/0...

Collapse
 
raheelshan profile image
Raheel Shan • Edited

As for the components, Lets cover this part. Take a look at the example below.

<?php

namespace App\DTO;

class HeaderDTO
{
    public function __construct(
        public string $currency,
        public string $language,
        public array $menu,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

The component class

<?php

namespace App\View\Components;

use Illuminate\View\Component;
use App\DTO\HeaderDTO;

class Header extends Component
{
    public function __construct(
        private HeaderDTO $header
    ) {}

    public function render()
    {
        return view('components.header', [
            'header' => $this->header,
            // or may be 
            // 'model' => $this->header,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The view for component

// components/header.blade.php
@php
    /** @var \App\DTO\HeaderDTO $header */
@endphp

<div class="header">
    <div class="currency">{{ $header->currency }}</div>
    <div class="language">{{ $header->language }}</div>

    <ul>
        @foreach($header->menu as $item)
            <li>{{ $item }}</li>
        @endforeach
    </ul>
</div>
Enter fullscreen mode Exit fullscreen mode

And fnally to use

<x-header :header="$headerDto" />
Enter fullscreen mode Exit fullscreen mode

Where $headerDto is an instance of HeaderDTO passed from a controller, layout, or globally shared LayoutViewModel.

So with Laravel components, you can pass a typed DTO instead of loose arrays. This way you get strict typing, IDE autocomplete, and consistent data contracts.

In a component, I suggest always passing only one property named model, and that property should be a DTO. This keeps all your components consistent and predictable.

<x-header :model="$headerDto" />
Enter fullscreen mode Exit fullscreen mode

Let me target custom component, twig and antler next. Please wait for me to do a little more research.