socialiteproviders/paypal
PayPal OAuth provider for Laravel Socialite. Adds PayPal login to your Laravel app via the Socialite Providers collection. Install with Composer, configure your client ID and secret, and authenticate users through PayPal in minutes.
This is a Socialite driver, not a payments SDK. It answers one question: "who is this PayPal user?" If you need to create or capture orders, you want the PayPal Checkout REST API — this package will not help you.
1. Install the driver.
composer require socialiteproviders/paypal
This pulls socialiteproviders/manager alongside it and expects laravel/socialite to be present already.
2. Register the listener. Without this you get Driver [paypal] not supported.
Laravel ≤10 — app/Providers/EventServiceProvider.php:
protected $listen = [
\SocialiteProviders\Manager\SocialiteWasCalled::class => [
\SocialiteProviders\PayPal\PayPalExtendSocialite::class.'@handle',
],
];
Laravel 11+ — app/Providers/AppServiceProvider.php:
use Illuminate\Support\Facades\Event;
use SocialiteProviders\Manager\SocialiteWasCalled;
public function boot(): void
{
Event::listen(function (SocialiteWasCalled $event) {
$event->extendSocialite('paypal', \SocialiteProviders\PayPal\Provider::class);
});
}
3. Credentials. Create a REST app in the PayPal developer dashboard, explicitly enable Log in with PayPal (it is a separate product toggle from Checkout), and set the return URL to your callback route.
// config/services.php
'paypal' => [
'client_id' => env('PAYPAL_CLIENT_ID'),
'client_secret' => env('PAYPAL_CLIENT_SECRET'),
'redirect' => env('PAYPAL_REDIRECT_URI'),
],
PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_SECRET=
PAYPAL_REDIRECT_URI="${APP_URL}/auth/paypal/callback"
4. First use case.
Route::get('/auth/paypal/redirect', fn () => Socialite::driver('paypal')->redirect());
Route::get('/auth/paypal/callback', function () {
$paypalUser = Socialite::driver('paypal')->user();
$user = User::updateOrCreate(
['paypal_id' => $paypalUser->getId()],
['name' => $paypalUser->getName(), 'email' => $paypalUser->getEmail()],
);
Auth::login($user);
return redirect('/dashboard');
});
Where to look first. The README for the exact listener snippet, then vendor/socialiteproviders/paypal/src/Provider.php — it is short and tells you precisely which endpoints, scopes, and response fields you get. If you need to understand getAuthUrl() / getTokenUrl() / mapUserToObject() semantics, fall back to Laravel\Socialite\Two\AbstractProvider.
Controller over closure. Two methods, one class. You get dependency injection, structured logging, and a callback you can actually test.
public function redirect()
{
return Socialite::driver('paypal')
->scopes(['openid', 'profile', 'email'])
->redirect();
}
public function callback(Request $request)
{
try {
$paypalUser = Socialite::driver('paypal')->user();
} catch (\Laravel\Socialite\Two\InvalidStateException $e) {
return redirect()->route('login')->withErrors('Login expired, please try again.');
}
// ...
}
Key on the PayPal payer ID, not email. $paypalUser->getId() is PayPal's stable identifier. Emails change and can be unverified — verified_account lives in the raw payload, so check $paypalUser->getRaw() before treating email as authoritative. The safe flow: match on paypal_id first, then offer to link to an existing local account by email rather than silently merging.
Persist the tokens, not just the profile. token, refreshToken, and expiresIn are all on the Socialite user object. If you will ever call PayPal APIs on the user's behalf, store them (encrypted cast) with an expiry.
$user->forceFill([
'paypal_id' => $paypalUser->getId(),
'paypal_token' => $paypalUser->token,
'paypal_refresh_token' => $paypalUser->refreshToken,
'paypal_expires_at' => now()->addSeconds($paypalUser->expiresIn ?? 0),
])->save();
Refresh later with Socialite::driver('paypal')->refreshToken($user->paypal_refresh_token)->user(), but verify this against your target environment before building on it — the refresh leg hits the token endpoint with different constraints than the initial exchange.
Schema. Keep a single users table with a nullable paypal_id. If you expect more than one social provider, prefer a social_accounts table (provider, provider_user_id, tokens) over adding columns per provider. Either way, wrap the callback body in a DB::transaction() so a partial profile write doesn't leave a half-linked account.
Testing. Never hit PayPal in tests. Mock at the Socialite boundary with Socialite::shouldReceive('driver->user')->andReturn($fakeUser), or bind a fake driver into the container. For the token exchange itself, Http::fake() with a captured token + userinfo response gives you better regression coverage than mocks — it catches payload shape changes.
Extension point. Need a different endpoint set, extra scopes, or custom field mapping? Subclass SocialiteProviders\PayPal\Provider, override getAuthUrl() / getTokenUrl() / mapUserToObject(), and register your class in the extendSocialite() call. This is the supported escape hatch and is far cleaner than fighting config for environment differences.
Driver [paypal] not supported means the SocialiteWasCalled listener is missing, sits in an unregistered provider, or you never ran config:clear after editing services.php.www., trailing slash — everything must match between PAYPAL_REDIRECT_URI and the PayPal app's return URL. Mismatches fail on PayPal's side before your app is reached, so Laravel logs stay empty. This is the single most common setup failure.email when it isn't enabled returns invalid_scope from the token endpoint, not an auth error. Add the scope in code, add it in the dashboard.dd(Socialite::driver('paypal')->redirect()->getTargetUrl()). Mixing a sandbox client ID against the live endpoint yields a confusing invalid_client.expiresIn, refresh, or re-authenticate.email nullable and never use it as a unique login key without verification.InvalidStateException is normal traffic. Users open the login tab, wander off, come back to a stale session. Friendly redirect, not a 500.$e->getMessage() and Socialite::driver('paypal')->getTokenUrl(). Socialite folds the response body into the exception message, which is usually enough. For the raw profile, dump $paypalUser->getRaw() temporarily.client_secret belongs in .env only. Deploying with config:cache means a rotated secret won't take effect until you re-cache.^4.3 constraint, but skim the actual diff if you depend on a specific endpoint or user field — provider payload changes ship without changelog prose. Pin the constraint and let your callback tests, not the version number, be your regression signal.How can I help you explore Laravel packages today?