How to structure Laravel inertia with Spatie Data
Learn How to structure Laravel inertia with Spatie Data using battle-tested patterns for folders, DTOs, transformers, validation, and testing—ship faster with cleaner code.
Why structure matters for Laravel + Inertia + Spatie Data
If you’ve ever shipped a Laravel + Inertia app and felt props getting messy or validation duplicated in three places, this guide is for you. Right at the start, it’s worth repeating our focus: How to structure Laravel inertia with Spatie Data. Clear structure pays off in speed, safety, and scale. Inertia bridges backend and frontend without an API layer, while Spatie Laravel Data acts as your type-safe “contract”: it validates inputs, shapes outputs, and keeps your props predictable. With a good design, your controllers stay thin, your Vue pages stay stable, and your teams move faster.
The triad: backend, transport, frontend
- Backend (Laravel): Eloquent models, actions/services, queries, policies.
- Transport (Spatie Data): DTOs that validate input and serialize output.
- Frontend (Inertia + Vue/React/Svelte): Pages and components that consume typed props.
Common pain points this stack solves
- Prop sprawl: Random arrays in Inertia::render become consistent DTOs.
- Duplicated validation: Move rules and messages into Data classes.
- Fragile refactors: DTOs provide a stable contract that’s easy to search and type-check.
- Inconsistent naming: Folder and class conventions make pages discoverable.
Project layout: folders, naming, and module boundaries
A predictable layout reduces cognitive load. Use domain-first modules—each feature owns its controllers, actions, Data, queries, and policies.
app/
Actions/
Data/
Domain/
Users/
Actions/
Data/
Http/
Controllers/
Models/
Policies/
Queries/
Http/
Controllers/
Policies/
Providers/
resources/
js/
Pages/
Users/
Index.vue
Show.vue
Edit.vue
Domain-first modules (Users, Billing, Catalog)
Group by domain, not layer. For example, Domain/Users contains everything “users” need. This mirrors your resources/js/Pages/Users.
Controllers, Actions, Data, ViewModels, Resources
- Controllers: small, expressive entry points.
- Actions: “do one thing well,” e.g., CreateUserAction.
- Data (Spatie): input/output DTOs, the contract.
- Queries: reusable, optimized Eloquent builders.
- (Optional) ViewModels/Resources: if you want special presentation logic.
Keeping Inertia pages and Vue components in sync
Match names across backend and frontend: Users/IndexData → Pages/Users/Index.vue. Make prop names stable and descriptive, not ad-hoc.
Spatie Data as your single source of truth
Spatie Data lets you express shape, casting, and validation in one place.
Input DTOs vs. Output DTOs
- Input DTOs handle form submissions.
- Output DTOs shape the props sent to pages/components.
// app/Domain/Users/Data/UserInputData.php
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\Validation\{Required, Email, Min};
class UserInputData extends Data
{
public function __construct(
#[Required, Min(3)] public string $name,
#[Required, Email] public string $email,
public ?string $password,
) {}
}
// app/Domain/Users/Data/UserOutputData.php
use Spatie\LaravelData\Data;
class UserOutputData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $email,
public string $created_at_human,
) {}
public static function fromModel(User $user): self
{
return new self(
$user->id,
$user->name,
$user->email,
$user->created_at->diffForHumans()
);
}
}
Casting, mapping, and transformers
Use Spatie’s casts to normalize dates, money, and enums. Keep formatting close to where data is defined to avoid scattered helpers.
Validation rules and messages in Data objects
Attach rules and custom messages to the Data class. This centralizes validation and keeps controllers tidy.
Inertia responses done right
Inertia should receive DTOs, not raw models or arrays. This makes refactors safe.
From Controller → Data → Inertia::render
// app/Domain/Users/Http/Controllers/UserIndexController.php
use Inertia\Inertia;
public function __invoke()
{
$users = User::query()->latest()->paginate(10);
$items = UserOutputData::collection($users->items());
return Inertia::render('Users/Index', [
'users' => $items,
'pagination' => [
'current_page' => $users->currentPage(),
'last_page' => $users->lastPage(),
],
]);
}
On the frontend, enjoy stable props:
// resources/js/Pages/Users/Index.vue (script setup)
interface UserOutputData {
id: number
name: string
email: string
created_at_human: string
}
Lazy props, partial reloads, and pagination
Use Inertia’s lazy props and preserveState to keep pages snappy. For big lists, send minimal fields via DTOs and rely on pagination metadata.
End-to-end request flow
Here’s a common create flow.
Create/Update flows with FormRequests vs. Data objects
You can validate in either FormRequest or Data. Pick one to avoid duplication. Many teams prefer Data for colocation.
// app/Domain/Users/Http/Controllers/UserStoreController.php
public function __invoke(Request $request, CreateUserAction $action)
{
$data = UserInputData::from($request); // validates
$user = $action->execute($data);
return to_route('users.index')
->with('success', 'User created.');
}
// app/Domain/Users/Actions/CreateUserAction.php
class CreateUserAction
{
public function execute(UserInputData $data): User
{
return User::create([
'name' => $data->name,
'email' => $data->email,
'password' => $data->password ? bcrypt($data->password) : null,
]);
}
}
Type-safe frontends with TypeScript (optional but powerful)
Even if you don’t migrate all components to TypeScript, typing page props pays off quickly.
Generating types from Spatie Data
Create parallel TS interfaces or generate types from PHP DTOs using simple scripts. The key is to keep names aligned: UserOutputData → UserOutputData.ts.
Transforming to TypeScript
Thanks to the typescript-transformer package, it is possible to automatically transform data objects into TypeScript definitions.
For example, the following data object:
// app/Domain/Users/Data/UserOutputData.php
use Spatie\LaravelData\Data;
class UserOutputData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $email,
public string $created_at_human,
) {}
public static function fromModel(User $user): self
{
return new self(
$user->id,
$user->name,
$user->email,
$user->created_at->diffForHumans()
);
}
}can be transformed to the following TypeScript type:
interface UserOutputData {
id: number
name: string
email: string
created_at_human: string
}And using this like:
App.Data.UserOutputData
How to setup one time
composer require spatie/laravel-typescript-transformer php artisan vendor:publish --tag=typescript-transformer-config
In config/typescript-transformer.php set:
'output' => resource_path('types/generated.d.ts'),
'transformers' => [
Spatie\LaravelData\Support\TypeScriptTransformer\DataTypeScriptTransformer::class,
],
1) Generate before dev & build
Add predev and prebuild scripts so types are regenerated automatically:
// vite.config.ts
plugins: [
vue({
script: {
globalTypeFiles: ['./resources/scripts/types/generated.d.ts'],
}
}),
watch({
pattern: "app/{Data,Enums}/**/*.php",
command: "php artisan typescript:transform",
}),
// eslintPlugin(),
],Reusable patterns: Actions, Queries, and Pipelines
DTOs shine when paired with clear business boundaries.
Command-Query Separation with Data DTOs
- Commands (Actions) mutate state, accept Input Data.
- Queries read state, return Output Data.
- Controllers orchestrate; they don’t do the work.
$user = CreateUserAction::run($data); return UserOutputData::from($user);
Authentication, authorization, and policies with Data
Protect sensitive fields before they ever reach the client.
Sanitizing and scoping props
Filter props in your DTO’s fromModel method (omit tokens, internal flags). Gate advanced props by policy checks:
$isAdmin = auth()->user()?->can('viewSensitive', $user);
return new self(
// ...
created_at_human: $isAdmin ? $user->created_at->toDateTimeString() : $user->created_at->diffForHumans(),
);
Error handling and form validation UX
Make server errors human-friendly.
Server-side errors → client-friendly messages
- Standardize error shapes from Data validation.
- In Vue, map field errors to inputs.
- Use Inertia useForm to preserve input and show inline errors.
const form = useForm({ name: '', email: '' })
form.post(route('users.store'))
Caching, pagination, and performance
DTOs help you send less. Cache where it counts.
Eager loading + lightweight DTOs
- Always eager load relations used by DTOs.
- Avoid sending giant trees; paginate and lazy-load child data.
- Memoize expensive transformations inside DTO factories if needed.
Testing strategy for confidence
Testing locks in your structure so it stays healthy as features grow.
Unit tests for Data, feature tests for Inertia responses
- Unit: casting, rules, transformers in Data classes.
- Feature: controller returns correct Inertia page and props.
- Browser (optional): smoke test critical flows via Dusk/Playwright.
public function test_user_output_data_serializes_expected_fields()
{
$user = User::factory()->create();
$data = UserOutputData::fromModel($user)->toArray();
$this->assertArrayHasKey('created_at_human', $data);
}
Deployment checklist and observability
- DTO versioning: When props change, consider v2 DTOs in parallel to avoid breaking pages.
- Telemetry: Log slow queries used by DTOs.
- Errors: Alert on validation error spikes after releases.
Frequently Asked Questions
1) Can I mix FormRequests with Spatie Data?
Yes, but pick a single source of validation truth per flow to avoid drift. Many teams choose Data for colocation.
2) Should I return Eloquent models directly to Inertia?
Prefer Output DTOs. They’re safer, smaller, and more explicit than raw models.
3) Where do I put mapping logic?
Inside the Data class (fromModel, custom casters) or dedicated mappers. Keep controllers thin.
4) How do I handle enums and dates?
Use Spatie Data casts and normalizers. Expose client-friendly strings or ISO dates as needed.
5) What about nested relations?
Return nested Output DTOs (e.g., PostOutputData with AuthorOutputData) and keep depth shallow. Paginate children.
6) How do I prevent leaking sensitive fields?
Never expose raw models. Whitelist fields in DTOs and gate extras with policies before serialization.
7) Does this work with React/Svelte instead of Vue?
Yes. Inertia is frontend-agnostic. The contract (DTOs) stays the same.
8) How many DTOs is too many?
Prefer specificity. Separate Input vs. Output DTOs; split read models by page if shapes differ.
Conclusion and next steps
We’ve walked through How to structure Laravel inertia with Spatie Data using domain-first folders, Actions/Queries, and clear Input/Output DTOs. This approach keeps props predictable, validation centralized, and pages stable. Start small: convert one page’s raw arrays into DTOs, wire the controller to return Inertia::render with typed props, and add tests. You’ll feel the difference immediately—cleaner code, faster teams, fewer regressions.
Helpful links:
- Laravel Docs: https://laravel.com/docs
- Inertia.js: https://inertiajs.com
- Spatie Laravel Data: https://spatie.be/docs/laravel-data
(external link)
Bonus: Minimal Starter Snippets
A. List page response
return Inertia::render('Users/Index', [
'users' => UserOutputData::collection(
User::query()->latest()->paginate(10)->items()
),
]);
B. Edit form load
return Inertia::render('Users/Edit', [
'user' => UserOutputData::fromModel($user),
]);
C. Update action
public function __invoke(Request $request, UpdateUserAction $action, User $user)
{
$data = UserInputData::from($request);
$action->execute($user, $data);
return back()->with('success', 'Updated.');
}
You can switch between modes anytime by simply typing the mode name (e.g., Article Mode, Blog Article + Image Mode, Custom Mode, Multilingual Mode).
Please login to leave a comment.