Here's a problem that every API faces: inconsistent response structures. One endpoint returns { "data": { ... } }. Another returns { "result": [...] }. A third returns { ... } directly. Your API consumers have to write different parsing logic for every endpoint. The solution: Response Wrappers. These are simple classes that implement Laravel's Responsable interface. They enforce that every response follows the same structure. One class per response type, no exceptions.
ModelResponse: For Single Resources
This section builds on the earlier thin controller that returned new ModelResponse(...). When a client creates, retrieves, or updates a single resource, they need to know exactly where to find it in your response. Clients should not have to guess whether the resource is under response.data, response.resource, or the root of the response. Every inconsistency in your API forces your consumer to write conditional logic. They check for multiple paths, write fallback handlers, and add debugging code. This is technical debt hiding in your integration layer.
// app/Http/Responses/ModelResponse.php
final readonly class ModelResponse implements Responsable
{
public function __construct(
private JsonResource $data,
private int $status = Response::HTTP_OK,
private array $headers = [],
) {}
public function toResponse(Request $request): Response
{
return new JsonResponse(
data: [
'data' => $this->data,
],
status: $this->status,
headers: HeaderFactory::default(
headers: $this->headers,
),
);
}
}
ModelResponse eliminates this confusion by establishing that every single resource endpoint returns the exact same structure: { "data": {...} }. Whether your client is creating a new license, retrieving a specific one, or updating an existing license, the response format never changes. This predictability means your consumer code remains simple and robust. They always know: the resource is under response.data. No checking. No fallbacks. No surprises. This consistency becomes powerful across your entire API lifecycle. When you change how licenses are stored internally, you only update the LicenseResource transformer. All your endpoints automatically return the new structure. When you add new fields to licenses, they propagate through every endpoint without your clients breaking. This is the benefit of a well-defined response contract.
{
"data": {
"key": "lic_789",
"name": "New License",
"domains": ["example.com"],
"created_at": "2026-02-12T10:30:00Z"
}
}
When you standardize on ModelResponse for all single resource returns, your test suite becomes simpler. You write a test assertion once, check for response.data, and that same assertion works everywhere. You don't need to write different test paths for create endpoints versus retrieve endpoints versus update endpoints. They all follow the same pattern. This consistency dramatically reduces tests you need to write while increasing confidence in your API's behavior.
Consider also how this scales when adding new features. You can include metadata about the resource, like permissions the current user has or related resources available, by adding it to ModelResponse::toResponse() once. Every single resource endpoint in your API gains that capability. This is architectural leverage, one change in one place benefits your entire API.
ErrorResponse: For Validation and Error Failures
Errors are inevitable. Invalid input arrives, external services fail, permissions are denied. The goal is to handle them gracefully and predictably.
Nothing frustrates API consumers more than trying to decode different error formats from different endpoints. One endpoint returns { "error": "message" }, another returns { "errors": [...] }, and yet another returns the error in a completely different structure.
ErrorResponse standardizes how your API communicates problems back to your clients. When validation fails, when an operation is forbidden, or when something goes wrong, your response always uses the same structure: { "errors": [...] }. This consistency means your consumer can write one error handler that works everywhere. They know that validation errors arrive with structured field-level messages. They know that 422 responses mean validation failed. They know exactly where to find the error messages in the response. This isn't just about consistency, it's about debugging speed.
// app/Http/Responses/ErrorResponse.php
final readonly class ErrorResponse implements Responsable
{
public function __construct(
private string $message,
private int $status = Response::HTTP_UNPROCESSABLE_ENTITY,
private array $headers = [],
) {}
public function toResponse($request): Response
{
return new JsonResponse(
data: [
'errors' => [
json_decode($this->message, true) ?? $this->message,
],
],
status: $this->status,
headers: HeaderFactory::errors(
headers: $this->headers,
),
);
}
}
When a client's request fails, they can immediately understand what went wrong without digging through documentation or trial-and-error. If something failed.
{
"errors": [
{
"name": ["The name field is required."],
}
]
}
CollectionResponse: For Lists and Iterations
When your API returns lists of resources, consistency is critical. Without it, each collection endpoint becomes a mystery to your consumers. Clients should not have to guess whether the data is under data, under items, or at the root level. Every list endpoint that varies in structure means your clients have to write custom parsing logic. This creates friction, introduces bugs, and makes integration painful.
CollectionResponse solves this by establishing a single, predictable structure for every collection you return. Whether you're listing licenses, users, or domains, the response always wraps items under the items key: { "items": [...] }. This consistency means your consumers write one parser, one error handler, one test, and it works everywhere. Multiply that across dozens of endpoints and you've eliminated enormous amounts of duplicate, fragile code.
// app/Http/Responses/CollectionResponse.php
final readonly class CollectionResponse implements Responsable
{
public function __construct(
private AnonymousResourceCollection $data,
private int $status = Response::HTTP_OK,
private array $headers = [],
) {}
public function toResponse(Request $request): Response
{
return new JsonResponse(
data: [
'items' => $this->data,
],
status: $this->status,
headers: HeaderFactory::default(
headers: $this->headers,
),
);
}
}
By default, it returns HTTP 200, and that's precisely the point. The Response Wrapper handles HTTP concerns like status codes. The controller doesn't need to think about it. This separation is what makes controllers thin and focused. This demonstrates the real power of centralization.
{
"items": [
{ "key": "lic_123", "name": "My License", "domains": ["example.com"], ... },
{ "key": "lic_456", "name": "Another", "domains": ["another.com"], ... }
]
}
The real power emerges when you consider the client experience. A frontend developer can write a single utility function that handles pagination, item rendering, and error handling, and use it for every collection endpoint in your API.
If your requirements change and you need to switch from items to results in your response structure, you update it once in CollectionResponse. Every endpoint that uses it automatically adopts the new structure, even across dozens of collection endpoints.
But the benefits extend far beyond simple field renaming. You can add features to all collection responses without touching a single controller by implementing them in the Response Wrapper. Pagination metadata, rate limit headers, request tracing, and performance monitoring can all be centralized and propagated everywhere. This is how you avoid maintenance headaches, scale your codebase efficiently, and keep your API agile as requirements evolve and new features emerge.
MessageResponse: For Async Operations and Confirmations
Not every request returns data. Sometimes your API accepts a request but doesn't immediately complete it. You queue a background job. You defer processing. You acknowledge receipt but say "check back later." In these cases, your API should communicate what happened without pretending to return something it doesn't have.
// app/Http/Responses/MessageResponse.php
final readonly class MessageResponse implements Responsable
{
public function __construct(
private string $message,
private int $status = Response::HTTP_OK,
private array $headers = [],
) {}
public function toResponse(Request $request): Response
{
return new JsonResponse(
data: [
'message' => $this->message,
],
status: $this->status,
headers: HeaderFactory::default(
headers: $this->headers,
),
);
}
}
MessageResponse is built for these scenarios. It returns a simple message confirmation that the operation was accepted and will be processed. This is crucial for async operations. When a client deletes a license, you might queue the deletion to happen in the background. The client doesn't need the deleted license back. They need to know: "I got your request, it's queued"
{
"message": "If exists, license will be deleted."
}
Now your API is predictable. Every consumer writes one parser that works everywhere. You can add response headers without changing a single controller. You can add logging or metrics to Response Wrappers once and benefit everywhere. When you add a new endpoint, you use one of these four wrappers and instantly get consistent behavior.
ModelResponse— Single resource responses:{ "data": {...} }for create, read, update operationsCollectionResponse— Multiple items:{ "items": [...] }for list operationsErrorResponse— Error handling:{ "errors": [...] }for validation failures and exceptionsMessageResponse— Async confirmations:{ "message": "..." }for queued operations and fire-and-forget jobs
Pick the right wrapper for your response type. Return it from your controller. Everything else, status codes, headers, JSON structure, serialization, is handled automatically. That's not just consistency, that's a professional API.