nilanjan-k / api-response-formatter
A production-ready Laravel package for standardized, consistent JSON API responses.
Package info
github.com/nilanjan-k/api-response-formatter
pkg:composer/nilanjan-k/api-response-formatter
Requires
- php: ^8.1
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/pagination: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
This package is auto-updated.
Last update: 2026-06-01 08:21:19 UTC
README
A production-ready Laravel package that gives every API response — success, error, validation failure, paginated list, or unhandled exception — the same predictable JSON envelope. Frontend and mobile clients always know exactly what shape to expect, regardless of which controller or service produced it.
{
"status": true,
"code": 200,
"message": "Users fetched successfully.",
"data": [ ],
"errors": null,
"meta": null
}
Table of Contents
- Requirements
- Installation
- Configuration
- Usage
- Available methods
- Response shape reference
- Paginated responses
- Exception handler integration
- Macro support
- BaseResource
- Localization / i18n
- Security considerations
- Testing
- Contributing
- Changelog
- License
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.1 |
| Laravel | 10, 11, 12, or 13 |
Installation
From Packagist (normal)
composer require nilanjan-k/api-response-formatter
Laravel's package auto-discovery registers the service provider and the
ApiResponse facade alias automatically. No manual edits to config/app.php
are needed.
Local development / path repository
If you are working on the package itself alongside a test application, tell
Composer where to find it via a path repository entry — no Packagist
publishing required.
1. Add the path repository to your Laravel app's composer.json:
{
"repositories": [
{
"type": "path",
"url": "../api-response-formatter",
"options": {
"symlink": true
}
}
]
}
2. Require the package:
composer require nilanjan-k/api-response-formatter:@dev
Composer creates a symlink from vendor/nilanjan-k/api-response-formatter to
your local package folder, so every edit you make is reflected instantly — no
composer update needed.
3. Verify auto-discovery ran:
php artisan package:discover --ansi
You should see NilanjanK\ApiResponseFormatter\ApiResponseServiceProvider
listed as discovered.
Configuration
Publishing the config file
# Publish the config file php artisan vendor:publish --tag=api-response-config # Publish the language files (optional — for customising default messages) php artisan vendor:publish --tag=api-response-lang
The config file is published to config/api-response.php.
The lang files are published to lang/vendor/api-response/.
Config options
| Key | Type | Default | Description |
|---|---|---|---|
include_meta |
bool |
true |
Include a "meta" key in every response. null for non-paginated responses; populated for paginated responses. |
include_timestamp |
bool |
false |
Append "timestamp" (ISO-8601) to every response. |
include_request_id |
bool |
false |
Append "request_id" to every response. Uses the incoming X-Request-Id header only if it is a valid UUID v4; otherwise generates a fresh one. |
debug |
bool |
false |
When true and APP_DEBUG=true, include exception details in 500 responses. Stack-frame arguments are always stripped before serialisation. Leave false in production. |
default_messages |
array |
[] |
Override the default message for any HTTP status code. Falls back to lang/en/messages.php for missing codes. |
Usage
Three styles are available. Pick whichever fits your team's conventions — they all produce identical JSON.
Option A — Facade
use NilanjanK\ApiResponseFormatter\Facades\ApiResponse; class UserController extends Controller { public function index(): JsonResponse { return ApiResponse::success(User::all(), 'Users fetched successfully.'); } public function store(StoreUserRequest $request): JsonResponse { $user = User::create($request->validated()); return ApiResponse::created($user, 'User created successfully.'); } public function update(UpdateUserRequest $request, User $user): JsonResponse { $user->update($request->validated()); return ApiResponse::success($user, 'User updated.'); } public function destroy(User $user): JsonResponse { $user->delete(); return ApiResponse::noContent(); } }
Option B — HasApiResponse trait
Add use HasApiResponse directly in a controller. No static calls or
facade imports needed.
use NilanjanK\ApiResponseFormatter\Traits\HasApiResponse; class PostController extends Controller { use HasApiResponse; public function index(): JsonResponse { return $this->success(Post::paginate(15), 'Posts listed.'); } public function show(Post $post): JsonResponse { return $this->success($post, 'Post retrieved.'); } public function store(StorePostRequest $request): JsonResponse { return $this->created( Post::create($request->validated()), 'Post created.' ); } public function destroy(Post $post): JsonResponse { $post->delete(); return $this->noContent(); } }
Option C — Global helper functions
Four global functions are auto-loaded via helpers.php. They are available
anywhere — middleware, jobs, service classes, Artisan commands.
// Resolve the manager instance and chain any method api_response()->success($data); api_response()->notFound('Item not found.'); // Named shortcut helpers api_success($data, 'Done.'); // → 200 api_error('Something broke.', 500); // → any error code api_paginated($paginator, 'Listed.'); // → 200 with meta
Available methods
| Method | HTTP code | status |
|---|---|---|
success($data, $message, $code = 200) |
200 | true |
created($data, $message) |
201 | true |
noContent() |
204 | — |
paginated($paginator, $message, $code = 200) |
200 | true |
error($message, $code = 400, $errors) |
any | false |
validationError($errors, $message) |
422 | false |
unauthorized($message) |
401 | false |
forbidden($message) |
403 | false |
notFound($message) |
404 | false |
serverError($message, $errors) |
500 | false |
All $message and $errors parameters are optional. When $message is
omitted the package looks up a default from config/api-response.php
(default_messages) and falls back to lang/en/messages.php.
Response shape reference
Every response (except noContent) always contains the same six keys so
clients can write a single deserialiser.
{
"status": bool — true on success, false on error
"code": int — mirrors the HTTP status code
"message": string — human-readable description
"data": mixed|null — payload on success, null on error
"errors": mixed|null — error details on failure, null on success
"meta": object|null — pagination info (paginated only), otherwise null
}
Optional fields (enabled via config):
"timestamp": string — ISO-8601 datetime (include_timestamp = true)
"request_id": string — UUID v4 (include_request_id = true)
Success — 200
{
"status": true,
"code": 200,
"message": "Users fetched successfully.",
"data": [{ "id": 1, "name": "Alice" }],
"errors": null,
"meta": null
}
Created — 201
{
"status": true,
"code": 201,
"message": "User created successfully.",
"data": { "id": 42, "name": "Bob" },
"errors": null,
"meta": null
}
Validation error — 422
{
"status": false,
"code": 422,
"message": "The given data was invalid.",
"data": null,
"errors": {
"email": ["The email field is required."],
"name": ["The name must be at least 3 characters."]
},
"meta": null
}
Not found — 404
{
"status": false,
"code": 404,
"message": "The requested resource was not found.",
"data": null,
"errors": null,
"meta": null
}
Server error — 500 (with debug = true and APP_DEBUG = true)
{
"status": false,
"code": 500,
"message": "An internal server error occurred. Please try again later.",
"data": null,
"errors": {
"exception": "RuntimeException",
"message": "Something exploded.",
"file": "/var/www/app/Services/PaymentService.php",
"line": 84,
"trace": [ ... ]
},
"meta": null
}
Note: Stack-frame
argsare always removed from the trace before serialisation, even in debug mode, to prevent leaking passwords, tokens, or sensitive model state. See Security considerations.
Paginated responses
Pass any AbstractPaginator instance — including the result of
Eloquent's paginate() or simplePaginate():
public function index(): JsonResponse { $users = User::paginate(15); return ApiResponse::paginated($users, 'Users listed.'); // or: $this->paginated($users, 'Users listed.'); // or: api_paginated($users, 'Users listed.'); }
Response:
{
"status": true,
"code": 200,
"message": "Users listed.",
"data": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
],
"errors": null,
"meta": {
"current_page": 1,
"per_page": 15,
"total": 100,
"last_page": 7,
"from": 1,
"to": 15
}
}
simplePaginate() / CursorPaginator do not support total and
last_page — those fields will be null in the meta block.
Exception handler integration
The HandlesApiExceptions trait automatically converts unhandled exceptions
into the standard JSON envelope. Wire it up once and every unhandled exception
your application throws will return a consistent error response.
Laravel 11 and 12 (bootstrap/app.php)
<?php use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Http\Request; use NilanjanK\ApiResponseFormatter\Traits\HandlesApiExceptions; use Throwable; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', ) ->withExceptions(function (Exceptions $exceptions) { $exceptions->render(function (Throwable $e, Request $request) { if ($request->expectsJson()) { return (new class { use HandlesApiExceptions; })->renderApiException($e, $request); } }); })->create();
Laravel 10 (app/Exceptions/Handler.php)
<?php namespace App\Exceptions; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use NilanjanK\ApiResponseFormatter\Traits\HandlesApiExceptions; use Throwable; class Handler extends ExceptionHandler { use HandlesApiExceptions; public function render($request, Throwable $e) { if ($request->expectsJson()) { return $this->renderApiException($e, $request); } return parent::render($request, $e); } }
Exceptions handled automatically
| Exception class | Response code | Message source |
|---|---|---|
ValidationException |
422 | Exception's own errors() bag |
AuthenticationException |
401 | Exception's own message |
AuthorizationException |
403 | Generic translated message (raw message never exposed) |
ModelNotFoundException |
404 | Generic translated message |
HttpExceptionInterface |
Actual HTTP code | Exception message or lang fallback |
| Everything else | 500 | Generic translated message (+ debug block if enabled) |
Macro support
Register custom response types at runtime — for example in a
ServiceProvider::boot() method:
use NilanjanK\ApiResponseFormatter\Facades\ApiResponse; // In AppServiceProvider::boot() or any other service provider: ApiResponse::macro('teapot', function () { return ApiResponse::error("I'm a teapot", 418); }); ApiResponse::macro('accepted', function (mixed $data = null, string $message = 'Accepted.') { return ApiResponse::success($data, $message, 202); }); ApiResponse::macro('tooManyRequests', function (string $message = 'Too many requests.') { return ApiResponse::error($message, 429); });
Then use them anywhere:
return ApiResponse::teapot(); return ApiResponse::accepted($job, 'Your job is queued.'); return ApiResponse::tooManyRequests();
BaseResource
Extend BaseResource instead of JsonResource to have single-resource
responses automatically wrapped in the standard envelope without any extra
controller code:
<?php namespace App\Http\Resources; use Illuminate\Http\Request; use NilanjanK\ApiResponseFormatter\Http\Resources\BaseResource; class UserResource extends BaseResource { public function toArray(Request $request): array { return [ 'id' => $this->id, 'name' => $this->name, 'email' => $this->email, 'created_at' => $this->created_at->toIso8601String(), ]; } }
In your controller:
// 200 with a custom message return UserResource::make($user)->withMessage('User fetched.'); // 201 on creation return UserResource::make($user) ->withMessage('User created.') ->withStatusCode(201);
Response:
{
"status": true,
"code": 200,
"message": "User fetched.",
"data": { "id": 1, "name": "Alice", "email": "alice@example.com", "created_at": "..." },
"errors": null,
"meta": null
}
Localization / i18n
The default English messages live in lang/en/messages.php. Publish them to
provide translations for additional locales:
php artisan vendor:publish --tag=api-response-lang
Files are published to lang/vendor/api-response/. Add a sibling directory
for each locale you support — Laravel's translation system resolves the
correct file automatically based on App::getLocale().
lang/
└── vendor/
└── api-response/
├── en/
│ └── messages.php ← already exists after publish
├── fr/
│ └── messages.php ← create this for French
└── de/
└── messages.php ← create this for German
Example lang/vendor/api-response/fr/messages.php:
<?php return [ 200 => 'Opération réussie.', 201 => 'Ressource créée avec succès.', 400 => 'Requête invalide.', 401 => 'Non authentifié. Veuillez vous connecter.', 403 => "Vous n'avez pas la permission d'effectuer cette action.", 404 => 'La ressource demandée est introuvable.', 422 => 'Les données fournies sont invalides.', 500 => 'Une erreur interne est survenue. Veuillez réessayer.', ];
You can also override a message for a specific HTTP code without touching
the lang files — use default_messages in config/api-response.php:
'default_messages' => [ 404 => 'Nothing here. Double-check the URL.', 503 => 'We are down for maintenance. Back shortly.', ],
Security considerations
Debug mode and stack traces
Setting debug = true in config/api-response.php (together with
APP_DEBUG=true) adds exception details to 500 responses.
Stack-frame function arguments are always stripped before the trace is
serialised, even when debug mode is on. PHP's getTrace() includes the actual
runtime values passed to each function — passwords, API tokens, and model
attributes would otherwise appear verbatim in the JSON response. The package
uses array_diff_key($frame, ['args' => true]) on every frame.
// What PHP's getTrace() would expose without the fix:
frame[0]['args'][0] => "SuperSecret123!" ← stripped by this package
// What the package actually serialises:
{ "function": "login", "file": "...", "line": 42 } ← args gone
Always keep debug = false in production.
X-Request-Id reflection
When include_request_id = true the incoming X-Request-Id header is
reflected in the response. The package validates the header against a strict
UUID v4 regex before echoing it. Any non-conforming value is silently replaced
with a server-generated UUID — preventing header injection and log poisoning.
AuthorizationException messages
AuthorizationException messages are never reflected in responses.
Custom policy messages often expose internal model names, record IDs, or
business-rule text (e.g. "Cannot update App\Models\BankAccount with ID 9").
The package always returns the generic translated 403 message instead.
Testing
Run the test suite inside the package directory:
# Install dev dependencies composer install # Run all tests composer test # Run with code coverage (requires Xdebug or PCOV) composer test-coverage
The suite uses Orchestra Testbench and covers:
- Every response method returns the correct HTTP status code
- JSON envelope always contains the required six keys
status/codevalues match the HTTP code- Paginated meta block is correctly populated from the paginator
include_timestamptoggle adds / removes thetimestampkeyinclude_request_idtoggle and UUID validation / rejectioninclude_meta = falsesuppresses meta even on paginated responses- Validation error structure matches the
errorsbag - Exception handler converts
ValidationException→ 422,AuthenticationException→ 401,AuthorizationException→ 403,ModelNotFoundException→ 404,HttpException→ its code, everything else → 500 - Stack traces never contain
argseven when debug mode is on AuthorizationExceptionraw message is never reflected- Debug defaults to off when config key is missing
- Facade alias resolves to the same singleton
- Global helper functions proxy to the manager correctly
- Macros can be registered and called
Contributing
Contributions are welcome! Please follow these steps:
- Fork the repository and create a branch from
main. - Write tests for any new feature or bug fix — PRs without tests will not be merged.
- Run the test suite and ensure everything passes:
composer test - Apply code style with Laravel Pint:
composer lint - Update
CHANGELOG.mdunder an[Unreleased]heading. - Open a pull request with a clear title and description.
Please follow PSR-12 coding standards.
All new public API must include a DocBlock with @param and @return tags.
Changelog
See CHANGELOG.md for a full list of changes per release.
License
The MIT License (MIT). See LICENSE for full details.