DTOs are simple, practical structures that remove ambiguity in data flow. They are easy to learn and immediately improve clarity and tooling support.
// app/Http/DataObjects/LicenseDTO.php
final readonly class LicenseDTO
{
public function __construct(
public string $key,
public string $name,
public array $domains,
public Carbon $created_at,
) {}
}
A DTO is dead simple: it's just a class that holds data. That's it. No methods, no logic, no side effects. Just properties and a constructor. Pure data shipping from one place to another.
When you work with arrays from external APIs, shapes can vary. One endpoint says the license looks like ['key' => 'lic_123', 'name' => 'License']. Another says it's ['id' => 1, 'title' => 'License']. That leads to scattered conditionals and brittle key lookups. A typo in a key name can survive until production, and your IDE cannot autocomplete keys it does not know.
With DTOs, you say: "Here's what a license always looks like. Here are its properties and types."
Now your IDE knows. Your type checker knows. Everyone knows. The readonly keyword means once you create this DTO, it doesn't change. DTOs are snapshots. They represent data as it was at a specific moment. You don't modify them; if you need different data, you create a new DTO. This pairs with type safety: you know exactly what you have, and it won't mutate under your feet.
Three Layers: Model, DTO, Resource
Here's where people get confused, so let me be super clear.
Models, These are your Eloquent models that talk to the database. A License model might have relationships, database timestamps, accessor methods, all kinds of database-specific stuff. That's fine. Models are for your database layer.
DTOs, Data transfer objects. They flow between layers in your system. You fetch raw data from an external API, map it to a DTO, and suddenly everything inside your codebase works with typed objects. DTOs never touch the database. Ever. They're purely for shuttling typed data around.
Resources, These are what you send to consumers. They're what transforms your typed DTO into the shape your API actually returns. Same license, but now it's formatted for the outside world.
API Response (raw JSON/array)
↓
Mapper: "Convert this to a typed DTO"
↓
Controller receives: LicenseDTO $license
↓
Resource: "Transform this DTO into API JSON"
↓
Response Wrapper: "Add status, headers, send it"
Each layer does one job and knows nothing about the others. Models don't care about API responses. Resources don't care about database queries. DTOs just sit in the middle holding typed data. Beautiful.
The DTO is your internal contract. "Here's all the data we have about a license."
The Resource is your external contract. "Here's what we're willing to expose about a license."
The Response Wrapper is your HTTP contract. "Here's how we structure the JSON we send you."
All three layers work together. DTOs give you type safety. Resources give you security. Response Wrappers give you consistency. That's the power of these boring, predictable patterns.
Resources: Model-to-JSON Transformation
Now that you've got typed DTOs flowing through your system, the next step is Resources. They take those typed DTOs and shape them into the exact JSON your API sends to consumers.
In Laravel, Resources traditionally transform Eloquent Models to JSON. Here, they serve the same purpose but transform our typed DTOs instead. The principle is identical: Resources control what data leaves your API. Resources are your final transformation layer before data leaves your API. A Resource is a class that takes your typed DTO and transforms it into the exact JSON structure your API consumers see.
Here's the thing: your DTOs and models contain everything. Every field, every relationship, every piece of internal logic. Your API shouldn't expose all of that. You might have fields like internal_notes, monthly_cost, security_key, profit_margin. Your API consumers shouldn't see those. Ever.
Without Resources, you're one mistake away from exposing something sensitive. A developer might return a full model: return $license->toArray(). Boom. Now your API is leaking cost information to competitors. Now API keys meant for internal use are in the wild. Now you have a security incident.
Resources prevent this. They're an explicit whitelist of what your API exposes. Nothing leaves your controller unless you explicitly put it in the Resource's toArray() method.
// app/Http/Resources/LicenseResource.php
/** @property LicenseDTO $resource */
final class LicenseResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'key' => $this->resource->key,
'name' => $this->resource->name,
'domains' => $this->resource->domains,
'created_at' => $this->resource->created_at,
];
}
}
The $this->resource is your DTO or model. You pick which properties to expose. You can rename them if needed (license_name → name). You can compute new values. You can include related resources conditionally. You control everything that leaves your API.
This is why Resources matter: they establish three distinct layers, internal data, transformation logic, and API output, each with a clear, enforcing boundary.
Your DTO should have everything you need. All the data, all the relationships, everything internal. That's fine. It's typed, it's immutable, it's safe to pass around.
Your Resource is the gatekeeper. It's where you decide "this field is safe to expose," or "this field is internal only." You iterate through your DTO properties and explicitly whitelist what goes into the JSON. Nothing is implicit. Everything is intentional.
Your API response is predictable. Consumers only see what the Resource explicitly includes. The Resource defines whether fields like monthly cost are exposed, so you can confirm output by reviewing a single file. When your requirements change, when you add new internal fields to your DTO, your API doesn't accidentally expose them. The Resource still only exposes what it whitelists. That's safety.
Resources add a lot of benefits to your solution:
Security through explicitness. You explicitly declare which fields are API-safe. Nothing sneaks out by accident. No accidental exposure of internal data, costs, or secrets. Your frontend and mobile developers get exactly what you decided they should get, nothing more.
Flexibility without coupling. When your database schema changes, your DTO adds new properties, or an external API adds fields, your controllers and public API remain stable. You change the Resource's toArray() method once, and all your endpoints automatically use the new shape. No scattered changes, no inconsistency.
Consistency across endpoints. Every endpoint that returns licenses uses LicenseResource. Every endpoint that returns users uses UserResource. The transformation is consistent everywhere, so clients write one parser and it works across endpoints. Code reviews are simpler because output shape is centralized in the Resource.
Computed properties and conditional data. Resources let you compute new values on the fly. Maybe you want to include a url field that's computed from the key. Maybe you want to conditionally include permissions based on the current user. The Resource handles all of this dynamically.
Mappers: The Glue
Mappers convert raw data to DTOs. They accept data in whatever shape comes from the external API or database, validate and transform that data by handling missing fields and type conversions like parsing date strings, and return a guaranteed typed DTO with properties you know exist.
Mappers are pure functions: they take input and return output with no side effects, no database calls, no mutating global state. This makes them incredibly easy to test and reason about. Because they're pure, you can use the same mapper everywhere, your controller uses it, your command-line jobs use it, your API webhook handlers use it. Everyone gets the identical transformation every time. This reusability is powerful because it means you're never duplicating the logic to convert raw data into typed objects.
// app/Http/Mappers/LicenseDTOMapper.php
final readonly class LicenseDTOMapper
{
public static function toDTO(array $data): LicenseDTO
{
return new LicenseDTO(
key: $data['key'],
name: $data['name'],
domains: $data['domains'] ?? [],
created_at: Carbon::parse($data['created_at']),
);
}
public static function toDTOCollection(array $items): array
{
return array_map(fn ($item) => self::toDTO($item), $items);
}
}
Mappers also serve as the crucial abstraction boundary between your external data sources and your internal system. Your controller never sees the raw API response. The mapper ensures that everything inside your codebase works with typed DTOs. When the external API changes its response format, and it will, you only update the mapper. Your entire application continues working because it's already receiving the standardized DTO format it expects.
// Raw data from Statamic API
$rawData = [
'key' => 'lic_123',
'name' => 'My License',
// domains is missing!
'created_at' => '2026-02-18T10:30:00Z',
];
// Mapper transforms it
$dto = LicenseDTOMapper::toDTO($rawData);
// Now you have a guaranteed LicenseDTO
echo $dto->key; // 'lic_123' ✓
echo $dto->name; // 'My License' ✓
echo $dto->domains; // [] ✓ (defaults to empty)
echo $dto->created_at; // Carbon instance ✓
Your external API returns a messy array. Keys might be missing. Dates come as strings. The domains array might not even exist if there are no domains.
A mapper's job is to say: "I don't care how messy the input is. I promise to give you back a clean, typed LicenseDTO every single time."
The mapper handled the missing domains field gracefully with $data['domains'] ?? []. It parsed the date string into a Carbon instance and ensured every property exists and has the right type.
What about Testing mappers? It becomes trivial because they're pure functions. To test your entire flow, you don't mock HTTP responses or spin up external APIs. You simply create a test array and pass it to the mapper:
// tests/Feature/Test.php
test('mapper handles missing optional fields', function () {
$rawData = [
'key' => 'lic_test',
'name' => 'Test License',
'created_at' => '2026-02-18T10:00:00Z',
// domains is missing
];
$dto = LicenseDTOMapper::toDTO($rawData);
expect($dto->domains)->toBe([]);
expect($dto->key)->toBe('lic_test');
});
Done. You've verified the mapper handles the case. Now you know that when the API sometimes omits the domains field, your code won't break.