yzen.dev / laravel-json-api-response
Helpers for building JSON:API responses with responder classes
Package info
github.com/yzen-dev/laravel-json-api-response
pkg:composer/yzen.dev/laravel-json-api-response
Requires
- php: ^8.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/pagination: ^10.0|^11.0|^12.0
Requires (Dev)
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^10.5
README
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/httpilluminate/pagination
Installation
composer require yzen.dev/json-api-response
Requirements
- PHP
^8.0 illuminate/httpilluminate/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+jsonCharset: 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:
AuthenticationJsonApiExceptionAuthorizationJsonApiExceptionResourceConflictJsonApiExceptionResourceNotFoundJsonApiExceptionErpSystemJsonApiException
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.