Un problema al que se enfrenta toda API: estructuras de respuesta inconsistentes. Un endpoint devuelve { "data": { ... } }. Otro devuelve { "result": [...] }. Un tercero devuelve { ... } directamente. Quien consume tu API tiene que escribir lógica de análisis distinta para cada endpoint. La solución: los Response Wrappers. Una clase abstracta pequeña posee el contrato —el estado, las cabeceras y la forma del sobre— y una subclase por tipo de respuesta aporta la carga. Imponen que toda respuesta siga la misma estructura. Una clase por tipo de respuesta, sin excepciones.
BaseResponse: un solo sitio que conoce el sobre
Todo wrapper necesita las mismas tres cosas: un estado, cabeceras opcionales y un cuerpo. Escribir eso tres veces es exactamente cómo el cuarto acaba siendo sutilmente distinto. Ponlo en una base abstracta una vez, y a cada wrapper le queda la única pregunta que de verdad los diferencia: qué va en el cuerpo.
// The shared base. Subclasses answer one question: what is the data?
abstract readonly class BaseResponse implements Responsable
{
/**
* @param array<string, string> $headers
*/
public function __construct(
protected int $status = Response::HTTP_OK,
protected array $headers = [],
) {}
/**
* @return array<string, mixed>
*/
abstract protected function getData(): array;
protected function getHeaderFactory(): string
{
return 'default';
}
public function toResponse($request): Response
{
$headers = HeaderFactory::{$this->getHeaderFactory()}(
headers: $this->headers,
);
return new JsonResponse(
data: $this->getData(),
status: $this->status,
headers: $headers,
);
}
}
toResponse() se escribe una vez. Un wrapper que quiera cabeceras distintas sobrescribe getHeaderFactory() y nada más. Esto importa más de lo que parece: el día que añadas una cabecera a todas las respuestas de la API —un identificador de petición, un aviso de deprecación— la añades aquí, y la reciben todos los endpoints.
ModelResponse: para recursos individuales
Esta sección se apoya en el controlador fino de antes, que devolvía new ModelResponse(...). Cuando un cliente crea, consulta o actualiza un recurso individual, necesita saber exactamente dónde encontrarlo en tu respuesta. No debería tener que adivinar si el recurso está bajo response.data, response.resource o en la raíz. Cada inconsistencia de tu API obliga a quien la consume a escribir lógica condicional: comprueba varias rutas, escribe manejadores de reserva y añade código de depuración. Eso es deuda técnica escondida en tu capa de integración.
final readonly class ModelResponse extends BaseResponse
{
/**
* @param array<string, string> $headers
*/
public function __construct(
private JsonResource $data,
int $status = Response::HTTP_OK,
array $headers = [],
) {
parent::__construct($status, $headers);
}
/**
* @return array<string, mixed>
*/
protected function getData(): array
{
return [
'data' => $this->data,
];
}
}
Esa es la clase entera. Sin toResponse(), sin montar cabeceras, sin gestionar el estado: la base ya lo hizo todo.
ModelResponse elimina esa confusión estableciendo que todo endpoint de recurso individual devuelve exactamente la misma estructura: { "data": {...} }. Ya esté tu cliente creando una licencia, consultando una concreta o actualizando una existente, el formato nunca cambia. Esa previsibilidad hace que el código del consumidor siga siendo simple y robusto. Siempre saben: el recurso está bajo response.data. Sin comprobaciones. Sin reservas. Sin sorpresas. Y esta consistencia se vuelve potente a lo largo de todo el ciclo de vida de la API: cuando cambies cómo se almacenan las licencias internamente, sólo actualizas el transformador LicenseResource. Todos tus endpoints devuelven automáticamente la estructura nueva.
{
"data": {
"key": "lic_789",
"name": "New License",
"domains": ["example.com"],
"created_at": "2026-02-12T10:30:00Z"
}
}
Cuando estandarizas en ModelResponse para todos los recursos individuales, tu suite de pruebas se simplifica. Escribes una aserción una vez —comprueba response.data— y esa misma aserción funciona en todas partes. No necesitas rutas de prueba distintas para endpoints de creación, de consulta o de actualización. Todos siguen el mismo patrón.
Piensa también en cómo escala esto al añadir funcionalidades. Puedes incluir metadatos sobre el recurso —permisos del usuario actual, recursos relacionados disponibles— añadiéndolos una vez en ModelResponse. Todos los endpoints de recurso individual de tu API ganan esa capacidad. Eso es apalancamiento arquitectónico: un cambio en un sitio beneficia a toda tu API.
ErrorResponse: para validacion y fallos
Los errores son inevitables. Llega entrada inválida, fallan servicios externos, se deniegan permisos. El objetivo es manejarlos con elegancia y de forma predecible.
Nada frustra más a quien consume una API que tener que descifrar formatos de error distintos según el endpoint. Uno devuelve { "error": "message" }, otro { "errors": [...] }, y un tercero el error en una estructura completamente distinta.
ErrorResponse estandariza cómo tu API comunica los problemas. Cuando falla la validación, cuando una operación está prohibida o cuando algo va mal, tu respuesta siempre usa la misma estructura: { "errors": [...] }. Esa consistencia significa que tu consumidor puede escribir un único manejador de errores que funcione en todas partes. Sabe que los errores de validación llegan con mensajes estructurados a nivel de campo. Sabe que un 422 significa que la validación falló. Esto no va sólo de consistencia: va de velocidad de depuración.
final readonly class ErrorResponse extends BaseResponse
{
public function __construct(
private string $message,
int $status = Response::HTTP_UNPROCESSABLE_ENTITY,
array $headers = [],
) {
parent::__construct($status, $headers);
}
/**
* @return array<string, mixed>
*/
protected function getData(): array
{
return [
'errors' => [
json_decode($this->message, true) ?? $this->message,
],
];
}
protected function getHeaderFactory(): string
{
return 'errors';
}
}
Este es el único wrapper que quiere cabeceras distintas, y getHeaderFactory() es todo el mecanismo para decirlo.
Cuando la petición de un cliente falla, puede entender de inmediato qué salió mal sin escarbar en la documentación ni ir a prueba y error.
{
"errors": [
{
"name": ["The name field is required."],
}
]
}
CollectionResponse: para listas e iteraciones
Cuando tu API devuelve listas de recursos, la consistencia es crítica. Sin ella, cada endpoint de colección es un misterio para quien consume. No debería tener que adivinar si los datos están bajo data, bajo items o en la raíz. Cada endpoint de lista con estructura distinta obliga a tus clientes a escribir análisis a medida. Eso genera fricción, introduce errores y hace dolorosa la integración.
CollectionResponse lo resuelve estableciendo una estructura única y predecible para cada colección que devuelvas. Ya listes licencias, usuarios o dominios, la respuesta siempre envuelve los elementos bajo la clave items: { "items": [...] }. Esa consistencia significa que tus consumidores escriben un analizador, un manejador de errores, una prueba, y funciona en todas partes. Multiplícalo por decenas de endpoints y habrás eliminado cantidades enormes de código duplicado y frágil.
final readonly class CollectionResponse extends BaseResponse
{
/**
* @param array<string, string> $headers
*/
public function __construct(
private AnonymousResourceCollection $data,
int $status = Response::HTTP_OK,
array $headers = [],
) {
parent::__construct($status, $headers);
}
/**
* @return array<string, mixed>
*/
protected function getData(): array
{
return [
'items' => $this->data,
];
}
}
Por defecto devuelve HTTP 200, y eso es precisamente el punto. El Response Wrapper se encarga de lo HTTP, incluidos los códigos de estado. El controlador no tiene que pensarlo. Esa separación es lo que mantiene finos y enfocados a los controladores.
{
"items": [
{ "key": "lic_123", "name": "My License", "domains": ["example.com"], ... },
{ "key": "lic_456", "name": "Another", "domains": ["another.com"], ... }
]
}
El poder real aparece cuando piensas en la experiencia del cliente. Alguien de frontend puede escribir una única función de utilidad que maneje paginación, renderizado de elementos y errores, y usarla para cada endpoint de colección de tu API.
Si tus requisitos cambian y necesitas pasar de items a results, lo actualizas una vez en CollectionResponse. Todos los endpoints que lo usan adoptan automáticamente la estructura nueva, aunque sean decenas.
Pero los beneficios van mucho más allá de renombrar un campo. Puedes añadir funcionalidades a todas las respuestas de colección sin tocar un solo controlador, implementándolas en el wrapper. Metadatos de paginación, cabeceras de límite de frecuencia, trazado de peticiones y monitorización de rendimiento pueden centralizarse y propagarse a todas partes.
MessageResponse: para operaciones asincronas y confirmaciones
No toda petición devuelve datos. A veces tu API acepta una petición pero no la completa de inmediato. Encolas un job en segundo plano. Difieres el procesamiento. Acusas recibo y dices «vuelve más tarde». En esos casos, tu API debería comunicar qué pasó sin fingir que devuelve algo que no tiene.
final readonly class MessageResponse extends BaseResponse
{
/**
* @param array<string, string> $headers
*/
public function __construct(
private string $message,
int $status = Response::HTTP_OK,
array $headers = [],
) {
parent::__construct($status, $headers);
}
/**
* @return array<string, mixed>
*/
protected function getData(): array
{
return [
'message' => $this->message,
];
}
}
MessageResponse está hecho para estos escenarios. Devuelve una confirmación simple de que la operación se aceptó y se procesará. Esto es crucial para operaciones asíncronas. Cuando un cliente borra una licencia, puede que encoles el borrado en segundo plano. El cliente no necesita la licencia borrada de vuelta. Necesita saber: «recibí tu petición, está en cola».
{
"message": "If exists, license will be deleted."
}
Ahora tu API es predecible. Cada consumidor escribe un analizador que funciona en todas partes. Puedes añadir cabeceras de respuesta sin cambiar un solo controlador. Puedes añadir registro o métricas a los Response Wrappers una vez y beneficiarte en todas partes. Cuando añades un endpoint nuevo, usas uno de estos cuatro wrappers y obtienes comportamiento consistente al instante.
ModelResponse— Recurso individual:{ "data": {...} }para crear, leer y actualizarCollectionResponse— Varios elementos:{ "items": [...] }para listadosErrorResponse— Errores:{ "errors": [...] }para fallos de validación y excepcionesMessageResponse— Confirmaciones asíncronas:{ "message": "..." }para operaciones en cola
Elige el wrapper adecuado para tu tipo de respuesta. Devuélvelo desde el controlador. Todo lo demás —códigos de estado, cabeceras, estructura JSON, serialización— se maneja automáticamente. Eso no es sólo consistencia: eso es una API profesional.