In the last three articles of this series, we explored how to bring discipline and type safety into Laravel Blade views:

1. Structuring Blade data with ViewModels.

2. Enabling Autocomplete in Blade partials.

3. Making Blade fully typed with View Validation and Type-Safety.

Now, let’s take the next logical step: using DTOs (Data Transfer Objects) to strictly control which model properties reach the view, while still enjoying autocomplete in your IDE and runtime validation when things go wrong.

The Core Problem

Eloquent models may have 20, 30, or even 50+ columns, plus a forest of relationships and helper methods. For a given Blade view, you rarely need all that. Maybe you just want a few select fields — but by default you get everything. That means autocomplete in your IDE tempts you with fields you never intended to load, and queries return more than you asked for. Let's assume Product example. For a given blade view, you only need a few columns:

  • Product name
  • Product image
  • Product price
  • Product stock

If you pass the raw Eloquent model into your view, you get autocomplete for everything (even what you don’t need). Worse, you can accidentally rely on a field you never intended to load from the database.

DTOs as the Solution

DTOs (Data Transfer Objects) act as lean contracts: a stripped-down data class with only the properties your Blade actually needs. Nothing more. This way, your IDE only suggests relevant fields, and the database query fetches only what’s required. 

                                namespace App\DTO\Product;
use App\DTO\BaseDTO;

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

The Generic BaseDTO Class

                                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()
        );
    }
}
                            

The BaseDTO class will have single method columns() to convert properties to array. This will be useful. How? we will see later.

ViewModels with DTOs

Since we are using ViewModels we can define our ViewModel for Products page like this.

                                namespace App\ViewModels;

use App\DTO\Product\SimpleProduct;
use App\DTO\Category\SimpleCategory;
use App\DTO\Brand\SimpleBrand;

class ProductsViewModel {
    /** @var SimpleProduct[] */
    public array $products;
    /** @var SimpleCategory[] */
    public array $categories;
    /** @var SimpleBrand[] */
    public array $brands;
}
                            

And now its time for controller. See how we are going to use our ViewModel in controller.

Example with Repository Pattern

                                <?php

namespace App\Http\Controllers;

use App\Repositories\ProductRepository;
use App\Repositories\CategoryRepository;
use App\Repositories\BrandRepository;
use App\ViewModels\ProductsViewModel;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    private ProductRepository $products;
    private CategoryRepository $categories;
    private BrandRepository $brands;

    public function __construct(
        ProductRepository $products,
        CategoryRepository $categories,
        BrandRepository $brands
    ) {
        $this->products = $products;
        $this->categories = $categories;
        $this->brands = $brands;
    }

    public function index(Request $request)
    {
        $viewModel = new ProductsViewModel();
        $viewModel->products = $this->products->all();
        $viewModel->categories = $this->categories->all();
        $viewModel->brands = $this->brands->all();

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

Example with AQC Design Pattern

Read AQC Design pattern here.

                                <?php

namespace App\Http\Controllers;

use App\Helpers\ResponseHelper;
use App\Queries\Products\GetProducts;
use App\Queries\Categories\GetCategories;
use App\Queries\Brands\GetBrands;
use App\ViewModels\ProductsViewModel;
use App\DTO\Product\SimpleProduct;
use App\DTO\Category\SimpleCategory;
use App\DTO\Brand\SimpleBrand;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    public function index(Request $request)
    {
        $params = $request->all();

        $viewModel = new ProductsViewModel();

        $viewModel->products   = GetProducts::handle($params , SimpleProduct::columns());
        $viewModel->categories = GetCategories::handle($params , SimpleCategory::columns());
        $viewModel->brands     = GetBrands::handle($params , SimpleBrand::columns());

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

But I prefer to move the logic out of controller to make controllers lean.

                                <?php

namespace App\Http\Controllers;

use App\ViewModels\ProductsViewModel;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    public function index(Request $request)
    {
        $params = $request->all();
        $viewModel = new ProductsViewModel();
        $response = $viewModel->handle($params);
        return view('products.index', ['model' => $response]);
    }
}
                            

And ViewModel

                                namespace App\ViewModels;

use App\DTO\Product\SimpleProduct;
use App\DTO\Category\SimpleCategory;
use App\DTO\Brand\SimpleBrand;
use App\Queries\Products\GetProducts;
use App\Queries\Categories\GetCategories;
use App\Queries\Brands\GetBrands;

class ProductsViewModel {
    /** @var SimpleProduct[] */
    public array $products;
    /** @var SimpleCategory[] */
    public array $categories;
    /** @var SimpleBrand[] */
    public array $brands;

    public function handle($params)
    {
        $this->products   = GetProducts::handle($params , SimpleProduct::columns());
        $this->categories = GetCategories::handle($params , SimpleCategory::columns());
        $this->brands     = GetBrands::handle($params , SimpleBrand::columns());
        return $this;
    }
}
                            

Finally in the blade view or partial, we now have autocomplete with exact fields we want.

Autocomplete of DTO class properties
Autocomplete of DTO class properties

Autocomplete vs 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 …”. 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.

Example: 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);
    }
}

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

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

                            

Why this helps

You don’t hand-roll factories. One generic DTOMapper does it.

You still define DTO classes for each view context (since that’s what gives you autocomplete and discipline), but creating them isn’t painful anymore because hydration is automatic.

Recommendations & Trade-offs

While this pattern enforces discipline and autocomplete, it comes with trade-offs you should be aware of:

  • Always use $model as the variable name in Blade views and partials for consistency.
                                // controller example

class ProductController extends Controller
{
    public function index(Request $request)
    {
        $viewModel = new ProductsViewModel();
        $data = $viewModel->handle($request->all());
        return view('products',['model' => $data]);
    }
}

// partial example
@include('partials.product', ['model' => $product])

                            
  • At the top of each Blade, declare the DTO with @var $model so that autocomplete and type safety are guaranteed.
                                {{-- view example --}}

{{-- products.blade.php --}}
@php
    /** @var \App\ViewModels\ProductsViewModel $model */
@endphp

{{-- partial example --}}
@php
    /** @var \App\DTO\Product\SimpleProduct $model */
@endphp

                            
  • Accept the disconnection: once data is mapped into a DTO, you lose access to Eloquent model methods, accessors, and relationships. Any calculations or transformations should be done beforehand (in your controller or service).
  • DTO properties must match the Eloquent model’s columns when they originate from that model. Otherwise, queries will break or properties will be missing.
  • DTOs can also be hydrated from arbitrary sources (aggregates, APIs, services). They represent the final shape of data expected by the view, not necessarily the raw model.

Final Thoughts: What We Achieved

  • Started with ViewModels → to bring structure and clarity to what data Blade views receive.
  • Added autocomplete via DTOs → so IDEs guide us with real property hints.
  • Bound ViewModels to views and partials → making dependencies explicit and consistent across the app.
  • Restricted data to DTO-defined columns only → ensuring queries fetch only what the view actually needs. Introduced strict enforcement → if a Blade template accesses a property not defined in its DTO, an exception is thrown.

Result: Blade is now a strict, predictable consumer of data — self-documenting, safer, and faster to work with.


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

 Raheel Shan | Support my work
Raheel Shan | Support my work
Support my work
raheelshan.com
Comments


Comment created and will be displayed once approved.