This article is not an advertisement. It is a technical tutorial discussing software development patterns in Laravel.
Here’s the rewritten article:
Elevating Laravel Blade: Achieving Strict Type Safety and Autocomplete with DTOs
In previous discussions, we explored methods to enhance the structure and type safety of Laravel Blade views. This journey included organizing Blade data with ViewModels, enabling autocomplete for Blade partials, and implementing full type-safety through view validation.
Now, we advance this approach by integrating Data Transfer Objects (DTOs). This crucial step allows developers to precisely control which model properties are exposed to the view, ensuring not only IDE autocomplete but also robust runtime validation.
The Challenge of Over-Exposed Eloquent Models
Eloquent models, by design, often contain a wealth of attributes—tens of columns, numerous relationships, and helper methods. However, a typical Blade view rarely requires access to this entire dataset. Passing a raw Eloquent model to a view means:
- Excessive Data: Queries might fetch more data than necessary, potentially impacting performance.
- IDE Confusion: Autocomplete suggestions in your IDE become cluttered with irrelevant fields, making it harder to find what’s needed.
- Accidental Dependencies: Developers can inadvertently rely on model attributes or relationships that were not explicitly intended for the view, leading to brittle code.
Consider a Product
model. A view might only need its name
, image
, price
, and stock
. Without DTOs, the entire Product
object—with all its underlying complexity—is accessible.
DTOs: The Lean Data Contract
Data Transfer Objects (DTOs) offer an elegant solution by serving as minimalist contracts. They are simple data classes that contain only the specific properties required by a particular Blade view. This targeted approach brings several advantages:
- Focused Autocomplete: Your IDE will only suggest the relevant fields defined in the DTO.
- Optimized Queries: By knowing exactly which fields are needed, you can construct queries that fetch only those columns.
- Clear Intent: The DTO explicitly documents the expected data shape for the view.
Here’s an example of a SimpleProduct
DTO:
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 Foundation: A Generic BaseDTO
Class
To streamline DTO creation and enforce strictness, a BaseDTO
class can be invaluable. It establishes core behaviors for all your DTOs:
namespace App\DTO;
use InvalidArgumentException;
use ReflectionClass;
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
prevents access or assignment to properties not explicitly defined, ensuring strict data contracts. The columns()
method is particularly useful, returning an array of all public properties, which can be leveraged for selective database queries.
Integrating DTOs with ViewModels
When combined with ViewModels, DTOs create a powerful and predictable data structure for entire pages. A ViewModel can encapsulate multiple DTO collections, representing all the distinct data segments a view requires.
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;
}
Controller and Data Preparation Strategies
Now, let’s see how these DTOs and ViewModels are populated within a controller context.
Using the Repository Pattern:
When employing a Repository Pattern, your controller might look like this:
<?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(); // Assuming DTO mapping happens inside repository
$viewModel->categories = $this->categories->all();
$viewModel->brands = $this->brands->all();
return view('products.index', ['model' => $viewModel]);
}
}
Using the Atomic Query Construction (AQC) Pattern:
For more explicit control over data fetching, the AQC pattern allows you to pass the DTO’s columns directly to query handlers. This ensures only necessary data is retrieved.
<?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();
// Using DTO::columns() to specify desired fields for the query
$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]);
}
}
Lean Controllers: Moving Logic to ViewModel:
For even cleaner controllers, the data preparation logic can be encapsulated within the ViewModel itself:
<?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();
// ViewModel handles data fetching and DTO mapping
$response = $viewModel->handle($params);
return view('products.index', ['model' => $response]);
}
}
And the ViewModel would then include the handle
method:
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;
}
}
With this setup, your Blade views and partials will now benefit from precise autocomplete, displaying only the fields defined in your DTOs.
Autocomplete vs. Strict Enforcement
While DTO annotations provide excellent IDE autocomplete, true discipline comes from wrapping your query results into DTOs at runtime. This way, any attempt to access a property not defined in the DTO will throw an InvalidArgumentException
, enforcing the data contract beyond just IDE hints.
Schema Drift Protection
A significant benefit of this approach is early detection of schema mismatches. If a DTO property doesn’t correspond to an actual database column (when deriving from a model), MySQL will generate an “Unknown column” error, preventing silent failures and ensuring your DTOs stay in sync with your database schema.
Understanding the Trade-offs: Losing Eloquent Helpers
DTOs are intentionally “dumb” data containers. This means any Eloquent accessors, mutators, relationships, or custom helper methods will not be available directly on the DTO instances. All data preparation, transformation, and calculations should occur in the controller or service layer before converting data to DTOs. Views should only consume the final, prepared data contract.
Minimizing Mapping Overhead with a Generic Mapper
The idea of mapping data into DTOs might seem like extra work, but it’s remarkably efficient with 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);
}
}
This mapper simplifies DTO hydration:
// For single models
$dto = DTOMapper::map($product, SimpleProduct::class);
// For collections (e.g., from Eloquent)
$dtos = $products->map(fn($p) => DTOMapper::map($p, ProductDTO::class));
This generic solution eliminates the need for hand-rolling factories for each DTO, making hydration automatic and painless while preserving the benefits of clearly defined DTO classes.
Recommendations for Best Practices
To maximize the benefits of DTOs and ViewModels in Laravel Blade:
- Consistent Variable Naming: Always use
$model
as the variable name for your main data object within Blade views and partials for uniformity.// 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])
- Blade Type Declarations: At the top of each Blade file or partial, explicitly declare the DTO type using
@php /** @var \App\ViewModels\ProductsViewModel $model */ @endphp
. This ensures your IDE provides accurate autocomplete and type-safety checks.{{-- products.blade.php --}} @php /** @var \App\ViewModels\ProductsViewModel $model */ @endphp {{-- partial example --}} @php /** @var \App\DTO\Product\SimpleProduct $model */ @endphp
- Embrace Disconnection: Understand that DTOs disconnect data from Eloquent’s active record features. All necessary computations or relationship loading must happen before mapping to a DTO.
- Property Alignment: When a DTO originates from an Eloquent model, its properties should match the model’s column names to avoid issues during querying or mapping.
- Flexible Data Sources: DTOs are versatile; they can be hydrated from various sources like APIs, aggregates, or other services, not just Eloquent models. Their primary role is to define the final data shape for the view.
Conclusion: A Transformed Blade Experience
By integrating DTOs into our Laravel Blade workflow, we achieve a profound transformation:
- Structured Data: ViewModels provide clear data architecture for views.
- Intelligent Autocomplete: DTOs guide the IDE with precise property hints.
- Explicit Dependencies: ViewModels bound to views and partials make data flow transparent.
- Optimized Data Fetching: Data is restricted to only DTO-defined columns, improving query efficiency.
- Strict Enforcement: Runtime validation ensures that only defined properties can be accessed, preventing unexpected errors.
The result is a Laravel Blade environment that is strict, predictable, self-documenting, inherently safer, and significantly faster to develop within. This disciplined approach fosters cleaner code and a more robust application.