laravel-json-api/non-eloquent
Laravel JSON:API adapter for non-Eloquent data sources, letting you expose custom repositories or external APIs through the JSON:API spec with resources, relationships, filtering, sorting, and pagination—no Eloquent models required.
Prerequisites — prove the base works first. This package is an extension of laravel-json-api/json-api, not a standalone. Before installing it you need:
laravel-json-api/json-api installed and configuredServer classWithout that baseline you cannot distinguish a broken non-Eloquent schema from a broken base setup. Get an Eloquent resource green first.
Install and pin.
composer require laravel-json-api/non-eloquent
Then read composer.json in the vendor directory. The package tracks a specific laravel-json-api/* major; your PHP and Laravel constraints must line up too. With 4 stars and 0 dependents, you are the compatibility test suite — pin both this package and the parent.
"laravel-json-api/json-api": "4.x.x",
"laravel-json-api/non-eloquent": "1.x.x"
Where to look first, in order.
vendor/laravel-json-api/non-eloquent/src/Schema.php — the abstract base class. Read it end to end before writing anything. It is small, and it is both schema and adapter, so its abstract method signatures are the contract. Return types and method names in the vendor source override anything you read online.Store contract (or equivalent) that defines how records are retrieved, filtered, sorted, and paginated.laravel-json-api/json-api docs on Schemas, Adapters, Requests, and Filtering / Sorting / Pagination. Everything above the storage layer is unchanged, and that is where most of your day-to-day configuration lives.Minimal steps.
app/JsonApi/V1/<Resource>/Schema.php extending the package's base schema instead of the Eloquent Schema.Server::allResources() alongside your Eloquent schemas.fields() using the parent's field classes. These read from a plain object/array through value resolvers (callbacks), not Eloquent attribute magic:public function fields(): array
{
return [
ID::make('id', fn ($obj) => (string) $obj->id),
Str::make('name', fn ($obj) => $obj->displayName()),
DateTime::make('createdAt', fn ($obj) => $obj->createdAt),
];
}
all() and find($id) for reads; create(), update(), delete() only if writable. Verify exact names and return types against the abstract class — the vendor source is the source of truth.JsonApiRoute::server('v1')->resources()->all();
JsonApiRequest classes for query validation and sparse-fieldset handling.First use case — a read-only reference resource. Countries, currencies, categories, feature flags, or a cached external API payload. Implement all() and find() and nothing else. This exercises the entire adapter path (routing, serialization, includes, fieldsets, pagination) without touching writes or relationship side effects.
Day-to-day mental model. Treat the package as a translation layer between an arbitrary data source and the schema conventions you already know. Your working loop is: pick a data source → wrap it in a repository → write a thin schema with resolvers → register it → hit the endpoint → assert on the JSON:API document.
Wrap a repository, not an array. Put the schema in the role of a thin delegator. Keep persistence and fetch logic in a service the rest of your app already uses, so you can swap the backing source (static array → Redis → API) without touching the API layer:
class CountrySchema extends \LaravelJsonApi\NonEloquent\Schema
{
public function __construct(private CountryRepository $repo) {}
public function all(): iterable { return $this->repo->all(); }
public function find(string $id): ?object { return $this->repo->find($id); }
}
This also keeps the schema small enough that if the package stalls, you can reimplement it as a custom adapter over the same repository — the day-to-day code you wrote does not move.
DTOs as first-class resources. For external API payloads, map the response into a readonly DTO inside the repository, then let schema resolvers read from it. This yields a stable field contract even when the upstream payload shifts; the resolvers absorb the translation. It also gives you a natural place to normalize IDs and types (CarbonImmutable, string, etc.) before they reach serialization.
Reuse upstream filter/sort/pagination configuration. Everything above the storage layer is unchanged. Register filters and sorts on the schema exactly as you would on an Eloquent schema, and use the standard pagination objects. The whole point is that page[number], filter[...], sort, include, and fields[...] behave identically to the rest of the API — do not hand-roll a parallel implementation in a controller.
Relationships to real Eloquent models. This is the highest-value and highest-risk pattern. A non-Eloquent resource can declare a to-one or to-many relation to an Eloquent resource, and includes resolve through the standard machinery. Implement the relation using the parent's relation classes, returning related identifiers or models from your own accessor. Spike this specifically — it is where the adapter's assumptions are most likely to leak.
Writes are optional and separate. Read-only? Do not implement create/update/delete. Writable? Pair them with request classes that call your service; treat the schema method as a thin delegator. Business rules do not belong in schemas.
Feature-flag the rollout. Register the non-Eloquent schema on its own Server (or behind a route group) initially, so you can A/B the response shape against any existing endpoint without disturbing Eloquent resources. Promote it once contract tests are green.
Contract tests, not unit tests. Assert on the serialized JSON:API document — type, id, attributes, relationships, links, meta for pagination — not on schema internals. These tests survive upstream refactors and catch the ID/linkage bugs you will actually hit.
Batch aggressively at the repository layer. No lazy loading exists here, so an include over a remote source fans out into a call per parent unless you preload in the repository. Fetch related records in bulk, cache the result of all(), and treat the upstream as slow by default.
A repeatable per-resource workflow. Once the first schema is green, each additional non-Eloquent resource follows the same checklist:
all, find, optionally create/update/delete).fields() with resolvers, then filters/sorts/pagination config copied from an equivalent Eloquent schema.allResources().Keeping to this list means new non-Eloquent endpoints cost minutes, not a design review.
IDs are entirely your responsibility. Eloquent magically supplies a stable primary key; a DTO does not. A null, array-shaped, or unstable ID breaks linkage, find(), and includes in ways that surface far from the cause. Normalize to strings at the schema boundary and make find($id) defensive against type mismatches.
Authorization is not inherited. Eloquent resources get scoping and policies through the model layer. Non-Eloquent resources bypass all of that unless you wire it explicitly — in the repository, in the request's authorize(), or in a scoping hook. Audit every such endpoint for data leakage before it ships; this is the primary security risk of adopting the pattern.
Value resolvers are callbacks, not attributes. Field classes do not know about $model->name. Every field requires a resolver. Missing or wrong-typed resolvers show up as silently-blank attributes or type errors mid-serialization — check these first when a field disappears.
Sparse fieldsets change what runs. When a client requests fields[countries]=name, only name is resolved. Do not put required side effects or expensive computation inside a resolver that may never be called; do that in the repository fetch instead.
Data source failures are your error semantics. A timeout, a 5xx, or a partial payload must become a deliberate JSON:API error, not a 500 with a stack trace. Decide the contract — empty collection, error object, or partial — and test it. Upstream unavailability is a routine case, not an edge case.
Watch query-parameter support. If you implement filtering/sorting by hand instead of reusing the parent's configuration, you lose the validation and error semantics that the rest of the API already provides. Do not diverge; the value of this package is conformance, not novelty.
Debugging order. When something is wrong:
allResources().find() returns the object you think it does.id / type linkage correctness.The adapter surface is tiny; most bugs live in your resolvers or your IDs.
Pin and read the source. With this adoption level, treat the package as a small vendored utility. Budget an afternoon to read it fully and to be able to fork it — it is a few hundred lines of thin adapter. Do not make it a critical-path dependency without a named owner watching upstream.
Verify the version matrix. PHP, Laravel, and laravel-json-api versions must all line up. If constraints do not match, that is a fork decision, not a workaround.
Exit plan. Keep schemas thin and repositories thick. If the package stalls, each schema can be reimplemented as a custom adapter over the same repository, and your day-to-day code is unchanged.
How can I help you explore Laravel packages today?