Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Json Api Laravel Package

sycho/json-api

A Laravel/PHP package for building JSON:API-compliant APIs. It helps serialize resources, handle relationships and includes, and return standardized responses, making it easier to expose clean, spec-compliant endpoints with less boilerplate.

View on GitHub
Deep Wiki
Context7

Getting Started

sycho/json-api is a framework-agnostic serializer for the JSON:API spec — it is the maintained fork of tobscure/json-api and is what Flarum ships. It has no Laravel integration: no service provider, no facade, no config, no response helper. You install it and wire it yourself.

composer require sycho/json-api

Orient yourself in vendor/sycho/json-api/src/ — the whole surface is five concepts:

Class Role
Serializer\AbstractSerializer Maps one Eloquent model → type/id/attributes/relationships/links/meta
Element\Resource / Element\Collection Wraps one model / a collection for the data member
Document The top-level object: data, meta, links, errors, jsonapi
Parameters Parses include, fields[type], sort, page, filter from the query string
Relationship / Link Relationship objects and link value objects

First use case — a GET /api/posts/{post} endpoint. Create app/JsonApi/PostSerializer.php:

namespace App\JsonApi;

use Sycho\JsonApi\Serializer\AbstractSerializer;
use Sycho\JsonApi\Element\Collection;

class PostSerializer extends AbstractSerializer
{
    protected $type = 'posts';

    public function getId($post): string
    {
        return (string) $post->id;      // spec requires a string
    }

    public function getAttributes($post, array $fields = null): array
    {
        $attributes = [
            'title'      => $post->title,
            'body'       => $post->body,
            'created-at' => $post->created_at->toIso8601String(),
        ];

        // honour ?fields[posts]=title
        return $fields === null ? $attributes : array_intersect_key($attributes, array_flip($fields));
    }

    public function getRelationships($post, $included): array
    {
        return [
            'author'   => $this->hasOne($post->author, new UserSerializer()),
            'comments' => $this->hasMany($post->comments, new CommentSerializer()),
        ];
    }
}

Then in a controller:

use Sycho\JsonApi\Document;
use Sycho\JsonApi\Element\Resource;
use Sycho\JsonApi\Parameters;

public function show(Request $request, Post $post)
{
    $params = new Parameters($request->query());   // 0.x line takes a PSR-7 request instead
    $includes = $params->getInclude();

    $post->load($this->mapIncludes($includes));    // you own eager loading

    $document = (new Document(new Resource($post, new PostSerializer())))
        ->setJsonApi(['version' => '1.1'])
        ->setLinks(['self' => $request->fullUrl()]);

    return response()->json($document->toArray(), 200, [
        'Content-Type' => 'application/vnd.api+json',
    ]);
}

If that first request returns data.type/data.id/data.attributes and the media type is accepted by your client, everything else is incremental.

Implementation Patterns

Wrap the response once. Add a JsonApiResponse helper or a response()->jsonapi() macro so every endpoint produces the same envelope and content type. This is the single highest-leverage investment — it replaces the service provider the package doesn't give you.

class JsonApiResponse
{
    public static function make(Document $doc, int $status = 200)
    {
        return response()->json($doc->toArray(), $status, [
            'Content-Type' => 'application/vnd.api+json',
        ]);
    }
}

One serializer per resource type, composed by relationships. Serializers are cheap value objects; instantiate them inline. Keep getRelationships() returning all relationships unconditionally — the document uses the include list to decide whether each becomes full data or just a links.related stub, so you get discoverable links for free.

Map include → eager loads. The package never touches your ORM. Translate dotted include paths (author.company) into with() arguments in one place; otherwise you get N+1 on every relationship.

$map = ['author' => 'author', 'comments' => 'comments.user', 'author.company' => 'author.company'];
$post->load(array_values(array_intersect_key($map, array_flip($includes))));

Pagination belongs to you, not the package. Use LengthAwarePaginator for the query, then hand-build links/meta:

$paginator = Post::paginate($params->getPage()['size'] ?? 20);

$document = (new Document(new Collection($paginator->getCollection(), new PostSerializer())))
    ->setLinks([
        'self'  => $request->fullUrlWithQuery(['page' => $paginator->currentPage()]),
        'first' => $paginator->url(1),
        'next'  => $paginator->nextPageUrl(),
        'last'  => $paginator->url($paginator->lastPage()),
    ])
    ->setMeta([
        'page' => [
            'current-page' => $paginator->currentPage(),
            'per-page'     => $paginator->perPage(),
            'total'        => $paginator->total(),
        ],
    ]);

Errors via the exception handler. JSON:API error objects need title/detail/status/source.pointer. In bootstrap/app.php (or Handler::render), convert ValidationException into a Document with setErrors([...]) and a 422, mapping Laravel's dot-notation keys to pointers: name.first → /data/attributes/name/first.

Whitelist sort and filter field names before they reach the query builder. Since v0.5.2 the package validates the shape of the limit and filter parameters, so malformed or out-of-range values are rejected before your code sees them — but it still doesn't know your schema. getSort() and the accepted filter keys arrive as raw client strings and must be intersected with an allowlist per resource type.

Nested documents as sub-requests. For batch endpoints (POST /api/batch), build one Document per operation and stack their arrays — the library won't do atomic operations for you.

Gotchas and Tips

  • v0.5.2 validates limit and filter — drop-in, but audit for behaviour change. Malformed limit values and malformed filter parameters are now rejected by Parameters instead of being passed through verbatim. This closes the "raw client string hits the query builder" hole, but check anything that relied on lenient parsing: custom filter parsers, tests that feed garbage query strings, and clients sending an empty ?filter=. sort is still not validated — keep your own whitelist.
  • Construct Parameters where a rejection can become a JSON:API error. Now that parsing can fail, instantiate it in one place (a middleware, a base controller method, or a small JsonApiRequest wrapper) with a try/catch that renders a 400/422 Document of error objects and the correct content type — otherwise a bad ?filter[foo]=1 surfaces as a generic Laravel 500 or a redirect instead of a spec-compliant error.
  • IDs must be serialized as strings. (string) $model->id in getId(). Integer IDs cause strict clients (Ember Data, json-api-client) to reject or silently mis-key the store.
  • Content type is application/vnd.api+json, not application/json. Laravel's response()->json() defaults to the wrong one; set the header explicitly and be aware Accept negotiation isn't done for you.
  • No relationship data unless it's in include. If a client sees only relationships.author.links.related, that's correct JSON:API — not a bug. Conversely, don't return a relationship that has no route; the related link is generated from naming conventions in some setups and can 404.
  • Sparse fieldsets are opt-in per attribute. getAttributes() receives the requested field list (or null); returning the full array regardless is the most common spec violation and leaks fields you didn't intend to expose.
  • Attribute/relationship name collisions silently break output. Never return a key from getAttributes() that also appears in getRelationships().
  • The package does not protect you from recursive includes. ?include=author.posts.author.posts... will loop through your eager loads. Cap include depth (2–3) and 400 on longer paths.
  • Document holds a reference, not a snapshot. If you mutate models after building the document (e.g. in a middleware) and then call toArray(), you'll serialize the mutated state — build the document last.
  • Empty to-one relationships: pass null as the model to a Resource to emit data: null; passing an empty collection emits data: []. Mixing these up is the classic "relationship not clearing" bug in clients.
  • meta on a relationship vs. on the resource live in different places — relationship meta goes on the Relationship object, resource meta via getMeta(). Putting counts on the wrong one means clients can't find them.
  • Error objects are plain arrays, not value objects, in current versions. Emit ['status' => '422', 'title' => 'Unprocessable Entity', 'detail' => '...', 'source' => ['pointer' => '/data/attributes/email']].
  • Version pinning matters. This is a low-traffic fork (1 star, releases are infrequent, PHP 8.x support added by the Flarum team); v0.5.2 is the current patch. It works and is battle-tested through Flarum, but the "0 dependents" figure is misleading — actual usage is inside Flarum's monorepo. Pin ^0.5.2 (or ^2.0 if you're on the newer line) and read CHANGELOG before bumping; the parameter-validation fix is worth taking deliberately rather than by accident.
  • If you're starting greenfield and want batteries included, weigh laravel-json-api (route/model/schema generation, filtering, pagination, validation out of the box). Choose sycho/json-api when you want a ~1k-line, zero-magic serializer you can read end to end and whose only job is shaping the document.
  • Testing: assert with assertJsonPath('data.type', 'posts') and assertHeader('Content-Type', 'application/vnd.api+json'); add a test that requests ?fields[posts]=title and asserts the other keys are absent — that's where regressions hide. Add a second test for ?filter[...]/?page[size]= garbage asserting a 4xx JSON:API error document (not a stack trace), now that v0.5.2 rejects those inputs.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle