How to structure Laravel API with Spatie Data
Fermin Perdomo
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
- RegisterUserData validates the payload.
- RegisterUserAction creates the user and handles side effects (event, welcome email).
- 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
Newsletter
Get new posts delivered straight to your inbox.
Great Tools for Developers
Git Tower
Get Started - It's FreeA powerful Git client for Mac and Windows that simplifies version control.
Mailcoach
Start freeSelf-hosted email marketing platform for sending newsletters and automated emails.
Uptimia
Start freeWebsite monitoring and performance testing tool to ensure your site is always up and running.
Cloudways
Start freeManaged cloud hosting platform that simplifies server management for developers.
Comments
No comments yet. Be the first to share your thoughts.