Los DTOs son estructuras simples y prácticas que eliminan la ambigüedad en el flujo de datos. Son fáciles de aprender y mejoran de inmediato la claridad y el soporte del entorno.
final readonly class LicenseDTO
{
/**
* @param array<string> $domains
*/
public function __construct(
public string $key,
public string $name,
public array $domains,
public string $created_at,
) {}
}
Fíjate en que created_at sigue siendo una cadena. Es tentador convertirlo aquí en un objeto de fecha, porque ya estás tocando el valor, pero el trabajo del DTO es decir qué envió el proveedor, no interpretarlo. Convierte donde necesites una fecha, y el DTO se mantendrá como un registro fiel de la respuesta en lugar de una reinterpretación con pérdida.
Un DTO es sencillísimo: es sólo una clase que guarda datos. Ya está. Sin métodos, sin lógica, sin efectos secundarios. Sólo propiedades y un constructor. Datos puros viajando de un sitio a otro.
Cuando trabajas con arrays de APIs externas, las formas varían. Un endpoint dice que la licencia es ['key' => 'lic_123', 'name' => 'License']. Otro dice que es ['id' => 1, 'title' => 'License']. Eso lleva a condicionales dispersos y búsquedas frágiles por clave. Una errata en un nombre de clave puede sobrevivir hasta producción, y tu IDE no puede autocompletar claves que no conoce.
Con DTOs dices: «así es siempre una licencia. Estas son sus propiedades y sus tipos».
Ahora lo sabe tu IDE. Lo sabe tu verificador de tipos. Lo sabe todo el mundo. La palabra clave readonly significa que, una vez creado, el DTO no cambia. Los DTOs son instantáneas: representan los datos tal como estaban en un momento concreto. No los modificas; si necesitas datos distintos, creas un DTO nuevo. Eso casa con la seguridad de tipos: sabes exactamente qué tienes, y no va a mutar bajo tus pies.
Tres capas: modelo, DTO, resource
Aquí es donde la gente se lía, así que voy a ser muy claro.
Modelos. Son tus modelos de Eloquent que hablan con la base de datos. Un modelo License puede tener relaciones, marcas de tiempo, métodos de acceso, todo tipo de cosas específicas de la base de datos. Está bien. Los modelos son para tu capa de datos.
DTOs. Objetos de transferencia de datos. Fluyen entre capas de tu sistema. Traes datos crudos de una API externa, los mapeas a un DTO, y de pronto todo dentro de tu base de código trabaja con objetos tipados. Los DTOs nunca tocan la base de datos. Nunca. Sirven puramente para llevar datos tipados de un lado a otro.
Resources. Son lo que envías a quien consume. Son lo que transforma tu DTO tipado en la forma que tu API realmente devuelve. La misma licencia, pero ya formateada para el mundo exterior.
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"
Cada capa hace un trabajo y no sabe nada de las otras. A los modelos no les importan las respuestas de la API. A los resources no les importan las consultas a base de datos. Los DTOs se quedan en medio sosteniendo datos tipados. Precioso.
El DTO es tu contrato interno. «Estos son todos los datos que tenemos sobre una licencia.»
El Resource es tu contrato externo. «Esto es lo que estamos dispuestos a exponer sobre una licencia.»
El Response Wrapper es tu contrato HTTP. «Así estructuramos el JSON que te enviamos.»
Las tres capas trabajan juntas. Los DTOs te dan seguridad de tipos. Los resources te dan seguridad. Los Response Wrappers te dan consistencia. Ese es el poder de estos patrones aburridos y predecibles.
Resources: transformar de modelo a JSON
Ahora que tienes DTOs tipados fluyendo por tu sistema, el siguiente paso son los Resources. Cogen esos DTOs y les dan la forma exacta del JSON que tu API envía.
En Laravel, los Resources tradicionalmente transforman modelos de Eloquent a JSON. Aquí cumplen el mismo propósito, pero transformando nuestros DTOs tipados. El principio es idéntico: los Resources controlan qué datos salen de tu API. Son tu última capa de transformación antes de que los datos se marchen.
La cuestión es esta: tus DTOs y modelos contienen todo. Cada campo, cada relación, cada pieza de lógica interna. Tu API no debería exponer todo eso. Puedes tener campos como internal_notes, monthly_cost, security_key, profit_margin. Quien consume tu API no debería verlos. Nunca.
Sin Resources estás a un error de exponer algo sensible. Alguien puede devolver un modelo completo: return $license->toArray(). Y listo: ahora tu API filtra información de costes a la competencia. Ahora hay claves de API internas circulando por ahí. Ahora tienes un incidente de seguridad.
Los Resources lo previenen. Son una lista blanca explícita de lo que tu API expone. Nada sale de tu controlador salvo que lo pongas explícitamente en el método toArray() del Resource.
// 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,
];
}
}
$this->resource es tu DTO o modelo. Tú eliges qué propiedades exponer. Puedes renombrarlas si hace falta (license_name → name). Puedes calcular valores nuevos. Puedes incluir recursos relacionados de forma condicional. Controlas todo lo que sale de tu API.
Por eso importan los Resources: establecen tres capas distintas —datos internos, lógica de transformación y salida de la API— cada una con una frontera clara que se hace cumplir.
Tu DTO debería tenerlo todo. Todos los datos, todas las relaciones, todo lo interno. Está bien. Está tipado, es inmutable y es seguro pasarlo de un lado a otro.
Tu Resource es el portero. Es donde decides «este campo es seguro de exponer» o «este campo es sólo interno». Recorres las propiedades de tu DTO y pones explícitamente en la lista blanca lo que va al JSON. Nada es implícito. Todo es intencionado.
Tu respuesta es predecible. Los consumidores sólo ven lo que el Resource incluye explícitamente. El Resource define si un campo como el coste mensual se expone, así que puedes confirmar la salida revisando un solo fichero. Cuando cambien tus requisitos, cuando añadas campos internos nuevos a tu DTO, tu API no los expone por accidente. Eso es seguridad.
Los Resources aportan varios beneficios:
Seguridad por explicitud. Declaras explícitamente qué campos son seguros para la API. Nada se escapa por accidente. Ninguna exposición accidental de datos internos, costes o secretos.
Flexibilidad sin acoplamiento. Cuando cambia tu esquema, cuando tu DTO añade propiedades o una API externa añade campos, tus controladores y tu API pública siguen estables. Cambias toArray() una vez y todos tus endpoints usan automáticamente la forma nueva.
Consistencia entre endpoints. Todo endpoint que devuelve licencias usa LicenseResource. Todo endpoint que devuelve usuarios usa UserResource. La transformación es consistente en todas partes, así que los clientes escriben un analizador y funciona en todos los endpoints.
Propiedades calculadas y datos condicionales. Los Resources te dejan calcular valores al vuelo. Quizá quieras incluir un campo url calculado a partir de la clave. Quizá quieras incluir permisos condicionalmente según el usuario actual. El Resource maneja todo eso dinámicamente.
Mappers: el pegamento
Los mappers convierten datos crudos en DTOs. Aceptan datos en la forma que venga de la API externa o de la base de datos, validan y transforman esos datos —manejando campos ausentes y conversiones de tipo— y devuelven un DTO tipado garantizado, con propiedades que sabes que existen.
Los mappers son funciones puras: toman una entrada y devuelven una salida, sin efectos secundarios, sin llamadas a base de datos, sin mutar estado global. Eso los hace increíblemente fáciles de probar y de razonar. Y como son puros, puedes usar el mismo mapper en todas partes: lo usa tu controlador, lo usan tus comandos de consola, lo usan tus manejadores de webhooks. Todos obtienen la transformación idéntica siempre.
final readonly class LicenseDTOMapper
{
/**
* @param array<string, mixed> $license
*/
public static function toDTO(array $license): LicenseDTO
{
$key = $license['key'] ?? null;
$name = $license['name'] ?? null;
$created_at = $license['created_at'] ?? null;
if (! is_string($key) || ! is_string($name) || ! is_string($created_at)) {
throw new RuntimeException(
'Statamic license response is malformed: '.json_encode($license)
);
}
// A license with no domains is valid; tolerate an absent or non-array field.
/** @var array<string> $domains */
$domains = is_array($license['domains'] ?? null)
? array_values($license['domains'])
: [];
return new LicenseDTO(
key: $key,
name: $name,
domains: $domains,
created_at: $created_at,
);
}
/**
* @param Collection<int, array<string, mixed>> $licenses
* @return Collection<int, LicenseDTO>
*/
public static function toDTOCollection(Collection $licenses): Collection
{
return $licenses->map(self::toDTO(...));
}
}
Aquí es donde la frontera se gana el sueldo. Que falte domains está bien y se resuelve con un array vacío. Que falte key no está bien, y el mapper lo dice en voz alta, en el borde, con la carga ofensiva adjunta —en lugar de dejar que un null viaje tres capas hacia dentro y falle en un sitio que no puede explicarse.
toDTOCollection toma y devuelve una Collection en vez de un array, así el resultado sigue funcionando con todo lo demás del framework, y self::toDTO(...) es un callable de primera clase: el mapper se pasa como función, no envuelto en un closure que sólo reenvía su argumento.
Los mappers también sirven de frontera de abstracción crucial entre tus fuentes de datos externas y tu sistema interno. Tu controlador nunca ve la respuesta cruda. El mapper garantiza que todo dentro de tu base de código trabaja con DTOs tipados. Cuando la API externa cambie su formato de respuesta —y lo hará— sólo actualizas el mapper. Tu aplicación entera sigue funcionando porque ya recibe el formato de DTO estandarizado que espera.
// 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 ✓
Tu API externa devuelve un array desordenado. Puede que falten claves. Las fechas llegan como cadenas. El array domains puede no existir siquiera si no hay dominios.
El trabajo de un mapper es decir: «me da igual lo desordenada que sea la entrada. Prometo devolverte un LicenseDTO limpio y tipado, siempre».
¿Y probar mappers? Se vuelve trivial porque son funciones puras. Para probar tu flujo entero no simulas respuestas HTTP ni levantas APIs externas. Simplemente creas un array de prueba y se lo pasas:
// 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');
});
Listo. Has verificado que el mapper maneja el caso. Ahora sabes que cuando la API omita a veces el campo domains, tu código no se romperá.