pr4w/laravel-social-tokens
Laravel Social Tokens offers a simple way to store, retrieve, and manage social OAuth access tokens for providers like Facebook, Google, and Twitter, with Eloquent models and easy integration into Laravel applications.
Install and publish:
composer require pr4w/laravel-social-tokens
php artisan vendor:publish --tag="social-tokens-migrations" # also check --tag="social-tokens-config"
php artisan migrate
The package ships a social_tokens table (user, provider, provider_id, access/refresh tokens, expires_at) plus a HasSocialTokens trait. It does not perform the OAuth dance — that is still Laravel Socialite's job. This package is the persistence layer for whatever Socialite hands back.
Wire it up:
use Pr4w\LaravelSocialTokens\Traits\HasSocialTokens;
class User extends Authenticatable
{
use HasSocialTokens;
}
First use case — persist a token in your Socialite callback:
Route::get('/auth/{provider}/callback', function (string $provider) {
$socialiteUser = Socialite::driver($provider)->user();
auth()->user()->socialTokens()->updateOrCreate(
['provider' => $provider],
[
'provider_id' => $socialiteUser->getId(),
'access_token' => $socialiteUser->token,
'refresh_token' => $socialiteUser->refreshToken,
'expires_at' => $socialiteUser->expiresIn
? now()->addSeconds($socialiteUser->expiresIn)
: null,
]
);
return redirect()->route('dashboard');
});
Where to look first: the published migration (it defines the constraints you will fight later), then the config file for encryption/casts and model swapping, then the trait for the available helpers (hasSocialToken, getSocialToken, the socialTokens relation). Read the README last — the migration is the source of truth.
Upsert-on-login, not create-on-login. A user re-authenticating should overwrite their row, not accumulate duplicates. updateOrCreate keyed on ['provider' => ...] is the canonical pattern; it also refreshes provider_id if the user re-linked a different account.
Single accessor service. Don't sprinkle relation queries through controllers. Wrap token retrieval + refresh in one class so every call site has the same expiry semantics:
class SocialTokenManager
{
public function tokenFor(User $user, string $provider): string
{
$token = $user->getSocialToken($provider); // or ->socialTokens()->where(...)->firstOrFail()
if ($token->expires_at?->isPast() && $token->refresh_token) {
$fresh = Socialite::driver($provider)->refreshToken($token->refresh_token);
$token->update([
'access_token' => $fresh->token,
'refresh_token' => $fresh->refreshToken ?? $token->refresh_token,
'expires_at' => $fresh->expiresIn ? now()->addSeconds($fresh->expiresIn) : null,
]);
}
return $token->access_token;
}
}
Proactive refresh via a scheduled command. Rather than refreshing inline on every request (adding latency to the hot path), schedule a nightly job that refreshes tokens expiring within 24 hours and marks the rest. Inline refresh stays as a fallback for the case where the job missed.
Multi-provider iteration. Because tokens are keyed per provider, a user can hold GitHub + Google + Slack simultaneously. Any UI that lists "connected accounts" should read the socialTokens collection directly rather than issuing one query per provider.
Decrypt-at-the-edge. Tokens are encrypted at rest by the package's cast. Read the plaintext only in the HTTP client call, never store a decrypted copy on the model or in a DTO that gets cached or serialized into a session.
Testing. Because the cast uses Laravel encryption, tests need a real APP_KEY — RefreshDatabase plus a token factory, and avoid Crypt::setKey() juggling mid-suite. Assert against $token->access_token, not the raw DB column.
APP_KEY is now load-bearing. Rotating the key silently invalidates every stored token — all users re-authenticate. All queue workers and web nodes must share the key. If you run blue/green deploys, this is a real migration event, not a config tweak.
expires_at is frequently null. GitHub and several flows return no expires_in, so the column stays null. Treat null as "does not expire," not as "expired" — a naive expires_at->isPast() null-guard bug will force re-auth loops. Use ?->isPast() as above.
No refresh token unless you asked for one. Socialite returns refreshToken = null unless you pass offline params (e.g. ->with(['access_type' => 'offline']) for Google). If you skip that, the refresh branch in your manager never fires and users hit a hard expiry wall. Decide this at the login redirect, not later.
Refresh tokens rotate. Google and others return a new refresh token on refresh and invalidate the old one. Always persist $fresh->refreshToken ?? $token->refresh_token — dropping it locks the account out on the next cycle.
Unique index reality check. (user_id, provider) means one account per provider per user; (provider, provider_id) means one row per external account. These are different products ("linked accounts" vs "global identity map"). Check which the migration shipped before you build on it, and handle QueryException on duplicate for concurrent callbacks from two tabs.
Don't let the tokens leak into logs. Avoid ->toArray() on the model in exception handlers or Log::info($token). The cast decrypts on access, so a stray dump is a plaintext credential in your log aggregator — add the token attributes to $hidden on any custom model.
Catch silent discards. If you swap in your own model or add columns, Model::preventSilentlyDiscardingAttributes() in a local environment catches $fillable typos in the updateOrCreate arrays — otherwise a misspelled refresh_token key fails quietly and you only find out at expiry.
Extension points. Publish the migration to add scope, meta JSON, or last_refreshed_at columns; the config supports binding a custom model if you need extra behavior. Prefer subclassing over editing the vendor migration in place, so future upgrades stay clean.
On v3.4.0 specifically: it is a minor bump with no migration or config changes called out — the compare link shows only commit history, so there is no documented breaking surface. Pin ^3.4, run composer update pr4w/laravel-social-tokens, and re-exercise the refresh path plus your callback route in staging: the risky surface for any release in this package is token handling, and that is worth one manual OAuth round-trip rather than trusting CI alone.
How can I help you explore Laravel packages today?