b1-road / laravel
Official Road SDK for Laravel — BFF auth, proxy, and client for the Road IAM platform.
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.15.2
- illuminate/cache: ^11.0|^12.0|^13.0
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/routing: ^11.0|^12.0|^13.0
- illuminate/session: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- league/uri: ^7.0
- spatie/laravel-data: ^4.0
- symfony/uid: ^7.0
- web-token/jwt-framework: ^3.3|^4.0
Requires (Dev)
- inertiajs/inertia-laravel: ^2.0 || ^3.0
- larastan/larastan: ^2.9|^3.0
- laravel/pint: ^1.17
- nette/php-generator: ^4.1
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
- phpstan/phpstan: ^1.11|^2.0
Suggests
- inertiajs/inertia-laravel: Auto-mounts ShareRoadContext and powers the zero-JWT @b1-road/react widget integration (^2.0).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-08 20:32:13 UTC
README
The official Road SDK for Laravel apps. True BFF auth against the Road IAM platform — the Auth Server JWT is held by your Laravel process and never reaches the browser.
Status: pre-1.0 (
0.xalpha). While Road serves thealphaAPI contract the surface may shift between minor versions; it stabilises when the API graduatesalpha→v1. The SDK is feature-complete: BFF auth, the full Members/Roles/Invitations/IAM client with auto-pagination, authorization primitives, retries/idempotency, service-to-service mode, and webhooks.
Install
composer require b1-road/laravel:@alpha php artisan road:install
The
:@alphasuffix is required while the package is pre-1.0: the current release is0.1.0-alpha.2, and Composer's defaultminimum-stability(stable) would otherwise refuse a pre-release version. Drop the suffix once a stable0.1.0ships.On Laravel 13, add
-W:composer require b1-road/laravel:@alpha -W. The Laravel 13 skeleton locksbrick/math 0.18, whichweb-token/jwt-framework(a transitive dependency of the SDK) does not yet permit;-Wlets Composer downgrade that locked transitive dependency to a compatible0.17, with no effect on your app code. Laravel 11 and 12 install with a plaincomposer require— no flag needed. (Tracking upstreamweb-token/jwt-frameworksupport forbrick/math 0.18to drop the flag on 13 too.)
road:install publishes the config, then interactively prompts for the four
values it can't infer — ROAD_API_BASE_URL, AUTH_SERVER_ISSUER_URL, the client
id, and the client secret (from your Road Dev Portal). It derives
AUTH_SERVER_REDIRECT_URI from your APP_URL, writes everything to .env, and
offers to run road:doctor. Run it with --no-interaction in CI to append
blank stubs instead.
Only one env var is optional:
# Set only if your Auth Server issues project-scoped (audience'd) tokens. # Left blank, the audience is neither requested nor validated. AUTH_SERVER_AUDIENCE=
Visit /auth/road/login to complete OIDC. After the callback, the
Laravel session is the source of truth for identity.
Five-minute quickstart
1. Protect a route
use B1Road\Laravel\Facades\Road; Route::middleware('road')->group(function () { Route::get('/whoami', fn () => Road::user()->toArray()); Route::get('/me', fn () => Road::client()->me()->get()); });
Road::user() returns a RoadUser value object resolved from the
session-stored Auth Server tokens. Road::client() exposes the typed
Road API client.
One person, two ids. Road::userId() is the Auth Server user id: the key
for anything you store against your own login. Road::roadUserId() is the
Road user id, the one Bridge (onBehalfOfUser, the person a provider route
names), IAM subjects and webhook payloads (userId) carry. Sending one where
the other is expected is refused or matches nobody. The SDK reads
roadUserId() from Road the first time a session asks and keeps it with the
session's tokens. If Road can't be reached, it throws the client's exception
instead of handing you the wrong id.
2. Render Road widgets in Inertia
road:install already copied the React provider into your app, at
resources/js/lib/road-inertia-provider.tsx. It is a file you own, not an npm
package (there is none for it). To copy it on its own, or to refresh it after
upgrading this package:
php artisan vendor:publish --tag=road-inertia --force
The provider imports @b1-road/react and @inertiajs/react, so install them
from npm:
npm install @b1-road/react @inertiajs/react
Wrap your app:
import { RoadInertiaProvider } from '@/lib/road-inertia-provider'; export default function App({ children }) { return <RoadInertiaProvider>{children}</RoadInertiaProvider>; }
@b1-road/react widgets (<BusinessUnitSwitcher />, <BusinessUnitsMgmt />)
work without any frontend JWT handling — they fetch through the
/road-api/* BFF proxy using the Laravel session cookie.
The ShareRoadContext middleware that hydrates props.road is
auto-mounted into the web middleware group when
inertiajs/inertia-laravel is installed — no manual middleware
registration. Opt out with ROAD_INERTIA_ENABLED=false if you need to
wire it manually (custom HTTP kernel, multiple Inertia setups, etc).
road:doctor verifies the wiring on every run.
How auth works (BFF model)
Browser ── session cookie ──▶ Laravel ── Bearer (Auth Server JWT) ──▶ Road API
│
│ TokenStore (session)
▼
Auth Server (OIDC discovery + JWKS)
- Browser holds only an
httpOnly; Secure; SameSite=LaxLaravel session cookie. - The Auth Server
access_token,refresh_token, andid_tokenlive in the BFF token store. Refresh rotation is invisible to the integrator and the browser. - The
/road-api/{any?}proxy forwards browser calls to Road. The browser sends the session cookie viacredentials: 'include'; the proxy attaches the Bearer server-side. - The Road SDK never gives the browser a JWT. This is true BFF as defined by the IETF OAuth WG BCP for browser-based apps — ranked above the Token-Mediating Backend pattern that earlier React SDK drafts used.
Server-side client
Road::client() mirrors @b1-road/nestjs's client one-to-one.
// Me Road::client()->me()->get(); // CurrentUser Road::client()->me()->businessUnits(); // MyBusinessUnits Road::client()->me()->permissions(); // MyPermissions // Business units — get(), create(), update(), and the navigator shorthand Road::client()->businessUnits()->get($buId); // BusinessUnitDetail Road::client()->businessUnits($buId)->fetch(); // same; navigator style Road::client()->businessUnits()->create(['name' => 'B1']); // slug derived Road::client()->businessUnits()->get($buId, include: ['members', 'roles']); // Members / Roles / Invitations hang off the BU and act on themselves foreach (Road::client()->businessUnits($buId)->members() as $member) { /* … */ } Road::client()->businessUnits($buId)->members()->suspend($memberId); Road::client()->businessUnits($buId)->members()->assignRole($memberId, $roleId); // BU or platform role Road::client()->businessUnits($buId)->members()->revokeRole($memberId, $roleId); Road::client()->businessUnits($buId)->roles()->create(['name' => 'Editor', 'permissions' => ['read:Member']]); Road::client()->businessUnits($buId)->invitations()->create([ 'email' => 'x@b1.app', 'roleId' => $roleId, 'platformRoleIds' => [$platformRoleId], // optional — grant platform-subscription roles on acceptance ]); Road::client()->invitations()->accept($invitationId); // A membership carries the platforms its BU subscribes to foreach (Road::client()->me()->memberships() as $m) { // ...or ->businessUnits()->memberships foreach ($m->platformSubscriptions as $sub) { /* $sub->platformId, $sub->scopeId */ } } // Roles defined on a platform the BU subscribes to $roles = Road::client()->me()->platformRoles($platformPublicId, $buId); // …or resolve one subscription directly by the platform's public id $sub = Road::client()->businessUnits($buId)->subscriptions('plat_payment_gw'); Road::can('read', 'Invoice')->in($sub->scopeId); // check a platform-scoped permission // The signed-in user's Eduzz products — Road calls Eduzz with the user's // server-held token; iteration auto-paginates. foreach (Road::client()->me()->eduzzProducts() as $product) { // $product->name, $product->payment->price['value'], … } // A 403 carrying EDUZZ_REAUTH_REQUIRED means the user must reconnect Eduzz; // the code rides in the RoadAuthzException message + payload. // IAM control plane Road::client()->iam()->authorize([...]); Road::client()->iam()->scope($scopeId)->roles()->all(); Road::client()->iam()->assignments()->create([...]);
Listings are auto-paginating iterators — foreach walks every page,
transparently following cursors. Need one bounded page? ->firstPage(limit: 50).
Prefer Laravel collection chaining? ->lazy()->filter(...). include: expands
related resources in one call (today via client-side fan-out, collapsing to a
single round-trip once the API ships native expand).
Authorization
Use Road's permission system on your own custom routes, not just when proxying Road API calls. Three integrator entry points, all backed by a single enforcement code path:
Middleware string form (closures, inline routes)
use B1Road\Laravel\Facades\Road; Route::middleware(['road.errors', 'road', 'road.permission:read,Member,buId']) ->get('/bus/{buId}/members', fn (string $buId) => MyRepo::members($buId));
The args are action, Subject, scopeSource. scopeSource is a route
parameter name by default (buId); prefix with input: to pull from
the request body/query (input:business_unit_id).
PHP attribute (controllers)
use B1Road\Laravel\Attributes\RequirePermission; use B1Road\Laravel\Authorization\{Action, Subject}; class MembersController { #[RequirePermission(Action::Read, Subject::Member, in: 'buId')] public function index(string $buId): JsonResponse { /* ... */ } #[RequirePermission(Action::Manage, Subject::Member, in: 'buId')] public function destroy(string $buId, string $memberId): JsonResponse { /* ... */ } }
Apply road.permission.attribute middleware in the route group to
enable enforcement; the attribute also works at class level (with
#[SkipAuthorization] overriding for individual methods).
Programmatic (anywhere)
// Boolean predicate if (! Road::can(Action::Read, Subject::Member)->in($buId)->check()) { return abort(403); } // Throws on deny with a structured DecisionTrace Road::assert(Road::can(Action::Update, Subject::Role)->in($buId)); // Single round-trip for multiple checks [$canRead, $canUpdate, $canDelete] = Road::canMany([ Road::can(Action::Read, Subject::Member), Road::can(Action::Update, Subject::Member), Road::can(Action::Delete, Subject::Member), ])->in($buId)->resolve(); // Inspect the decision (the "why" — same shape every Road SDK surfaces) $trace = Road::can(Action::Read, Subject::Member)->in($buId)->trace(); // $trace->verdict, $trace->grants, $trace->reason, ...
Laravel's Gate (opt-in)
Prefer Laravel's native authorization? Turn on the Gate bridge and reach Road
through Gate::allows, $user->can, and Blade @can — no second authz API to
learn. Enable it once:
ROAD_BRIDGE_GATE=true # or config/road.php → 'bridges' => ['gate' => true]
Gate::allows('road:read:Project', $buId); // → Road::can('read', 'Project')->in($buId)->check() $request->user()->can('road:create:Project', $buId);
@can('road:update:Project', $buId) <button>Edit</button> @endcan
The ability is road:{action}:{Subject} and the first argument is the business
unit id. Anything not prefixed road: (or malformed) falls through to your
app's own gates and policies untouched — the bridge only answers Road abilities.
This is a bridge to Laravel's Gate (B1Road\Laravel\Bridges\GateBridge). It is
unrelated to Platform Bridge, the cross-platform capability; for that, see
Platform Bridge.
The permission algebra
Permissions are "$action:$Subject" strings. The enum cases match the
wire form exactly: Action::Read->value === 'read',
Subject::Member->value === 'Member'. The wildcard '*' grants
everything in scope; manage:Subject grants every CRUD verb on that
Subject. Use ->raw('custom:Permission') on a Can builder for
platform-defined permissions outside Road's canonical set.
Errors
Every error thrown by the SDK is a RoadException subclass:
| Class | HTTP | error.code |
When |
|---|---|---|---|
RoadAuthnException |
401 | unauthenticated (or specific OIDC code) |
No session, expired session, OIDC validation failure |
RoadAuthzException |
403 | permission_denied |
Authenticated but no grant. Carries a DecisionTrace rendered into the message. |
RoadNotFoundException |
404 | not_found |
Road API said 404 |
RoadConflictException |
409 | conflict |
Duplicate / version skew |
RoadValidationException |
422 / 400 | validation_error |
Carries fieldErrors keyed by field |
RoadRateLimitException |
429 | rate_limited |
Carries retryAfter (seconds) — never auto-retried |
RoadServerException |
5xx | server_error |
Retried with backoff (transient) |
RoadNetworkException |
502 | network_error |
Unreachable upstream — retried with backoff |
RoadApiException |
varies | varies | Catch-all for non-mapped statuses |
RoadBridgeExchangeException |
Road's | the OAuth error (BU_NOT_SUBSCRIBED_TO_PLATFORM, …) |
bridge()->exchangeForUser() refused. Carries description and, on RATE_LIMITED, retryAfter |
RoadBridgeSetupException |
500 | service_credentials_missing, platform_id_missing, person_required |
A Bridge consumer helper is not set up; thrown before anything is sent |
RoadExtensionSessionException |
401 | malformed, signature_mismatch, expired, … |
ExtensionSessionVerifier refused an embed session context (see Platform Extensions) |
All errors are parsed from the API's RFC 7807 Problem Details and carry a stable
code, a requestId, and a docs URL. When the API names the specific refusal
(Road API v0.43.0 and later: MEMBER_NOT_FOUND, PLATFORM_NOT_ACTIVE, …),
errorCode carries that code; the values in the table are the fallback when it
does not. Branch on the exception class for the category and on errorCode for
the case.
The road.errors middleware (auto-applied to auth/road/*,
/road/whoami, and /road-api/*) renders these as:
- JSON for
Accept: application/json, XHR, or/road-api/*:{ "error": { "code": "unauthenticated", "message": "...", "requestId": "...", "docs": "..." } } - Redirect to login for
text/html401 (withintended=anderror=query params). - Flash + redirect to / for other browser-flow errors.
Testing
The SDK ships an in-memory fake — no Auth Server, no JWKS, no HTTP traffic:
use B1Road\Laravel\Facades\Road; use B1Road\Laravel\Testing\ActsAsRoadUser; use B1Road\Laravel\Testing\RoadScenario; uses(ActsAsRoadUser::class); it('lists my business units', function () { $fake = Road::fake( RoadScenario::make() ->withUser('u_owner', email: 'eduardo@b1.app', name: 'Eduardo') ->withBusinessUnit('bu_1', name: 'B1') ->withMember('bu_1', 'u_owner', roles: ['Owner']) ); $this->actingAsRoadUser('u_owner'); Route::middleware('road')->get('/my-bus', function () { return Road::client()->me()->businessUnits(); }); $this->getJson('/my-bus')->assertOk(); $fake->assertCalled('GET', '/me/business-units'); });
Road::fake($scenario) swaps the container's RoadClient binding for a
test instance routed through an in-memory backend. The returned
RoadFakeAssertions object is the only supported assertions surface —
assertCalled, assertNothingCalled, assertCallCount.
Telemetry
The HTTP transport fires events on a RoadTelemetry binding. The default
implementation (NoopTelemetry) ignores them. To collect metrics, bind
your own:
use B1Road\Laravel\Telemetry\RoadTelemetry; $this->app->bind(RoadTelemetry::class, MyPulseTelemetry::class);
Event shape matches @b1-road/nestjs and @b1-road/react —
{ method, path, status, durationMs, requestId, traceId, attempts } — so
one sink covers every Road SDK.
Service-to-service mode
For queued jobs, scheduled commands, and anything with no browser session,
Road::asService() returns a client that authenticates as the service principal
instead of the request user:
Road::asService()->client()->businessUnits($buId)->members()->all();
Configure credentials in .env. The Dev Portal and the MCP issue a shared
secret (client_credentials), the mode you can set up yourself:
ROAD_SERVICE_MODE=client_credentials ROAD_SERVICE_CLIENT_ID=... ROAD_SERVICE_CLIENT_SECRET=...
The SDK also accepts a signed assertion (ROAD_SERVICE_MODE=private_key_jwt
with ROAD_SERVICE_KEY_ID + ROAD_SERVICE_PRIVATE_KEY), but that key cannot be
issued self-service. Ask the Road team for one.
The SDK acquires a token from the Auth Server, caches it (until just before
expiry, with a lock so concurrent workers don't stampede), and re-acquires
transparently on a 401. asService() uses a dedicated context, so a request
handler can call Road::user() and dispatch a job with Road::asService()
without cross-contamination.
Platform Bridge: calling another platform
As a consumer, every Bridge call acts for the signed-in person in one business
unit. Inside a road-protected route, one call gets a token for the provider:
use B1Road\Laravel\Exceptions\RoadBridgeExchangeException; try { $token = Road::client()->bridge()->exchangeForUser( audience: 'plat_provider', // the provider platform scope: ['read:Task'], // permission codes, never a template name businessUnitId: $buId, ); // call the provider with "Authorization: Bearer {$token['access_token']}" } catch (RoadBridgeExchangeException $e) { // $e->errorCode() is Road's code; $e->description says what happened and the next step }
It needs the service credential (ROAD_SERVICE_CLIENT_ID /
ROAD_SERVICE_CLIENT_SECRET, see above) and your platform
(ROAD_PLATFORM_ID). What it does, in order:
- Asks Road for a presence assertion for that business unit with the
person's session (
POST /bridge/presence-assertions).Road::client()->bridge()->presenceAssertion($buId)is this step alone. - Exchanges your platform's service credential for a token audienced at
the provider (
POST /bridge/token-exchange), withbusiness_unitandpresence_assertionalongside the RFC 8693 fields. The person's token never goes on this call.
A refusal from the exchange throws RoadBridgeExchangeException with the code
from the guide's table. The only automatic retry is one fresh service token on
invalid_token. A missing credential, platform id or signed-in person throws
RoadBridgeSetupException before anything is sent. There is no unattended
form: a job with nobody logged in cannot make a Bridge call. The full flow and
every refusal are in road_guide('bridge').
Platform Bridge: accepting another platform's calls
When another platform calls your API through Platform Bridge, it sends a
brokered token. Receiving that token is not the authorization. It says who the
caller is and that the token was minted for you. What the caller may actually
do is a separate question, and the road.bridge middleware asks it for you.
It is the Laravel counterpart of bridgeEnforce() in the Node SDKs.
1. Configure your service credential. The middleware asks Road as your
platform, with the same credential Road::asService() uses (see above). Point
the cache at a store every worker shares:
ROAD_SERVICE_CLIENT_ID=... ROAD_SERVICE_CLIENT_SECRET=... ROAD_PLATFORM_BRIDGE_CACHE_STORE=redis
2. Protect a route. Name the permission it requires:
Route::middleware('road.bridge:read:Charge')->get('/partner/charges', ChargeIndex::class);
When the route serves one business unit's data, or one end-user's, say where to read them. A token that names a tenant is refused unless it matches, and so is a token minted on behalf of a different end-user:
// args: permission, tenant source, acting-user source Route::middleware('road.bridge:read:Charge,buId,userId') ->get('/partner/bu/{buId}/users/{userId}/charges', ChargeIndex::class);
A source is a route parameter or input:<key>. When the tenant lives
somewhere else (a header, a subdomain), register a resolver once, in a service
provider:
use B1Road\Laravel\Http\Middleware\EnforceBridgeGrant; EnforceBridgeGrant::resolveTenantUsing(fn (Request $r) => $r->header('X-Tenant')); EnforceBridgeGrant::resolveActingUserUsing(fn (Request $r) => $r->route('userId'));
Road cannot make these two checks for you, because only your app knows whose
data a request touches. So a token that names a tenant or an end-user is
refused on a route that gives no way to check it (tenant_unverifiable,
acting_user_unverifiable). The reverse holds too: on a route with a tenant
source, a token that names no business unit is refused (cross_tenant), and
with an acting-user source, a token minted with no person present is refused
(cross_user).
One leg per route. The middleware accepts Bridge tokens only, unless the route says otherwise. Routes an installed extension's backend calls name the data leg as the fourth argument, and leave the acting-user source empty when they serve unattended calls:
// args: permission, tenant source, acting-user source, leg Route::middleware('road.bridge:read:Course,buId,,extensions') ->get('/partner/bu/{buId}/courses', CourseIndex::class);
any accepts both legs, for a route that genuinely serves both kinds of
caller. A token from the other leg is refused with wrong_leg.
3. Read the context in your controller.
use B1Road\Laravel\Bridge\BridgeContext; $bridge = BridgeContext::of($request); $bridge->businessUnitId; // the tenant the token is scoped to, if any $bridge->onBehalfOfUser; // the end-user it acts for, if any $bridge->can('refund:Charge'); // anything else the token carries
4. Subscribe to bridge.grant.revoked. Answers are cached per token, so a
revoked grant would otherwise keep working until its entry ages out. With
webhooks on (see Webhooks) and your endpoint subscribed to
bridge.grant.revoked, the SDK drops every cached answer the moment the event
arrives. extension.install.uninstalled does the same. There is nothing to
register yourself.
What a refusal looks like. Denials are JSON with a stable reason, the same
codes the Node middleware uses: 401 missing_token; 403 not_authorized,
cross_tenant, cross_user, tenant_unverifiable,
acting_user_unverifiable, degraded_context; 503 authorization_unavailable. Each one also fires a BridgeAccessDenied event for
your logs and metrics. Every decision on a permission is reported back to
Road's audit trail after the response has gone out, with the path but never
the query string.
Caching and the fail mode. Road is asked once per token and the answer is
reused for 60 seconds on read verbs (read, list, view, get) and 5
seconds on anything else. An answer is never reused past the token's own
expiry. When Road cannot be reached, the default is to fail closed: no fresh
answer, no access (503). Set ROAD_PLATFORM_BRIDGE_MAX_STALENESS to a number
of seconds to keep serving cached answers up to that age during an outage
(BridgeContext::$servedStale tells you when that happened); 300 is a sane
ceiling. A refusal from Road is never overridden by the cache, and it drops
that token's cached answer, so a later outage cannot bring it back. Road gets 2
seconds per attempt and one retry (ROAD_PLATFORM_BRIDGE_AUTHORIZE_TIMEOUT
sets the seconds), so during an outage the 503 comes in about 4 seconds, not
after the client's general timeout and retries. The Node middleware follows
the same rules with the same defaults.
Reading your platform's Bridge audit. The platform's owner can list the
Bridge traffic it took part in: inbound is other platforms reaching yours,
outbound is yours reaching others. Each row is a BridgeAuditEntry naming
the other platform, the event (exchange, check, attempt,
grant_created, grant_revoked), the decision and a reason code. It needs the
owner's user token, so call it through Road::client() on a request where the
owner is signed in; the service credential gets 403.
foreach (Road::client()->bridge()->audit('plat_…', 'inbound', allowed: false) as $row) { logger()->info('bridge refusal', [$row->createdAt, $row->counterparty?->name, $row->reason]); }
Platform Extensions: verifying the embed session
An extension's front end runs in an iframe on the host's page. It asks the host
for a session context (useExtensionHost from @b1-road/react/extension)
and posts signed to your backend. Road signed it with the extension's
signing secret, which the browser never sees, so your backend is the only
place it can be checked. Until then, nothing in it is true.
// routes/api.php: no CSRF here, the signed context is the credential. use B1Road\Laravel\Exceptions\RoadExtensionSessionException; use B1Road\Laravel\Extensions\ExtensionSessionVerifier; use Illuminate\Http\Request; Route::post('/embed-session', function (Request $request) { // road_rotate_extension_secret (kind='signing') writes it to .env as // ROAD_EXTENSION_SIGNING_SECRET_<EXTENSION ID>. Read it through config() // if you cache your config. $verifier = new ExtensionSessionVerifier((string) env('ROAD_EXTENSION_SIGNING_SECRET_EXT_ABC123')); try { $session = $verifier->verify($request->json()->all()); } catch (RoadExtensionSessionException $e) { return response()->json(['code' => $e->errorCode()], 401); } // Start your own session here: $session->userId, $session->installId, … return ['userId' => $session->userId]; });
verify() checks the HMAC over payload in constant time, the format version
(v === 1) and the 5-minute window, with 30 seconds of clock skew either side.
It returns an ExtensionSession:
| Property | What it is |
|---|---|
userId |
The person on the host page, as a Road user id. Not the Auth Server id. |
installId |
The install (exti_…). Send it as install on POST /extensions/token-exchange. |
extensionId |
Your extension (ext_…). |
businessUnitId |
The business unit. Scope everything you store for this person by it. |
issuedAt, expiresAt |
The context's window, as DateTimeImmutable. |
expectInstall: refuses a context for another install, maxAgeSeconds:
accepts only contexts younger than Road's 5 minutes, and now: pins the clock
in tests. A refusal throws RoadExtensionSessionException (401), whose
errorCode() is one of its constants: malformed, signature_mismatch,
unsupported_version, expired, not_yet_valid or install_mismatch, the
same codes as the Node SDKs. An empty secret throws InvalidArgumentException
when the verifier is built, because that is a deploy mistake, not a bad
request.
A context lives 5 minutes: verify it once and start your own session from the
result. embedAssertion, when the iframe sends one, is outside the signature;
pass it on untouched as embed_assertion on the data-leg exchange, where Road
checks it.
Escape hatches
When the typed surface doesn't cover something, drop a level — you never have to leave the SDK:
// Raw call to an endpoint the client doesn't model yet. Returns the decoded // body ({ data } not unwrapped); errors still map to RoadException. $body = Road::client()->request('GET', '/some/new/endpoint', query: ['limit' => 10]); Road::client()->transport(); // the underlying HTTP transport, for full control // Act as a user whose access token you already hold (outside the request // session) — mirrors Road::asService() but for a user principal. Road::asUser($accessToken)->client()->me()->get();
Webhooks
Opt in with ROAD_WEBHOOKS_ENABLED=true and set ROAD_WEBHOOK_SECRET. The SDK
mounts a single signed endpoint (default POST /road/webhooks, outside the
web group — no CSRF) that verifies the HMAC-SHA256 signature and dispatches
each delivery onto Laravel's event bus. Register ordinary listeners:
use B1Road\Laravel\Webhooks\Events\MemberSuspended; Event::listen(MemberSuspended::class, function (MemberSuspended $event) { // $event->id, $event->data->memberId, $event->data->businessUnitId });
Every delivery also fires a catch-all RoadWebhookReceived. The endpoint
fails closed — 503 when no secret is configured, 401 on a bad signature —
and returns 200 for unknown event types (forward-compatible).
Artisan commands
| Command | Purpose |
|---|---|
road:install |
Publish config + Inertia JS provider, append .env stubs |
road:doctor |
Connectivity + config smoke check (env, the environment and Road API base it resolved, reachability, JWKS, clock skew, redirect_uri shape, session driver, middleware, proxy mount). With a service credential set, it also asks Road whether the platform is ready as a Bridge provider: UNKNOWN_PROVIDER fails, PROVIDER_NOT_HOMOLOGATED warns (only matters if you serve Bridge calls); without one the probe is skipped |
road:whoami |
Print the session-stored user's claims |
road:generate-dtos |
Regenerate (or --check) the typed DTOs from the OpenAPI contract |
Configuration
The full config shape is published to config/road.php:
| Key | Description |
|---|---|
road.environment |
production / sandbox / local — set by ROAD_ENVIRONMENT (or ROAD_ENV). Selects the hosted API URL; it is not the whole migration, since the sandbox issuer and client credentials do not exist in production and must be replaced too |
road.api.base_url |
Road API base URL. Defaults to the hosted URL for road.environment; set ROAD_API_BASE_URL only for a local stack or your own gateway |
road.api.timeout |
HTTP timeout in seconds (default 10) |
road.api.jwks_ttl |
OIDC discovery + JWKS cache TTL in seconds (default 600) |
road.auth_server.* |
OIDC client credentials + scopes |
road.proxy.enabled |
Auto-mount /road-api/{any?} (default true) |
road.proxy.prefix |
Proxy URL prefix (default road-api) |
road.proxy.allow |
Glob allowlist of paths the proxy will forward |
road.api.retry.* |
Transient-failure retries: enabled, max_attempts (3), base_delay_ms (250) |
road.token_store |
Where the BFF caches Auth Server tokens (session) |
road.inertia.enabled |
Inject props.road into Inertia shared props (default true) |
road.debug.header_enabled |
Surface DecisionTrace on errors when X-Road-Debug: 1 |
road.service.* |
Service-to-service credentials for Road::asService() |
road.platform_id |
Your platform's plat_… (ROAD_PLATFORM_ID), which a Bridge presence assertion is bound to |
road.webhooks.* |
Webhook receiver: enabled, path, secret, tolerance, verify |
road.platform_bridge.* |
road.bridge middleware: cache_store, read_ttl (60), write_ttl (5), max_staleness (0, fail-closed), authorize_timeout (2), strict_tenancy, strict_acting_user, report_attempts |
Naming note
This SDK refers to the identity provider as "Auth Server" in all public-facing surfaces (config keys, error messages, public types). The concrete OIDC provider behind it is an implementation detail of the Road platform, not an integrator concern — your integration targets the generic Auth Server contract, never a specific vendor.
Troubleshooting
oidc_state_mismatch after callback. The session was lost between
the login redirect and the callback. Check SESSION_DOMAIN matches your
app's host, and that your session cookie is SameSite=Lax (default).
Cross-origin React shells need SameSite=None + a CORS-cleared proxy
origin — not supported in this MVP.
invalid_token on every request. Likely clock skew. Run
php artisan road:doctor — it compares your clock against the Auth
Server's Date header. Anything above 30 seconds breaks JWT
validation. Fix: NTP sync.
Auth Server returns redirect_uri_mismatch. The redirect_uri in
your .env doesn't exactly match a redirect URI registered for your OIDC
app in the Auth Server console. Register the exact callback URL — scheme,
host, port, and path must all match — and retry.
401 on every /road-api/* call. The session is missing or expired.
Try visiting /auth/road/login directly in the browser. If that
redirects through the OIDC dance and lands back at /, the session
should be populated — confirm with php artisan road:whoami.
Quality bar
This SDK is bound by standards/SDK_DX_BAR.md — the canonical
quality principles every Road SDK is held to. The plan that produced
this MVP lives at docs/plans/08-laravel-sdk-plan.md.