Skip to main content
Laravel, shipping fast.
Chapter 2 · Core Architecture

Responsable Wrappers: Consistent JSON

Julian Beaujardin

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. One small abstract class owns the contract — the status, the headers, and the shape of the envelope — and one subclass per response type supplies the payload. They enforce that every response follows the same structure. One class per response type, no exceptions.

BaseResponse: One Place That Knows the Envelope

Every wrapper needs the same three things: a status, optional headers, and a body. Writing that three times is how the fourth one ends up subtly different. Put it in an abstract base once, and each wrapper is left with the only question that actually differs between them — what goes in the body.

// 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() is written once. A wrapper that wants different headers overrides getHeaderFactory() and nothing else. This matters more than it looks: the day you add a header to every response in the API — a request id, a deprecation notice — you add it here, and every endpoint gets it.

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.

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,
        ];
    }
}

That is the whole class. No toResponse(), no header assembly, no status handling — the base already did all of it.

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.

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';
    }
}

This is the one wrapper that wants different headers, and getHeaderFactory() is the entire mechanism for saying so.

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.

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,
        ];
    }
}

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.

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 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 operations
  • CollectionResponse — Multiple items: { "items": [...] } for list operations
  • ErrorResponse — Error handling: { "errors": [...] } for validation failures and exceptions
  • MessageResponse — 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.