league/oauth2-linkedin
A LinkedIn OAuth 2.0 provider for the League OAuth2 Client. It handles LinkedIn authorization redirects, access token exchange, and resource owner details, making LinkedIn login integration simple in PHP applications.
league/oauth2-linkedin is a provider plugin for league/oauth2-client — it does not implement OAuth by itself. Install it standalone and Composer pulls in the base client:
composer require league/oauth2-linkedin
You get one class you'll actually touch: League\OAuth2\Client\Provider\LinkedIn (plus LinkedInResourceOwner).
Before writing code, create an app in the LinkedIn Developer Portal, copy the Client ID / Client Secret, and register a redirect URI. LinkedIn matches redirect URIs exactly (scheme, host, trailing slash) and requires HTTPS except for http://localhost.
Where to look first:
League\OAuth2\Client\Provider\LinkedIn — the endpoint URLs (/oauth/v2/authorization, /oauth/v2/accessToken), default scopes, and what getResourceOwner() fetches.league/oauth2-client docs — the flow (getAuthorizationUrl → getAccessToken → getResourceOwner) is identical across providers; only the LinkedIn class is package-specific.LinkedInResourceOwner — which accessors exist (getId(), getName(), getEmail(), getImageUrl(), toArray()).First use case — a minimal callback flow:
use League\OAuth2\Client\Provider\LinkedIn;
$provider = new LinkedIn([
'clientId' => config('services.linkedin.client_id'),
'clientSecret' => config('services.linkedin.client_secret'),
'redirectUri' => route('linkedin.callback'),
]);
// Step 1: redirect
$url = $provider->getAuthorizationUrl(['scope' => ['r_liteprofile', 'r_emailaddress']]);
session(['linkedin_state' => $provider->getState()]);
return redirect($url);
// Step 2: callback
abort_unless(request('state') === session('linkedin_state'), 403, 'Invalid state');
$token = $provider->getAccessToken('authorization_code', ['code' => request('code')]);
$user = $provider->getResourceOwner($token);
$user->getId(); // LinkedIn member id
$user->getName();
$user->getEmail();
$user->toArray(); // raw payload — inspect this first when debugging
That is the entire happy path. Everything else is integration work.
Two-route controller is the standard shape. One action builds the authorization URL and stores state; the other exchanges the code. Keep the provider construction in a small factory or resolve it from the container so config lives in config/services.php:
'linkedin' => [
'client_id' => env('LINKEDIN_CLIENT_ID'),
'client_secret' => env('LINKEDIN_CLIENT_SECRET'),
'redirect' => env('LINKEDIN_REDIRECT_URI'),
],
State belongs in the session, not the URL. Generate it with getAuthorizationUrl() (it sets $provider->getState() as a side effect), persist it, and compare on callback. In a stateless API you'll need to carry it in a signed cookie or a database row keyed to the user.
Persist the token, not the profile. Store linkedin_id on your users table for lookup, plus linkedin_access_token (cast encrypted) and linkedin_token_expires_at. Then resolve users with an upsert pattern:
$user = User::firstOrNew(['linkedin_id' => $owner->getId()]);
$user->fill([
'name' => $owner->getName(),
'email' => $user->email ?? $owner->getEmail(),
]);
$user->linkedin_access_token = $token->getToken();
$user->linkedin_token_expires_at = now()->addSeconds($token->getExpires());
$user->save();
Use $token for later API calls. The provider gives you getAuthenticatedRequest() / getResponse() so the same token drives calls beyond login (posting to /v2/ugcPosts, reading /v2/me, etc.). Set the LinkedIn-specific headers yourself — most v2 endpoints need X-Restli-Protocol-Version: 2.0.0, and versioned endpoints need LinkedIn-Version: YYYYMM:
$request = $provider->getAuthenticatedRequest(
'GET',
'https://api.linkedin.com/v2/me',
$token,
['headers' => [
'X-Restli-Protocol-Version' => '2.0.0',
'LinkedIn-Version' => '202405',
]]
);
$response = $provider->getParsedResponse($request);
Scopes are per-request, not per-instance. Pass ['scope' => [...]] to getAuthorizationUrl(); the provider's default scopes are only a fallback. Request the minimum — r_liteprofile / r_emailaddress were the historical defaults.
Test without hitting LinkedIn. The provider accepts a Guzzle client via constructor options, and oauth2-client ships mock responses. Use MockHandler in feature tests so getAccessToken and getResourceOwner return fixtures; assert your user-resolution logic, not LinkedIn.
Treat it as a login library, not a social SDK. For richer LinkedIn actions (sharing, org pages), keep using this package for the token handshake and call the REST API with Http::withToken(...) or the authenticated-request helpers — don't expect the provider to wrap every endpoint.
openid, profile, email scopes and must use https://api.linkedin.com/v2/userinfo. The legacy r_liteprofile / r_emailaddress scopes are deprecated for new apps, and getResourceOwner() will 403 if it still targets /v2/me. Workaround: use the provider purely for the token exchange, then call /v2/userinfo yourself with the access token, or subclass LinkedIn and override getResourceOwnerDetailsUrl() / getResourceOwner(). Verify against your app's actual approved scopes before shipping.r_emailaddress, LinkedIn historically returned email from a separate /v2/emailAddress call, not the profile payload. Always inspect $owner->toArray() before trusting getEmail(). With OIDC email comes from userinfo.invalid_redirect_uri on tiny differences. Configure one URI per environment in the portal and keep env() values in sync — trailing slashes and http/https are common culprits.state comparison is a CSRF hole. guzzlehttp will happily complete the exchange without it; nothing in the package enforces it for you.IdentityProviderException. Token exchange and resource-owner calls throw League\OAuth2\Client\Provider\Exception\IdentityProviderException. Its getResponseBody() carries LinkedIn's real error (invalid_request, invalid_scope, etc.) — log that, not just the message. Users denying consent surface as access_denied in the query string before you ever reach the provider.rw_organization_admin require LinkedIn's Marketing Developer Platform review. Requesting an unapproved scope fails at the authorization step, not at token exchange, so it looks like a config bug.league/oauth2-client to a ^2.7-era version compatible with your PHP, and run your test suite on PHP 8.2+ to catch deprecations before production.$token->getToken() in a log line or an Inertia prop is a 60-day credential. Encrypt at rest and exclude from toArray() output on your user model.LinkedIn for custom endpoints, inject a configured Guzzle client (timeouts, retries) via constructor options, and override getBaseAuthorizationUrl() / getDefaultScopes() for regional or OIDC variants rather than forking the package.How can I help you explore Laravel packages today?