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
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.
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.
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;
}
}
This handle()
method is not part of the ViewModel concept. I just do it as I feel it is a suitable place to prepare data.
Finally in the blade view or partial, we now have autocomplete with exact fields we want.
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.