Search by

b1-road / laravel

kalil.reisviniciusquicoli

Official Road SDK for Laravel — BFF auth, proxy, and client for the Road IAM platform.

v0.1.0-alpha.6 2026-10-08 15:03 UTC

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.x alpha). While Road serves the alpha API contract the surface may shift between minor versions; it stabilises when the API graduates alpha → 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 :@alpha suffix is required while the package is pre-1.0: the current release is 0.1.0-alpha.2, and Composer's default minimum-stability (stable) would otherwise refuse a pre-release version. Drop the suffix once a stable 0.1.0 ships.

On Laravel 13, add -W: composer require b1-road/laravel:@alpha -W. The Laravel 13 skeleton locks brick/math 0.18, which web-token/jwt-framework (a transitive dependency of the SDK) does not yet permit; -W lets Composer downgrade that locked transitive dependency to a compatible 0.17, with no effect on your app code. Laravel 11 and 12 install with a plain composer require — no flag needed. (Tracking upstream web-token/jwt-framework support for brick/math 0.18 to 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=Lax Laravel session cookie.
  • The Auth Server access_token, refresh_token, and id_token live 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 via credentials: '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/html 401 (with intended= and error= 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:

  1. 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.
  2. Exchanges your platform's service credential for a token audienced at the provider (POST /bridge/token-exchange), with business_unit and presence_assertion alongside 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.