liberusoftware/api-access
A Laravel package for managing API access: issue and validate API tokens, define abilities or scopes, and control which clients can reach your endpoints. Integrates with Laravel's auth and middleware to keep API access secure and straightforward.
This is a module package (liberusoftware/module-api-access), not a standalone library: it's designed to drop into the Liberu modular Laravel application and own the API surface concerns — tokens, service identities, scopes, versions, rate limits, idempotency, expiry, revocation. With zero stars and zero dependents, treat the repo as the source of truth and budget an hour to read it before wiring it into anything.
Install and discover
composer require liberusoftware/api-access
Liberu modules are usually auto-discovered; if not, register the provider in bootstrap/providers.php (Laravel 11+) or config/app.php. Then confirm what the package actually ships before running anything:
composer show liberusoftware/api-access # real version + autoload paths
ls vendor/liberusoftware/api-access # README, config/, database/migrations/, src/
php artisan vendor:publish --provider="Liberusoftware\\ApiAccess\\ApiAccessServiceProvider"
php artisan migrate
Where to look first, in order
config/api-access.php (or whatever the publish tag emits) — this is where token TTL, default scopes, version allow-list, rate-limit buckets and idempotency TTL will be defined. Read it before writing code; it's effectively the package's API.database/migrations/ — the schema tells you more than the README will. Look for tables like api_tokens, api_scopes, service_identities, idempotency_keys. The columns (expires_at, revoked_at, last_used_at, abilities) define your behaviour.src/Http/Middleware/ — the middleware aliases you'll attach to routes.routes/ — whether the package registers its own token/revocation endpoints or expects you to.First use case: protect one endpoint end-to-end
The minimal valuable slice is: issue a token → call a route → get rejected without the token and without the scope → succeed with both.
// routes/api.php
Route::middleware(['auth.api-access', 'scope:orders.read', 'throttle:api-access'])
->prefix('v1')
->group(function () {
Route::get('/orders/{order}', [OrderController::class, 'show']);
});
Then mint a token (via tinker, an artisan command, or the package's issuing action) and hit the route with Authorization: Bearer .... If you get 401 without a token and 403 with a token missing orders.read, the plumbing is correct and you can build outward from there.
Two identity types, two flows. The package distinguishes user tokens from service identities. Keep them separate in your code: user tokens get scopes derived from the user's role/permissions at issue time; service identities get a fixed scope set from config and rotate on a schedule.
// issuing for a user — scopes should come from policy, not the request body
$scopes = $user->hasRole('ops') ? ['orders.read', 'orders.write'] : ['orders.read'];
$token = ApiAccess::issueToken(
identity: $user,
name: $request->input('device_name', 'default'),
scopes: $scopes,
expiresAt: now()->addDays(config('api-access.user_token_ttl_days', 30)),
);
return response()->json(['token' => $token->plainTextToken, 'expires_at' => $token->expires_at]);
// issuing for a machine — client credentials, never a user record
$identity = ServiceIdentity::where('client_id', $clientId)->firstOrFail();
// secret is hashed at creation; compare in the package, don't roll your own
Scope mapping lives in config, not in controllers. Resolve scopes to route groups once so that adding an endpoint is a one-line config change rather than a middleware edit scattered across files:
// config/api-access.php
'scopes' => [
'orders.read' => [OrderController::class, 'show', 'index'],
'orders.write' => [OrderController::class, 'store', 'update'],
],
If the package doesn't ship this, implement it as a small Route::macro and keep it in your app — it's the single highest-leverage thing you can add.
Versioning: pick one mechanism and be consistent. The package advertises version policy, which usually means an allow-list plus deprecation signalling. Two workable shapes:
/api/v1/...) — simplest, cacheable, visible in logs. Map v1/v2 to distinct controllers and let the package enforce that only configured versions are routable.Accept-Version: 2) — use when clients can't change URLs. You'll need the middleware to rewrite/select the controller.Don't run both. If you use headers, still emit Deprecation and Sunset headers from a single middleware so clients have a migration runway.
Route::middleware(['auth.api-access', 'api.version:v1,v2', 'scope:orders.read'])
->prefix('v1');
Rate limiting: key on identity and bucket. The default Laravel throttle:60,1 keys on IP, which is wrong for token-based APIs (NAT, mobile carriers). Register named limiters that key on the token/identity and separate read from write:
// AppServiceProvider::boot()
RateLimiter::for('api-access', fn (Request $r) => Limit::perMinute(
$r->user()?->api_rate_limit ?? 60
)->by($r->user()?->id ?: $r->ip()));
RateLimiter::for('api-access-write', fn (Request $r) => Limit::perMinute(10)
->by('write:'.($r->user()?->id ?: $r->ip()))
->response(fn () => response()->json(['message' => 'Slow down'], 429)
->header('Retry-After', 60)));
Expose the standard X-RateLimit-Limit / X-RateLimit-Remaining / Retry-After headers so clients can back off without parsing error bodies.
Idempotency on every mutating endpoint. This is the feature that pays for itself the first time a client retries a payment. Apply it as middleware on POST/PUT/PATCH:
Route::post('/payments', [PaymentController::class, 'store'])
->middleware(['auth.api-access', 'scope:payments.write', 'idempotent']);
The contract to document for clients:
Idempotency-Key: <uuid>.409 Conflict (or 422), never silently overwrite.400 if you want strictness, or allow it and log the gap.Persist a hash of the request (method + path + canonicalised body) alongside the response body and status. Scope the uniqueness to (identity_id, key), not to key alone — otherwise one tenant can poison another's retries.
Expiry and revocation: make revocation instant and cheap. Store revoked_at and short-circuit in the auth guard, then cache the "is this token revoked?" answer in Redis with a short TTL (30–60s) rather than hitting the DB on every request. Hook revocation into the events you already have:
Event::listen(PasswordReset::class, fn ($e) => ApiAccess::revokeAllFor($e->user));
Event::listen(Logout::class, fn ($e) => ApiAccess::revokeCurrent($e->user));
For service identities, revocation should also invalidate any cached credentials — listen for the package's revocation event rather than polling.
Authorization stays in policies. Scopes answer "can this token do X at all"; policies answer "can this actor do X to this record". Don't collapse them — a token with orders.read must still fail on $this->authorize('view', $order) for another tenant's order.
Testing. Wrap your assertions in a small helper so tests read like intent, not plumbing:
it('rejects a token without the scope', function () {
$token = ApiAccess::issueToken($user, 'test', ['orders.write']);
$this->withToken($token->plainTextToken)
->getJson('/api/v1/orders/1')
->assertForbidden();
});
it('replays an idempotent response', function () {
$key = Str::uuid();
$a = $this->withToken($t)->withHeader('Idempotency-Key', $key)->postJson('/api/v1/payments', $body);
$b = $this->withToken($t)->withHeader('Idempotency-Key', $key)->postJson('/api/v1/payments', $body);
expect($b->json())->toEqual($a->json())->and($b->status())->toBe($a->status());
});
The package is unproven — pin it and read the diff on upgrade. Zero stars, zero dependents, and a release date in the future mean you are effectively the first production user. Use an exact version constraint ("liberusoftware/api-access": "1.0.3"), commit composer.lock, and diff src/ between releases. Do not assume semver.
Module autoloading bites first. If composer show works but nothing else does, the module's service provider isn't registered or its namespace doesn't match the directory. Check composer.json → autoload.psr-4 in the vendor package, then php artisan about to confirm the provider loaded. This is the single most common reason "the package does nothing".
Middleware order matters more than you think. Put auth before scope before throttle-with-identity. If throttle runs first, $request->user() is null and every request gets bucketed by IP — you'll see the entire office sharing 60 req/min and blame the package.
->middleware(['auth.api-access', 'scope:orders.read', 'throttle:api-access'])
Rate limiting silently breaks with the array cache driver. If you run more than one app server and CACHE_STORE=array or file per container, limits are per-process and effectively meaningless. Use Redis for both rate limits and the revocation cache. Same for idempotency if the package stores keys in cache — check whether it uses the DB (safe) or cache (unsafe without a shared store).
Idempotency records grow forever. Every retry stores a response body. Set a TTL (24h is typical; 7 days for payment endpoints), index (identity_id, idempotency_key) uniquely, and add a scheduled prune. Also cap stored body size — a 5 MB response written per retry will fill your DB.
Clock skew and expiry. Store expiries as UTC timestamp/immutable_datetime and compare with now() in the app's timezone-agnostic form. A token that expires "in 30 seconds" on a server with a drifting clock produces 401s that look like permission bugs. If the package compares with Carbon::now()->diffInSeconds() you can get sign flips — assert expires_at > now() directly.
Scope wildcards are a footgun. If the package supports orders.*, confirm whether it matches orders.read only or also orders.read.secret. Test it. Prefer explicit scopes at issuance time and expand wildcards in one place you control.
Version negotiation vs. route prefixes. If you use /v1 prefixes, don't also let Accept-Version override routing — you'll get a request to /v1/orders served by the v2 controller and a very confusing bug report. Pick one and delete the other path through the middleware.
Service-identity secrets need rotation, not just creation. Confirm secrets are stored hashed and compared in constant time. Add a rotated_at and a grace window where the old secret still works, or you'll be unable to rotate without downtime.
Debugging: make the decision legible. When a request 403s, you want to know whether it was "no token", "expired", "revoked", or "missing scope" — four different fixes. Log a structured decision line in the guard, and use:
php artisan route:list --middleware # what's actually attached
php artisan route:list --path=api -v # verify scope strings match config
Add a X-Api-Error: scope_missing (or similar) response header in non-production so you can diagnose from a single curl instead of a debug session.
Extension points to lean on. Before forking, check whether the package binds interfaces you can swap in the container: a token repository, a scope resolver, a rate-limit strategy, an idempotency store. Rebinding IdempotencyStore::class to your own Redis-backed implementation is usually a two-line service provider change and far better than patching vendor code.
Escape hatch: you may not need this. If your requirement is "users can call our API with a token", Sanctum plus three custom middleware gets you scopes, expiry and rate limiting in an afternoon, with a bus factor you can reason about. api-access earns its place when you genuinely need service identities, version policy, and idempotency in one coherent layer — if you need only one of those three, take the smaller dependency.
How can I help you explore Laravel packages today?