spomky-labs/oauth2-server-scope-manager
A PHP package for managing OAuth 2.0 scopes in the spomky-labs OAuth2 server ecosystem. It helps define, validate, and resolve scopes for authorization requests and access tokens, simplifying scope handling in your OAuth2 server.
This is a legacy component of the spomky-labs/oauth2-server v4/v5 stack, split out as a standalone library so the scope-resolution logic could be swapped (array-backed, Doctrine-backed, custom). It is effectively unmaintained — that whole server was folded into thephpleague/oauth2-server v5+, and this package did not survive the merge. Treat it as something you inherit or evaluate, not something you adopt greenfield.
Minimal steps:
composer require spomky-labs/oauth2-server-scope-manager
If Composer refuses, check composer.json for the PHP constraint — the package predates PHP 7.2+ strictness and often needs --ignore-platform-reqs on modern runtimes, or a "config": {"platform": {"php": "7.1"}} shim. That friction alone is a signal.
Where to look first, in this order:
src/ — find the interface (historically SpomkyLabs\OAuth2Server\ScopeManager\ScopeManagerInterface, but confirm; namespaces moved between releases). The interface is the package; everything else is one or two concrete implementations and a factory.Default* / *ScopeManager class — this tells you whether scopes are config-driven or storage-driven.README.md — usually a 20-line example wiring it into the server. Read it literally; don't infer behaviour.composer.lock — check which version of the parent oauth2-server package (if any) is present. Version skew between the scope manager and the server is the #1 source of "why is my scope silently dropped".First use case: you have an authorization endpoint and need to answer three questions — is this scope known?, is it allowed for this client?, what do I grant when none is requested? The scope manager is the single object that answers all three.
$manager = new DefaultScopeManager([
'available_scopes' => ['read', 'write', 'admin'],
'default_scopes' => ['read'],
]);
$manager->isScopeAvailable('write'); // true
$manager->isScopeAvailable('delete'); // false
$manager->getDefaultScopes(); // ['read']
Wire it into the server's grant/endpoint construction (constructor injection or the bundle's service definition, depending on how you're integrating), then request a token with scope=read write and confirm an unknown scope is rejected rather than ignored.
Config-driven manager for small scope sets. If your scopes are a fixed vocabulary baked into a deploy, the default manager is all you need. Keep the list in a config file, not in code, so ops can see it. This is the right choice for internal APIs with < 20 scopes.
Storage-backed manager for per-client scopes. The moment scopes vary per client, you need a custom implementation hitting your database. The pattern is a thin class implementing the same interface, injecting a repository, and memoising per-request:
final class DoctrineScopeManager implements ScopeManagerInterface
{
private $cache = [];
public function __construct(private ScopeRepository $scopes, private ClientScopeRepository $clientScopes) {}
public function isScopeAvailable($scope): bool
{
return isset($this->cache['global'][$scope])
?? $this->cache['global'][$scope] = $this->scopes->exists($scope);
}
public function isScopeAvailableForClient(ClientInterface $client, $scope): bool
{
return $this->isScopeAvailable($scope)
&& $this->clientScopes->allows($client->getIdentifier(), $scope);
}
}
Two-layer validation. Always check global availability and per-client availability. It's tempting to encode "admin scope only exists for the admin client" purely in the client table; don't — keep the global vocabulary as the source of truth so a typo in a client row can't invent a scope.
Decorator for observability. Because the manager is asked about every scope on every authorize/token request, it's the ideal place to instrument. Wrap the real manager:
final class LoggingScopeManager implements ScopeManagerInterface
{
public function __construct(private ScopeManagerInterface $inner, private LoggerInterface $log) {}
public function isScopeAvailableForClient(ClientInterface $client, $scope): bool
{
$ok = $this->inner->isScopeAvailableForClient($client, $scope);
$this->log->info('scope.check', ['client' => $client->getIdentifier(), 'scope' => $scope, 'granted' => $ok]);
return $ok;
}
// ...delegate the rest
}
This gives you the audit trail you need when a customer reports "my token is missing a scope" — the answer is almost never the manager, it's what the client requested.
Testing. In-memory manager with a fixed vocabulary, constructed per test. Don't mock the interface in unit tests of your grants — you'll end up asserting on method call order instead of behaviour. Keep an integration test that boots the real server with the array manager and exercises an actual token request; that's the only test that catches scope plumbing bugs.
Integration shape. The manager is injected into the server/grant objects, not called by your controllers. Your controller's job is to pass the raw scope parameter through untouched and let the library reject/trim it. Resist adding a array_intersect in the controller "just to be safe" — you'll diverge from what the token endpoint does and get inconsistent behaviour between authorize and token.
It's abandoned — budget a migration. The end state of this ecosystem is thephpleague/oauth2-server (v8+) plus league/oauth2-server-bundle for Symfony. The scope concept survives as ScopeEntityInterface and the finalizeScopes method on grant classes; the separate manager object does not survive. If you're evaluating this for new work, stop and evaluate the league package instead. If you're inheriting it, know that the migration is a rewrite of the scope layer, not a rename.
Scope strings are space-delimited per RFC 6749 and case-sensitive. Read ≠ read. The library generally does not normalise case or trim aggressively, so a client sending read write (double space) or read,write may produce surprising results. Validate and normalise the raw parameter at the edge if you can't trust clients.
Default scopes are a fallback, not a grant. When the client omits scope, the manager's defaults are applied — but they still go through per-client validation in a correct implementation. If you see default scopes leaking to clients that shouldn't have them, your custom implementation is probably short-circuiting the client check on the "no scope requested" path. Test that path explicitly; it's the most commonly skipped branch.
Silent dropping is the default failure mode. Most OAuth2 servers trim the requested scope set down to what's permitted rather than erroring, unless the client explicitly asks for something invalid. So an empty scope in the resulting token is usually a permissions problem, not a bug. Log the requested-vs-granted diff (see the decorator pattern above) or you'll debug this blind.
Check the interface signature against your installed version. This package had several releases with breaking changes as the parent server evolved (isScopeAvailable vs isScopeAvailableForClient, nullable vs throwing ClientInterface params, string vs ScopeInterface arguments). Read vendor/spomky-labs/oauth2-server-scope-manager/src/ and implement exactly what's there — copying an interface from a blog post or an older release will produce a class that doesn't satisfy the type hint and a confusing TypeError deep inside the grant.
Version-lock against the parent server. oauth2-server and oauth2-server-scope-manager must be compatible releases. Pin both exactly ("x.y.z", no caret) and keep them in the same commit. Mixed minors manifest as "the server ignores my custom manager" because the server is type-hinting a different interface.
Cache invalidation on client scope changes. If you memoise per-request (recommended) you're fine. If you memoise across requests — APCu, a static, a Symfony cache pool — remember client scope changes won't take effect until TTL expiry. Given scope changes are rare, a short TTL (60s) is a reasonable trade; a stale-cache bug here presents as "permissions changed but tokens still carry the old scope", which is genuinely hard to diagnose.
Extension point: the manager is the seam, use it. The one genuinely good thing about this package is that scope policy is a single injectable interface rather than scattered if statements. If you must stay on it, route all scope decisions through your implementation — including admin UI dropdowns ("which scopes can I grant this client?"), which is where hand-rolled duplicates usually creep in and drift out of sync with the server's view.
How can I help you explore Laravel packages today?