psx/oauth2
PSX OAuth2 is a PHP OAuth2 server library for building secure authorization and token endpoints. It supports standard grant types, scopes, access and refresh tokens, and pluggable storage adapters, making it easy to add OAuth2 authentication to your API.
psx/oauth2 is the OAuth2 consumer and provider implementation from the PSX framework (apioo/chriskapp). It is not a Laravel package: there is no service provider, no facade, no config file, and no Laravel-specific documentation. Treat it as a low-level library you wire yourself. With 0 stars and 0 dependents, assume you are the first production user and budget time for reading the source.
Install and pin
composer require psx/oauth2
composer why-not psx/oauth2 # check for PHP / psx-http conflicts with your Laravel version
Check composer.lock immediately afterwards. This package drags in psx/http, psx/schema, psx/data-adjacent abstractions and PSR-7/PSR-17 dependencies; version pins from older PSX releases frequently collide with Laravel 10/11's PHP requirement. If why-not shows a hard conflict, stop — do not force it.
Where to look first
src/Consumer/ — the client side (AuthorizationCode, ClientCredentials, Password, RefreshToken, ConsumerInterface).src/Provider/ — the server side (AuthorizationCode, ProviderAbstract, token/authorization abstractions).src/AccessToken.php — the value object returned by both sides (getAccessToken(), getRefreshToken(), getExpiresIn(), getScope(), getTokenType()).First use case
Pick the consumer side and the client credentials grant, used in a queued job or a scheduled command that calls a third-party API. It is the least framework-coupled path: one HTTP call to the token endpoint, one value object back, no redirects, no session, no storage. Prove that works in a standalone php artisan tinker snippet before touching the authorization-code flow or the provider side.
// config/services.php
'vendor_api' => [
'token_url' => env('VENDOR_TOKEN_URL'),
'client_id' => env('VENDOR_CLIENT_ID'),
'client_secret'=> env('VENDOR_CLIENT_SECRET'),
],
Never instantiate PSX consumers in controllers. The library takes an HTTP client in the constructor, so build it in a service provider and resolve it from the container. Keep your own interface in front of it so you can swap to league/oauth2-client later without touching call sites.
namespace App\Services\OAuth;
interface TokenProvider
{
public function token(): string;
public function forget(): void;
}
final class PsxClientCredentialsProvider implements TokenProvider
{
public function __construct(
private \PSX\Oauth2\Consumer\ConsumerInterface $consumer,
private \Illuminate\Contracts\Cache\Repository $cache,
) {}
public function token(): string
{
return $this->cache->remember('oauth:vendor_api', now()->addMinutes(50), function () {
$token = $this->consumer->getAccessToken(); // confirm signature in src/Consumer
// Cache for expires_in minus a safety margin, not expires_in itself.
$this->cache->put('oauth:vendor_api', $token->getAccessToken(), now()->addSeconds(max(60, $token->getExpiresIn() - 60)));
return $token->getAccessToken();
});
}
}
Bind it in AppServiceProvider::register() and inject TokenProvider — not the PSX class — into jobs, commands, and HTTP clients.
Keep the redirect and the callback in a dedicated controller and use named routes. PSX hands you a URI string for the redirect; you return a plain Laravel redirect. On the callback, validate state before exchanging the code.
public function redirect(Request $request)
{
$state = Str::random(40);
$request->session()->put('oauth.state', $state);
return redirect()->away(
$this->consumer->getAuthorizationUri('read write', $state)
);
}
public function callback(Request $request, TokenRepository $tokens)
{
abort_unless(hash_equals($request->session()->pull('oauth.state'), (string) $request->query('state')), 403);
abort_if($request->query('error'), 400, $request->query('error_description'));
$token = $this->consumer->getAccessToken($request->query('code'));
$tokens->storeForUser($request->user(), $token); // persist refresh_token + absolute expiry
return redirect()->route('dashboard');
}
Persist refresh_token in a oauth_connections table keyed by (user_id, provider). The library gives you relative expires_in — always store expires_at = now()->addSeconds($expiresIn) yourself.
The cleanest integration for day-to-day use: a small wrapper around Http::withToken() that refreshes once and retries. Do not scatter refresh logic across jobs.
Http::withToken($provider->token())
->retry(2, 200, function ($e) use ($provider) {
if ($e instanceof ConnectionException || $e->response?->status() !== 401) return false;
$provider->forget(); // force a re-fetch / refresh-token exchange
return true;
})
->get($url);
The provider half expects you to subclass an abstract (authorization-code provider) and answer questions like "what is the client secret for this client id", "is this redirect URI registered", "who is this user", "store this token". Implement those against Eloquent models (OauthClient, OauthAccessToken, OauthRefreshToken). Two integration notes:
response($psr7->getBody()->getContents(), $psr7->getStatusCode(), $psr7->getHeaders()).routes/api.php (or exclude them from VerifyCsrfToken), because OAuth2 token requests are application/x-www-form-urlencoded POSTs without a CSRF token.Because everything goes through a PSR-7 client you can substitute, inject a fake/stub HTTP client in the container during tests rather than hitting Http::fake() (which only intercepts Laravel's client, not PSX's). Assert on token caching and on the retry-on-401 path — those are the two places bugs hide.
league/oauth2-client (consumer) and league/oauth2-server (provider) are the default choices; psx/oauth2 is defensible mainly if you are already in the PSX/psx/api ecosystem or want a compact reference implementation. Decide this before writing code, not after.src/ and the tests over any blog post, and verify every method signature you copy.HttpException/ValidationException at your controller boundary, or your API will return 500s on invalid grants.getExpiresIn() is seconds-from-now. Store absolute timestamps, and always subtract a margin (30–60s) to absorb clock skew between you and the provider.expires_at yourself and run the refresh-token grant explicitly.src/Consumer before designing a public/native-client flow. If it is absent, do not use this library for public clients — use confidential clients only, or switch libraries.http vs https, and port numbers must match what is registered with the provider. This is the #1 cause of "invalid_grant" during setup.web (session/CSRF) and usually outside auth — authentication happens inside the grant. Getting this wrong yields confusing 419s.client_secret and code), confirm Content-Type: application/x-www-form-urlencoded, confirm the grant_type string, and check whether the provider expects credentials in the body or in a Basic auth header.TokenProvider interface, migrating to league/oauth2-client should be a one-class change. Keep that interface honest — do not leak PSX\Oauth2\AccessToken into your domain layer.How can I help you explore Laravel packages today?