trivolink / unified-api
Unified JSON API contract for Inertia-powered Laravel backends — serve web, mobile and desktop clients from one set of routes.
Requires
- php: ^8.2
- inertiajs/inertia-laravel: ^3.0
Requires (Dev)
- laravel/pint: ^1.16
- laravel/sanctum: ^4.3
- orchestra/testbench: ^9.2|^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
This package is not auto-updated.
Last update: 2026-08-27 12:39:19 UTC
README
One Laravel backend, three clients. Web keeps Inertia SSR HTML (SPA + SEO); mobile and desktop apps receive a standardized JSON envelope — from the same URLs and the same controllers.
New here and deciding? Read docs/ANALYSIS.md — the problem, the alternatives compared, honest trade-offs, and when this package is the wrong tool.
Response matrix
| Request | Response |
|---|---|
Accept: text/html |
Inertia SSR HTML (unchanged) |
X-Inertia: true |
Inertia page object (unchanged SPA navigation) |
Accept: application/json |
Envelope: {data, meta, message, version} |
{
"data": { "users": 5, "auth": { "user": "Tania" } },
"meta": { "component": "Dashboard", "url": "/dashboard" },
"message": "Profile updated.",
"version": "v1"
}
data— fully resolved page props (shared + page props, eager: deferred/optional props included)meta—component(screen hint) andurl, each disableable viaunified-api.meta.*; always a JSON object, never an arraymessage— flashedmessagekey when present, elsenullversion— API contract version (UNIFIED_API_VERSION, defaultv1)
POST endpoints that redirect respond 200 (configurable) with
meta.redirect instead of a 302/303, so native clients never silently
follow redirects. Validation and HTTP errors keep their status code and
arrive wrapped: {data: null, message, errors?, version} — including
exception-rendered responses (401 unauthenticated, 404, validation 422,
throttle 429, server 5xx).
Install
composer require trivolink/unified-api
Publish config (optional):
php artisan vendor:publish --tag=unified-api-config
Auth: Sanctum dual-mode
composer require laravel/sanctum php artisan install:api
Swap the auth middleware on your shared (web) route group:
Route::middleware(['auth:sanctum', ...]) // was: 'auth'
Sanctum's guard checks Authorization: Bearer <token> first (issue
personal access tokens to your mobile/desktop apps) and falls back to
the web session for browsers — your existing Fortify/session flow keeps
working untouched.
Getting a first token: POST /api/token
Mobile/desktop clients bootstrap their bearer token with an email + password exchange (stateless, throttled to 5/min by default):
POST /api/token {"email": "tania@example.com", "password": "...", "device_name": "iphone-15"}
{"data": {"token": "1|abc123..."}, "meta": {}, "message": null, "version": "v1"}
Wrong credentials get the 422 envelope with errors.email. The route
requires the user model to use Laravel\Sanctum\HasApiTokens and can be
configured or disabled under unified-api.token_endpoint:
'token_endpoint' => [ 'enabled' => env('UNIFIED_API_TOKEN_ENDPOINT', true), 'path' => env('UNIFIED_API_TOKEN_PATH', 'api/token'), 'middleware' => ['throttle:5,1'], ],
CSRF
Browser POSTs still require CSRF tokens (Inertia sends them
automatically). Stateless JSON clients must not be blocked by CSRF, so
swap the framework middleware in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: [ // ... your existing exceptions ]); // Laravel 13+ (the web group ships PreventRequestForgery): $middleware->replaceInGroup( 'web', \Illuminate\Foundation\Http\Middleware\PreventRequestForgery::class, \Trivium\UnifiedApi\Middleware\ValidateCsrfTokenExceptApiClients::class, ); // On Laravel 11-12, target ValidateCsrfToken::class instead. })
This is CSRF-safe: the exemption requires the custom
Accept: application/json header, which cross-site forms can never set
and cross-origin fetches cannot send without passing a CORS preflight.
Bearer-authenticated requests carry no ambient cookie credentials for an
attacker to ride.
Mobile/Desktop client checklist
- Send
Accept: application/jsonon every request. - Bootstrap:
POST /api/tokenwith email + password, storedata.token. - Authenticate every request with
Authorization: Bearer <token>. - Read
version; when it differs from your compiled-in contract (e.g. you shippedv1, server now sendsv2), prompt the user to update. - On
meta.redirect, navigate explicitly — do not rely on HTTP redirect following.
Why not just send X-Inertia from mobile?
The Inertia page object is a UI protocol, not an API contract:
component names refer to React/Vue page components the native app
does not have, url/version exist for browser history and asset
hashing, and partial-reload/deferred/merge semantics assume the Inertia
JS client. The envelope is a stable, minimal contract purpose-built for
native consumers.
Testing your app
Everything Inertia offers keeps working (assertInertia etc.). For
unified clients, assert on the envelope:
$this->get('/dashboard', ['Accept' => 'application/json']) ->assertOk() ->assertJsonPath('version', 'v1') ->assertJsonPath('data.users', 5);
Contract testing
The envelope's data is your resolved page props — for native clients,
those props ARE the API. Freeze their shape with snapshot tests so a
web refactor that renames, retypes or drops a prop fails CI instead of
silently breaking shipped apps:
use function envelopeSnapshot; // global helper, autoloaded test('dashboard envelope contract', function () { $user = User::factory()->create(); envelopeSnapshot('dashboard', fn () => $this ->actingAs($user) ->get(route('dashboard'), ['Accept' => 'application/json'])); });
The first run writes tests/Snapshots/UnifiedApi/dashboard.json (shape
only — key tree plus JSON types; values never recorded, so factory data
and timestamps cannot flake). Later runs compare. Non-2xx responses fail
immediately: error envelopes are not contracts.
When a snapshot diff appears in a PR, the change rule is two lines:
- additive (new keys only) — regenerate:
ENVELOPE_SNAPSHOT_UPDATE=1 vendor/bin/pest - breaking (remove/rename/retype) — bump
UNIFIED_API_VERSION, update consumers, and regenerate in the same commit
Store the snapshot path override for unusual layouts with
EnvelopeSnapshot::usingSnapshotPath(...); the default is
base_path('tests/Snapshots/UnifiedApi').
Development
composer install composer test # phpunit composer lint # pint
Before tagging a release, follow docs/PUBLISHING.md (license, metadata, lock handling, CI, Packagist).