Fermin Perdomo

Senior Full Stack Engineer | PHP | JavaScript

How to structure Laravel inertia with Spatie Data

Fermin Perdomo
October 23, 2025

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).


Reactions

Loading reactions...
Log in to react to this post.

Comments

Please login to leave a comment.

Newsletter