Prerequisites. You need a Laravel app that already runs Passport (composer require laravel/passport, php artisan passport:install) with a working user provider. This package is an extension of Passport, not a replacement — the /oauth/token, /oauth/authorize, and client-management plumbing still come from Passport itself.
Install and publish.
composer require admin9/laravel-oidc-server
php artisan vendor:publish --provider="Admin9\OidcServer\OidcServiceProvider"
php artisan migrate # only if the package ships extra tables
If the tagged publish fails, list tags first: php artisan vendor:publish and pick the oidc / oidc-config entry interactively.
Where to look first, in this order:
config/oidc.php (or whatever the published file is named) — signing keys, issuer URL, enabled endpoints, claim mappings.php artisan route:list --path=oauth and php artisan route:list --path=.well-known — this is the fastest way to see exactly which endpoints the package registered and under what middleware. Do this before reading any docs.src/ directory — with 6 stars and 0 dependents, the source is the documentation. Budget an hour to read the controllers for Discovery, JWKS, and UserInfo.First use case to validate: get the discovery document to render correctly.
curl -s https://your-app.test/.well-known/openid-configuration | jq
If issuer, authorization_endpoint, token_endpoint, jwks_uri, and userinfo_endpoint are all absolute URLs pointing at your real host, you are 80% of the way there. Then verify JWKS:
curl -s https://your-app.test/oauth/jwks | jq '.keys[0] | {kty, alg, kid, use}'
You should get an RSA public key with a non-empty kid. If kid is missing, most OIDC clients will still work but key rotation becomes impossible — flag it early.
1. Register clients the Passport way, then attach OIDC scopes. A client is only an OIDC client if it requests the openid scope. Create the OAuth client normally, then ensure openid, profile, and email exist as scopes:
// in a seeder or an admin action
Passport::tokensCan([
'openid' => 'Authenticate via OpenID Connect',
'profile' => 'Read your name and profile',
'email' => 'Read your email address',
]);
$client = DB::table('oauth_clients')->insertGetId([
'name' => 'Acme Dashboard',
'redirect' => 'https://acme.test/auth/callback',
'personal_access_client' => false,
'password_client' => false,
'revoked' => false,
]);
2. Authorization Code + PKCE for SPAs and mobile. The package does not add a new grant type — the SPA hits Passport's /oauth/authorize, gets a code, and exchanges it at /oauth/token. The difference after installing this package is that the token response includes an id_token alongside the access and refresh tokens when openid was requested. Verify that by decoding the JWT at jwt.io or with firebase/php-jwt.
3. Customize claims in one place, not in controllers. The most common day-to-day task is "the ID token should contain department" or "UserInfo should stop leaking phone". The package will have a claims resolver / user-info transformer you can bind. Look for an interface in src/Contracts and bind your implementation:
// AppServiceProvider::register()
$this->app->bind(
\Admin9\OidcServer\Contracts\ClaimResolver::class,
\App\Oidc\CompanyClaimResolver::class,
);
Keep this class pure — take a User, return an array. Do not do auth()->user() inside it; the resolver is often called from a token-introspection or userinfo request where the "current" user is not the subject.
4. Scope-gated claims. OIDC clients expect email to appear only when the email scope was granted. Drive the resolver off the token's scopes rather than off the user's database columns:
public function resolve(User $user, array $scopes): array
{
$claims = ['sub' => (string) $user->id];
if (in_array('email', $scopes, true)) {
$claims['email'] = $user->email;
$claims['email_verified'] = $user->hasVerifiedEmail();
}
if (in_array('profile', $scopes, true)) {
$claims['name'] = $user->name;
}
return $claims;
}
5. Resource servers use introspection, not UserInfo. For service-to-service checks, POST the token to the introspection endpoint with the client credentials in HTTP Basic auth. Cache the result for the token's remaining lifetime — introspection is a network hop on every request otherwise.
6. RP-initiated logout. Wire your app's logout to hit the package's end-session endpoint with id_token_hint and a whitelisted post_logout_redirect_uri. The whitelist matters: unvalidated post-logout redirects are an open-redirect class bug, and reviewers will look for it.
7. Testing. Prefer issuing a real token over Passport::actingAs() when asserting OIDC behaviour, because the claims pipeline only runs on real token issuance. A cheap contract test is to boot the app in a test case and assert the discovery document's issuer equals config('app.url').
"admin9/laravel-oidc-server": "1.0.*") and be prepared to fork. Check the GitHub issues tab before upgrading.Passport::useClientModel) and key handling across v10–v12. If the package's service provider assumes a specific Passport major, installation on a newer Passport will fail at boot with a class-not-found rather than a clean error. Confirm in composer.json's constraints.issuer behind a proxy. Almost every deployment bug is issuer resolving to http://localhost or an internal hostname. Set APP_URL correctly and configure TrustProxies so $request->getSchemeAndHttpHost() is right. The discovery document is cached by clients — a wrong issuer poisons them for hours.kid. Clients cache JWKS aggressively. If you rotate Passport keys, publish the new key in JWKS before signing with it, and keep the old kid listed for overlap. Never rotate both keys at once.openid scope is the on/off switch. A token issued without it is an ordinary OAuth token with no ID token and (usually) a 403 from UserInfo. When a client reports "no id_token", check the granted scopes before anything else.Authorization: Basic header yields a generic 401 that looks like a bad token. Distinguish the two in your logs.config/cors.php's paths./oauth/* or a /.well-known handler, confirm load order with php artisan route:list. Duplicate route names silently shadow.php artisan config:cache after editing the OIDC config and re-run route:list — a cached config is the single most common cause of "my change did nothing".How can I help you explore Laravel packages today?