league/oauth2-client
league/oauth2-client is a PHP library providing a base for OAuth 2.0 integrations. It includes a GenericProvider for Bearer-token services, follows RFC 6749, and can be extended for provider-specific behavior.
league/oauth2-client is a low-level OAuth 2.0 client, not a full auth scaffold. You get the authorization URL builder, the token endpoint exchange, token/expiry objects, and a resource-owner fetch. Everything else (state validation, user persistence, token storage) is yours.
Install the base and a provider package. The base package alone only ships GenericProvider; real-world providers live in separate packages:
composer require league/oauth2-client
composer require league/oauth2-google # or oauth2-github, oauth2-facebook, etc.
Where to look first
https://oauth2-client.thephpleague.com/usage/ — the canonical redirect → callback → token → resource-owner loop./providers/league/ and /providers/thirdparty/) before writing anything custom.AbstractProvider in src/Provider/AbstractProvider.php — almost every customization point (getBaseAccessTokenUrl, createResourceOwner, checkResponse, getDefaultScopes) lives there.First use case: "Sign in with Google" in a Laravel app. Read credentials from config/services.php so nothing is hard-coded:
// config/services.php
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URI'), // must match the console exactly
],
// routes/web.php
Route::get('/auth/google/redirect', [GoogleController::class, 'redirect'])->name('oauth.google');
Route::get('/auth/google/callback', [GoogleController::class, 'callback']);
// app/Http/Controllers/GoogleController.php
use League\OAuth2\Client\Provider\Google;
public function redirect()
{
$provider = $this->provider();
$url = $provider->getAuthorizationUrl([
'scope' => ['openid', 'email', 'profile'],
]);
// getAuthorizationUrl() also generates the state; persist it yourself.
session(['oauth2.google.state' => $provider->getState()]);
return redirect()->away($url);
}
public function callback(Request $request)
{
$expected = session()->pull('oauth2.google.state'); // pull => one-time use
abort_unless($expected && hash_equals($expected, (string) $request->input('state')), 403);
$provider = $this->provider();
$token = $provider->getAccessToken('authorization_code', [
'code' => $request->input('code'),
]);
$owner = $provider->getResourceOwner($token); // $owner->toArray() for raw claims
// ... match/create user, log in ...
}
private function provider(): Google
{
return new Google([
'clientId' => config('services.google.client_id'),
'clientSecret' => config('services.google.client_secret'),
'redirectUri' => config('services.google.redirect'),
]);
}
That five-line callback (validate state → exchange code → fetch owner) is the whole package in practice. Everything below is variations on it.
Wrap the provider in a container binding. Re-instantiating new Google([...]) per request is fine, but a singleton or factory keeps config in one place and makes testing trivial:
// app/Providers/AppServiceProvider.php
$this->app->bind(Google::class, fn () => new Google([
'clientId' => config('services.google.client_id'),
'clientSecret' => config('services.google.client_secret'),
'redirectUri' => config('services.google.redirect'),
]));
Use GenericProvider for anything spec-compliant without a dedicated package — including your own Laravel Passport server:
$provider = new League\OAuth2\Client\Provider\GenericProvider([
'clientId' => config('services.passport.client_id'),
'clientSecret' => config('services.passport.client_secret'),
'redirectUri' => route('oauth.callback'),
'urlAuthorize' => url('/oauth/authorize'),
'urlAccessToken' => url('/oauth/token'),
'urlResourceOwnerDetails' => url('/api/user'),
]);
Tokens are objects, not strings. Persist the whole thing; it round-trips through JSON cleanly (access_token, refresh_token, expires_in):
$user->forceFill([
'oauth_access_token' => $token->getToken(),
'oauth_refresh_token' => $token->getRefreshToken(),
'oauth_expires_at' => $token->getExpires(), // unix timestamp, null if no expires_in
])->save();
Cast secrets with Laravel's encrypted cast and never put tokens in logs or toArray() dumps.
Refresh proactively, in a job, not inline. Check expiry before making calls and refresh in a queued job so a slow provider never blocks a request:
if ($token->hasExpired()) {
$new = $provider->getAccessToken('refresh_token', [
'refresh_token' => $user->oauth_refresh_token,
]);
// Many providers rotate refresh tokens — always overwrite, never assume the old one survives.
$user->forceFill([
'oauth_access_token' => $new->getToken(),
'oauth_refresh_token' => $new->getRefreshToken() ?? $user->oauth_refresh_token,
'oauth_expires_at' => $new->getExpires(),
])->save();
$token = $new;
}
Use PKCE for public/mobile/SPA clients (supported since 2.7.0). Generate the verifier before redirect, store it in the session, and pass it back on exchange:
$provider->setPkceCode($verifier = $provider->getPkceCode()); // after getAuthorizationUrl()
session(['oauth2.pkce' => $provider->getPkceCode()]);
// callback
$provider->setPkceCode(session()->pull('oauth2.pkce'));
$token = $provider->getAccessToken('authorization_code', ['code' => $request->code]);
Extend AbstractProvider for vendor quirks. When a provider wants a non-standard param, HTTP Basic auth on the token endpoint, or a weird JSON envelope, subclass and override the narrowest method: getBaseAccessTokenUrl(), getAccessTokenRequest(), prepareAccessTokenResponse(), createResourceOwner(), or checkResponse(). HttpBasicAuthOptionProvider and GuardedPropertyTrait (both added in 2.4.0) exist precisely for these cases.
Inject a configured Guzzle client for logging, retries, or a corporate proxy. Every provider accepts an httpClient option:
new Google([
'clientId' => /* ... */,
'httpClient' => new \GuzzleHttp\Client([
'handler' => $stack, // middleware stack with logging/retry
]),
]);
Account linking. Always key on (provider, resource_owner_id) — $owner->getId() maps to sub for OIDC providers — rather than on email, which can change or be absent. Store email separately and only auto-link to an existing user if the provider asserts email_verified.
Testing. Mock the two HTTP calls with Guzzle's MockHandler (passed via httpClient), assert the state round-trip, and use the library's time-override hook (added in 2.6.0) to test expiry without sleep(). Don't hit real providers in CI.
state for you. It generates and exposes it; comparing it to your session value is your job. Use hash_equals and pull it from the session so it can't be replayed.redirect_uri mismatch is the #1 failure. It must be byte-identical to what's registered with the provider — scheme, host, port, trailing slash. Prefer generating it once with route()/url() and using that same value in both the config and the console.getAuthorizationUrl(['scope' => ['a', 'b']]). Also note 2.8.1 changed scope-sending behavior on the token request ("only provide scopes in access token when set in options") — if a provider rejects an unexpected scope param, upgrade or don't pass one.access_type=offline and typically only on the first consent (prompt=consent forces re-issue). Handle getRefreshToken() === null gracefully — don't assume a refresh path exists.IdentityProviderException, not generic Guzzle errors. Provider error payloads land in $e->getResponseBody(); that's where invalid_grant, invalid_client, and rate-limit messages live. getAccessToken() also throws UnexpectedValueException when the token endpoint returns non-JSON (2.3.1+).expires_in => 0 is valid (treated as immediately expired since 2.1.0) and expires values that aren't numeric were fixed in 2.8.0 — pin a recent 2.8+ release if you're parsing expiry yourself.access_token. AccessToken::getValues() exposes vendor extras that some providers only return once at issue time.oauth2-client when Socialite doesn't ship your provider, when you need PKCE or a custom token endpoint, or when you want the token objects rather than Socialite's two()/user() façade. Both can coexist in one app.client_secret and code; scope any debug middleware to strip the token endpoint.How can I help you explore Laravel packages today?