How to structure Laravel API with Spatie Data

Fermin Perdomo Fermin Perdomo
schedule 7 min read

Why Spatie Data fits modern Laravel APIs

In modern Laravel apps, we want clear boundaries: controllers should coordinate, actions should do work, and data should move in and out in a predictable way. Spatie’s laravel-data package gives you Data objects (like DTOs) with helpful features: typed properties, validation, casting, and easy serialization. That means less glue code and fewer bugs from loose arrays.

Data classes help you:

  • Build explicit contracts for requests and responses.
  • Keep controllers thin by hydrating input into typed objects.
  • Serialize output in a consistent shape (including nested relations).
  • Add transformations and default values in one place.
  • Reduce drift between your docs, your code, and what clients actually get.

The result? A clean API that is easy to maintain, test, and scale.


Folder structure that scales

A tidy structure makes big projects feel small. Here’s a proven layout:

app/
  Actions/
    User/
      RegisterUserAction.php
      UpdateProfileAction.php
  Data/
    User/
      RegisterUserData.php
      UserData.php
      AddressData.php
  Domain/
    User/
      Models/User.php
      Policies/UserPolicy.php
      Repositories/UserRepository.php
  Http/
    Controllers/Api/V1/
      AuthController.php
      UserController.php
    Middleware/
  Support/
    Enums/
    Exceptions/
    Transformers/
routes/
  api.php

  • Actions hold use cases.
  • Data holds DTOs (request/response).
  • Domain holds models and domain logic.
  • Controllers/Api/V1 maps to versioned routes.
  • Support is a home for cross-cutting utilities.

Installing & bootstrapping the package

Install Spatie Data:

composer require spatie/laravel-data

(If you publish config, keep defaults at first; the package works great out of the box.)

Defining request Data objects

Request DTOs validate and sanitize input before it hits your core logic.

<?php

namespace App\Data\User;

use Spatie\LaravelData\Attributes\Validation\Email;
use Spatie\LaravelData\Attributes\Validation\Min;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Data;

class RegisterUserData extends Data
{
    public function __construct(
        #[Required, Email] public string $email,
        #[Required, Min(8)] public string $password,
        public ?string $name,
    ) {}

    public static function rules(): array
    {
        return [
            'name' => ['nullable', 'string', 'max:100'],
        ];
    }
}

Tips

  • Put strict rules on required fields using attributes.
  • Use rules() for anything dynamic or complex.
  • Default null for optional fields you’ll fill later.

Defining response Data objects

Response DTOs shape what your API returns:

<?php

namespace App\Data\User;

use Spatie\LaravelData\Data;

class UserData extends Data
{
    public function __construct(
        public int $id,
        public string $email,
        public ?string $name,
        public ?string $avatarUrl,
    ) {}

    public static function fromModel(\App\Domain\User\Models\User $user): self
    {
        return new self(
            id: $user->id,
            email: $user->email,
            name: $user->name,
            avatarUrl: $user->profile_photo_url,
        );
    }
}

  • Keep PII out unless clients need it.
  • Derive presentation-friendly fields (like avatarUrl) here.
  • Expose nested relations with nested Data classes.

Mapping Eloquent models to Data

Use static constructors like from() or fromModel() to hydrate a Data class from a model. For saving, expose a toModel() or pass the DTO into an Action that handles persistence. Handle nullables carefully and centralize type conversions (like enums or money).

Controllers that stay thin

Controllers should coordinate, not compute. Accept a request DTO, pipe it into an Action, and return a response DTO.

<?php

namespace App\Http\Controllers\Api\V1;

use App\Actions\User\RegisterUserAction;
use App\Data\User\RegisterUserData;
use App\Data\User\UserData;
use Illuminate\Http\JsonResponse;

class AuthController
{
    public function register(RegisterUserData $data, RegisterUserAction $action): JsonResponse
    {
        $user = $action->execute($data);

        return response()->json(
            UserData::fromModel($user)->toArray()
        );
    }
}

Services & Actions (use cases)

Actions encapsulate business rules:

<?php

namespace App\Actions\User;

use App\Data\User\RegisterUserData;
use App\Domain\User\Models\User;
use Illuminate\Support\Facades\Hash;

class RegisterUserAction
{
    public function execute(RegisterUserData $data): User
    {
        return User::create([
            'email' => $data->email,
            'name' => $data->name,
            'password' => Hash::make($data->password),
        ]);
    }
}

  • Keep side effects (emails, events) in the Action.
  • Return domain objects or response DTOs—be consistent.

Validation and error messages

Because request DTOs validate automatically, bad input never reaches your Actions. Customize messages via attributes or messages() on the Data class. Localize with Laravel’s language files so clients get friendly errors.

API resources vs. Data

Laravel Resources and Spatie Data overlap. A simple path:

  • Use Data everywhere for consistency (requests and responses).
  • When migrating from Resources, wrap existing transforms inside a Data::from() method, then remove the old Resource gradually.
  • Keep an adapter for a deprecation period if external clients rely on the old shape.

Versioning your API

Create clear namespaces:

app/Http/Controllers/Api/V1/...
app/Http/Controllers/Api/V2/...
app/Data/V1/...
app/Data/V2/...

  • Freeze DTOs in V1; only add fields carefully.
  • Introduce breaking changes in V2.
  • Mark deprecation dates in your docs and responses.

Pagination, filters, and sorting

For lists, return a collection of Data with pagination metadata:

public function index(): \Illuminate\Http\JsonResponse
{
    $paginator = \App\Domain\User\Models\User::query()->paginate();

    return response()->json([
        'data' => \App\Data\User\UserData::collection($paginator->items())->toArray(),
        'meta' => [
            'current_page' => $paginator->currentPage(),
            'per_page' => $paginator->perPage(),
            'total' => $paginator->total(),
        ],
    ]);
}

Pro tip: If your API needs complex filters/sorts, pair Data with a query helper (e.g., Laravel scopes or a query builder package) and wrap incoming filter params in a request Data object.

Authentication & authorization

  • Use Sanctum for SPA/mobile tokens or Passport for OAuth2.
  • Check policies in Actions to keep controllers thin:
$this->authorize('update', $user);

If authorization fails, return a consistent error DTO (see next section).

Consistent error envelopes

Adopt a simple Problem Details pattern:

class ErrorData extends \Spatie\LaravelData\Data
{
    public function __construct(
        public string $type,
        public string $title,
        public int $status,
        public ?string $detail = null
    ) {}
}

On exceptions, map to ErrorData and return a uniform shape. Clients will love the consistency.

File uploads & media handling

Use a request DTO for the upload:

class UploadAvatarData extends Data
{
    public function __construct(
        #[\Spatie\LaravelData\Attributes\Validation\Image]
        public \Illuminate\Http\UploadedFile $file
    ) {}
}

In the Action, store the file and return a response DTO with the public URL.

Testing with Data

  • Feature tests: Post JSON that matches your request DTO; assert on the response DTO shape.
  • Unit tests: Hydrate DTOs from arrays and assert transforms.
  • Snapshot serialization: expect(UserData::fromModel($user)->toArray()) to match a known structure.

Performance & caching wins

  • Use UserData::collection($models)->only('id','email') when you don’t need every field.
  • Avoid heavy nested relations unless needed; consider lazy transforms inside Data constructors.
  • Cache serialized DTOs for hot endpoints with stable outputs.

Observability & logs

Log DTO arrays instead of raw requests or models:

\Log::info('user.registered', UserData::fromModel($user)->toArray());

You get structured, searchable logs without leaking sensitive data.

Example end-to-end flow (V1)

Route

Route::prefix('v1')->group(function () {
    Route::post('/auth/register', [AuthController::class, 'register']);
});

Controller → Action → DTOs

  1. RegisterUserData validates the payload.
  2. RegisterUserAction creates the user and handles side effects (event, welcome email).
  3. UserData serializes the response.

Sample request

{
  "email": "[email protected]",
  "password": "supersecret",
  "name": "Jane Doe"
}

Sample response

{
  "id": 1,
  "email": "[email protected]",
  "name": "Jane Doe",
  "avatarUrl": null
}

This shows exactly how to structure Laravel API with Spatie Data in practice—clean, explicit, and easy to maintain.

FAQs

1) Can I use Form Requests with Data?
Yes. But you usually don’t need to. Inject the request Data class in the controller method and let it validate automatically.

2) How do I hide sensitive fields (like passwords)?
Never include them in response DTOs. For input, accept the field in a request DTO but don’t echo it back. You can also omit fields using only()/except() when serializing.

3) What’s the best way to handle enums?
Use PHP backed enums in your Data properties and cast from strings/ints inside the from() method. Keep all conversion logic in one place.

4) How should I manage breaking changes?
Introduce a new versioned namespace (V2) and new DTOs. Keep V1 stable and document a deprecation window.

5) Do I still need Resources?
Not strictly. Data can replace them. If you have existing Resources, migrate gradually by mirroring shapes in Data classes.

6) How do I document my API?
Because DTOs are explicit, it’s easier to generate or hand-write OpenAPI schemas. Use your DTOs as the source of truth for request/response shapes.

7) How do I validate nested arrays (like addresses)?
Create nested Data classes (e.g., AddressData) and use them as typed properties inside your parent DTO. Validation applies recursively.

8) Is this overkill for small projects?
Not really. You’ll write a bit more upfront, but you’ll save time as soon as requirements change or the team grows.

Conclusion & next steps

You’ve seen How to structure Laravel API with Spatie Data from top to bottom: a scalable folder layout, clean request/response DTOs, thin controllers, action-driven use cases, consistent errors, and testable code. Start small—wrap one endpoint with request and response Data—then roll out the pattern across your API. Your future self (and your teammates) will thank you.

Further reading:

  • Spatie’s official docs for Laravel Data: https://spatie.be/docs/laravel-data


Reactions

lock You need to be logged in to react.
Log In

Newsletter

Get new posts delivered straight to your inbox.

mail

Great Tools for Developers

Git Tower

Git Tower

A powerful Git client for Mac and Windows that simplifies version control.

Visit arrow_forward
Mailcoach

Mailcoach

Self-hosted email marketing platform for sending newsletters and automated emails.

Visit arrow_forward
Uptimia

Uptimia

Website monitoring and performance testing tool to ensure your site is always up and running.

Visit arrow_forward
Cloudways

Cloudways

Managed cloud hosting platform that simplifies server management for developers.

Visit arrow_forward

Comments

No comments yet. Be the first to share your thoughts.

chat_bubble Join the conversation — log in to leave a comment.
Log In