Type-safe Inertia responses with View Models
In most Inertia Laravel applications, your controllers probably look something like this:
class PreferencesController{ public function __invoke() { $config = Config::query()->where('type', 'free')->get(); return Inertia::render('user/preferences/View', [ 'user' => UserData::fromModel(Auth::user()), 'locale_options' => Locale::options(), 'config' => ConfigData::collect($config), // ... ]); }}
We will pass a component as a string user/preferences/View
and for this scenario we will pass data that we need for filling a dropdown selectbox in order to change the user's default locale and some system-level configuration scoped to type free.
Now, in your React or Vue app, you likely define a TypeScript interface that mirrors this structure manually:
type Options = { value: string; label: string }[]; interface PreferencesProps { user: UserData; locale_options: Options; config: ConfigData[];}
Here’s the problem: there’s no connection between the backend and frontend type definitions. If a backend developer changes config
to configuration
, TypeScript won’t catch it. You’ll only notice at runtime.
Let’s fix that.
Inertia tooling
With two Spatie packages, we can build that bridge between backend and frontend:
-
spatie/laravel-data
This package gives you enriched typed DTOs. You can use them to transform models into structured data and validate input/output while keeping things type-safe.
This package scans your PHP data classes and generates matching TypeScript types from them. That means you only define your data structure once in PHP, and the frontend gets type-safe definitions.
When the transformer runs, it generates this in your generated.d.ts
file:
export type ConfigData = { id: number; name: string; type: string; value: string;};
View models
A view model is a structured class that contains all the data your frontend component needs. Think of it as the contract between your backend and frontend.
⚠️ Don’t confuse view models with Laravel API Resources. Resources transform a single model into JSON. View models, on the other hand, organize and structure all the data passed to a view, which means it can contain multiple different structures.
Let’s refactor the previous controller using a view model:
class PreferencesController{ public function __invoke() { return Inertia::render( 'user/preferences/View', ViewPreferencesViewModel::create( Auth::user(), Config::query()->where('type', 'free'), ) ); }}
The controller stays clean and focused. All the transformation logic is moved into the view model.
This doesn’t look much different yet, but the biggest benefit when using this in an Inertia stack lies elsewhere.
Here's how the ViewPreferencesViewModel might look:
use Spatie\LaravelData\Data;use Spatie\LaravelData\Attributes\DataCollectionOf;use Spatie\TypeScriptTransformer\Attributes\LiteralTypeScriptType; class ViewPreferencesViewModel extends Data{ public function __construct( public UserData $user, #[DataCollectionOf(ConfigData::class)] public array $config, #[LiteralTypeScriptType('Options')] public array $localeOptions, ) {} public static function create(User $user, Builder $configQuery): self { return new self( user: UserData::fromModel($user), config: ConfigData::collect($configQuery->get()), localeOptions: Locale::options(), ); }}
A Few Notes
-
The view model is just a Data class. This means its structure is automatically picked up by the TypeScript transformer. I do prefer to keep the naming of ViewModel, as this is part of the Http layer, and typically I would place these files in
Http/ViewModels
and not mix them with other DTOs. -
I prefer to do all transformations (like converting models to DTOs or collecting nested data) in the view model.
-
As a rule of thumb, I pass queries to the view model instead of executing them in the controller. This keeps things flexible, especially when adding conditional filters.
Typed frontend props
When the transformer runs, it generates this in your generated.d.ts
file:
export type ViewPreferencesViewModel = { user: UserData; config: ConfigData[]; localeOptions: Options;};
Now, in your React component, you can use this type directly:
import type { ViewPreferencesViewModel } from '@/types/generated'; type PreferencesProps = PageProps & ViewPreferencesViewModel;
No more duplicated manual typing. And now, if someone renames config to configuration in the backend, TypeScript will immediately throw an error like this:
Property 'config' does not exist on type 'ViewPreferencesViewModel'.
This makes breaking changes much more visible during development, instead of in production.
Notes on spatie/typescript-transformer
At the time of writing, v2 of typescript-transformer can’t infer shapes of generic arrays like this:
/** * @param array<ConfigData> $config * @param array{label:string, value:string} $localeOptions**/public function __construct( public UserData $user, public array $config, public array $localeOptions,) {}
This will result in these generated types:
config: any[];localeOptions: any[];
But you can help it out by annotating them explicitly:
#[LiteralTypeScriptType('Options')]public array $localeOptions,
If you have a Livewire codebase, view models can still be useful. Seb wrote an excellent post about this: https://sebastiandedeyne.com/laravel-blade-view-models/