Skip to content
Muhammet Şafak
tr
Web Development 4 min read

Standardizing API responses: a consistent contract

How returning the same response structure from every endpoint simplifies client code and makes errors predictable.


When multiple clients are running against the same API — an iOS app, a React SPA, another service — what happens when each endpoint returns a different response shape? Every client writes its own parsing logic, a separate if block gets added for each error format, one endpoint returns a data key while another returns result. Over time, this inconsistency becomes hidden debt that accumulates on top of client code.

I ran into this problem firsthand across several projects over the past year. The solution doesn’t require changing your technology stack: it’s enough to write the contract into the API itself, not around it.

The anatomy of a consistent response structure

A good API response contract guarantees three things: whether the outcome was a success or failure, the payload being carried, and error details if any exist. A simple skeleton that covers all three:

{
  "success": true,
  "data": {},
  "message": null,
  "errors": null
}

A failed response carries success: false, data: null, a short description in message, and field-level errors in errors. The client only ever needs to check the success key — everything else is consistent.

A response wrapper class in PHP

Rather than writing this structure by hand in every controller, I moved it into a single class:

<?php

class ApiResponse
{
    public static function success($data = null, string $message = null, int $status = 200): array
    {
        return response()->json([
            'success' => true,
            'data'    => $data,
            'message' => $message,
            'errors'  => null,
        ], $status);
    }

    public static function error(string $message, array $errors = [], int $status = 422): array
    {
        return response()->json([
            'success' => false,
            'data'    => null,
            'message' => $message,
            'errors'  => $errors ?: null,
        ], $status);
    }
}

Usage in a controller is straightforward:

public function store(Request $request)
{
    $validated = $request->validate([
        'name'  => 'required|string|max:255',
        'email' => 'required|email|unique:users',
    ]);

    $user = User::create($validated);

    return ApiResponse::success($user, 'User created.', 201);
}

When a validation error occurs, you need to catch Laravel’s own ValidationException and return it through the same contract. Customizing Handler.php is enough for this — one place covers all API errors.

The relationship between HTTP status codes and the success flag

Some teams argue “we already have 2xx/4xx, we don’t need a success flag.” In my experience, that’s not quite right: codes like 207 (Multi-Status), 422, and 409 are interpreted differently depending on the client library; the success flag eliminates that ambiguity. On the other hand, ignoring HTTP status codes altogether is also a mistake: returning errors with a 200 causes caching and logging issues on the client side. The two should be consistent with each other.

Pagination and list responses

For endpoints that return lists, it’s better practice to wrap the data with pagination metadata rather than returning a plain array:

{
  "success": true,
  "data": {
    "items": [],
    "meta": {
      "current_page": 1,
      "per_page": 15,
      "total": 243,
      "last_page": 17
    }
  },
  "message": null,
  "errors": null
}

Laravel’s LengthAwarePaginator already produces this metadata; the only work is moving it under data.meta.

Bringing the contract to the team: the backward compatibility question

The biggest benefit of defining the contract at the start of a project is that it prevents arguments down the road. But retrofitting a contract onto an existing API is a different challenge: there are live clients, and they expect a specific structure. At that point, two options remain: bump the version (/v2/) or apply the standard contract only to new endpoints.

I chose the second path. I left existing endpoints untouched and applied the contract to everything I added going forward. I wrote an adapter layer on the client side to abstract both. It’s a temporary burden, but far less risky than breaking existing clients.

Documenting the contract

No matter how solid the contract is, a second developer won’t know about it if it isn’t documented. Writing an OpenAPI (Swagger) schema makes a huge difference here; the response structure is defined once and every endpoint references it. I don’t have a full OpenAPI integration yet, but at minimum I keep three example responses in the README.md.


A consistent response contract isn’t a magic solution, but the return on investment far exceeds the cost. Client code becomes simpler, error scenarios become predictable, and when a new endpoint gets added there’s no debate about structure. Making this decision at the start of a project is much easier than going back to fix it later.

Tags: #API
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind