Looking to hire Laravel developers? Try LaraJobs

laravel maintained by b1-road

Description
Official Road SDK for Laravel — BFF auth, proxy, and client for the Road IAM platform.
Last update
2026/09/25 21:59 (dev-main)
License
Downloads
14
Tags
auth - laravel - rbac - road - oidc - iam - b1 - bff

Comments
comments powered by Disqus

Road SDK for Laravel

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.

2. Render Road widgets in Inertia

php artisan vendor:publish --tag=road-inertia

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

All errors are parsed from the API's RFC 7807 Problem Details and carry a stable code, a requestId, and a docs URL.

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 — either a shared secret (client_credentials) or a signed assertion (private_key_jwt):

ROAD_SERVICE_MODE=client_credentials
ROAD_SERVICE_CLIENT_ID=...
ROAD_SERVICE_CLIENT_SECRET=...
# or: ROAD_SERVICE_MODE=private_key_jwt with ROAD_SERVICE_KEY_ID + ROAD_SERVICE_PRIVATE_KEY

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: 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).

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). A refusal from Road is never overridden by the cache.

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, reachability, JWKS, clock skew, redirect_uri shape, session driver, middleware, proxy mount)
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.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), 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.