PHPackages                             simplesquid/saloonphp-oauth - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. simplesquid/saloonphp-oauth

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

simplesquid/saloonphp-oauth
===========================

Concurrent-safe OAuth auto-refresh for Saloon v4 and Laravel

v0.1.0(3mo ago)00[1 PRs](https://github.com/simplesquid/saloonphp-oauth/pulls)MITPHPPHP ^8.4CI passing

Since Apr 15Pushed 3mo agoCompare

[ Source](https://github.com/simplesquid/saloonphp-oauth)[ Packagist](https://packagist.org/packages/simplesquid/saloonphp-oauth)[ Docs](https://github.com/simplesquid/saloonphp-oauth)[ RSS](/packages/simplesquid-saloonphp-oauth/feed)WikiDiscussions main Synced 3w ago

READMEChangelog (1)Dependencies (12)Versions (4)Used By (0)

Saloon OAuth Auto-Refresh
=========================

[](#saloon-oauth-auto-refresh)

[![Latest Version on Packagist](https://camo.githubusercontent.com/e0d9e376732814bc602a3f07ebbbd0b06d83e8e08097e00da3abef6dbd56d743/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f73696d706c6573717569642f73616c6f6f6e7068702d6f617574682e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/simplesquid/saloonphp-oauth)[![GitHub Tests Action Status](https://camo.githubusercontent.com/78164d7fd9a7f9a0d789779944cf60693205783565808e83f22d471a173116ff/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f73696d706c6573717569642f73616c6f6f6e7068702d6f617574682f72756e2d74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/simplesquid/saloonphp-oauth/actions?query=workflow%3Arun-tests+branch%3Amain)[![GitHub Code Style Action Status](https://camo.githubusercontent.com/1fcd615a573ca2a50e98939ae4aa8bb2385378662d28b9a5970ab9e4085d5955/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f73696d706c6573717569642f73616c6f6f6e7068702d6f617574682f6669782d7068702d636f64652d7374796c652d6973737565732e796d6c3f6272616e63683d6d61696e266c6162656c3d636f64652532307374796c65267374796c653d666c61742d737175617265)](https://github.com/simplesquid/saloonphp-oauth/actions?query=workflow%3A%22Fix+PHP+code+style+issues%22+branch%3Amain)[![Total Downloads](https://camo.githubusercontent.com/438469f74004fa5a7b8aa74dffa23635c6106317f18c10a1ee76e5d784f54185/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f73696d706c6573717569642f73616c6f6f6e7068702d6f617574682e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/simplesquid/saloonphp-oauth)

Automatic, concurrent-safe OAuth token management for [Saloon v4](https://docs.saloon.dev) connectors in Laravel. Handles refresh for the Authorization Code grant, caching for the Client Credentials grant, and uses a distributed lock so two processes don't refresh the same token at the same time.

Requirements
------------

[](#requirements)

- PHP ^8.4
- Laravel ^12.0 or ^13.0
- Saloon ^4.0
- A cache store that supports locking (Redis, Memcached, DynamoDB, or Database). The `file` driver does not support locking.

Installation
------------

[](#installation)

```
composer require simplesquid/saloonphp-oauth
```

Publish and run the migration:

```
php artisan vendor:publish --tag="saloon-oauth-migrations"
php artisan migrate
```

Optionally publish the config:

```
php artisan vendor:publish --tag="saloon-oauth-config"
```

Usage
-----

[](#usage)

### Authorization Code Flow

[](#authorization-code-flow)

Add the `HasAutoRefresh` trait to a connector using Saloon's `AuthorizationCodeGrant`. The only method you need to implement is `resolveTokenKey()`:

```
use Saloon\Http\Connector;
use Saloon\Traits\OAuth2\AuthorizationCodeGrant;
use SimpleSquid\SaloonOAuth\Concerns\HasAutoRefresh;

final class ExactOnlineConnector extends Connector
{
    use AuthorizationCodeGrant;
    use HasAutoRefresh;

    public function __construct(private readonly int $userId) {}

    protected function resolveTokenKey(): string
    {
        return "user:{$this->userId}:exact-online";
    }

    protected function defaultOauthConfig(): OAuthConfig
    {
        return OAuthConfig::make()
            ->setClientId(config('services.exact.client_id'))
            ->setClientSecret(config('services.exact.client_secret'))
            ->setRedirectUri(config('services.exact.redirect_uri'));
    }

    public function resolveBaseUrl(): string
    {
        return 'https://start.exactonline.nl/api';
    }
}
```

When you send a request, `defaultAuth()` loads the token from the store, checks expiry, and refreshes it if needed — all within a distributed lock.

### Client Credentials Flow

[](#client-credentials-flow)

Add the `HasClientCredentialsCache` trait. No extra methods are required — the token key defaults to the connector's class name, so one token is cached per connector class:

```
use Saloon\Http\Connector;
use Saloon\Traits\OAuth2\ClientCredentialsGrant;
use SimpleSquid\SaloonOAuth\Concerns\HasClientCredentialsCache;

final class InternalApiConnector extends Connector
{
    use ClientCredentialsGrant;
    use HasClientCredentialsCache;

    protected function defaultOauthConfig(): OAuthConfig
    {
        return OAuthConfig::make()
            ->setClientId(config('services.internal.client_id'))
            ->setClientSecret(config('services.internal.client_secret'));
    }

    public function resolveBaseUrl(): string
    {
        return 'https://api.internal.example.com';
    }
}
```

When no token exists or the current one is expired, a new token is acquired automatically.

### Storing the Initial Token

[](#storing-the-initial-token)

This step is only needed for the Authorization Code flow — client credentials tokens are acquired automatically on first use.

After the OAuth callback, persist the token so `HasAutoRefresh` can find it on subsequent requests:

```
use SimpleSquid\SaloonOAuth\Contracts\TokenStore;

public function callback(Request $request, TokenStore $store): RedirectResponse
{
    $connector = new ExactOnlineConnector($request->user()->id);

    $authenticator = $connector->getAccessToken(
        code: $request->query('code'),
        state: $request->query('state'),
        expectedState: session('oauth_state'),
    );

    $store->put("user:{$request->user()->id}:exact-online", $authenticator);

    return redirect()->route('dashboard');
}
```

### Revoking a Token

[](#revoking-a-token)

```
$store->revoke("user:{$userId}:exact-online");
```

See [Revoke vs Forget](#revoke-vs-forget) for the difference between soft-revocation and hard-deletion.

Configuration
-------------

[](#configuration)

```
return [
    // Database table name for token storage.
    'table' => 'oauth_tokens',

    // Eloquent model used by EloquentTokenStore.
    'model' => \SimpleSquid\SaloonOAuth\Models\OAuthToken::class,

    // Distributed lock settings. The cache store must implement LockProvider.
    'lock' => [
        'store' => null,   // Cache store name (null = default). "file" does NOT support locking.
        'ttl'   => 30,     // How long the lock is held before auto-releasing (seconds).
        'wait'  => 10,     // How long to wait to acquire the lock (seconds).
    ],

    // Seconds before actual expiry to trigger a proactive refresh.
    'expiry_buffer' => 300,
];
```

Customization
-------------

[](#customization)

Each connector resolves four protected methods. Override any of them to change behaviour for that connector:

MethodDefaultPurpose`resolveTokenKey()`abstract on `HasAutoRefresh`; `static::class` on `HasClientCredentialsCache`Unique key for this connector's token`resolveTokenStore()``app(TokenStore::class)`Token persistence backend`resolveTokenLocker()``app(TokenLocker::class)`Distributed lock implementation`resolveExpiryBuffer()``config('saloon-oauth.expiry_buffer')`Seconds before expiry to trigger a proactive refreshFor application-wide changes, rebind the contracts in a service provider instead:

```
$this->app->bind(TokenStore::class, MyCustomTokenStore::class);
$this->app->bind(TokenLocker::class, MyCustomTokenLocker::class);
```

### Token Store

[](#token-store)

The default `EloquentTokenStore` persists tokens to the `oauth_tokens` table via the `OAuthToken` model. Any implementation of the `TokenStore` contract can replace it.

**Custom model.** To add relationships, scopes, or extra columns, extend `OAuthToken` and point the config at your class:

```
namespace App\Models;

use SimpleSquid\SaloonOAuth\Models\OAuthToken;

class UserOAuthToken extends OAuthToken
{
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class, 'user_id');
    }
}
```

```
// config/saloon-oauth.php
'model' => \App\Models\UserOAuthToken::class,
```

If you add columns, follow up with your own migration — don't edit the published one.

**Custom table.** Change `config('saloon-oauth.table')`. The default model reads this value from `getTable()`.

**Custom backend.** Implement the `TokenStore` contract. Required invariants:

- `get($key)` returns `null` for missing keys, throws `TokenRevokedException` for revoked ones.
- `put($key, $auth)` throws `TokenRevokedException` if the key exists and is revoked (this is what prevents a concurrent refresh from un-revoking a token).
- `revoke($key)` on a missing key is a silent no-op.

### Token Locker

[](#token-locker)

`CacheTokenLocker` delegates to a Laravel cache store's `LockProvider`. The two timeouts control different things:

- **`ttl`** — how long the lock itself is held before auto-releasing. Set this longer than your slowest expected token refresh (including network timeouts). If it's too short, a slow refresh can expire the lock and let another process in.
- **`wait`** — how long a competing request blocks waiting for the lock. Set this longer than your typical refresh, but shorter than your HTTP timeout. If it's exceeded, `LockTimeoutException` is thrown.

Override per-connector, or rebind globally:

```
$this->app->bind(TokenLocker::class, fn () => new CacheTokenLocker(
    $redisLockProvider,
    ttl: 60,
    wait: 30,
));
```

For single-process contexts where locking is unnecessary (tests, one-off artisan commands), use `NullLocker` — it just calls the callback.

### Expiry Buffer

[](#expiry-buffer)

Tokens are treated as expired `expiry_buffer` seconds before their actual expiry. The default of 300 seconds gives the refresh enough headroom to complete before the in-flight token dies. Shorter values mean tokens are used closer to their real lifetime; longer values refresh more eagerly.

A token with a `null` `expiresAt` is treated as non-expiring — it won't be refreshed until revoked or forgotten.

Storage Details
---------------

[](#storage-details)

### Schema

[](#schema)

ColumnTypeNotes`id`bigintPrimary key`key`string, uniqueYour application-defined key (e.g. `"user:42:exact-online"`)`access_token`textEncrypted at rest via Laravel's `encrypted` cast (`APP_KEY` required)`refresh_token`text, nullableEncrypted at rest`expires_at`timestamp, nullableWhen the access token expires; `null` means never`revoked_at`timestamp, nullable, indexedSet by `revoke()`; non-null means the token is dead`created_at` / `updated_at`timestampsStandard Eloquent timestamps### Key Conventions

[](#key-conventions)

Keys are free-form strings you choose per authentication context. Keep them stable — don't embed things like request IDs. Common patterns:

- **Per-user OAuth** (most common): `"user:{$userId}:{$provider}"`.
- **Per-tenant OAuth**: `"tenant:{$tenantId}:{$provider}"`.
- **Client credentials**: defaults to `static::class` — one token per connector class. Override `resolveTokenKey()` only if you need per-instance tokens.

Keys must fit in `varchar(255)`.

### Revoke vs Forget

[](#revoke-vs-forget)

- **`revoke($key)`** — soft-delete. Sets `revoked_at` and clears the `access_token` / `refresh_token` columns. `get()` and `put()` both throw `TokenRevokedException` afterwards. The row stays in the table as an audit trail.
- **`forget($key)`** — hard-delete. Removes the row entirely. Use this before re-authorising with the same key:

```
$store->forget("user:{$userId}:exact-online");
$store->put("user:{$userId}:exact-online", $newAuthenticator);
```

Failure Semantics
-----------------

[](#failure-semantics)

The traits surface failures through custom exceptions and degrade gracefully where they can:

ExceptionWhen`TokenRevokedException`A revoked token is loaded — or a concurrent refresh tries to persist over a key that was revoked mid-refresh`TokenAcquisitionFailedException`Refresh or acquisition failed; wraps the underlying Saloon exception`LockTimeoutException`Couldn't acquire the lock within `lock.wait``InvalidCacheStoreException`Configured cache store doesn't implement `LockProvider`**Transient persist failures are non-fatal.** If the refresh HTTP call succeeds but `$store->put()` fails (DB outage, etc.), the exception is sent through Laravel's `report()` helper and the fresh token is still returned to the caller. The current request succeeds; the next request will retry the refresh.

**Concurrent revokes are respected.** If a revoke lands while a refresh is in flight, `put()` refuses to overwrite the revoked row and throws `TokenRevokedException`. The current request fails — matching the intent of the revocation — and the token stays dead.

Testing
-------

[](#testing)

The package ships two test doubles:

- `SimpleSquid\SaloonOAuth\Support\NullLocker` — no-op locker for single-process tests.
- `SimpleSquid\SaloonOAuth\Testing\InMemoryTokenStore` — array-backed store that mirrors the `EloquentTokenStore` semantics (including the revoked-key protection).

Either inject them into your connector via the resolver overrides, or rebind the contracts in your test's service container.

Run the package's own tests with:

```
composer test
```

Changelog
---------

[](#changelog)

Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently.

Security Vulnerabilities
------------------------

[](#security-vulnerabilities)

Please review [our security policy](../../security/policy) on how to report security vulnerabilities.

Credits
-------

[](#credits)

- [Matthew Poulter](https://github.com/mdpoulter)
- [All Contributors](../../contributors)

License
-------

[](#license)

The MIT License (MIT). Please see [License File](LICENSE.md) for more information.

###  Health Score

34

—

LowBetter than 75% of packages

Maintenance81

Actively maintained with recent releases

Popularity0

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity43

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

100d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/e90efcfce673cabc08f0101ee076b8060508c33e7693c048d58e44e4a53b2300?d=identicon)[simplesquid](/maintainers/simplesquid)

---

Top Contributors

[![mdpoulter](https://avatars.githubusercontent.com/u/1091085?v=4)](https://github.com/mdpoulter "mdpoulter (8 commits)")

---

Tags

auto-refreshlaraveloauthphpsaloonsaloonphplaraveloauthsaloonsaloonphpsimplesquidauto-refresh

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/simplesquid-saloonphp-oauth/health.svg)

```
[![Health](https://phpackages.com/badges/simplesquid-saloonphp-oauth/health.svg)](https://phpackages.com/packages/simplesquid-saloonphp-oauth)
```

###  Alternatives

[defstudio/telegraph

A laravel facade to interact with Telegram Bots

813336.8k3](/packages/defstudio-telegraph)[harris21/laravel-fuse

Circuit breaker for Laravel queue jobs. Protect your workers from cascading failures.

45955.7k](/packages/harris21-laravel-fuse)[masterix21/laravel-licensing

Laravel licensing package with polymorphic assignment to any model, activation keys, expirations/renewals, and seat control via LicenseUsage. Supports offline verification with public-key–signed tokens, a CLI to generate/rotate/revoke keys, and an extensible architecture via config and contracts.

1613.3k4](/packages/masterix21-laravel-licensing)[simplestats-io/laravel-client

Server-side analytics for Laravel that follows the full funnel from visit to registration to payment, attributed to the channel that drove it. Revenue, MRR, churn and ad-spend profit (ROAS/CAC) per channel. GDPR compliant, ad-blocker proof.

5022.6k](/packages/simplestats-io-laravel-client)[rawilk/profile-filament-plugin

Profile &amp; MFA starter kit for filament.

3914.8k](/packages/rawilk-profile-filament-plugin)[codebar-ag/laravel-docuware

DocuWare integration with Laravel

1123.7k](/packages/codebar-ag-laravel-docuware)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
