nilanjan-k/api-response-formatter

A production-ready Laravel package for standardized, consistent JSON API responses.

Maintainers

Package info

github.com/nilanjan-k/api-response-formatter

pkg:composer/nilanjan-k/api-response-formatter

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-06-01 08:11 UTC

This package is auto-updated.

Last update: 2026-06-01 08:21:19 UTC


README

Latest Version on Packagist PHP Version Laravel Tests License

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

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 args are 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 / code values match the HTTP code
  • Paginated meta block is correctly populated from the paginator
  • include_timestamp toggle adds / removes the timestamp key
  • include_request_id toggle and UUID validation / rejection
  • include_meta = false suppresses meta even on paginated responses
  • Validation error structure matches the errors bag
  • Exception handler converts ValidationException → 422, AuthenticationException → 401, AuthorizationException → 403, ModelNotFoundException → 404, HttpException → its code, everything else → 500
  • Stack traces never contain args even when debug mode is on
  • AuthorizationException raw 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:

  1. Fork the repository and create a branch from main.
  2. Write tests for any new feature or bug fix — PRs without tests will not be merged.
  3. Run the test suite and ensure everything passes: composer test
  4. Apply code style with Laravel Pint: composer lint
  5. Update CHANGELOG.md under an [Unreleased] heading.
  6. 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.