Skip to content

Routing

Routes register themselves from config/jsonapi.php — one route per discovered type × allowed operation, per server. This is the opposite of the Symfony bundle (where you import a route type by hand). This page covers the auto-registration, the manual-placement macro, route names, and route:cache safety (PLAN decision 4).

Auto-registration

On boot, the package registers every allowed operation for every discovered resource under its server's prefix, middleware, and optional domain from config/jsonapi.php:

GET    /api/albums                          jsonapi.albums.index
GET    /api/albums/{id}                      jsonapi.albums.show
POST   /api/albums                           jsonapi.albums.create
PATCH  /api/albums/{id}                      jsonapi.albums.update
DELETE /api/albums/{id}                      jsonapi.albums.delete
GET    /api/albums/{id}/{relationship}       …  # related + relationship read/mutation

Only the operations a resource allows are emitted (readOnly/operations — see resources); the rest simply don't exist.

Route names

Route names are stable and predictable:

  • default server: jsonapi.{type}.{action} — e.g. jsonapi.albums.show;
  • named server: jsonapi.{server}.{type}.{action} — e.g. jsonapi.admin.albums.show.

Use them with route() and URL::route() as normal. The docs routes follow the same scheme (jsonapi.openapi.json, jsonapi.admin.openapi.json) — see openapi.

Id route constraints

An Id field's matchAs() becomes a ->where('id', …) route constraint, so a malformed id is a clean route miss (404) rather than a 500 deep in the provider:

Id::make()->encodeUsing(new ProductIdCodec())->matchAs('prod-[0-9a-f]+');
// → route {id} constrained to prod-[0-9a-f]+

uuid() / ulid() id strategies apply their natural format constraint automatically.

Server middleware and domain

Attach middleware or a host constraint per server in the config:

'servers' => [
    'default' => ['prefix' => 'api',   'middleware' => ['throttle:api'], 'domain' => null],
    'admin'   => ['prefix' => 'admin', 'middleware' => ['auth:sanctum'], 'domain' => 'admin.example.test'],
],

With an empty base_uri, a server's links carry the prefix its routes matched under, and the request host stands in for the domain (see configuration). A configured base_uri must include the prefix.

Manual placement

Opt out of auto-registration and place the routes yourself with the Route::jsonApi() macro — useful to nest them inside your own route group (extra middleware, a versioned prefix):

// A service provider's register():
use haddowg\JsonApiLaravel\Facades\JsonApi;
JsonApi::ignoreRoutes();

// routes/api.php:
Route::middleware('auth:sanctum')->prefix('v2')->group(function () {
    Route::jsonApi();          // the default server's routes here
    Route::jsonApi('admin');   // a named server's routes here
});

Route::jsonApi(?string $server = null) emits the same operation-gated route set the auto-registration would, inside the surrounding group. Links built from an empty base_uri follow that group's prefix (/v2 above), because the package reads the prefix off the matched route rather than from the server config.

Is this a JSON:API route?

Every route the package registers (resources, relationships, custom actions and atomic operations) carries the _jsonapi route default. $request->isJsonApi() reads it off the matched route, so your own middleware can tell the package's routes apart without depending on its internals:

use Closure;
use Illuminate\Http\Request;

final class AddLegacyEnvelope
{
    public function handle(Request $request, Closure $next)
    {
        if ($request->isJsonApi()) {
            return $next($request);   // leave JSON:API responses alone
        }

        // ... the app's own behaviour
        return $next($request);
    }
}

The answer comes from the matched route, so it is only meaningful after Laravel's router has matched one. Route middleware (including a server's middleware and a group around Route::jsonApi()) sees the right answer. Global middleware runs before routing and always gets false, as does anything else that runs before the route is matched.

Which endpoint it is

$request->jsonApi() goes further and says which endpoint matched. It returns a JsonApiRoute, or null wherever isJsonApi() is false:

$route = $request->jsonApi();

$route?->server;                  // 'default', or the named server
$route?->type;                    // 'albums'; null on the atomic route
$route?->id;                      // '1' from /albums/1; null on collection routes
$route?->relationship;            // 'artist' from /albums/1/artist and /albums/1/relationships/artist
$route?->isRelationshipEndpoint;  // true only for /relationships/{relationship}
$route?->action;                  // 'publish' from /albums/1/-actions/publish
$route?->actionScope;             // ActionScope::Resource or ActionScope::Collection
$route?->isAtomic;                // true on the atomic operations route
$route?->target();                // core's Target for the endpoint; null on the atomic route

id and relationship are the route parameters as the URL carried them. Nothing is loaded: there is no model on a JsonApiRoute, and middleware runs before the package fetches one. Code that needs the model belongs in a lifecycle hook or a custom action, which receive it.

There is no Server on it either. Resolve the server for the request from the container, which bases its links where the request found it (see configuration):

use haddowg\JsonApiLaravel\Server\ServerRegistry;

$server = app(ServerRegistry::class)->forRequest($request);

Without the macros

Both macros delegate to static methods on JsonApiRoute, for code that prefers a typed call:

use haddowg\JsonApiLaravel\Routing\JsonApiRoute;

JsonApiRoute::matches($request);   // isJsonApi()
JsonApiRoute::of($request);        // jsonApi()
JsonApiRoute::MARKER;              // '_jsonapi', the route default both check

Larastan and the IDE helpers that read Laravel's registered macros (barryvdh/laravel-ide-helper) pick the macros up from the booted application, so $request->jsonApi()?->type is typed ?string. Plain PHPStan without Larastan doesn't know about macros, so call the static methods there.

route:cache safety

Route registration reads the cached discovery snapshot rather than re-scanning the filesystem, so php artisan route:cache works. Warm the snapshot with the optimize pipeline (php artisan jsonapi:optimize) before caching routes in production; in development the scan runs live on first boot.