From 35b505e156e32147fb1479268fb4b446a34720bc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:41:48 +0000 Subject: [PATCH 01/11] feat(auth): add optional retrieveById() cache to EloquentUserProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in, per-provider cache for retrieveById() lookups — the hot path on every authenticated request in a Swoole worker, where one DB query per request per worker adds up fast at scale. Public surface: - enableCache(?string $storeName, int $ttl, ?string $prefix): static - isCacheEnabled(): bool - clearUserCache(mixed $identifier): void - resolveUserCacheKeyUsing(Closure): void (static, global) - flushState(): void (static, test isolation) Key design points: - Accepts a store NAME (nullable = default store), not a pre-resolved repository, so the invalidation registry can re-resolve by name via Container::getInstance()->make('cache') without holding provider refs. - Validates the resolved Store against a whitelist (RedisStore, DatabaseStore, FileStore, SwooleStore, StackStore) via instanceof BEFORE any instance state is mutated. Rejected stores leave the provider uncached and don't register descriptors or listeners. - Cache keys are {prefix}:{model-FQCN}:{identifier}, with the FQCN memoized once in enableCache() so the hot path doesn't recompute it. The FQCN segment is always present so providers using different models never collide — even when two models share a basename across namespaces. - Global static resolveUserCacheKeyUsing() callback shapes the identifier segment, evaluated at call time so tenant-like per-request context is current (a config-file closure would capture boot-time). - Model saved/deleted listeners invalidate via a descriptor registry of plain-data arrays keyed by deterministic hash — no provider refs, so AuthManager::forgetGuards() / re-resolve cycles don't leak providers, and duplicate configs collapse on insert. - Listener registration is guarded by Model::getEventDispatcher() being non-null, and the cacheEventsRegistered flag is only set after a successful attach, so if the dispatcher isn't set yet at first enableCache() time the next call retries. - Null-sentinel caching of missing users prevents repeated DB queries for nonexistent ids. - updateRememberToken/rehashPasswordIfRequired rely on the listener firing on the save they already do — no explicit clear inside them. --- src/auth/src/EloquentUserProvider.php | 318 ++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) diff --git a/src/auth/src/EloquentUserProvider.php b/src/auth/src/EloquentUserProvider.php index a7c2f4b5c..8b6723d4c 100755 --- a/src/auth/src/EloquentUserProvider.php +++ b/src/auth/src/EloquentUserProvider.php @@ -5,16 +5,80 @@ namespace Hypervel\Auth; use Closure; +use Hypervel\Cache\DatabaseStore; +use Hypervel\Cache\FileStore; +use Hypervel\Cache\RedisStore; +use Hypervel\Cache\StackStore; +use Hypervel\Cache\SwooleStore; +use Hypervel\Container\Container; use Hypervel\Contracts\Auth\Authenticatable as UserContract; use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Hashing\Hasher as HasherContract; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; +use InvalidArgumentException; use SensitiveParameter; class EloquentUserProvider implements UserProvider { + /** + * Sentinel value cached for user IDs that don't exist. + * + * Must be serializable (not an object) because it's stored in an + * external cache store. + * + * @var array{__auth_null_sentinel: true} + */ + protected const NULL_SENTINEL = ['__auth_null_sentinel' => true]; + + /** + * Whitelist of cache store classes supported for auth user caching. + * + * Checked with instanceof in ensureSupportedAuthCacheStore(), so + * legitimate subclasses of these stores are also accepted. + * + * @var list + */ + private const array SUPPORTED_AUTH_CACHE_STORES = [ + RedisStore::class, + DatabaseStore::class, + FileStore::class, + SwooleStore::class, + StackStore::class, + ]; + + /** + * The callback used to build the identifier segment of cache keys. + * + * Global for all cached Eloquent user providers. Set once in a service + * provider's boot() method. Evaluated at call time so it can read + * per-request context (e.g., tenant ID from Context). + * + * @var null|(Closure(mixed): string) + */ + protected static ?Closure $cacheKeyResolver = null; + + /** + * Registry of cache descriptors per model class. + * + * Each entry is keyed by a deterministic descriptor hash, holding + * enough information to rebuild the exact cache key on invalidation + * (storeName, prefix, modelSegment) without retaining a reference + * to any provider instance. Duplicate configs collapse on insert. + * + * @var array> + */ + protected static array $cachedProviders = []; + + /** + * Whether model event listeners have been registered for a model class. + * + * @var array + */ + protected static array $cacheEventsRegistered = []; + /** * The callback that may modify the user retrieval queries. * @@ -22,6 +86,37 @@ class EloquentUserProvider implements UserProvider */ protected ?Closure $queryCallback = null; + /** + * The cache store for user lookups. + */ + protected ?CacheRepository $cache = null; + + /** + * The cache store name (null = default store). + * + * Stored so the descriptor registry can re-resolve the store by name + * on invalidation without holding a strong reference to this provider. + */ + protected ?string $cacheStoreName = null; + + /** + * The cache TTL in seconds. + */ + protected int $cacheTtl = 300; + + /** + * The cache key prefix. + */ + protected string $cachePrefix = 'auth_users'; + + /** + * Memoized model key segment (the fully qualified class name). + * + * Computed once in enableCache() and reused on every retrieveById() + * to avoid per-request string work on the hot path. + */ + protected string $modelSegment = ''; + /** * Create a new database user provider. * @@ -37,6 +132,33 @@ public function __construct( * Retrieve a user by their unique identifier. */ public function retrieveById(mixed $identifier): ?UserContract + { + if (! $this->cache) { + return $this->fetchUserById($identifier); + } + + $key = $this->buildCacheKey($identifier); + $cached = $this->cache->get($key); + + if ($cached === self::NULL_SENTINEL) { + return null; + } + + if ($cached !== null) { + return $cached; + } + + $user = $this->fetchUserById($identifier); + + $this->cache->put($key, $user ?? self::NULL_SENTINEL, $this->cacheTtl); + + return $user; + } + + /** + * Fetch a user by ID from the database. + */ + protected function fetchUserById(mixed $identifier): ?UserContract { $model = $this->createModel(); @@ -83,6 +205,9 @@ public function updateRememberToken(UserContract $user, #[SensitiveParameter] st $user->save(); $user->timestamps = $timestamps; + + // Cache invalidation (when caching is enabled) is handled by the + // saved model event listener — no explicit clear needed here. } /** @@ -150,6 +275,199 @@ public function rehashPasswordIfRequired(UserContract $user, #[SensitiveParamete ])->save(); } + /** + * Enable cross-request caching for user lookups. + * + * Accepts a store name (or null for the default store) rather than a + * pre-resolved repository so the descriptor registry can re-resolve + * by name on invalidation and avoid holding strong references. + * + * A null or empty-string prefix is normalized to the feature default + * ('auth_users') so misconfiguration does not create hard-to-read keys + * with a leading colon. + * + * The store is validated against the supported-drivers whitelist BEFORE + * any instance state is mutated, so a rejected store leaves the provider + * in its prior (uncached) state and does not register a descriptor or + * model event listeners. + * + * @throws InvalidArgumentException when the resolved store is not supported + */ + public function enableCache(?string $storeName, int $ttl = 300, ?string $prefix = 'auth_users'): static + { + $cache = Container::getInstance()->make('cache')->store($storeName); + $this->ensureSupportedAuthCacheStore($cache); + + $this->cache = $cache; + $this->cacheStoreName = $storeName; + $this->cacheTtl = $ttl; + $this->cachePrefix = $prefix === null || $prefix === '' ? 'auth_users' : $prefix; + $this->modelSegment = $this->model; + + $this->registerCacheInvalidationEvents(); + + return $this; + } + + /** + * Determine if cross-request user caching is enabled. + */ + public function isCacheEnabled(): bool + { + return $this->cache !== null; + } + + /** + * Clear the cached user for the given identifier. + * + * Uses the same key resolver as retrieveById(), so it respects + * tenant context and custom key callbacks. + */ + public function clearUserCache(mixed $identifier): void + { + $this->cache?->forget($this->buildCacheKey($identifier)); + } + + /** + * Set the cache key resolver for all cached Eloquent user providers. + * + * The callback receives the user identifier and should return a string + * that uniquely identifies the user within the current context (e.g., + * including tenant ID for multi-tenant apps). Called once in a service + * provider's boot() method — the closure is evaluated fresh on each + * retrieveById() call so per-request context like tenant ID is current. + * + * The fully qualified model class name is always included in the key + * automatically. The resolver only controls the identifier segment. + * + * @param Closure(mixed): string $callback + */ + public static function resolveUserCacheKeyUsing(Closure $callback): void + { + static::$cacheKeyResolver = $callback; + } + + /** + * Flush static state for test isolation. + */ + public static function flushState(): void + { + static::$cacheKeyResolver = null; + static::$cachedProviders = []; + static::$cacheEventsRegistered = []; + } + + /** + * Ensure the configured cache store is supported for auth user caching. + * + * Throws when the resolved Store is not an instance of one of the + * whitelisted classes. Called from enableCache() before any instance + * state is mutated, so a rejected store leaves the provider in its + * prior uncached state. Uses instanceof so legitimate subclasses of + * supported stores are accepted. + * + * @throws InvalidArgumentException + */ + protected function ensureSupportedAuthCacheStore(CacheRepository $cache): void + { + $store = $cache->getStore(); + + foreach (self::SUPPORTED_AUTH_CACHE_STORES as $supported) { + if ($store instanceof $supported) { + return; + } + } + + throw new InvalidArgumentException(sprintf( + 'Auth user caching does not support cache store [%s]. See the auth cache documentation for supported stores.', + $store::class + )); + } + + /** + * Build the cache key for a user identifier. + * + * Always includes the fully qualified model class name (memoized in + * enableCache()) so providers using different models never collide — + * even when two models share a basename across namespaces. The custom + * resolver (if set) controls the identifier segment only. + */ + protected function buildCacheKey(mixed $identifier): string + { + $identifierSegment = static::$cacheKeyResolver + ? (static::$cacheKeyResolver)($identifier) + : (string) $identifier; + + return $this->cachePrefix . ':' . $this->modelSegment . ':' . $identifierSegment; + } + + /** + * Register this provider's cache descriptor and set up model event + * listeners for automatic cache invalidation. + * + * Uses a descriptor-based registry: each (storeName, prefix, modelSegment) + * triple is stored under a deterministic hash so duplicate configs + * collapse. On save/delete, the listener iterates descriptors for the + * model class, re-resolves each store by name via the cache manager, + * rebuilds the key using the current global resolver callback, and + * calls forget(). Nothing holds a reference to a provider instance — + * safe against forgetGuards() + re-resolve cycles under Swoole. + * + * Event listener registration is guarded by the model's dispatcher + * being non-null — HasEvents::registerModelEvent() silently no-ops + * when the dispatcher isn't set, so we only mark the class as + * registered AFTER a successful attempt, leaving a retry window on + * the next enableCache() call. + */ + protected function registerCacheInvalidationEvents(): void + { + $modelClass = $this->model; + + // Insert or replace the descriptor — duplicate configs collapse. + $descriptorKey = md5( + ($this->cacheStoreName ?? '') . '|' . $this->cachePrefix . '|' . $this->modelSegment + ); + + static::$cachedProviders[$modelClass][$descriptorKey] = [ + 'storeName' => $this->cacheStoreName, + 'prefix' => $this->cachePrefix, + 'modelSegment' => $this->modelSegment, + ]; + + if (isset(static::$cacheEventsRegistered[$modelClass])) { + return; + } + + // registerModelEvent() silently no-ops if the dispatcher isn't set. + // Use the public getEventDispatcher() since Model::$dispatcher is + // protected. Inside withoutEvents() this returns a NullDispatcher + // wrapping the real one — non-null, so we proceed, and the listener + // still attaches to the real dispatcher underneath. + if ($modelClass::getEventDispatcher() === null) { + return; + } + + $invalidate = static function (UserContract $user): void { + $id = $user->getAuthIdentifier(); + $identifierSegment = static::$cacheKeyResolver + ? (static::$cacheKeyResolver)($id) + : (string) $id; + + $cacheManager = Container::getInstance()->make('cache'); + + foreach (static::$cachedProviders[$user::class] ?? [] as $descriptor) { + $cacheManager + ->store($descriptor['storeName']) + ->forget($descriptor['prefix'] . ':' . $descriptor['modelSegment'] . ':' . $identifierSegment); + } + }; + + $modelClass::saved($invalidate); + $modelClass::deleted($invalidate); + + static::$cacheEventsRegistered[$modelClass] = true; + } + /** * Get a new query builder for the model instance. */ From f672e85812277a65a4480601696e63e586a40988 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:41:55 +0000 Subject: [PATCH 02/11] feat(auth): wire auth cache config to EloquentUserProvider::enableCache() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createEloquentProvider() now calls $provider->enableCache() when the cache config block has enabled=true. Passes the store NAME (nullable; null = default store), TTL, and prefix straight through — the provider re-resolves the store by name on invalidation, so no pre-resolved repository is held at this layer. No change to the database provider path. --- src/auth/src/CreatesUserProviders.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/auth/src/CreatesUserProviders.php b/src/auth/src/CreatesUserProviders.php index 9cff256c3..39204f1c6 100644 --- a/src/auth/src/CreatesUserProviders.php +++ b/src/auth/src/CreatesUserProviders.php @@ -71,7 +71,17 @@ protected function createDatabaseProvider(array $config): DatabaseUserProvider */ protected function createEloquentProvider(array $config): EloquentUserProvider { - return new EloquentUserProvider($this->app['hash'], $config['model']); + $provider = new EloquentUserProvider($this->app['hash'], $config['model']); + + if (! empty($config['cache']['enabled'])) { + $provider->enableCache( + $config['cache']['store'] ?? null, + (int) ($config['cache']['ttl'] ?? 300), + $config['cache']['prefix'] ?? 'auth_users', + ); + } + + return $provider; } /** From f098245004d55d7347fb2161a557ce55c79cd62f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:42:07 +0000 Subject: [PATCH 03/11] feat(auth): add Auth::clearUserCache() convenience method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes a guard-scoped wrapper around EloquentUserProvider::clearUserCache() for apps that need to invalidate cached user entries from write paths that bypass Eloquent model events — pivot-table writes for roles and permissions, mass update() / raw DB queries, queue jobs, external processes, etc. Signature: clearUserCache(mixed $identifier, ?string $guard = null): void Behaviour: - Omitted $guard uses the default guard. - Resolves the guard's existing provider via getProvider() instead of constructing a throwaway one. - No-op when the guard doesn't expose getProvider() (custom guards that don't use GuardHelpers — checked via method_exists to avoid hitting AuthManager::__call and triggering BadMethodCallException at runtime). - No-op when the provider isn't an EloquentUserProvider or caching is disabled. The provider's key resolver (if set) runs, so in a tenant-aware setup this clears the entry for the current tenant context. --- src/auth/src/AuthManager.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/auth/src/AuthManager.php b/src/auth/src/AuthManager.php index 15acc40c3..bc6e61ddb 100755 --- a/src/auth/src/AuthManager.php +++ b/src/auth/src/AuthManager.php @@ -231,6 +231,34 @@ public function resolveUsersUsing(Closure $userResolver): static return $this; } + /** + * Clear the cached user for the given identifier. + * + * Uses the specified guard's existing provider instance to avoid + * creating throwaway provider objects. If the guard doesn't expose + * getProvider() (custom guards that don't use GuardHelpers), or the + * provider is not an EloquentUserProvider, or caching is disabled, + * this is a no-op. + */ + public function clearUserCache(mixed $identifier, ?string $guard = null): void + { + $guardInstance = $this->guard($guard); + + // getProvider() lives on the GuardHelpers trait, not the Guard + // contract. Custom guards (via extend()/viaRequest()) may not use + // the trait — without this check, __call forwarding would throw + // BadMethodCallException at runtime. + if (! method_exists($guardInstance, 'getProvider')) { + return; + } + + $provider = $guardInstance->getProvider(); /* @phpstan-ignore method.notFound (getProvider() is on GuardHelpers trait, not the Guard contract) */ + + if ($provider instanceof EloquentUserProvider) { + $provider->clearUserCache($identifier); + } + } + /** * Register a custom driver creator Closure. */ From c3f9e6609e5534a7ebf9b998df5184504f6e0b84 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:42:13 +0000 Subject: [PATCH 04/11] feat(auth): advertise clearUserCache on the Auth facade Adds the @method static docblock entry for Auth::clearUserCache() next to resolveUsersUsing so IDEs autocomplete and phpstan understands the call through the facade. --- src/support/src/Facades/Auth.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/support/src/Facades/Auth.php b/src/support/src/Facades/Auth.php index 571d707c0..94bd63931 100644 --- a/src/support/src/Facades/Auth.php +++ b/src/support/src/Facades/Auth.php @@ -14,6 +14,7 @@ * @method static \Hypervel\Auth\AuthManager viaRequest(string $driver, callable $callback) * @method static \Closure userResolver() * @method static \Hypervel\Auth\AuthManager resolveUsersUsing(\Closure $userResolver) + * @method static void clearUserCache(mixed $identifier, ?string $guard = null) * @method static \Hypervel\Auth\AuthManager extend(string $driver, \Closure $callback) * @method static \Hypervel\Auth\AuthManager provider(string $name, \Closure $callback) * @method static bool hasResolvedGuards() From 17f29edf56f6875f6abb54c74131d9575c84d7ba Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:42:23 +0000 Subject: [PATCH 05/11] feat(auth): add user-lookup cache block to the default auth config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the per-provider 'cache' block to the default 'users' provider: 'cache' => [ 'enabled' => env('AUTH_USERS_CACHE_ENABLED', false), 'store' => env('AUTH_USERS_CACHE_STORE'), 'ttl' => env('AUTH_USERS_CACHE_TTL', 300), 'prefix' => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'), ], Disabled by default; a single env flip turns it on. The block is preceded by a prescriptive docblock listing supported stores (redis, database, file, swoole, stack), rejected stores (array, null, session, failover), cross-node behaviour (fully-shared vs node-local vs stack microcaching), and the recommended high-scale topology (stack = [swoole L1 → redis L2]). Full rationale lives in the auth caching documentation; the config comment stays short and prescriptive. --- src/foundation/config/auth.php | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/foundation/config/auth.php b/src/foundation/config/auth.php index fd65ce36d..a474e128c 100644 --- a/src/foundation/config/auth.php +++ b/src/foundation/config/auth.php @@ -73,6 +73,45 @@ 'users' => [ 'driver' => 'eloquent', 'model' => env('AUTH_MODEL', App\Models\User::class), // @phpstan-ignore class.notFound + + /* + |------------------------------------------------------------------ + | User Lookup Cache (opt-in, Eloquent provider only) + |------------------------------------------------------------------ + | + | Caches retrieveById() lookups across requests. Disabled by + | default. Credential and token lookups are never cached + | (security). + | + | Supported stores: 'redis', 'database', 'file', 'swoole', 'stack'. + | Any other driver ('array', 'null', 'session', 'failover') is + | rejected. + | + | Cross-node behaviour: + | - 'redis' / 'database': fully shared — invalidation is global. + | - 'file' / 'swoole': node-local, no cross-node invalidation + | (single-instance deployments only). + | - 'stack' with a node-local upper tier (e.g. [swoole, redis]): + | eventually consistent — the shared lower tier clears + | globally, but each node's L1 serves its stale entry until + | the L1 TTL expires. This is the microcaching trade-off. + | + | High-scale: the recommended topology is a 'stack' cache with + | 'swoole' as L1 (3–5s) and 'redis' as L2 — the microcaching + | pattern eliminates the majority of Redis round-trips for + | authed requests at high concurrency. See the auth caching + | documentation for the full explanation. + | + | Caveat: only the outer store is validated. A stack with an + | unsupported inner tier (e.g. [array, redis]) won't be caught. + | + */ + 'cache' => [ + 'enabled' => env('AUTH_USERS_CACHE_ENABLED', false), + 'store' => env('AUTH_USERS_CACHE_STORE'), + 'ttl' => env('AUTH_USERS_CACHE_TTL', 300), + 'prefix' => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'), + ], ], ], From 082f6d2c721ba5c541c1c9c00666ce0fbe1c50f4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:42:34 +0000 Subject: [PATCH 06/11] feat(testbench): mirror the auth cache block in testbench config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testbench's auth.providers.users wholly replaces the framework's users entry during config merge (the merge is one level deep on 'providers', not recursive), so any key we want present at runtime needs to be repeated here — including the new 'cache' block. Same shape and same docblock as the framework config. Disabled by default, so this change is purely discoverability/consistency for testbench-bootstrapped test suites and skeletons cloned from testbench. --- src/testbench/hypervel/config/auth.php | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/testbench/hypervel/config/auth.php b/src/testbench/hypervel/config/auth.php index 011eca69d..ab2a0a783 100644 --- a/src/testbench/hypervel/config/auth.php +++ b/src/testbench/hypervel/config/auth.php @@ -7,6 +7,45 @@ 'users' => [ 'driver' => 'eloquent', 'model' => Hypervel\Foundation\Auth\User::class, + + /* + |------------------------------------------------------------------ + | User Lookup Cache (opt-in, Eloquent provider only) + |------------------------------------------------------------------ + | + | Caches retrieveById() lookups across requests. Disabled by + | default. Credential and token lookups are never cached + | (security). + | + | Supported stores: 'redis', 'database', 'file', 'swoole', 'stack'. + | Any other driver ('array', 'null', 'session', 'failover') is + | rejected. + | + | Cross-node behaviour: + | - 'redis' / 'database': fully shared — invalidation is global. + | - 'file' / 'swoole': node-local, no cross-node invalidation + | (single-instance deployments only). + | - 'stack' with a node-local upper tier (e.g. [swoole, redis]): + | eventually consistent — the shared lower tier clears + | globally, but each node's L1 serves its stale entry until + | the L1 TTL expires. This is the microcaching trade-off. + | + | High-scale: the recommended topology is a 'stack' cache with + | 'swoole' as L1 (3–5s) and 'redis' as L2 — the microcaching + | pattern eliminates the majority of Redis round-trips for + | authed requests at high concurrency. See the auth caching + | documentation for the full explanation. + | + | Caveat: only the outer store is validated. A stack with an + | unsupported inner tier (e.g. [array, redis]) won't be caught. + | + */ + 'cache' => [ + 'enabled' => env('AUTH_USERS_CACHE_ENABLED', false), + 'store' => env('AUTH_USERS_CACHE_STORE'), + 'ttl' => env('AUTH_USERS_CACHE_TTL', 300), + 'prefix' => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'), + ], ], ], ]; From 295508dda61aba168e103bc2e62e07aa12e8a5c7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:42:50 +0000 Subject: [PATCH 07/11] docs(auth): document the user lookup cache feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new 'User Lookup Cache' section to the auth package README covering: - Purpose: opt-in cross-request cache for retrieveById() to eliminate one DB query per authenticated request under Swoole. - Config examples: minimum Redis setup and the high-scale stack (swoole L1 + redis L2) setup. - Microcaching rationale: what it is, why it matters at scale, and the concrete wins (p99 latency, Redis tier sizing, bandwidth, resilience during brief Redis outages). - Invalidation model: four layers (provider writes via save(), model events, manual Auth::clearUserCache, TTL), with explicit notes on same-node propagation via Swoole Table and bounded cross-node staleness when using a stack with a node-local L1. - Manual invalidation API: full parameter semantics, how the model is chosen from the guard, multi-guard / multi-model behaviour (one-provider-shared-by-many-guards vs different-model-per-guard), tenant-resolver interaction, and no-op conditions. - TTL guidance per scenario. - Store selection guide mirroring the config docblock. - Tenant-aware cache keys with the resolveUserCacheKeyUsing() pattern and why it has to be a static callback, not a config closure. - Gotchas: withQuery caching effect, mass-update event bypass, outer- only stack validation. - Threat-model notes for high-security providers. Marked with a @TODO so the whole section can be lifted into the 0.4 documentation site once it lands — the README is just the interim home. --- src/auth/README.md | 175 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 174 insertions(+), 1 deletion(-) diff --git a/src/auth/README.md b/src/auth/README.md index 72e9229d8..90403b5a9 100644 --- a/src/auth/README.md +++ b/src/auth/README.md @@ -1,4 +1,177 @@ Auth for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/auth) \ No newline at end of file +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/auth) + + + +## User Lookup Cache + +Optional cross-request cache for `EloquentUserProvider::retrieveById()`. Disabled by default. When enabled, each authenticated request can hit the cache instead of re-querying the database for the current user — a large win under Swoole where workers are long-lived and request volume is high. + +Only `retrieveById()` is cached. Credential and token lookups (`retrieveByCredentials`, `retrieveByToken`) are never cached for security — they must always see fresh data. + +### Enabling it + +Per-provider config in `config/auth.php`: + +```php +'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => App\Models\User::class, + 'cache' => [ + 'enabled' => env('AUTH_USERS_CACHE_ENABLED', false), + 'store' => env('AUTH_USERS_CACHE_STORE'), // null = default cache store + 'ttl' => env('AUTH_USERS_CACHE_TTL', 300), + 'prefix' => env('AUTH_USERS_CACHE_PREFIX', 'auth_users'), + ], + ], +], +``` + +Minimum env setup for single Redis node: + +```env +AUTH_USERS_CACHE_ENABLED=true +AUTH_USERS_CACHE_STORE=redis +``` + +High-scale recommended setup (`stack` with Swoole L1 + Redis L2): + +```env +AUTH_USERS_CACHE_ENABLED=true +AUTH_USERS_CACHE_STORE=stack +``` + +### Why microcaching helps at scale + +At high request volume, every authenticated request hits the user store. Without this cache, that's one Redis `GET` per request per worker. Even at modest RPS this is thousands of Redis round-trips per second just to hydrate `Auth::user()`. + +The recommended `stack = [swoole (3–5s) → redis]` topology ("microcaching") keeps hot lookups in each worker's Swoole Table for a few seconds. The same user making multiple requests in that window hits the L1 and skips the Redis round-trip entirely. L1 hit rates of 90%+ are typical for authenticated traffic with even a 3-second TTL, which adds up to: + +- Lower p99 latency — L1 reads are nanoseconds, Redis is hundreds of microseconds +- Smaller Redis tier — most of the load never reaches it +- Less network bandwidth — serialized user models stay inside the worker +- Brief Redis outage tolerance — L1 keeps serving authed requests for a few seconds if Redis goes down + +### Invalidation model + +Four layers, most-automatic to most-manual: + +1. **Provider writes** — `updateRememberToken()` and `rehashPasswordIfRequired()` both call `$user->save()`, which fires the `saved` model event. Invalidation is handled by the listener (layer 2), not by an explicit clear inside those methods. + +2. **Model events** — when caching is enabled for a provider, the provider registers `saved` and `deleted` listeners on the user model class. Any code path that modifies the user through Eloquent — `$user->save()`, `$user->update(...)`, `$user->delete()` — triggers cache invalidation. This covers controller updates, profile edits, admin changes. + +3. **Manual** — for writes that bypass Eloquent events (pivot table changes for roles/permissions, raw DB queries, mass `update()`, external processes), clear explicitly via `Auth::clearUserCache(...)` — see "Manual invalidation API" below. + +4. **TTL expiry** — even if active invalidation is missed, entries expire on their TTL and the next request fetches fresh data. + +**Within a node:** `SwooleStore` uses a Swoole Table in shared memory, so one `forget()` from any worker clears it for every worker on that node. + +**Across nodes:** only the shared tiers (`redis`, `database`) propagate. If you use `stack = [swoole, redis]`, invalidation clears the origin node's L1 + the shared Redis — but other nodes' Swoole L1s keep serving stale entries until their own L1 TTL expires. That bounded staleness window (a few seconds) is the microcaching trade-off. Cross-node pub/sub invalidation is out of scope for this feature; apps that need strict global consistency should skip the L1 tier. + +### Manual invalidation API + +```php +Auth::clearUserCache(mixed $identifier, ?string $guard = null): void +``` + +Call this after any write path that doesn't fire Eloquent model events — typical scenarios: + +- Pivot table writes for roles/permissions (`$user->roles()->attach(...)`, `detach`, `sync`) +- Raw query builder or PDO writes (`DB::table('users')->update(...)`) +- Mass updates (`User::query()->where(...)->update(...)` — Laravel's `Builder::update()` does not fire model events) +- Queue jobs, scheduled commands, or external services modifying users through non-Eloquent paths + +**Parameters:** + +- **`$identifier`** — the user's auth identifier (what `retrieveById()` expects). For the default Eloquent-based guard this is the user's primary key. Use the same value you'd pass to `Auth::loginUsingId()`. +- **`$guard`** — the guard name to clear against, or `null` to use the application's default guard. The method resolves that guard, finds its provider, and clears the cache entry for **that provider's model**. + +**How the model is chosen:** + +The cache key includes the provider's model FQCN, so `Auth::clearUserCache(42, 'web')` only clears `App\Models\User:42`, not `App\Models\Landlord:42`. The guard determines the provider; the provider determines the model. + +**Multi-guard / multi-model apps:** + +| Setup | Behaviour | +|---|---| +| One provider shared by multiple guards (e.g. `web`, `api`, `sanctum`, `jwt` all point at `users`) | One call with any of those guard names clears the single shared cache keyspace. Calling for each guard is redundant. | +| Different guards with different models (e.g. `web → User`, `admin → Admin`, `landlord → Landlord`) | You must call once per guard/model you want to invalidate. `Auth::clearUserCache(42)` with no guard name clears *only* the default guard's model — a landlord update that hits `Landlord:42` needs `Auth::clearUserCache(42, 'landlord')`. | +| Default guard omitted in a multi-guard setup | Clears for the default guard *only*, not all guards. In non-trivial deployments, always pass the guard name explicitly to avoid surprises. | + +**Tenant-aware resolver interaction:** + +If you've registered `EloquentUserProvider::resolveUserCacheKeyUsing(...)`, `clearUserCache()` uses the same resolver — so it clears the entry for the **current** tenant context, not every tenant's copy. To clear the same user across multiple tenants, call `clearUserCache()` once per tenant context. + +**No-ops:** + +- If the guard's provider is not an `EloquentUserProvider` (e.g. a custom `RequestGuard`), the call is silently ignored. +- If caching is disabled for the provider, the call is a no-op. + +### TTL guidance + +| Scenario | Guidance | +|---|---| +| Profile updates (name, avatar, preferences) | Default 300s is fine. Model events clear on save. | +| Password change | Irrelevant — session invalidation logs the user out. The cache miss on their next login is one-off. | +| Permission revocation (direct on user model) | Model events clear on save. | +| Permission revocation (via pivot table / bulk query) | Model events don't fire. Either call `Auth::clearUserCache($id)` explicitly, or accept the TTL staleness window. | +| High-security providers (financial/admin) | Use a tight L1 TTL (1–2s), skip the L1 tier, or disable caching entirely for that provider. | + +### Store selection guide + +| Store | Multi-node | Notes | +|---|---|---| +| `redis` | ✓ | Standard choice. Shared invalidation, fast, well-understood. | +| `database` | ✓ | Shared. Slower than Redis but still a major win over per-request hydration, especially with in-memory/unlogged Postgres tables. | +| `file` | ✗ | Node-local. Single-instance deployments only. | +| `swoole` | ✗ | Node-local, shared memory. Fastest single-node option; also the ideal L1 tier inside a `stack`. | +| `stack` | partial | Eventually consistent if a node-local tier (swoole/file) is layered above a shared tier (redis/database). See "Invalidation model" above. | + +Rejected drivers (throw on `enableCache`): + +- `session` — scoped to the current user's session; would cache user data inside one user's session. +- `array` — coroutine-local after the upcoming rewrite; nothing persists across requests. +- `null` — discards writes. +- `failover` — ambiguous fallback semantics; silently degrades onto an unsafe tier when the primary is down. + +Stack composition caveat: only the outer store is validated. A stack built with an unsupported inner tier (e.g. `[array, redis]`) won't be caught — pick sensible tiers yourself. + +### Tenant-aware cache keys + +Default cache key format is `{prefix}:{fqcn}:{identifier}` — e.g. `auth_users:App\Models\User:42`. The fully-qualified model class name is always included so providers using different user models never collide. + +For multi-tenant apps where the same user ID resolves to different rows per tenant (tenant global scopes, shared user tables), register a global resolver in a service provider's `boot()`: + +```php +use Hypervel\Auth\EloquentUserProvider; + +public function boot(): void +{ + EloquentUserProvider::resolveUserCacheKeyUsing( + fn (mixed $identifier) => tenantId() . ':' . $identifier, + ); +} +``` + +Produces keys like `auth_users:App\Models\User:5:42` (prefix, FQCN, tenant 5, user 42). + +**Why a static callback, not a config closure?** Config files are evaluated once at boot in Swoole. A closure calling `tenantId()` in the config would capture the boot-time tenant (likely null), not the per-request tenant. The static resolver callback runs fresh on each `retrieveById()`, reading the current coroutine's context. + +### Gotchas + +- **`withQuery()` caches the first-seen shape.** If the provider has a `withQuery()` callback that eager-loads relations, the first uncached call caches the result including those relations. Every subsequent hit returns the same loaded relations. This is usually what you want for auth. +- **Bulk updates bypass Eloquent events.** `User::query()->update([...])`, raw `DB::update(...)`, pivot inserts/deletes via `attach/detach` — none of these fire model events. Use `Auth::clearUserCache($id)` after such writes or accept TTL staleness. +- **The whitelist only checks the outer store.** `stack = [array, redis]` passes the check because the outer class is `StackStore`. Responsibility for sensible tier selection is yours. + +### Threat model + +For auth-sensitive contexts (admin panels, financial actions), consider: + +- Shorter L1 TTL (1–2s) — still absorbs bursts, narrower staleness window +- Skip L1 entirely — use plain `redis` instead of `stack` +- Disable caching for that provider — set `enabled => false` for the specific guard's provider + +Password changes and session revocation are not staleness-sensitive — session invalidation already logs the user out, so the auth cache's state becomes moot on the user's next request. \ No newline at end of file From 041448ee8a4f589867d6f3f4c37b4c166053f901 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:42:57 +0000 Subject: [PATCH 08/11] test(auth): wire EloquentUserProvider::flushState() into the test subscriber Resets the provider's static state (the cache key resolver, descriptor registry, and listener-registered flag) between tests so cache-related tests are isolated from each other. Placed alphabetically between AuthenticationException::flushState() and Middleware\Authenticate::flushState(). --- tests/AfterEachTestSubscriber.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/AfterEachTestSubscriber.php b/tests/AfterEachTestSubscriber.php index bcc4931f3..83f019de1 100644 --- a/tests/AfterEachTestSubscriber.php +++ b/tests/AfterEachTestSubscriber.php @@ -31,6 +31,7 @@ public function notify(Finished $event): void \Hypervel\ApiClient\PendingRequest::flushCache(); \Hypervel\Auth\Access\Gate::flushState(); \Hypervel\Auth\AuthenticationException::flushState(); + \Hypervel\Auth\EloquentUserProvider::flushState(); \Hypervel\Auth\Middleware\Authenticate::flushState(); \Hypervel\Auth\Middleware\RedirectIfAuthenticated::flushState(); \Hypervel\Auth\Notifications\ResetPassword::flushState(); From c64755b615d56da1da3a933120b7fc5cd918961b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:43:13 +0000 Subject: [PATCH 09/11] test(auth): unit tests for EloquentUserProvider cache behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New dedicated test class kept separate from AuthEloquentUserProviderTest so the base-provider coverage stays focused and the caching feature gets its own home. Covers, with Mockery doubles only (no container, no DB, no dispatcher): - Cache disabled: retrieveById falls through to the DB path with no cache interaction. - Basic operation: miss → DB → put, hit → no DB, missing-user null sentinel stored on miss, null returned on sentinel hit. - retrieveByCredentials and retrieveByToken never touch the cache. - Key format: default {prefix}:{FQCN}:{id}, null/'' prefix normalizes to the feature default, custom resolver used for the identifier segment, custom resolver receives the raw identifier, FQCN always present in the key even with a custom resolver. - Supported-store whitelist (data provider over Redis/Database/File/ Swoole/Stack): enableCache accepts each. - Unsupported-store rejection (data provider over Array/Null/Session/ Failover): enableCache throws InvalidArgumentException. - Validation-failure ordering: after a rejected store, the provider is still disabled, the descriptor registry is empty, the events-registered flag is untouched, and retrieveById falls through to the DB path — confirms 'validate before mutate'. - Manual clearUserCache: forgets the right key, respects the custom resolver, no-op when caching is disabled. - flushState resets the resolver, descriptor registry, and events-registered flag. Uses a stub Model subclass (EloquentCacheProviderUserStub) because the registerCacheInvalidationEvents() path touches ::getEventDispatcher() on the model class, which needs a real class to resolve. --- .../AuthEloquentUserProviderCacheTest.php | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 tests/Auth/AuthEloquentUserProviderCacheTest.php diff --git a/tests/Auth/AuthEloquentUserProviderCacheTest.php b/tests/Auth/AuthEloquentUserProviderCacheTest.php new file mode 100644 index 000000000..0bc2c7c9f --- /dev/null +++ b/tests/Auth/AuthEloquentUserProviderCacheTest.php @@ -0,0 +1,468 @@ +cacheManager = m::mock(CacheManager::class); + $container->instance('cache', $this->cacheManager); + } + + // ------------------------------------------------------------------ + // Cache disabled (default behaviour) + // ------------------------------------------------------------------ + + public function testRetrieveByIdWithoutCacheDoesNotTouchCache() + { + $this->cacheManager->shouldNotReceive('store'); + + $user = m::mock(Authenticatable::class); + $provider = $this->providerExpectingDbFetch($user, 42); + + $this->assertSame($user, $provider->retrieveById(42)); + } + + // ------------------------------------------------------------------ + // Cache enabled — basic operation + // ------------------------------------------------------------------ + + public function testRetrieveByIdCachesMissedLookup() + { + $repo = $this->stubCache(RedisStore::class); + $user = m::mock(Authenticatable::class); + $key = $this->buildDefaultKey(42); + + $repo->shouldReceive('get')->once()->with($key)->andReturn(null); + $repo->shouldReceive('put')->once()->with($key, $user, 300)->andReturn(true); + + $provider = $this->providerExpectingDbFetch($user, 42); + $provider->enableCache(null); + + $this->assertSame($user, $provider->retrieveById(42)); + } + + public function testRetrieveByIdReturnsCachedUser() + { + $repo = $this->stubCache(RedisStore::class); + $user = m::mock(Authenticatable::class); + + $repo->shouldReceive('get')->once()->with($this->buildDefaultKey(42))->andReturn($user); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $this->assertSame($user, $provider->retrieveById(42)); + } + + public function testRetrieveByIdCachesNullSentinelForMissingUser() + { + $repo = $this->stubCache(RedisStore::class); + $sentinel = ['__auth_null_sentinel' => true]; + $key = $this->buildDefaultKey(999); + + $repo->shouldReceive('get')->once()->with($key)->andReturn(null); + $repo->shouldReceive('put')->once()->with($key, $sentinel, 300)->andReturn(true); + + $provider = $this->providerExpectingDbFetch(null, 999); + $provider->enableCache(null); + + $this->assertNull($provider->retrieveById(999)); + } + + public function testRetrieveByIdReturnsNullForCachedSentinel() + { + $repo = $this->stubCache(RedisStore::class); + $sentinel = ['__auth_null_sentinel' => true]; + + $repo->shouldReceive('get')->once()->with($this->buildDefaultKey(999))->andReturn($sentinel); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $this->assertNull($provider->retrieveById(999)); + } + + public function testRetrieveByCredentialsIsNeverCached() + { + $repo = $this->stubCache(RedisStore::class); + $repo->shouldNotReceive('get'); + $repo->shouldNotReceive('put'); + + $expectedUser = m::mock(Authenticatable::class); + $model = m::mock(Model::class); + $builder = m::mock(Builder::class); + $model->shouldReceive('newQuery')->once()->andReturn($builder); + $builder->shouldReceive('where')->once()->with('username', 'u'); + $builder->shouldReceive('first')->once()->andReturn($expectedUser); + + $provider = $this->providerMock(); + $provider->expects($this->once())->method('createModel')->willReturn($model); + $provider->enableCache(null); + + $this->assertSame($expectedUser, $provider->retrieveByCredentials(['username' => 'u'])); + } + + public function testRetrieveByTokenIsNeverCached() + { + $repo = $this->stubCache(RedisStore::class); + $repo->shouldNotReceive('get'); + $repo->shouldNotReceive('put'); + + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getRememberToken')->once()->andReturn('tok'); + $model = m::mock(Model::class); + $builder = m::mock(Builder::class); + $model->shouldReceive('newQuery')->once()->andReturn($builder); + $model->shouldReceive('getAuthIdentifierName')->once()->andReturn('id'); + $builder->shouldReceive('where')->once()->with('id', 1)->andReturn($builder); + $builder->shouldReceive('first')->once()->andReturn($user); + + $provider = $this->providerMock(); + $provider->expects($this->once())->method('createModel')->willReturn($model); + $provider->enableCache(null); + + $this->assertSame($user, $provider->retrieveByToken(1, 'tok')); + } + + // ------------------------------------------------------------------ + // Cache key resolution + // ------------------------------------------------------------------ + + public function testDefaultCacheKeyIncludesFqcnAndIdentifier() + { + $repo = $this->stubCache(RedisStore::class); + $expectedKey = self::DEFAULT_KEY_PREFIX . ':' . self::MODEL . ':42'; + + $repo->shouldReceive('get')->once()->with($expectedKey)->andReturn(m::mock(Authenticatable::class)); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->retrieveById(42); + } + + public function testEnableCacheNormalizesBlankPrefixToDefault() + { + // Two enableCache() calls with blank prefixes (null and '') should both + // produce keys using the 'auth_users' default. We set up two distinct + // repositories returned in sequence from store(null). + $repo1 = m::mock(CacheRepository::class); + $repo1->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $repo2 = m::mock(CacheRepository::class); + $repo2->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + + $this->cacheManager->shouldReceive('store')->with(null) + ->andReturn($repo1, $repo2); + + $expectedKey = self::DEFAULT_KEY_PREFIX . ':' . self::MODEL . ':42'; + $repo1->shouldReceive('get')->once()->with($expectedKey)->andReturn(m::mock(Authenticatable::class)); + $repo2->shouldReceive('get')->once()->with($expectedKey)->andReturn(m::mock(Authenticatable::class)); + + $providerNull = $this->providerWithoutDbFetch(); + $providerNull->enableCache(null, 300, null); + $providerNull->retrieveById(42); + + $providerEmpty = $this->providerWithoutDbFetch(); + $providerEmpty->enableCache(null, 300, ''); + $providerEmpty->retrieveById(42); + } + + public function testCustomCacheKeyResolverIsUsed() + { + EloquentUserProvider::resolveUserCacheKeyUsing(fn (mixed $id): string => "tenant5:{$id}"); + + $repo = $this->stubCache(RedisStore::class); + $expectedKey = self::DEFAULT_KEY_PREFIX . ':' . self::MODEL . ':tenant5:42'; + $repo->shouldReceive('get')->once()->with($expectedKey)->andReturn(m::mock(Authenticatable::class)); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->retrieveById(42); + } + + public function testCustomCacheKeyResolverReceivesIdentifier() + { + $received = null; + EloquentUserProvider::resolveUserCacheKeyUsing(function (mixed $id) use (&$received): string { + $received = $id; + + return (string) $id; + }); + + $repo = $this->stubCache(RedisStore::class); + $repo->shouldReceive('get')->once()->andReturn(m::mock(Authenticatable::class)); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->retrieveById(42); + + $this->assertSame(42, $received); + } + + public function testCacheKeyAlwaysIncludesFqcnEvenWithCustomResolver() + { + EloquentUserProvider::resolveUserCacheKeyUsing(fn (mixed $id): string => "wrapper:{$id}"); + + $capturedKey = null; + $repo = $this->stubCache(RedisStore::class); + $repo->shouldReceive('get')->once()->andReturnUsing(function (string $key) use (&$capturedKey) { + $capturedKey = $key; + + return m::mock(Authenticatable::class); + }); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->retrieveById(42); + + $this->assertStringContainsString(self::MODEL, $capturedKey); + } + + // ------------------------------------------------------------------ + // Supported-store whitelist + // ------------------------------------------------------------------ + + #[DataProvider('supportedStoreProvider')] + public function testEnableCacheAcceptsSupportedStores(string $storeClass) + { + $this->stubCache($storeClass); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $this->assertTrue($provider->isCacheEnabled()); + } + + // ------------------------------------------------------------------ + // Data providers + // ------------------------------------------------------------------ + + public static function supportedStoreProvider(): iterable + { + yield 'Redis' => [RedisStore::class]; + yield 'Database' => [DatabaseStore::class]; + yield 'File' => [FileStore::class]; + yield 'Swoole' => [SwooleStore::class]; + yield 'Stack' => [StackStore::class]; + } + + #[DataProvider('unsupportedStoreProvider')] + public function testEnableCacheRejectsUnsupportedStores(string $storeClass) + { + $this->stubCache($storeClass); + + $provider = $this->providerWithoutDbFetch(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/does not support cache store/'); + + $provider->enableCache(null); + } + + public static function unsupportedStoreProvider(): iterable + { + yield 'Array' => [ArrayStore::class]; + yield 'Null' => [NullStore::class]; + yield 'Session' => [SessionStore::class]; + yield 'Failover' => [FailoverStore::class]; + } + + public function testEnableCacheLeavesProviderInDisabledStateWhenValidationFails() + { + $this->stubCache(ArrayStore::class); + + $user = m::mock(Authenticatable::class); + $provider = $this->providerExpectingDbFetch($user, 42); + + try { + $provider->enableCache(null); + $this->fail('Expected InvalidArgumentException'); + } catch (InvalidArgumentException) { + // expected + } + + $this->assertFalse($provider->isCacheEnabled()); + + $reflection = new ReflectionClass(EloquentUserProvider::class); + $descriptors = $reflection->getStaticPropertyValue('cachedProviders'); + $registered = $reflection->getStaticPropertyValue('cacheEventsRegistered'); + $this->assertArrayNotHasKey(self::MODEL, $descriptors); + $this->assertArrayNotHasKey(self::MODEL, $registered); + + // Provider still falls through to the DB path on retrieveById. + $this->assertSame($user, $provider->retrieveById(42)); + } + + // ------------------------------------------------------------------ + // Manual invalidation + // ------------------------------------------------------------------ + + public function testClearUserCacheRemovesCachedEntry() + { + $repo = $this->stubCache(RedisStore::class); + $repo->shouldReceive('forget')->once()->with($this->buildDefaultKey(42))->andReturn(true); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->clearUserCache(42); + } + + public function testClearUserCacheUsesCustomKeyResolver() + { + EloquentUserProvider::resolveUserCacheKeyUsing(fn (mixed $id): string => "tenant:{$id}"); + + $repo = $this->stubCache(RedisStore::class); + $expectedKey = self::DEFAULT_KEY_PREFIX . ':' . self::MODEL . ':tenant:42'; + $repo->shouldReceive('forget')->once()->with($expectedKey)->andReturn(true); + + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $provider->clearUserCache(42); + } + + public function testClearUserCacheIsNoOpWhenCacheDisabled() + { + $this->cacheManager->shouldNotReceive('store'); + + $provider = $this->providerWithoutDbFetch(); + + // No enableCache() — cache is null; clearUserCache must not blow up. + $provider->clearUserCache(42); + + $this->assertFalse($provider->isCacheEnabled()); + } + + // ------------------------------------------------------------------ + // flushState + // ------------------------------------------------------------------ + + public function testFlushStateClearsAllStaticState() + { + EloquentUserProvider::resolveUserCacheKeyUsing(fn (mixed $id): string => (string) $id); + + $this->stubCache(RedisStore::class); + $provider = $this->providerWithoutDbFetch(); + $provider->enableCache(null); + + $reflection = new ReflectionClass(EloquentUserProvider::class); + $this->assertNotSame([], $reflection->getStaticPropertyValue('cachedProviders')); + + EloquentUserProvider::flushState(); + + $this->assertNull($reflection->getStaticPropertyValue('cacheKeyResolver')); + $this->assertSame([], $reflection->getStaticPropertyValue('cachedProviders')); + $this->assertSame([], $reflection->getStaticPropertyValue('cacheEventsRegistered')); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + protected function providerMock(): EloquentUserProvider&MockObject + { + $hasher = m::mock(Hasher::class); + + return $this->getMockBuilder(EloquentUserProvider::class) + ->onlyMethods(['createModel']) + ->setConstructorArgs([$hasher, self::MODEL]) + ->getMock(); + } + + /** + * Provider whose createModel() returns a mock Model + Builder chain that + * yields $user for retrieveById($id). + */ + protected function providerExpectingDbFetch(?Authenticatable $user, mixed $id): EloquentUserProvider&MockObject + { + $model = m::mock(Model::class); + $builder = m::mock(Builder::class); + $model->shouldReceive('newQuery')->once()->andReturn($builder); + $model->shouldReceive('getAuthIdentifierName')->once()->andReturn('id'); + $builder->shouldReceive('where')->once()->with('id', $id)->andReturn($builder); + $builder->shouldReceive('first')->once()->andReturn($user); + + $provider = $this->providerMock(); + $provider->expects($this->once())->method('createModel')->willReturn($model); + + return $provider; + } + + /** + * Provider configured so that createModel() must never be called + * (cache-hit / cache-disabled paths). + */ + protected function providerWithoutDbFetch(): EloquentUserProvider&MockObject + { + $provider = $this->providerMock(); + $provider->expects($this->never())->method('createModel'); + + return $provider; + } + + /** + * Stub the cache manager to return a mocked repository backed by an + * instance of $storeClass. Returns the repository mock so tests can + * set further expectations on it. + */ + protected function stubCache(string $storeClass, ?string $name = null): MockInterface + { + $store = m::mock($storeClass); + $repo = m::mock(CacheRepository::class); + $repo->shouldReceive('getStore')->andReturn($store); + $this->cacheManager->shouldReceive('store')->with($name)->andReturn($repo); + + return $repo; + } + + protected function buildDefaultKey(mixed $identifier): string + { + return self::DEFAULT_KEY_PREFIX . ':' . self::MODEL . ':' . $identifier; + } +} + +class EloquentCacheProviderUserStub extends Model +{ +} From 64ca8294bb6b5f069e0bf361d399c929ddee415e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:43:26 +0000 Subject: [PATCH 10/11] test(auth): integration tests for EloquentUserProvider cache events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Testbench-based integration suite covering the paths that need a real event dispatcher and a real Model class (things the unit suite can't exercise). The cache repository itself is still a Mockery double so forget() calls are verifiable — what matters here is the real saved/deleted event firing, not the cache backend. Covers: - Cache is cleared on user save. - Cache is cleared on user delete. - Identical (store, prefix, modelSegment) configs dedupe in the descriptor registry. - Two distinct (store, prefix) configs for the same model each get invalidated on a single save — the listener attaches once, iterates both descriptors, fires exactly one forget() per descriptor (guards against accidental double-attach). - updateRememberToken and rehashPasswordIfRequired clear the cache via the saved event — no explicit clear inside the methods. - enableCache skips listener registration when the model has no dispatcher yet, leaves the events-registered flag unset so a later enableCache() call retries, but still registers the descriptor. - withQuery compatibility: the withQuery callback runs only on the cache-miss fetch; subsequent calls hit the cache. Uses Hypervel\Foundation\Auth\User (via #[WithMigration] + RefreshDatabase) so the model is a real Eloquent User with dispatch set up by the framework boot. --- .../Auth/EloquentUserProviderCacheTest.php | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 tests/Integration/Auth/EloquentUserProviderCacheTest.php diff --git a/tests/Integration/Auth/EloquentUserProviderCacheTest.php b/tests/Integration/Auth/EloquentUserProviderCacheTest.php new file mode 100644 index 000000000..71a24bb95 --- /dev/null +++ b/tests/Integration/Auth/EloquentUserProviderCacheTest.php @@ -0,0 +1,257 @@ +cacheManager = m::mock(CacheManager::class); + $this->app->instance('cache', $this->cacheManager); + } + + protected function afterRefreshingDatabase(): void + { + User::forceCreate([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => bcrypt('secret'), + ]); + } + + // ------------------------------------------------------------------ + // Cache invalidation — model events + // ------------------------------------------------------------------ + + public function testCacheIsClearedOnUserSave() + { + $user = User::query()->first(); + $expectedKey = $this->buildKey($user->getAuthIdentifier()); + + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($expectedKey)->andReturn(true); + + $this->makeCachedProvider(); + + $user->name = 'Updated'; + $user->save(); + } + + public function testCacheIsClearedOnUserDelete() + { + $user = User::query()->first(); + $expectedKey = $this->buildKey($user->getAuthIdentifier()); + + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($expectedKey)->andReturn(true); + + $this->makeCachedProvider(); + + $user->delete(); + } + + public function testDescriptorsDedupeOnIdenticalConfig() + { + $this->stubCache(); + + $this->makeCachedProvider(); + $this->makeCachedProvider(); + + $reflection = new ReflectionClass(EloquentUserProvider::class); + $descriptors = $reflection->getStaticPropertyValue('cachedProviders'); + + $this->assertArrayHasKey(User::class, $descriptors); + $this->assertCount(1, $descriptors[User::class]); + } + + public function testModelEventInvalidatesAllDescriptorsForSameModel() + { + // Two distinct provider configurations for the same model should + // produce two descriptors; saving the user should clear both keys. + $repoA = $this->stubCache('redis-a'); + $repoB = $this->stubCache('redis-b'); + + $user = User::query()->first(); + $keyA = self::DEFAULT_KEY_PREFIX . ':' . User::class . ':' . $user->getAuthIdentifier(); + $keyB = 'admin_users:' . User::class . ':' . $user->getAuthIdentifier(); + + $repoA->shouldReceive('forget')->once()->with($keyA)->andReturn(true); + $repoB->shouldReceive('forget')->once()->with($keyB)->andReturn(true); + + $providerA = new EloquentUserProvider($this->app['hash'], User::class); + $providerA->enableCache('redis-a'); + + $providerB = new EloquentUserProvider($this->app['hash'], User::class); + $providerB->enableCache('redis-b', 300, 'admin_users'); + + $user->name = 'Updated'; + $user->save(); + } + + public function testModelEventListenersRegisteredOnlyOnce() + { + // Two distinct providers with different configs. If the save/deleted + // listeners were attached per-enableCache, the single save below would + // invoke forget 4 times (2 listeners × 2 descriptors). We expect + // exactly 2 forget calls — one listener, iterating 2 descriptors. + $repoA = $this->stubCache('redis-a'); + $repoB = $this->stubCache('redis-b'); + + $user = User::query()->first(); + $keyA = self::DEFAULT_KEY_PREFIX . ':' . User::class . ':' . $user->getAuthIdentifier(); + $keyB = 'admin_users:' . User::class . ':' . $user->getAuthIdentifier(); + + $repoA->shouldReceive('forget')->once()->with($keyA)->andReturn(true); + $repoB->shouldReceive('forget')->once()->with($keyB)->andReturn(true); + + $providerA = new EloquentUserProvider($this->app['hash'], User::class); + $providerA->enableCache('redis-a'); + + $providerB = new EloquentUserProvider($this->app['hash'], User::class); + $providerB->enableCache('redis-b', 300, 'admin_users'); + + $user->name = 'Updated'; + $user->save(); + } + + // ------------------------------------------------------------------ + // Cache invalidation — provider writes + // ------------------------------------------------------------------ + + public function testUpdateRememberTokenClearsCache() + { + $user = User::query()->first(); + $expectedKey = $this->buildKey($user->getAuthIdentifier()); + + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($expectedKey)->andReturn(true); + + $provider = $this->makeCachedProvider(); + + $provider->updateRememberToken($user, 'new-remember-token'); + } + + public function testRehashPasswordClearsCache() + { + $user = User::query()->first(); + $expectedKey = $this->buildKey($user->getAuthIdentifier()); + + $repo = $this->stubCache(); + $repo->shouldReceive('forget')->once()->with($expectedKey)->andReturn(true); + + $provider = $this->makeCachedProvider(); + + $provider->rehashPasswordIfRequired($user, ['password' => 'newpassword'], force: true); + } + + // ------------------------------------------------------------------ + // Dispatcher ordering + // ------------------------------------------------------------------ + + public function testEnableCacheSkipsListenerRegistrationWhenDispatcherAbsent() + { + $this->stubCache(); + + // Drop the dispatcher, then enable caching. The provider should + // populate its descriptor but skip listener registration, leaving + // $cacheEventsRegistered untouched for this model. + Model::unsetEventDispatcher(); + + $provider = new EloquentUserProvider($this->app['hash'], User::class); + $provider->enableCache(null); + + $reflection = new ReflectionClass(EloquentUserProvider::class); + $descriptors = $reflection->getStaticPropertyValue('cachedProviders'); + $registered = $reflection->getStaticPropertyValue('cacheEventsRegistered'); + + $this->assertArrayHasKey(User::class, $descriptors); + $this->assertArrayNotHasKey(User::class, $registered); + } + + // ------------------------------------------------------------------ + // withQuery() compatibility + // ------------------------------------------------------------------ + + public function testRetrieveByIdCachesResultWithEagerLoadedRelations() + { + // A withQuery callback that runs during the DB fetch should affect + // the first (cache-miss) retrieval. Subsequent calls hit the cache + // and return the cached User instance without re-running the query. + $user = User::query()->first(); + $expectedKey = $this->buildKey($user->getAuthIdentifier()); + + $repo = $this->stubCache(); + $repo->shouldReceive('get')->twice()->with($expectedKey) + ->andReturn(null, $user); // first call: miss; second: hit + $repo->shouldReceive('put')->once()->with($expectedKey, m::type(User::class), 300) + ->andReturn(true); + + $withQueryInvocations = 0; + $provider = new EloquentUserProvider($this->app['hash'], User::class); + $provider->enableCache(null); + $provider->withQuery(function ($builder) use (&$withQueryInvocations): void { + ++$withQueryInvocations; + }); + + $first = $provider->retrieveById($user->getAuthIdentifier()); + $second = $provider->retrieveById($user->getAuthIdentifier()); + + $this->assertNotNull($first); + $this->assertNotNull($second); + $this->assertSame(1, $withQueryInvocations, 'withQuery callback should run only on the cache-miss fetch'); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + protected function makeCachedProvider(): EloquentUserProvider + { + $provider = new EloquentUserProvider($this->app['hash'], User::class); + $provider->enableCache(null); + + return $provider; + } + + protected function stubCache(?string $name = null): MockInterface + { + $repo = m::mock(CacheRepository::class); + $repo->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $this->cacheManager->shouldReceive('store')->with($name)->andReturn($repo); + + return $repo; + } + + protected function buildKey(mixed $identifier): string + { + return self::DEFAULT_KEY_PREFIX . ':' . User::class . ':' . $identifier; + } +} From 7b6f5c5b7a82ed4fcf11f06232bb744301f0fc3c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:43:41 +0000 Subject: [PATCH 11/11] test(auth): cover AuthManager::clearUserCache() multi-guard paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four tests exercising the clearUserCache() convenience method end to end through the manager + provider + cache stack (cache manager stubbed via Container::instance, repository + store as Mockery doubles): - Custom guard without getProvider() — method_exists guard kicks in, call is a no-op, no BadMethodCallException from __call forwarding. - Specified guard uses that guard's provider/model — clearing user 42 on the 'admin' guard forgets AuthManagerCacheAdminStub:42 (not AuthManagerCacheUserStub:42), proving the guard determines the provider which determines the model. - Default guard + custom resolver — clearUserCache with no guard name uses the default guard's provider, and the key resolver runs so the forget key is the tenant-scoped one, not the raw id. - forgetGuards() + re-resolve does not accumulate provider descriptors: after forgetting the guard cache and re-resolving, the descriptor registry still has exactly one entry for the model. Guards against a potential leak where repeated resolve/forget cycles would grow the registry. Stub classes (AuthManagerCacheUserStub / AuthManagerCacheAdminStub) extend Foundation\Auth\User so getAuthIdentifier() and the model dispatcher behaviour are real. --- tests/Auth/AuthManagerTest.php | 150 +++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/tests/Auth/AuthManagerTest.php b/tests/Auth/AuthManagerTest.php index 36ac02195..dc91056f3 100644 --- a/tests/Auth/AuthManagerTest.php +++ b/tests/Auth/AuthManagerTest.php @@ -7,20 +7,26 @@ use Closure; use Hypervel\Auth\AuthManager; use Hypervel\Auth\DatabaseUserProvider; +use Hypervel\Auth\EloquentUserProvider; use Hypervel\Auth\RequestGuard; +use Hypervel\Cache\CacheManager; +use Hypervel\Cache\RedisStore; use Hypervel\Config\Repository; use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Contracts\Auth\Guard; use Hypervel\Contracts\Auth\UserProvider; +use Hypervel\Contracts\Cache\Repository as CacheRepository; use Hypervel\Contracts\Hashing\Hasher as HashContract; use Hypervel\Coroutine\Coroutine; use Hypervel\Database\ConnectionInterface; +use Hypervel\Foundation\Auth\User as FoundationUser; use Hypervel\Http\Request; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; +use ReflectionClass; class AuthManagerTest extends TestCase { @@ -327,6 +333,142 @@ public function testMagicCallDelegatesToDefaultGuard() $this->assertTrue($manager->check()); } + public function testClearUserCacheIsNoOpForCustomGuardWithoutGetProvider() + { + $manager = new AuthManager($container = $this->getContainer([ + 'guards' => [ + 'api' => ['driver' => 'custom'], + ], + ])); + + $guard = m::mock(Guard::class); + $manager->extend('custom', fn () => $guard); + + $manager->clearUserCache(42, 'api'); + + $this->addToAssertionCount(1); + } + + public function testClearUserCacheUsesSpecifiedGuardProvider() + { + $manager = new AuthManager($container = $this->getContainer([ + 'defaults' => [ + 'guard' => 'web', + ], + 'guards' => [ + 'web' => ['driver' => 'token', 'provider' => 'users'], + 'admin' => ['driver' => 'token', 'provider' => 'admins'], + ], + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => AuthManagerCacheUserStub::class, + 'cache' => ['enabled' => true, 'store' => 'web-store'], + ], + 'admins' => [ + 'driver' => 'eloquent', + 'model' => AuthManagerCacheAdminStub::class, + 'cache' => ['enabled' => true, 'store' => 'admin-store', 'prefix' => 'admin_users'], + ], + ], + ])); + + Container::setInstance($container); + $container->instance('hash', m::mock(HashContract::class)); + + $cacheManager = m::mock(CacheManager::class); + $adminRepo = m::mock(CacheRepository::class); + $adminRepo->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $adminRepo->shouldReceive('forget') + ->once() + ->with('admin_users:' . AuthManagerCacheAdminStub::class . ':42') + ->andReturn(true); + $cacheManager->shouldReceive('store')->with('admin-store')->andReturn($adminRepo); + $container->instance('cache', $cacheManager); + + $manager->clearUserCache(42, 'admin'); + } + + public function testClearUserCacheUsesDefaultGuardAndRespectsResolver() + { + $manager = new AuthManager($container = $this->getContainer([ + 'defaults' => [ + 'guard' => 'web', + ], + 'guards' => [ + 'web' => ['driver' => 'token', 'provider' => 'users'], + ], + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => AuthManagerCacheUserStub::class, + 'cache' => ['enabled' => true, 'store' => 'web-store'], + ], + ], + ])); + + Container::setInstance($container); + $container->instance('hash', m::mock(HashContract::class)); + + $cacheManager = m::mock(CacheManager::class); + $repo = m::mock(CacheRepository::class); + $repo->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $repo->shouldReceive('forget') + ->once() + ->with('auth_users:' . AuthManagerCacheUserStub::class . ':tenant:42') + ->andReturn(true); + $cacheManager->shouldReceive('store')->with('web-store')->andReturn($repo); + $container->instance('cache', $cacheManager); + + EloquentUserProvider::resolveUserCacheKeyUsing(fn (mixed $identifier): string => 'tenant:' . $identifier); + + $manager->clearUserCache(42); + } + + public function testForgetGuardsDoesNotAccumulateAuthCacheDescriptors() + { + $manager = new AuthManager($container = $this->getContainer([ + 'defaults' => [ + 'guard' => 'api', + ], + 'guards' => [ + 'api' => ['driver' => 'token', 'provider' => 'users'], + ], + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => AuthManagerCacheUserStub::class, + 'cache' => ['enabled' => true, 'store' => 'redis'], + ], + ], + ])); + + Container::setInstance($container); + $container->instance('hash', m::mock(HashContract::class)); + + $cacheManager = m::mock(CacheManager::class); + $firstRepo = m::mock(CacheRepository::class); + $firstRepo->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $secondRepo = m::mock(CacheRepository::class); + $secondRepo->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $cacheManager->shouldReceive('store')->with('redis')->andReturn($firstRepo, $secondRepo); + $container->instance('cache', $cacheManager); + + $firstGuard = $manager->guard('api'); + + $manager->forgetGuards(); + + $secondGuard = $manager->guard('api'); + + $this->assertNotSame($firstGuard, $secondGuard); + + $reflection = new ReflectionClass(EloquentUserProvider::class); + $descriptors = $reflection->getStaticPropertyValue('cachedProviders'); + + $this->assertArrayHasKey(AuthManagerCacheUserStub::class, $descriptors); + $this->assertCount(1, $descriptors[AuthManagerCacheUserStub::class]); + } + protected function getContainer(array $authConfig = []): Container { $container = new Container; @@ -337,3 +479,11 @@ protected function getContainer(array $authConfig = []): Container return $container; } } + +class AuthManagerCacheUserStub extends FoundationUser +{ +} + +class AuthManagerCacheAdminStub extends FoundationUser +{ +}