Looking to hire Laravel developers? Try LaraJobs

laravel-json-api-response maintained by yzen.dev

Description
Helpers for building JSON:API responses with responder classes
Last update
2026/05/15 16:50 (dev-main)
License
Links
Downloads
0

Comments
comments powered by Disqus

JsonApiResponse

Packagist Version GitHub Workflow Status Coverage License Packagist Downloads Packagist Downloads PHPStan

JsonApiResponse helps you build JSON:API-compatible response payloads with small responder classes.

The package is framework-light: the responder logic itself works with plain PHP objects and arrays, while HTTP responses and paginated lists use Laravel components:

  • illuminate/http
  • illuminate/pagination

Installation

composer require yzen.dev/json-api-response

Requirements

  • PHP ^8.0
  • illuminate/http
  • illuminate/pagination

Basic Responder

Create a responder by extending AbstractResponder and implementing two methods:

use YzendDev\Laravel\JsonApiResponse\Responder\AbstractResponder;

/**
 * @extends AbstractResponder<User>
 */
final class UserResponder extends AbstractResponder
{
    protected function resourceType(): string
    {
        return 'User';
    }

    protected function composeAttributes(mixed $entity): array
    {
        return [
            'name' => $entity->name,
            'isActive' => $entity->is_active,
            'createdAt' => $entity->created_at->toIso8601String(),
        ];
    }
}

Then compose a resource document:

$response = (new UserResponder())->compose($user);

The resulting payload follows the standard JSON:API shape:

[
    'data' => [
        'id' => 10,
        'type' => 'User',
        'attributes' => [
            'name' => 'yzen.dev',
            'isActive' => true,
            'createdAt' => '2026-05-15T12:00:00+00:00',
        ],
    ],
]

By default, resourceId() returns $entity->id. Override it if your entity uses a different identifier.

Relationships

Define relationships in relationships() and pass the related records through withRelationData().

use YzendDev\Laravel\JsonApiResponse\Responder\RelationshipDefinition;

protected function relationships(): array
{
    return [
        'countryFrom' => new RelationshipDefinition('country_from_id', CountryResponder::class),
        'cityFrom' => new RelationshipDefinition('city_from_id', CityResponder::class),
    ];
}

The relation data must be passed as a map keyed by the foreign key value:

$countries = [
    10 => $country,
    11 => $anotherCountry,
];

$responder = (new UserResponder())
    ->withRelationData('countryFrom', $countries);

If the foreign key is null, or the related object is missing from the map, the relationship value becomes null.

To-Many Relationships

Use ToManyRelationshipDefinition for grouped child resources:

use YzendDev\Laravel\JsonApiResponse\Responder\ToManyRelationshipDefinition;

protected function relationships(): array
{
    return [
        'pickups' => new ToManyRelationshipDefinition('id', PickupResponder::class),
    ];
}

Pass grouped data keyed by the parent entity field declared in entityKey:

$pickups = [
    5 => [
        $pickupA,
        $pickupB,
    ],
    6 => [
        $pickupC,
    ],
];

$responder = (new UserResponder())
    ->withRelationData('pickups', $pickups);

Nested Relationship Data

If a child responder has its own relationships, pass that data through withNestedRelationData():

$responder = (new UserResponder())
    ->withRelationData('pickups', $pickups)
    ->withNestedRelationData('pickups', 'city', [
        7 => $hamburg,
        8 => $berlin,
    ]);

This is useful when a to-many relation needs nested linked resources without loading them inside the responder itself.

Includes

Top-level included resources are defined through includes():

use YzendDev\Laravel\JsonApiResponse\Responder\IncludeDefinition;

protected function includes(): array
{
    return [
        'units' => new IncludeDefinition(UnitResponder::class),
    ];
}

Pass included entities as a flat array:

$responder = (new SomeResponder())->withIncludeData('units', [
    $unitA,
    $unitB,
]);

Paginated Lists

Use composeList() with Laravel's LengthAwarePaginator:

$payload = (new UserResponder())->composeList($paginator);

The package adds pagination metadata automatically:

[
    'meta' => [
        'totalRecords' => 50,
        'perPage' => 15,
        'currentPage' => 2,
        'lastPage' => 4,
    ],
]

HTTP Response Wrapper

YzendDev\Laravel\JsonApiResponse\Responder\JsonResponse extends Laravel's JsonResponse and sets JSON:API headers:

use YzendDev\Laravel\JsonApiResponse\Responder\JsonResponse;

return new JsonResponse($payload);

In a controller, a typical response looks like this:

use YzendDev\Laravel\JsonApiResponse\Responder\JsonResponse;

return new JsonResponse(
    status: JsonResponse::HTTP_OK,
    data: new UserResponder()->compose($request->user()),
);

Headers set by default:

  • Content-type: application/vnd.api+json
  • Charset: utf-8

Error Responses

Validation Errors

use YzendDev\Laravel\JsonApiResponse\Responder\ValidationErrorsResponder;

$errors = (new ValidationErrorsResponder())->compose([
    'email' => ['The email field is required.'],
]);

Ready-to-use JSON:API Error Documents

The package also ships a few ready-made JSON response classes:

  • AuthenticationJsonApiException
  • AuthorizationJsonApiException
  • ResourceConflictJsonApiException
  • ResourceNotFoundJsonApiException
  • ErpSystemJsonApiException

Global Exception Handling

In Laravel, the package works best when JSON:API error rendering is configured once in bootstrap/app.php.

use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use YzendDev\Laravel\JsonApiResponse\Responder\JsonResponse;
use YzendDev\Laravel\JsonApiResponse\Responder\Types\AuthenticationJsonApiException;
use YzendDev\Laravel\JsonApiResponse\Responder\Types\AuthorizationJsonApiException;
use YzendDev\Laravel\JsonApiResponse\Responder\Types\ResourceConflictJsonApiException;
use YzendDev\Laravel\JsonApiResponse\Responder\Types\ResourceNotFoundJsonApiException;
use YzendDev\Laravel\JsonApiResponse\Responder\ValidationErrorsResponder;

return Application::configure(basePath: dirname(__DIR__))
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->render(fn (AuthenticationException $e) => new AuthenticationJsonApiException());
        $exceptions->render(fn (AuthorizationException $e) => new AuthorizationJsonApiException());
        $exceptions->render(fn (AccessDeniedException $e) => new AuthorizationJsonApiException());

        $exceptions->render(function (ValidationException $e) {
            return new JsonResponse(
                status: JsonResponse::HTTP_UNPROCESSABLE_ENTITY,
                data: [
                    'errors' => (new ValidationErrorsResponder())->compose($e->errors()),
                ],
            );
        });

        $exceptions->render(fn (MethodNotAllowedHttpException $e) => new JsonResponse(status: 405));
        $exceptions->render(fn (ResourceNotFoundException $e) => new ResourceNotFoundJsonApiException($e));
        $exceptions->render(fn (NotFoundHttpException $e) => new ResourceNotFoundJsonApiException($e));
        $exceptions->render(fn (ModelNotFoundException $e) => new ResourceNotFoundJsonApiException($e));
        $exceptions->render(fn (FileNotFoundException $e) => new ResourceNotFoundJsonApiException($e));
        $exceptions->render(fn (ResourceConflictException $e) => new ResourceConflictJsonApiException($e));
    })
    ->create();

With this approach, application code can throw domain-level exceptions and let Laravel convert them into JSON:API error documents in one place:

if (! $user) {
    throw new ResourceNotFoundException('User was not found.');
}

That keeps controllers and services focused on business logic while response formatting stays centralized.

Notes

  • Responders are intentionally passive. They format already loaded data and do not fetch dependencies themselves.
  • Relationship maps and grouped child arrays should be prepared before calling the responder.
  • The public usage model in this package follows the examples from responder.md.