Developers judge an API by how it behaves when things go wrong. A response that says only "Something went wrong" with a 500 status sends them digging through documentation and support tickets. Clear, consistent error responses save time for everyone, including your own support team.
Use HTTP status codes correctly
Status codes are the first signal a client reads, and many libraries branch on them automatically.
- 400 Bad Request: the request is malformed, such as invalid JSON.
- 401 Unauthorized: authentication is missing or invalid.
- 403 Forbidden: the client is authenticated but not allowed to do this.
- 404 Not Found: the resource does not exist, or the client should not know it exists.
- 409 Conflict: the request conflicts with current state, such as a duplicate.
- 422 Unprocessable Content: the request is well-formed but fails validation.
- 429 Too Many Requests: rate limit exceeded; include a Retry-After header.
- 500 and 503: server-side failures the client did not cause.
Never return a 200 status with an error inside the body. It breaks monitoring, retries and client error handling.
Adopt one consistent format
Every error from every endpoint should share the same shape. The Problem Details standard (RFC 9457, which updated RFC 7807) is a good base, and you can extend it.
{
"type": "https://api.example.com/errors/insufficient-balance",
"title": "Insufficient balance",
"status": 409,
"detail": "The wallet balance is lower than the transfer amount.",
"code": "insufficient_balance",
"request_id": "7f3c9a2e"
}
Include a stable machine-readable code
Human-readable messages change as you improve wording or add translations. Clients should never parse them. Provide a short, stable code such as card_expired or email_taken that developers can switch on safely. Document every code and treat removing one as a breaking change.
Return field-level validation errors
When a form submission fails, tell the client exactly which fields are wrong and why, all at once rather than one per request.
{
"status": 422,
"code": "validation_failed",
"errors": {
"email": ["The email has already been taken."],
"phone": ["The phone number must be 10 digits."]
}
}
Laravel's validator already produces this structure, so map it into your standard envelope rather than inventing a second format.
Be helpful without leaking internals
Explain what the client can do to fix the problem, but never expose stack traces, SQL queries, file paths or server versions in production. Those details belong in your logs. Include a request ID in the response so you can find the full context when a developer contacts support.
Be careful with security-sensitive errors
Some errors should be deliberately vague. A login endpoint should say "Invalid email or password" rather than revealing which part was wrong. Returning 404 instead of 403 for resources a user should not know about prevents enumeration.
Make retries obvious
Clients need to know whether trying again might help. Rate limits and temporary outages should include a Retry-After header. Validation and permission errors will fail again until the request changes. Some teams add a retryable boolean to make this explicit.
Centralise error handling
Rather than formatting errors in every controller, convert exceptions into responses in one place, such as Laravel's exception handler. Custom exception classes can carry their status, code and message, which keeps controllers clean and responses consistent.
Document and test errors
List possible errors for each endpoint in your API reference, with example bodies. Write tests that assert status codes and error codes for common failure paths, so a refactor does not quietly change behaviour clients depend on.
Wrapping up
Good error responses use the right status code, follow one consistent format, include stable codes and field-level details, help without leaking internals and make retry behaviour clear. They turn frustrating failures into quick fixes.