From b8017c2c2ef3f1e58f5f82e797507ceb3549c4b8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:43:22 +0000 Subject: [PATCH 01/35] docs: tighten framework contribution guidance Clarify approval for Laravel API differences and limit package README comparisons to user-facing behavior. Record the event wildcard and request-context rules that affect coroutine-safe framework work. --- AGENTS.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5aebdee87..d489bcb30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ Anything found follows When to Stop and Report — "the task didn't ask me to fi 1. Read the related Hypervel APIs and tests. Check Laravel or the package upstream for an established public API and behavior. 2. Decide which state is local, coroutine-scoped, or worker-scoped before writing code — see Coroutine and Worker-Lifetime State. -3. Keep Laravel API parity unless Hypervel's runtime requires a deliberate difference. Record intentional differences as described under Porting rules. +3. Preserve Laravel API parity unless it conflicts with a concrete Hypervel architectural constraint, preserves a verified defect, or forces a workaround for an approved enhancement. Any difference requires user approval before planning or editing. 4. Test the public behavior, failure paths, coroutine isolation, and cleanup the feature needs. 5. Complete the verification order below. @@ -163,7 +163,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan - **No arbitrary string lengths** — use `->string('name')`, not `->string('name', 100)`. Don't invent limits; specify a length only when the domain or protocol defines one — exact (UUID: 36, ULID: 26, sha-256 hex token: 64) or a defined maximum (IPv6 address: 45). - **No database enums** — use string columns plus PHP enums. Adding a value to a database enum requires a migration. - **Prefer `timestamp` over `timestampTz` in migrations** — store times in UTC with plain `timestamp` columns, matching the normal convention in Laravel and Hypervel first-party migrations. Reserve `timestampTz` for columns that genuinely need database-level timezone semantics, such as integrating with an existing timezone-aware schema or columns written by clients in different session timezones. The schema API supports both — this is a column-choice convention, not an API restriction. -- **Guard optional event dispatches with `hasListeners()`** — before constructing and dispatching framework events, guard them with `hasListeners()` so hot paths skip event overhead when nobody is listening. Do not guard dispatches where dispatching is the side effect, such as jobs, broadcasts, webhooks, or command bus calls. +- **Guard optional event dispatches with `hasListeners()`** — before constructing and dispatching framework events, guard them with `hasListeners()` so hot paths skip event overhead when nobody is listening. Bare `*` listeners are passive observers and do not count; targeted wildcards do. Do not guard dispatches where dispatching is the side effect, such as jobs, broadcasts, webhooks, or command bus calls. - **Use `Sleep::usleep()` / `Sleep::sleep()` for delays in source code** — `Sleep` is fakeable in tests. Use raw `sleep()` / `usleep()` only where real time must pass, such as test harnesses and external-process polling. - **Use `xxh128` for internal non-cryptographic hashing** — cache and context keys, content checksums, and change detection. It is faster than `sha256`, which is reserved for trust boundaries: stored credential digests, signatures, and anything an attacker gains by forging. Seed it when the hashed value comes from user input, as `SwooleStore` does for its physical table keys. - **Use immutable dates by default** — Hypervel defaults to `Hypervel\Support\CarbonImmutable`, including where Laravel uses mutable Carbon. Create public or application-configurable dates through the `Date` facade or date helpers, and use exact `CarbonImmutable` for framework-owned internal or held values. Type configurable Carbon boundaries as `CarbonInterface` and native or third-party boundaries as `DateTimeInterface`. Capture the return value of every date modifier whose result must persist. Use `Hypervel\Support\Carbon` only for explicit mutable opt-out or conversion behavior. @@ -447,6 +447,8 @@ Examples: `tests/Inertia/CoroutineIsolationTest.php`, `tests/Container/Coroutine `request()` resolves from `RequestContext` — when no request exists in context (tests that don't make HTTP requests), each `request()` call creates a throwaway fallback instance. This means `request()->merge()` has no effect on subsequent `request()` calls. Replace `request()->merge(['key' => 'value'])` with `RequestContext::set(Request::create('/?key=value'))` to seed a stable request in context. +Seed application requests with `RequestContext::set()`; replacing the `'request'` binding with `instance()` bypasses coroutine-local behavior. + ### Static state and test cleanup `AfterEachTestSubscriber` handles framework-global cleanup between tests. It calls `flushState()` on framework classes that hold static state, and resets the container itself — `Container::flushState()` + `setInstance(null)` — plus `CoroutineContext::flush()`, with each test in a fresh coroutine. So container singleton/auto-singleton instance state and coroutine context don't leak between tests; only `static`/process-global state and live external resources need package cleanup, not mutable state on a container-cached instance. Do not duplicate framework-static resets in `tearDown()`; `AfterEachTestSubscriber` is their one authoritative registry. Tests still own resources they create, such as child coroutines, subscribers, processes, sockets, and temporary files, and must close or join them through exception-safe cleanup. @@ -695,7 +697,7 @@ When porting Laravel packages, whether first-party or third-party, keep them as - Not porting deprecated upstream code or backwards-compatibility shims for versions/features Hypervel does not support — Hypervel is a new framework with no backwards-compatibility burden, so deprecated APIs and compatibility code that exist only to support older versions should be omitted rather than ported. Here, "upstream" means the framework or package being ported, not one of its dependencies — a Symfony deprecation does not make a Laravel API deprecated while Laravel still retains it. If a deprecated upstream surface still contains behavior that Hypervel actively needs, keep the behavior but move it onto the correct non-deprecated Hypervel-owned surface instead of porting the deprecated alias/wrapper as-is - General performance improvements — but STOP and explain the opportunity to the user first for approval -Hypervel has no obligation to preserve behavior from earlier Hypervel versions, but compatibility with the Laravel public APIs that Hypervel intentionally supports is a current design requirement. Do not use the lack of a backwards-compatibility burden to rename, remove, or change the meaning of those APIs. Propose genuinely different behavior as a distinct Hypervel API; do not hide it inside a Laravel-shaped API. +Hypervel has no obligation to preserve Hypervel-specific behavior from earlier versions, but supported Laravel APIs—including named arguments and protected extension points—must remain compatible unless the user approves a difference under the API-parity rule above. If a Laravel API appears unsuitable for Hypervel, STOP, explain why, and obtain approval before planning or editing. Hypervel's lack of backwards compatibility constraints is not a reason to rename, remove, or change Laravel APIs. Approved adaptations take precedence over upstream fidelity. Preserve Laravel upstream naming, structure, and style everywhere else. @@ -758,7 +760,7 @@ When ported code adds a provider or listener, wire providers and aliases in both Keep everything else: behavioral descriptions, `@see` links, `@throws` annotations, warnings, contract explanations, usage notes. Modernize the title line to imperative form ("Returns" → "Return", "Retrieves" → "Retrieve") but do not remove or rewrite the body content beneath it. Translate non-English comments to English and fix grammar errors. -- **Record intentional Laravel differences where future ports will look** — When a Laravel feature is intentionally not ported because it does not fit Hypervel's Swoole/coroutine architecture, or because Hypervel has a better native equivalent, record it in three places so a future port cannot miss it: (1) the package README under `Differences From Laravel`, with the reason and what to use instead; (2) a concise source comment at the natural insertion point where the skipped method/class would otherwise sit; (3) a concise `REMOVED:` comment at the matching upstream test location when tests are skipped. This is a narrow exception to the "don't annotate divergences" rule: it applies only to intentionally omitted methods or features, never to ordinary ported-and-adapted code. Closed decisions only — real gaps still worth doing go in `docs/todo.md`. +- **Record intentional Laravel differences where future ports will look** — When a Laravel feature is intentionally not ported because it does not fit Hypervel's Swoole/coroutine architecture, or because Hypervel has a better native equivalent, record it in three places so a future port cannot miss it: (1) the package README under `Differences From Laravel` only for differences humans or LLMs using the package need to know or act on, with the reason and what to use instead — internal differences and fixes do not belong there; (2) a concise source comment at the natural insertion point where the skipped method/class would otherwise sit; (3) a concise `REMOVED:` comment at the matching upstream test location when tests are skipped. This is a narrow exception to the "don't annotate divergences" rule: it applies only to intentionally omitted methods or features, never to ordinary ported-and-adapted code. Closed decisions only — real gaps still worth doing go in `docs/todo.md`. - **Replace framework names in code** — any occurrence of the word `laravel` or `hyperf` in ported code (string literals, comments, prefixes, identifiers, etc.) must be replaced with `hypervel`, preserving the original casing. For example: `laravel_reserved_` → `hypervel_reserved_`, `LaravelExcelExporter` → `HypervelExcelExporter`, `HYPERF_VERSION` → `HYPERVEL_VERSION`. This does not apply to namespaces (which have their own conversion rules) or to references that describe the upstream source (e.g., docblock `@see` links to Laravel/Hyperf source). - **Don't copy Laravel/Hyperf-specific framework details just to stay 1:1** — keep the behavior the same, but if something only exists because of the upstream framework's own packages, providers, bootstrap system, or architecture, translate it to the Hypervel equivalent or STOP and ask if there isn't one. From 83f7ad8a79f8372958d73ac4af9562725b6bce80 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:43:26 +0000 Subject: [PATCH 02/35] docs: mark Laravel differences guide for deletion Prevent new entries while the guide awaits deletion and remove rare route-object guidance that does not belong in global application context. --- docs/ai/differences-vs-laravel.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ai/differences-vs-laravel.md b/docs/ai/differences-vs-laravel.md index 7bca87a61..2263a9bfa 100644 --- a/docs/ai/differences-vs-laravel.md +++ b/docs/ai/differences-vs-laravel.md @@ -1,3 +1,5 @@ + + # Hypervel vs Laravel — Differences Write Hypervel apps like Laravel apps, except for these differences. Most stem from Hypervel running on Swoole coroutines: long-lived workers, no per-request bootstrap, many concurrent requests per worker process. @@ -25,7 +27,6 @@ Write Hypervel apps like Laravel apps, except for these differences. Most stem f - Workers are long-lived; many requests run concurrently as coroutines inside one worker process. - Anything on a static property or singleton service is shared across all concurrent requests in that worker — treat it like global state. -- Routes retain caller-supplied closures, invokable objects, and object-method callables for the worker lifetime. Prefer `[Controller::class, 'method']` when the container should control the controller's singleton, scoped, or transient lifetime. ## Per-request state From ee66db298f6d499a82518fea79773090eff1523d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:43:31 +0000 Subject: [PATCH 03/35] fix(config): replay package merges after worker reload Record semantic package config operations instead of their first evaluated result so fresh workers merge against freshly loaded configuration. Preserve mutation order, seal replay, and cover both package helpers and dotenv reload. --- .../Configuration/ConfigMutationTracker.php | 37 ++++- src/support/src/ServiceProvider.php | 61 +++++--- .../ConfigMutationTrackerTest.php | 136 ++++++++++++++++++ .../Listeners/ReloadDotenvAndConfigTest.php | 52 +++++++ tests/Support/SupportServiceProviderTest.php | 92 ++++++++++++ 5 files changed, 357 insertions(+), 21 deletions(-) create mode 100644 tests/Foundation/Configuration/ConfigMutationTrackerTest.php diff --git a/src/foundation/src/Configuration/ConfigMutationTracker.php b/src/foundation/src/Configuration/ConfigMutationTracker.php index fbc27d168..5050448f4 100644 --- a/src/foundation/src/Configuration/ConfigMutationTracker.php +++ b/src/foundation/src/Configuration/ConfigMutationTracker.php @@ -4,17 +4,20 @@ namespace Hypervel\Foundation\Configuration; +use Closure; use Hypervel\Config\Repository; /** - * @internal + * Tracks configuration mutations made during application boot for worker replay. + * + * This is framework boot infrastructure rather than a userland extension point. */ class ConfigMutationTracker { /** * The ordered configuration mutations made during application boot. * - * @var list> + * @var list|Closure(Repository): void> */ protected array $mutations = []; @@ -38,6 +41,30 @@ public function observe(Repository $config): void }); } + /** + * Apply and record a configuration operation for worker replay. + * + * Boot-only. The operation is retained for worker-start replay and is + * re-evaluated against the freshly rebuilt configuration repository. + * + * @param Closure(Repository): void $mutation + */ + public function applyAndRecord(Repository $config, Closure $mutation): void + { + $wasRecording = $this->recording; + $this->recording = false; + + try { + $mutation($config); + } finally { + $this->recording = $wasRecording; + } + + if ($wasRecording) { + $this->mutations[] = $mutation; + } + } + /** * Replay the recorded mutations and stop tracking changes in this worker. * @@ -49,6 +76,12 @@ public function replay(Repository $config): void $this->recording = false; foreach ($this->mutations as $mutation) { + if ($mutation instanceof Closure) { + $mutation($config); + + continue; + } + $config->set($mutation); } } diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php index 9cce7b582..20f2aec25 100644 --- a/src/support/src/ServiceProvider.php +++ b/src/support/src/ServiceProvider.php @@ -5,6 +5,7 @@ namespace Hypervel\Support; use Closure; +use Hypervel\Config\Repository as ConcreteConfigRepository; use Hypervel\Console\Application as Artisan; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Foundation\CachesConfiguration; @@ -14,6 +15,7 @@ use Hypervel\Di\Aop\AspectCollector; use Hypervel\Di\ClassMap\ClassMapManager; use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\Configuration\ConfigMutationTracker; use Hypervel\View\Compilers\CompilerInterface; use ReflectionProperty; use RuntimeException; @@ -154,20 +156,31 @@ protected function mergeConfigFrom(string $path, string $key): void return; } + /** @var ConcreteConfigRepository $config */ $config = $this->app->make('config'); + $mergeableOptions = $this->mergeableOptions($key); + + // Package config can depend on the worker environment, so replay the + // merge operation after config reload rather than its master result. + $this->app->make(ConfigMutationTracker::class)->applyAndRecord( + $config, + static function (ConcreteConfigRepository $config) use ($path, $key, $mergeableOptions): void { + $packageDefaults = require $path; + $appConfig = $config->array($key, []); + $merged = array_merge($packageDefaults, $appConfig); + + foreach ($mergeableOptions as $option) { + if (isset($packageDefaults[$option], $appConfig[$option])) { + $merged[$option] = array_merge( + $packageDefaults[$option], + $appConfig[$option], + ); + } + } - $packageDefaults = require $path; - $appConfig = $config->array($key, []); - - $merged = array_merge($packageDefaults, $appConfig); - - foreach ($this->mergeableOptions($key) as $option) { - if (isset($packageDefaults[$option], $appConfig[$option])) { - $merged[$option] = array_merge($packageDefaults[$option], $appConfig[$option]); - } - } - - $config->set($key, $merged); + $config->set($key, $merged); + }, + ); } /** @@ -199,14 +212,24 @@ protected function mergeableOptions(string $name): array */ protected function replaceConfigRecursivelyFrom(string $path, string $key): void { - if (! ($this->app instanceof CachesConfiguration && $this->app->configurationIsCached())) { - $config = $this->app->make('config'); - - $config->set($key, array_replace_recursive( - require $path, - $config->array($key, []) - )); + if ($this->app instanceof CachesConfiguration && $this->app->configurationIsCached()) { + return; } + + /** @var ConcreteConfigRepository $config */ + $config = $this->app->make('config'); + + // Package config can depend on the worker environment, so replay the + // merge operation after config reload rather than its master result. + $this->app->make(ConfigMutationTracker::class)->applyAndRecord( + $config, + static function (ConcreteConfigRepository $config) use ($path, $key): void { + $config->set($key, array_replace_recursive( + require $path, + $config->array($key, []), + )); + }, + ); } /** diff --git a/tests/Foundation/Configuration/ConfigMutationTrackerTest.php b/tests/Foundation/Configuration/ConfigMutationTrackerTest.php new file mode 100644 index 000000000..78fe712c8 --- /dev/null +++ b/tests/Foundation/Configuration/ConfigMutationTrackerTest.php @@ -0,0 +1,136 @@ + 'master']); + $tracker->observe($master); + + $master->set('package', ['value' => 'raw']); + $tracker->applyAndRecord($master, static function (Repository $config): void { + $config->set('package.value', $config->string('worker')); + }); + $master->set('package.value', 'final'); + + $worker = new Repository(['worker' => 'worker']); + $tracker->replay($worker); + + $this->assertSame('final', $worker->get('package.value')); + } + + public function testSemanticMutationUsesFreshRepositoryState(): void + { + $tracker = new ConfigMutationTracker; + $master = new Repository(['environment' => 'master']); + $tracker->observe($master); + + $tracker->applyAndRecord($master, static function (Repository $config): void { + $config->set('package.environment', $config->string('environment')); + }); + + $this->assertSame('master', $master->get('package.environment')); + + $worker = new Repository(['environment' => 'worker']); + $tracker->replay($worker); + + $this->assertSame('worker', $worker->get('package.environment')); + } + + public function testSemanticMutationDoesNotRecordItsInternalSet(): void + { + $tracker = new ConfigMutationTracker; + $master = new Repository(['environment' => 'master']); + $tracker->observe($master); + + $tracker->applyAndRecord($master, static function (Repository $config): void { + $config->set('package.environment', $config->string('environment')); + }); + + $worker = new Repository(['environment' => 'worker']); + $setCalls = 0; + $worker->setMutationObserver(static function () use (&$setCalls): void { + ++$setCalls; + }); + $tracker->replay($worker); + + $this->assertSame('worker', $worker->get('package.environment')); + $this->assertSame(1, $setCalls); + } + + public function testReplayDoesNotGrowTheMutationLog(): void + { + $tracker = new ConfigMutationTracker; + $master = new Repository; + $tracker->observe($master); + $runs = 0; + + $tracker->applyAndRecord($master, static function (Repository $config) use (&$runs): void { + ++$runs; + $config->set('package.runs', $runs); + }); + + $firstWorker = new Repository; + $tracker->replay($firstWorker); + $secondWorker = new Repository; + $tracker->replay($secondWorker); + + $this->assertSame(3, $runs); + $this->assertSame(3, $secondWorker->get('package.runs')); + } + + public function testFailedSemanticMutationRestoresRecordingAndIsNotAppended(): void + { + $tracker = new ConfigMutationTracker; + $master = new Repository; + $tracker->observe($master); + $exception = null; + + try { + $tracker->applyAndRecord($master, static function (Repository $config): void { + $config->set('package.partial', true); + + throw new RuntimeException('Failed operation.'); + }); + } catch (RuntimeException $caught) { + $exception = $caught; + } + + $this->assertInstanceOf(RuntimeException::class, $exception); + $this->assertSame('Failed operation.', $exception->getMessage()); + + $master->set('package.recorded', true); + + $worker = new Repository; + $tracker->replay($worker); + + $this->assertNull($worker->get('package.partial')); + $this->assertTrue($worker->boolean('package.recorded')); + } + + public function testMutationsAfterReplayAreNotRecorded(): void + { + $tracker = new ConfigMutationTracker; + $master = new Repository; + $tracker->observe($master); + $master->set('package.value', 'boot'); + + $tracker->replay(new Repository); + $master->set('package.value', 'runtime'); + + $worker = new Repository; + $tracker->replay($worker); + + $this->assertSame('boot', $worker->get('package.value')); + } +} diff --git a/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php b/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php index 0f37caa9f..d5e544636 100644 --- a/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php +++ b/tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php @@ -6,12 +6,15 @@ use Hypervel\Config\Repository; use Hypervel\Core\Events\BeforeWorkerStart; +use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application; use Hypervel\Foundation\Bootstrap\LoadConfiguration; use Hypervel\Foundation\Listeners\ReloadDotenvAndConfig; use Hypervel\Support\DotenvManager; use Hypervel\Support\Env; use Hypervel\Support\Facades\Config as ConfigFacade; +use Hypervel\Support\ServiceProvider; +use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use Mockery as m; @@ -148,6 +151,55 @@ public function testTrackerSealsWhenThereAreNoBootMutations(): void $this->assertSame($originalName, $config->get('app.name')); } + public function testReloadReevaluatesPackageConfigMergesAgainstWorkerEnvironment(): void + { + $app = new Application(__DIR__ . '/../Fixtures'); + $app->useEnvironmentPath(__DIR__ . '/../Fixtures/envs'); + (new LoadConfiguration)->bootstrap($app); + DotenvManager::load([$app->environmentPath()]); + $tempDirectory = ParallelTesting::tempDir('ReloadDotenvAndConfigTest-package'); + mkdir($tempDirectory, 0777, true); + $packageConfigPath = $tempDirectory . '/package.php'; + + try { + file_put_contents($packageConfigPath, <<<'PHP' + env('TEST_KEY'), +]; +PHP); + + $provider = new class($app, $packageConfigPath) extends ServiceProvider { + public function __construct(Application $app, protected string $packageConfigPath) + { + parent::__construct($app); + } + + public function register(): void + { + $this->mergeConfigFrom($this->packageConfigPath, 'worker_package'); + $this->mergeConfigFrom($this->packageConfigPath, 'custom'); + } + }; + $provider->register(); + + $config = $app->make(Repository::class); + $this->assertSame('default_value', $config->get('worker_package.environment')); + $this->assertSame('default_value', $config->get('custom.environment')); + $this->assertSame('bar', $config->get('custom.foo')); + + $app->loadEnvironmentFrom('.env.testing'); + $app->make(ReloadDotenvAndConfig::class)->handle(m::mock(BeforeWorkerStart::class)); + + $this->assertSame('testing_value', $config->get('worker_package.environment')); + $this->assertSame('testing_value', $config->get('custom.environment')); + $this->assertSame('bar', $config->get('custom.foo')); + } finally { + (new Filesystem)->deleteDirectory($tempDirectory); + } + } + protected function createApp(): Application { $app = new Application(__DIR__ . '/../Fixtures/envs'); diff --git a/tests/Support/SupportServiceProviderTest.php b/tests/Support/SupportServiceProviderTest.php index bffd2f10b..c7d4f34ad 100644 --- a/tests/Support/SupportServiceProviderTest.php +++ b/tests/Support/SupportServiceProviderTest.php @@ -7,15 +7,19 @@ use Hypervel\Config\Repository as ConfigRepository; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application; +use Hypervel\Foundation\Configuration\ConfigMutationTracker; use Hypervel\Support\ServiceProvider; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use Mockery as m; +use WeakReference; class SupportServiceProviderTest extends TestCase { protected Application $app; + protected ConfigMutationTracker $configMutationTracker; + protected function setUp(): void { parent::setUp(); @@ -27,6 +31,13 @@ protected function setUp(): void ]); $app->shouldReceive('make')->with('config')->andReturn($config)->byDefault(); + $this->configMutationTracker = new ConfigMutationTracker; + $this->configMutationTracker->observe($config); + $app->shouldReceive('make') + ->with(ConfigMutationTracker::class) + ->andReturn($this->configMutationTracker) + ->byDefault(); + $one = new ServiceProviderForTestingOne($app); $one->boot(); $two = new ServiceProviderForTestingTwo($app); @@ -324,6 +335,83 @@ public function testMergeConfigFromWithMergeableOptionsWhenNoExistingConfig() $this->assertArrayHasKey('redis', $stores); } + public function testMergeConfigFromReplaysAgainstFreshApplicationConfig(): void + { + $masterConfig = new ConfigRepository([ + 'flat' => ['default' => 'master'], + ]); + $tracker = new ConfigMutationTracker; + $tracker->observe($masterConfig); + $app = m::mock(Application::class)->makePartial(); + $app->shouldReceive('configurationIsCached')->andReturn(false); + $app->shouldReceive('make')->with('config')->andReturn($masterConfig); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn($tracker); + + (new ServiceProviderForTestingFlat($app))->register(); + + $workerConfig = new ConfigRepository([ + 'flat' => ['default' => 'worker'], + ]); + $tracker->replay($workerConfig); + + $this->assertSame('worker', $workerConfig->get('flat.default')); + $this->assertSame('package-prefix', $workerConfig->get('flat.prefix')); + } + + public function testMergeableOptionsReplayWithoutRetainingTheProvider(): void + { + $masterConfig = new ConfigRepository; + $tracker = new ConfigMutationTracker; + $tracker->observe($masterConfig); + $app = m::mock(Application::class)->makePartial(); + $app->shouldReceive('configurationIsCached')->andReturn(false); + $app->shouldReceive('make')->with('config')->andReturn($masterConfig); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn($tracker); + + $provider = new ServiceProviderForTestingMergeableStores($app); + $providerReference = WeakReference::create($provider); + $provider->register(); + unset($provider); + gc_collect_cycles(); + + $workerConfig = new ConfigRepository([ + 'mergeable_stores' => [ + 'stores' => [ + 'redis' => ['driver' => 'redis', 'connection' => 'worker'], + ], + ], + ]); + $tracker->replay($workerConfig); + + $this->assertNull($providerReference->get()); + $this->assertSame('worker', $workerConfig->get('mergeable_stores.stores.redis.connection')); + $this->assertSame('array', $workerConfig->get('mergeable_stores.stores.array.driver')); + } + + public function testReplaceConfigRecursivelyFromReplaysAgainstFreshApplicationConfig(): void + { + $masterConfig = new ConfigRepository([ + 'flat' => ['default' => 'master'], + ]); + $tracker = new ConfigMutationTracker; + $tracker->observe($masterConfig); + $app = m::mock(Application::class)->makePartial(); + $app->shouldReceive('configurationIsCached')->andReturn(false); + $app->shouldReceive('make')->with('config')->andReturn($masterConfig); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn($tracker); + + (new ServiceProviderForTestingReplace($app))->register(); + + $workerConfig = new ConfigRepository([ + 'flat' => ['default' => 'worker', 'extra' => 'worker-value'], + ]); + $tracker->replay($workerConfig); + + $this->assertSame('worker', $workerConfig->get('flat.default')); + $this->assertSame('worker-value', $workerConfig->get('flat.extra')); + $this->assertSame('package-prefix', $workerConfig->get('flat.prefix')); + } + public function testMergeableOptionsDefaultsToEmptyArray() { $provider = new ServiceProviderForTestingFlat($this->app); @@ -338,6 +426,7 @@ public function testMergeConfigFromSkipsWhenConfigIsCached() $app = m::mock(Application::class)->makePartial(); $app->shouldReceive('configurationIsCached')->andReturn(true); $app->shouldReceive('make')->with('config')->never(); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->never(); $provider = new ServiceProviderForTestingFlat($app); $provider->register(); @@ -352,6 +441,7 @@ public function testMergeConfigFromRunsWhenConfigIsNotCached() $app = m::mock(Application::class)->makePartial(); $app->shouldReceive('configurationIsCached')->andReturn(false); $app->shouldReceive('make')->with('config')->andReturn($config); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn(new ConfigMutationTracker); $provider = new ServiceProviderForTestingFlat($app); $provider->register(); @@ -364,6 +454,7 @@ public function testReplaceConfigRecursivelyFromSkipsWhenCached() $app = m::mock(Application::class)->makePartial(); $app->shouldReceive('configurationIsCached')->andReturn(true); $app->shouldReceive('make')->with('config')->never(); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->never(); $provider = new ServiceProviderForTestingReplace($app); $provider->register(); @@ -379,6 +470,7 @@ public function testReplaceConfigRecursivelyFromRunsWhenNotCached() $app = m::mock(Application::class)->makePartial(); $app->shouldReceive('configurationIsCached')->andReturn(false); $app->shouldReceive('make')->with('config')->andReturn($config); + $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn(new ConfigMutationTracker); $provider = new ServiceProviderForTestingReplace($app); $provider->register(); From 7e1050573ac6053f10e09ae0d65bec58ee4d3bcb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:43:35 +0000 Subject: [PATCH 04/35] feat(cache): add shared serializable class policy Combine configured and provider-contributed classes into one worker-lifetime allowlist while preserving unrestricted and deny-all modes. Normalize and deduplicate sources without autoloading, finalize once after boot, and reject late registration. --- src/cache/src/SerializableClassPolicy.php | 167 ++++++++++++ tests/Cache/SerializableClassPolicyTest.php | 278 ++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 src/cache/src/SerializableClassPolicy.php create mode 100644 tests/Cache/SerializableClassPolicyTest.php diff --git a/src/cache/src/SerializableClassPolicy.php b/src/cache/src/SerializableClassPolicy.php new file mode 100644 index 000000000..00dc6ac90 --- /dev/null +++ b/src/cache/src/SerializableClassPolicy.php @@ -0,0 +1,167 @@ +> + */ + private array $resolvers = []; + + /** + * The finalized class policy. + * + * @var null|false|list + */ + private array|false|null $resolvedClasses = null; + + /** + * Whether the policy has been finalized for this process. + */ + private bool $finalized = false; + + /** + * Create a serializable class policy. + * + * @param null|(Closure(): (null|array|bool)) $configuredClassesResolver + */ + public function __construct( + private ?Closure $configuredClassesResolver = null, + ) { + } + + /** + * Register classes that PHP may unserialize. + * + * Boot-only. Contributions are held for the process-wide policy and cannot + * change after finalization. + * + * @param Closure(): array $resolver + * + * @throws LogicException + */ + public function allowUsing(Closure $resolver): void + { + if ($this->finalized) { + throw new LogicException( + 'Serializable cache classes must be declared from a service provider boot() method before the cache policy is finalized during process startup.' + ); + } + + $this->resolvers[] = $resolver; + } + + /** + * Finalize the serializable class policy. + * + * Boot-only. The result is frozen for every subsequent cache read in the + * process. + * + * @internal + */ + public function finalize(): void + { + if ($this->finalized) { + return; + } + + $this->resolvedClasses = $this->resolve(); + $this->finalized = true; + $this->configuredClassesResolver = null; + $this->resolvers = []; + } + + /** + * Unserialize a cached value through the effective policy. + */ + public function unserialize(string $value): mixed + { + $classes = $this->finalized + ? $this->resolvedClasses + : $this->resolve(); + + return $classes === null + ? unserialize($value) + : unserialize($value, ['allowed_classes' => $classes]); + } + + /** + * Resolve the effective serializable class policy. + * + * @return null|false|list + */ + private function resolve(): array|false|null + { + if ($this->configuredClassesResolver === null) { + return null; + } + + $configured = ($this->configuredClassesResolver)(); + + if ($configured === null || $configured === true) { + return null; + } + + if ($configured !== false && ! is_array($configured)) { + throw new InvalidArgumentException( + 'The cache.serializable_classes configuration must be null, true, false, or an array of class-strings.' + ); + } + + $classes = $configured === false + ? [] + : $this->normalizeClasses($configured, 'cache.serializable_classes configuration'); + + foreach ($this->resolvers as $index => $resolver) { + $classes = [ + ...$classes, + ...$this->normalizeClasses( + $resolver(), + "serializable class resolver [{$index}]", + ), + ]; + } + + if ($configured === false && $classes === []) { + return false; + } + + return array_values(array_unique($classes)); + } + + /** + * Validate and normalize a class list. + * + * @return list + */ + private function normalizeClasses(mixed $classes, string $source): array + { + if (! is_array($classes)) { + throw new InvalidArgumentException( + "The {$source} must return an array of class-strings; " . get_debug_type($classes) . ' returned.' + ); + } + + foreach ($classes as $key => $class) { + if (! is_string($class)) { + throw new InvalidArgumentException( + "The {$source} entry [{$key}] must be a class-string; " . get_debug_type($class) . ' given.' + ); + } + } + + /** @var list $normalized */ + $normalized = array_values($classes); + + return $normalized; + } +} diff --git a/tests/Cache/SerializableClassPolicyTest.php b/tests/Cache/SerializableClassPolicyTest.php new file mode 100644 index 000000000..8db616d41 --- /dev/null +++ b/tests/Cache/SerializableClassPolicyTest.php @@ -0,0 +1,278 @@ +allowUsing(static function () use (&$resolverRuns): array { + ++$resolverRuns; + + return [SerializablePolicyAllowedClass::class]; + }); + + $result = $policy->unserialize(serialize(new SerializablePolicyConfiguredClass)); + + $this->assertInstanceOf(SerializablePolicyConfiguredClass::class, $result); + $this->assertSame(0, $resolverRuns); + } + + public function testNullAndTrueConfiguredValuesAreUnrestrictedAndSkipDeclarations(): void + { + foreach ([null, true] as $configured) { + $resolverRuns = 0; + $policy = new SerializableClassPolicy(static fn () => $configured); + $policy->allowUsing(static function () use (&$resolverRuns): array { + ++$resolverRuns; + + return [SerializablePolicyAllowedClass::class]; + }); + + $result = $policy->unserialize(serialize(new SerializablePolicyConfiguredClass)); + + $this->assertInstanceOf(SerializablePolicyConfiguredClass::class, $result); + $this->assertSame(0, $resolverRuns); + } + } + + public function testFalseWithoutDeclarationsDeniesClasses(): void + { + $policy = new SerializableClassPolicy(static fn (): false => false); + + $result = $policy->unserialize(serialize(new SerializablePolicyAllowedClass)); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $result); + } + + public function testFalseWithDeclarationsAllowsContributedClasses(): void + { + $policy = new SerializableClassPolicy(static fn (): false => false); + $policy->allowUsing(static fn (): array => [SerializablePolicyAllowedClass::class]); + + $result = $policy->unserialize(serialize(new SerializablePolicyAllowedClass)); + + $this->assertInstanceOf(SerializablePolicyAllowedClass::class, $result); + } + + public function testConfiguredAndDeclaredClassesAreUnionedAcrossCollidingKeys(): void + { + $policy = new SerializableClassPolicy(static fn (): array => [ + 'configured' => SerializablePolicyConfiguredClass::class, + 'duplicate' => SerializablePolicyAllowedClass::class, + ]); + $policy->allowUsing(static fn (): array => [ + 'duplicate' => SerializablePolicyDeclaredClass::class, + 'allowed' => SerializablePolicyAllowedClass::class, + ]); + $policy->finalize(); + + $value = [ + new SerializablePolicyConfiguredClass, + new SerializablePolicyDeclaredClass, + new SerializablePolicyAllowedClass, + ]; + $result = $policy->unserialize(serialize($value)); + + $this->assertInstanceOf(SerializablePolicyConfiguredClass::class, $result[0]); + $this->assertInstanceOf(SerializablePolicyDeclaredClass::class, $result[1]); + $this->assertInstanceOf(SerializablePolicyAllowedClass::class, $result[2]); + } + + public function testMultipleResolversContributeClasses(): void + { + $policy = new SerializableClassPolicy(static fn (): false => false); + $policy->allowUsing(static fn (): array => [SerializablePolicyAllowedClass::class]); + $policy->allowUsing(static fn (): array => [SerializablePolicyDeclaredClass::class]); + + $result = $policy->unserialize(serialize([ + new SerializablePolicyAllowedClass, + new SerializablePolicyDeclaredClass, + ])); + + $this->assertInstanceOf(SerializablePolicyAllowedClass::class, $result[0]); + $this->assertInstanceOf(SerializablePolicyDeclaredClass::class, $result[1]); + } + + public function testUnserializeRecomputesBeforeFinalizationAndFreezesAfterward(): void + { + $classes = [SerializablePolicyAllowedClass::class]; + $resolverRuns = 0; + $policy = new SerializableClassPolicy(static fn (): false => false); + $policy->allowUsing(static function () use (&$classes, &$resolverRuns): array { + ++$resolverRuns; + + return $classes; + }); + + $this->assertInstanceOf( + SerializablePolicyAllowedClass::class, + $policy->unserialize(serialize(new SerializablePolicyAllowedClass)), + ); + + $classes = [SerializablePolicyDeclaredClass::class]; + + $this->assertInstanceOf( + SerializablePolicyDeclaredClass::class, + $policy->unserialize(serialize(new SerializablePolicyDeclaredClass)), + ); + + $policy->finalize(); + $classes = [SerializablePolicyConfiguredClass::class]; + + $this->assertInstanceOf( + __PHP_Incomplete_Class::class, + $policy->unserialize(serialize(new SerializablePolicyConfiguredClass)), + ); + $this->assertSame(3, $resolverRuns); + } + + public function testFinalizationClearsResolverCaptures(): void + { + $capture = new stdClass; + $reference = WeakReference::create($capture); + $policy = new SerializableClassPolicy(static fn (): false => false); + $policy->allowUsing(static function () use ($capture): array { + return [spl_object_id($capture) => SerializablePolicyAllowedClass::class]; + }); + + $policy->finalize(); + unset($capture); + gc_collect_cycles(); + + $this->assertNull($reference->get()); + } + + public function testDeclarationAfterFinalizationThrowsActionableException(): void + { + $policy = new SerializableClassPolicy(static fn (): false => false); + $policy->finalize(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('service provider boot()'); + + $policy->allowUsing(static fn (): array => [SerializablePolicyAllowedClass::class]); + } + + public function testResolverExceptionsPropagateUnchanged(): void + { + $policy = new SerializableClassPolicy(static fn (): false => false); + $policy->allowUsing(static fn (): never => throw new RuntimeException('Resolver failed.')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Resolver failed.'); + + $policy->finalize(); + } + + public function testInvalidConfiguredValueTypeFails(): void + { + $policy = new SerializableClassPolicy(static fn (): string => 'invalid'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cache.serializable_classes'); + + $policy->finalize(); + } + + public function testNonArrayResolverResultFails(): void + { + $policy = new SerializableClassPolicy(static fn (): false => false); + $policy->allowUsing(static fn (): false => false); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('resolver'); + + $policy->finalize(); + } + + public function testAssociativeConfiguredAndResolverArraysAreAccepted(): void + { + $policy = new SerializableClassPolicy(static fn (): array => [ + 'allowed' => SerializablePolicyAllowedClass::class, + ]); + $policy->allowUsing(static fn (): array => [ + 'declared' => SerializablePolicyDeclaredClass::class, + ]); + + $result = $policy->unserialize(serialize([ + new SerializablePolicyAllowedClass, + new SerializablePolicyDeclaredClass, + ])); + + $this->assertInstanceOf(SerializablePolicyAllowedClass::class, $result[0]); + $this->assertInstanceOf(SerializablePolicyDeclaredClass::class, $result[1]); + } + + public function testNonStringConfiguredEntryNamesItsSourceAndKey(): void + { + $policy = new SerializableClassPolicy(static fn (): array => [ + 'invalid' => new stdClass, + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cache.serializable_classes configuration entry [invalid]'); + + $policy->finalize(); + } + + public function testNonStringResolverEntryNamesItsSourceAndKey(): void + { + $policy = new SerializableClassPolicy(static fn (): false => false); + $policy->allowUsing(static fn (): array => [ + 'invalid' => new stdClass, + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('serializable class resolver [0] entry [invalid]'); + + $policy->finalize(); + } + + public function testUnknownClassNamesAreRetainedWithoutAutoloading(): void + { + $autoloadedClasses = []; + $autoload = static function (string $class) use (&$autoloadedClasses): void { + $autoloadedClasses[] = $class; + }; + spl_autoload_register($autoload); + + try { + $policy = new SerializableClassPolicy( + static fn (): array => ['MissingSerializablePolicyClass'] + ); + $policy->finalize(); + $result = $policy->unserialize(serialize(new SerializablePolicyAllowedClass)); + } finally { + spl_autoload_unregister($autoload); + } + + $this->assertSame([], $autoloadedClasses); + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $result); + } +} + +class SerializablePolicyAllowedClass +{ +} + +class SerializablePolicyConfiguredClass +{ +} + +class SerializablePolicyDeclaredClass +{ +} From 097adac6bb288eea2cd766aa516c3976a271bdaa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:43:40 +0000 Subject: [PATCH 05/35] feat(cache): apply class policy to array stores Preserve Laravel's scalar serializableClasses constructor API while allowing manager-built array and worker-array stores to use the shared live policy. Cover scalar compatibility, policy precedence, and serialized round trips. --- src/cache/src/AbstractArrayStore.php | 17 ++++++- src/cache/src/ArrayStore.php | 9 ++-- tests/Cache/CacheArrayStoreTest.php | 55 +++++++++++++++++++++++ tests/Cache/CacheWorkerArrayStoreTest.php | 35 +++++++++++++++ 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/src/cache/src/AbstractArrayStore.php b/src/cache/src/AbstractArrayStore.php index 7b49fe9ff..b3c99627a 100644 --- a/src/cache/src/AbstractArrayStore.php +++ b/src/cache/src/AbstractArrayStore.php @@ -27,13 +27,22 @@ abstract class AbstractArrayStore extends TaggableStore implements CanFlushLocks */ protected array|bool|null $serializableClasses; + /** + * The shared serializable class policy. + */ + protected ?SerializableClassPolicy $serializableClassPolicy; + /** * Create a new array-family store. */ - public function __construct(bool $serializesValues = false, array|bool|null $serializableClasses = null) - { + public function __construct( + bool $serializesValues = false, + array|bool|null $serializableClasses = null, + ?SerializableClassPolicy $serializableClassPolicy = null, + ) { $this->serializesValues = $serializesValues; $this->serializableClasses = $serializableClasses; + $this->serializableClassPolicy = $serializableClassPolicy; } /** @@ -299,6 +308,10 @@ protected function toTimestamp(int $seconds): float */ protected function unserialize(string $value): mixed { + if ($this->serializableClassPolicy !== null) { + return $this->serializableClassPolicy->unserialize($value); + } + if ($this->serializableClasses !== null) { return unserialize($value, ['allowed_classes' => $this->serializableClasses]); } diff --git a/src/cache/src/ArrayStore.php b/src/cache/src/ArrayStore.php index e762b75fe..8d1c48bc8 100644 --- a/src/cache/src/ArrayStore.php +++ b/src/cache/src/ArrayStore.php @@ -38,9 +38,12 @@ class ArrayStore extends AbstractArrayStore /** * Create a new Array store. */ - public function __construct(bool $serializesValues = false, array|bool|null $serializableClasses = null) - { - parent::__construct($serializesValues, $serializableClasses); + public function __construct( + bool $serializesValues = false, + array|bool|null $serializableClasses = null, + ?SerializableClassPolicy $serializableClassPolicy = null, + ) { + parent::__construct($serializesValues, $serializableClasses, $serializableClassPolicy); $suffix = (string) ++self::$contextKeySequence; diff --git a/tests/Cache/CacheArrayStoreTest.php b/tests/Cache/CacheArrayStoreTest.php index fa758e70f..c4f19755d 100644 --- a/tests/Cache/CacheArrayStoreTest.php +++ b/tests/Cache/CacheArrayStoreTest.php @@ -4,7 +4,9 @@ namespace Hypervel\Tests\Cache; +use __PHP_Incomplete_Class; use Hypervel\Cache\ArrayStore; +use Hypervel\Cache\SerializableClassPolicy; use Hypervel\Contracts\Cache\RefreshableLock; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; @@ -325,6 +327,59 @@ public function testValuesAreNotStoredByReference(): void $this->assertFalse(property_exists($retrievedObject, 'bar')); } + public function testSerializedValuesAreUnrestrictedByDefault(): void + { + $store = new ArrayStore(true); + $store->put('object', new stdClass, 10); + + $this->assertInstanceOf(stdClass::class, $store->get('object')); + } + + public function testSerializableClassesControlSerializedValues(): void + { + $denyingStore = new ArrayStore(true, false); + $allowingStore = new ArrayStore( + serializesValues: true, + serializableClasses: [stdClass::class], + ); + + $denyingStore->put('object', new stdClass, 10); + $allowingStore->put('object', new stdClass, 10); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $denyingStore->get('object')); + $this->assertInstanceOf(stdClass::class, $allowingStore->get('object')); + } + + public function testSerializableClassPolicyControlsSerializedValues(): void + { + $denyingStore = new ArrayStore( + serializesValues: true, + serializableClassPolicy: new SerializableClassPolicy(static fn (): false => false), + ); + $allowingStore = new ArrayStore( + serializesValues: true, + serializableClassPolicy: new SerializableClassPolicy(static fn (): array => [stdClass::class]), + ); + + $denyingStore->put('object', new stdClass, 10); + $allowingStore->put('object', new stdClass, 10); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $denyingStore->get('object')); + $this->assertInstanceOf(stdClass::class, $allowingStore->get('object')); + } + + public function testSerializableClassPolicyTakesPrecedenceOverScalarClasses(): void + { + $store = new ArrayStore( + serializesValues: true, + serializableClasses: [stdClass::class], + serializableClassPolicy: new SerializableClassPolicy(static fn (): false => false), + ); + $store->put('object', new stdClass, 10); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $store->get('object')); + } + public function testValuesAreStoredByReferenceIfSerializationIsDisabled(): void { $store = new ArrayStore; diff --git a/tests/Cache/CacheWorkerArrayStoreTest.php b/tests/Cache/CacheWorkerArrayStoreTest.php index 718c9a27a..449f8c753 100644 --- a/tests/Cache/CacheWorkerArrayStoreTest.php +++ b/tests/Cache/CacheWorkerArrayStoreTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\Cache; +use __PHP_Incomplete_Class; +use Hypervel\Cache\SerializableClassPolicy; use Hypervel\Cache\WorkerArrayStore; use Hypervel\Engine\Channel; use Hypervel\Support\CarbonImmutable; @@ -50,6 +52,39 @@ public function testSerializedValuesCanBeRetrievedRaw(): void $this->assertSame(serialize($object), $store->all(false)['object']['value']); } + public function testSerializableClassesControlSerializedValues(): void + { + $denyingStore = new WorkerArrayStore(true, false); + $allowingStore = new WorkerArrayStore( + serializesValues: true, + serializableClasses: [stdClass::class], + ); + + $denyingStore->put('object', new stdClass, 10); + $allowingStore->put('object', new stdClass, 10); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $denyingStore->get('object')); + $this->assertInstanceOf(stdClass::class, $allowingStore->get('object')); + } + + public function testSerializableClassPolicyControlsSerializedValues(): void + { + $denyingStore = new WorkerArrayStore( + serializesValues: true, + serializableClassPolicy: new SerializableClassPolicy(static fn (): false => false), + ); + $allowingStore = new WorkerArrayStore( + serializesValues: true, + serializableClassPolicy: new SerializableClassPolicy(static fn (): array => [stdClass::class]), + ); + + $denyingStore->put('object', new stdClass, 10); + $allowingStore->put('object', new stdClass, 10); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $denyingStore->get('object')); + $this->assertInstanceOf(stdClass::class, $allowingStore->get('object')); + } + public function testValuesCanBeIncrementedAndDecremented(): void { $store = new WorkerArrayStore; From 7584ebb62c59878f9617ad02a299e899d4f30f5c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:43:43 +0000 Subject: [PATCH 06/35] feat(cache): apply class policy to database stores Preserve Laravel's scalar serializableClasses constructor API while allowing manager-built database stores to use the shared live policy. Cover direct scalar construction and restricted object round trips. --- src/cache/src/DatabaseStore.php | 11 +++++ tests/Cache/CacheDatabaseStoreTest.php | 58 ++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/cache/src/DatabaseStore.php b/src/cache/src/DatabaseStore.php index d1b224e4e..5e8fb399e 100644 --- a/src/cache/src/DatabaseStore.php +++ b/src/cache/src/DatabaseStore.php @@ -68,6 +68,11 @@ class DatabaseStore implements CanFlushLocks, LockProvider, Store */ protected array|bool|null $serializableClasses; + /** + * The shared serializable class policy. + */ + protected ?SerializableClassPolicy $serializableClassPolicy; + /** * Create a new database store. */ @@ -80,6 +85,7 @@ public function __construct( array $lockLottery = [2, 100], int $defaultLockTimeoutInSeconds = 86400, array|bool|null $serializableClasses = null, + ?SerializableClassPolicy $serializableClassPolicy = null, ) { $this->resolver = $resolver; $this->connectionName = $connectionName; @@ -89,6 +95,7 @@ public function __construct( $this->lockLottery = $lockLottery; $this->defaultLockTimeoutInSeconds = $defaultLockTimeoutInSeconds; $this->serializableClasses = $serializableClasses; + $this->serializableClassPolicy = $serializableClassPolicy; } /** @@ -513,6 +520,10 @@ protected function unserialize(string $value): mixed $value = base64_decode($value); } + if ($this->serializableClassPolicy !== null) { + return $this->serializableClassPolicy->unserialize($value); + } + if ($this->serializableClasses !== null) { return unserialize($value, ['allowed_classes' => $this->serializableClasses]); } diff --git a/tests/Cache/CacheDatabaseStoreTest.php b/tests/Cache/CacheDatabaseStoreTest.php index e6d4089b7..36c69cafb 100644 --- a/tests/Cache/CacheDatabaseStoreTest.php +++ b/tests/Cache/CacheDatabaseStoreTest.php @@ -4,7 +4,9 @@ namespace Hypervel\Tests\Cache; +use __PHP_Incomplete_Class; use Hypervel\Cache\DatabaseStore; +use Hypervel\Cache\SerializableClassPolicy; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\PostgresConnection; @@ -14,6 +16,7 @@ use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; use Mockery as m; +use stdClass; class CacheDatabaseStoreTest extends TestCase { @@ -59,6 +62,46 @@ public function testDecryptedValueIsReturnedWhenItemIsValid() $this->assertSame('bar', $store->get('foo')); } + public function testSerializableClassesControlCachedObjects(): void + { + [$denyingStore, $denyingTable] = $this->getStore(false); + [$allowingStore, $allowingTable] = $this->getStore([stdClass::class]); + + foreach ([$denyingTable, $allowingTable] as $table) { + $table->shouldReceive('whereIn')->once()->with('key', ['prefixfoo'])->andReturn($table); + $table->shouldReceive('get')->once()->andReturn(new Collection([(object) [ + 'key' => 'prefixfoo', + 'value' => serialize(new stdClass), + 'expiration' => 999999999999999, + ]])); + } + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $denyingStore->get('foo')); + $this->assertInstanceOf(stdClass::class, $allowingStore->get('foo')); + } + + public function testSerializableClassPolicyControlsCachedObjects(): void + { + [$denyingStore, $denyingTable] = $this->getStore( + serializableClassPolicy: new SerializableClassPolicy(static fn (): false => false), + ); + [$allowingStore, $allowingTable] = $this->getStore( + serializableClassPolicy: new SerializableClassPolicy(static fn (): array => [stdClass::class]), + ); + + foreach ([$denyingTable, $allowingTable] as $table) { + $table->shouldReceive('whereIn')->once()->with('key', ['prefixfoo'])->andReturn($table); + $table->shouldReceive('get')->once()->andReturn(new Collection([(object) [ + 'key' => 'prefixfoo', + 'value' => serialize(new stdClass), + 'expiration' => 999999999999999, + ]])); + } + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $denyingStore->get('foo')); + $this->assertInstanceOf(stdClass::class, $allowingStore->get('foo')); + } + public function testValueIsReturnedOnPostgres() { [$store, $table] = $this->getPostgresStore(); @@ -444,8 +487,10 @@ public function testGetPrefixReturnsConfiguredPrefix() /** * Get a DatabaseStore instance with mocked dependencies. */ - protected function getStore(): array - { + protected function getStore( + array|bool|null $serializableClasses = null, + ?SerializableClassPolicy $serializableClassPolicy = null, + ): array { $resolver = m::mock(ConnectionResolverInterface::class); $connection = m::mock(ConnectionInterface::class); $table = m::mock(Builder::class); @@ -453,7 +498,14 @@ protected function getStore(): array $resolver->shouldReceive('connection')->with('default')->andReturn($connection); $connection->shouldReceive('table')->with('table')->andReturn($table); - $store = new DatabaseStore($resolver, 'default', 'table', 'prefix'); + $store = new DatabaseStore( + $resolver, + 'default', + 'table', + 'prefix', + serializableClasses: $serializableClasses, + serializableClassPolicy: $serializableClassPolicy, + ); return [$store, $table, $connection, $resolver]; } From 73f7c40fc7345c04771ee0073f54a481d4931938 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:43:47 +0000 Subject: [PATCH 07/35] feat(cache): apply class policy to file stores Preserve Laravel's scalar serializableClasses constructor API while allowing manager-built file stores to use the shared live policy. Propagate both serialization inputs to lock stores and cover restricted round trips and subclass paths. --- src/cache/src/FileStore.php | 19 +++++++- tests/Cache/CacheFileStoreTest.php | 75 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index 35eaac034..98a7b188e 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -49,6 +49,11 @@ class FileStore implements CanFlushLocks, LockProvider, Store */ protected array|bool|null $serializableClasses; + /** + * The shared serializable class policy. + */ + protected ?SerializableClassPolicy $serializableClassPolicy; + /** * Create a new file cache store instance. */ @@ -57,11 +62,13 @@ public function __construct( string $directory, ?int $filePermission = null, array|bool|null $serializableClasses = null, + ?SerializableClassPolicy $serializableClassPolicy = null, ) { $this->files = $files; $this->directory = $directory; $this->filePermission = $filePermission; $this->serializableClasses = $serializableClasses; + $this->serializableClassPolicy = $serializableClassPolicy; } /** @@ -227,7 +234,13 @@ public function lock(string $name, int $seconds = 0, ?string $owner = null): Fil $this->ensureCacheDirectoryExists($this->lockDirectory ?? $this->directory); return new FileLock( - new static($this->files, $this->lockDirectory ?? $this->directory, $this->filePermission, $this->serializableClasses), + new static( + $this->files, + $this->lockDirectory ?? $this->directory, + $this->filePermission, + $this->serializableClasses, + $this->serializableClassPolicy, + ), "file-store-lock:{$name}", $seconds, $owner @@ -455,6 +468,10 @@ protected function getPayload(string $key): array */ protected function unserialize(string $value): mixed { + if ($this->serializableClassPolicy !== null) { + return $this->serializableClassPolicy->unserialize($value); + } + if ($this->serializableClasses !== null) { return unserialize($value, ['allowed_classes' => $this->serializableClasses]); } diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index 6705c4b1e..f5ecc7ea9 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -4,8 +4,10 @@ namespace Hypervel\Tests\Cache; +use __PHP_Incomplete_Class; use Exception; use Hypervel\Cache\FileStore; +use Hypervel\Cache\SerializableClassPolicy; use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Filesystem\Filesystem; use Hypervel\Filesystem\LockableFile; @@ -14,7 +16,9 @@ use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use Mockery as m; +use ReflectionProperty; use RuntimeException; +use stdClass; class CacheFileStoreTest extends TestCase { @@ -96,6 +100,77 @@ public function testValidItemReturnsContents() $this->assertSame('Hello World', $store->get('foo')); } + public function testSerializableClassesControlCachedObjects(): void + { + $denyingFiles = $this->mockFilesystem(); + $denyingFiles->expects($this->once()) + ->method('get') + ->willReturn('9999999999' . serialize(new stdClass)); + $allowingFiles = $this->mockFilesystem(); + $allowingFiles->expects($this->once()) + ->method('get') + ->willReturn('9999999999' . serialize(new stdClass)); + + $denyingStore = new FileStore($denyingFiles, __DIR__, null, false); + $allowingStore = new FileStore( + $allowingFiles, + __DIR__, + serializableClasses: [stdClass::class], + ); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $denyingStore->get('foo')); + $this->assertInstanceOf(stdClass::class, $allowingStore->get('foo')); + } + + public function testSerializableClassPolicyControlsCachedObjects(): void + { + $denyingFiles = $this->mockFilesystem(); + $denyingFiles->expects($this->once()) + ->method('get') + ->willReturn('9999999999' . serialize(new stdClass)); + $allowingFiles = $this->mockFilesystem(); + $allowingFiles->expects($this->once()) + ->method('get') + ->willReturn('9999999999' . serialize(new stdClass)); + + $denyingStore = new FileStore( + $denyingFiles, + __DIR__, + serializableClassPolicy: new SerializableClassPolicy(static fn (): false => false), + ); + $allowingStore = new FileStore( + $allowingFiles, + __DIR__, + serializableClassPolicy: new SerializableClassPolicy(static fn (): array => [stdClass::class]), + ); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $denyingStore->get('foo')); + $this->assertInstanceOf(stdClass::class, $allowingStore->get('foo')); + } + + public function testLockStoreRetainsBothSerializationPolicies(): void + { + $files = m::mock(Filesystem::class)->shouldIgnoreMissing(); + $files->shouldReceive('exists')->andReturnTrue(); + $serializableClasses = [stdClass::class]; + $policy = new SerializableClassPolicy(static fn (): false => false); + $store = new FileStore( + $files, + __DIR__, + serializableClasses: $serializableClasses, + serializableClassPolicy: $policy, + ); + $lock = $store->lock('foo'); + + $storeProperty = new ReflectionProperty($lock, 'store'); + $lockStore = $storeProperty->getValue($lock); + $classesProperty = new ReflectionProperty($lockStore, 'serializableClasses'); + $policyProperty = new ReflectionProperty($lockStore, 'serializableClassPolicy'); + + $this->assertSame($serializableClasses, $classesProperty->getValue($lockStore)); + $this->assertSame($policy, $policyProperty->getValue($lockStore)); + } + public function testStoreItemProperlyStoresValues() { $files = $this->mockFilesystem(); From c1d4efa555693f5d8eb5edf70d5adb060b7bcce5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:45:44 +0000 Subject: [PATCH 08/35] feat(cache): apply class policy to Redis stores Route PHP-owned Redis deserialization through the shared live policy while preserving scalar direct construction, numeric fast paths, and native serializer bypass. Cover allowed and denied values at both the store and serialization-helper boundaries. --- src/cache/src/Redis/Support/Serialization.php | 6 ++ src/cache/src/RedisStore.php | 12 +++- tests/Cache/CacheRedisStoreTest.php | 50 ++++++++++++++++ .../Cache/Redis/Support/SerializationTest.php | 58 +++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/cache/src/Redis/Support/Serialization.php b/src/cache/src/Redis/Support/Serialization.php index fe6e6c829..d75c9b7bf 100644 --- a/src/cache/src/Redis/Support/Serialization.php +++ b/src/cache/src/Redis/Support/Serialization.php @@ -4,6 +4,7 @@ namespace Hypervel\Cache\Redis\Support; +use Hypervel\Cache\SerializableClassPolicy; use Hypervel\Redis\RedisConnection; use Redis; @@ -26,6 +27,7 @@ class Serialization */ public function __construct( protected array|bool|null $serializableClasses = null, + protected ?SerializableClassPolicy $serializableClassPolicy = null, ) { } @@ -133,6 +135,10 @@ private function phpUnserialize(mixed $value): mixed return $value; } + if ($this->serializableClassPolicy !== null) { + return $this->serializableClassPolicy->unserialize((string) $value); + } + if ($this->serializableClasses !== null) { return unserialize((string) $value, ['allowed_classes' => $this->serializableClasses]); } diff --git a/src/cache/src/RedisStore.php b/src/cache/src/RedisStore.php index 5d78c8fbe..9b637e05a 100644 --- a/src/cache/src/RedisStore.php +++ b/src/cache/src/RedisStore.php @@ -100,6 +100,11 @@ class RedisStore extends TaggableStore implements CanFlushLocks, LockProvider */ protected array|bool|null $serializableClasses; + /** + * The shared serializable class policy. + */ + protected ?SerializableClassPolicy $serializableClassPolicy; + /** * Create a new Redis store. */ @@ -108,9 +113,11 @@ public function __construct( string $prefix = '', string $connection = 'default', array|bool|null $serializableClasses = null, + ?SerializableClassPolicy $serializableClassPolicy = null, ) { $this->redis = $redis; $this->serializableClasses = $serializableClasses; + $this->serializableClassPolicy = $serializableClassPolicy; $this->setPrefix($prefix); $this->setConnection($connection); } @@ -448,7 +455,10 @@ public function getContext(): StoreContext */ public function getSerialization(): Serialization { - return $this->serialization ??= new Serialization($this->serializableClasses); + return $this->serialization ??= new Serialization( + serializableClasses: $this->serializableClasses, + serializableClassPolicy: $this->serializableClassPolicy, + ); } /** diff --git a/tests/Cache/CacheRedisStoreTest.php b/tests/Cache/CacheRedisStoreTest.php index e019a97db..cf9841e48 100755 --- a/tests/Cache/CacheRedisStoreTest.php +++ b/tests/Cache/CacheRedisStoreTest.php @@ -4,11 +4,14 @@ namespace Hypervel\Tests\Cache; +use __PHP_Incomplete_Class; use Hypervel\Cache\RedisStore; +use Hypervel\Cache\SerializableClassPolicy; use Hypervel\Contracts\Redis\Factory; use Hypervel\Redis\RedisProxy; use Hypervel\Tests\Cache\Redis\RedisCacheTestCase; use Mockery as m; +use stdClass; class CacheRedisStoreTest extends RedisCacheTestCase { @@ -30,6 +33,53 @@ public function testRedisValueIsReturned() $this->assertSame('foo', $store->get('foo')); } + public function testSerializableClassesControlRedisValues(): void + { + $denyingConnection = $this->mockConnection(); + $denyingConnection->shouldReceive('get') + ->once() + ->with('prefix:object') + ->andReturn(serialize(new stdClass)); + $allowingConnection = $this->mockConnection(); + $allowingConnection->shouldReceive('get') + ->once() + ->with('prefix:object') + ->andReturn(serialize(new stdClass)); + + $denyingStore = new RedisStore( + $this->createRedisFactory($denyingConnection), + 'prefix:', + 'default', + false, + ); + $allowingStore = new RedisStore( + $this->createRedisFactory($allowingConnection), + 'prefix:', + 'default', + serializableClasses: [stdClass::class], + ); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $denyingStore->get('object')); + $this->assertInstanceOf(stdClass::class, $allowingStore->get('object')); + } + + public function testSerializableClassPolicyControlsRedisValues(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get') + ->once() + ->with('prefix:object') + ->andReturn(serialize(new stdClass)); + $store = new RedisStore( + $this->createRedisFactory($connection), + 'prefix:', + 'default', + serializableClassPolicy: new SerializableClassPolicy(static fn (): false => false), + ); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $store->get('object')); + } + public function testRedisMultipleValuesAreReturned() { $connection = $this->mockConnection(); diff --git a/tests/Cache/Redis/Support/SerializationTest.php b/tests/Cache/Redis/Support/SerializationTest.php index f4c5d9e6d..1372af106 100644 --- a/tests/Cache/Redis/Support/SerializationTest.php +++ b/tests/Cache/Redis/Support/SerializationTest.php @@ -4,11 +4,14 @@ namespace Hypervel\Tests\Cache\Redis\Support; +use __PHP_Incomplete_Class; use Hypervel\Cache\Redis\Support\Serialization; +use Hypervel\Cache\SerializableClassPolicy; use Hypervel\Redis\PhpRedisConnection; use Hypervel\Testbench\TestCase; use Mockery as m; use Redis; +use stdClass; class SerializationTest extends TestCase { @@ -91,6 +94,61 @@ public function testUnserializePhpUnserializesWhenNoSerializerConfigured(): void $this->assertSame(['foo' => 'bar'], $this->serialization->unserialize($connection, serialize(['foo' => 'bar']))); } + public function testSerializableClassesControlPhpUnserialization(): void + { + $connection = $this->createConnection(serialized: false); + $denyingSerialization = new Serialization(false); + $allowingSerialization = new Serialization([stdClass::class]); + $value = serialize(new stdClass); + + $this->assertInstanceOf( + __PHP_Incomplete_Class::class, + $denyingSerialization->unserialize($connection, $value), + ); + $this->assertInstanceOf( + stdClass::class, + $allowingSerialization->unserialize($connection, $value), + ); + } + + public function testSerializableClassPolicyControlsPhpUnserialization(): void + { + $connection = $this->createConnection(serialized: false); + $denyingSerialization = new Serialization( + serializableClassPolicy: new SerializableClassPolicy(static fn (): false => false), + ); + $allowingSerialization = new Serialization( + serializableClassPolicy: new SerializableClassPolicy(static fn (): array => [stdClass::class]), + ); + $value = serialize(new stdClass); + + $this->assertInstanceOf( + __PHP_Incomplete_Class::class, + $denyingSerialization->unserialize($connection, $value), + ); + $this->assertInstanceOf( + stdClass::class, + $allowingSerialization->unserialize($connection, $value), + ); + } + + public function testNativeSerializerBypassesPhpClassPolicy(): void + { + $resolverRuns = 0; + $policy = new SerializableClassPolicy(static fn (): false => false); + $policy->allowUsing(static function () use (&$resolverRuns): array { + ++$resolverRuns; + + return [stdClass::class]; + }); + $serialization = new Serialization(serializableClassPolicy: $policy); + $connection = $this->createConnection(serialized: true); + $value = new stdClass; + + $this->assertSame($value, $serialization->unserialize($connection, $value)); + $this->assertSame(0, $resolverRuns); + } + public function testUnserializeReturnsNumericValuesRaw(): void { $connection = $this->createConnection(serialized: false); From c9e431e8e3f9f10d9b6115506fe52d20c7b6ccfa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:45:50 +0000 Subject: [PATCH 09/35] feat(cache): apply class policy to storage stores Preserve direct scalar allowlists while allowing manager-built storage stores to use the shared live policy. Derive physical paths with stable unseeded xxh128 over the prefixed key and pin the exact mapping in tests. --- src/cache/src/StorageStore.php | 13 ++++++- tests/Cache/CacheStorageStoreTest.php | 52 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/cache/src/StorageStore.php b/src/cache/src/StorageStore.php index bc7b3e56e..7cb927a32 100644 --- a/src/cache/src/StorageStore.php +++ b/src/cache/src/StorageStore.php @@ -39,6 +39,11 @@ class StorageStore implements Store */ protected array|bool|null $serializableClasses; + /** + * The shared serializable class policy. + */ + protected ?SerializableClassPolicy $serializableClassPolicy; + /** * Create a new storage cache store instance. */ @@ -47,11 +52,13 @@ public function __construct( string $directory = '', string $prefix = '', array|bool|null $serializableClasses = null, + ?SerializableClassPolicy $serializableClassPolicy = null, ) { $this->disk = $disk; $this->directory = trim($directory, '/'); $this->prefix = $prefix; $this->serializableClasses = $serializableClasses; + $this->serializableClassPolicy = $serializableClassPolicy; } /** @@ -196,6 +203,10 @@ protected function getPayload(string $key): array */ protected function unserialize(string $value): mixed { + if ($this->serializableClassPolicy !== null) { + return $this->serializableClassPolicy->unserialize($value); + } + if ($this->serializableClasses !== null) { return unserialize($value, ['allowed_classes' => $this->serializableClasses]); } @@ -216,7 +227,7 @@ protected function emptyPayload(): array */ public function path(string $key): string { - $parts = array_slice(str_split($hash = sha1($this->prefix . $key), 2), 0, 2); + $parts = array_slice(str_split($hash = hash('xxh128', $this->prefix . $key), 2), 0, 2); return trim($this->directory . '/' . implode('/', $parts) . '/' . $hash, '/'); } diff --git a/tests/Cache/CacheStorageStoreTest.php b/tests/Cache/CacheStorageStoreTest.php index 5994b3ec0..53b741ca0 100644 --- a/tests/Cache/CacheStorageStoreTest.php +++ b/tests/Cache/CacheStorageStoreTest.php @@ -4,11 +4,14 @@ namespace Hypervel\Tests\Cache; +use __PHP_Incomplete_Class; use Hypervel\Cache\Repository; +use Hypervel\Cache\SerializableClassPolicy; use Hypervel\Cache\StorageStore; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\Cache\Fixtures\ArrayFilesystem; use Hypervel\Tests\TestCase; +use stdClass; class CacheStorageStoreTest extends TestCase { @@ -22,6 +25,55 @@ public function testValuesCanBeStoredAndRetrieved(): void $this->assertStringStartsWith('cache/', $store->path('foo')); } + public function testPathUsesPrefixInXxh128Digest(): void + { + $prefix = 'prefix:'; + $key = 'foo'; + $hash = hash('xxh128', $prefix . $key); + $store = new StorageStore(new ArrayFilesystem, 'cache', $prefix); + + $this->assertSame( + 'cache/' . substr($hash, 0, 2) . '/' . substr($hash, 2, 2) . '/' . $hash, + $store->path($key), + ); + } + + public function testSerializableClassesControlCachedObjects(): void + { + $denyingStore = new StorageStore(new ArrayFilesystem, 'cache', '', false); + $allowingStore = new StorageStore( + new ArrayFilesystem, + 'cache', + serializableClasses: [stdClass::class], + ); + + $denyingStore->put('object', new stdClass, 60); + $allowingStore->put('object', new stdClass, 60); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $denyingStore->get('object')); + $this->assertInstanceOf(stdClass::class, $allowingStore->get('object')); + } + + public function testSerializableClassPolicyControlsCachedObjects(): void + { + $denyingStore = new StorageStore( + new ArrayFilesystem, + 'cache', + serializableClassPolicy: new SerializableClassPolicy(static fn (): false => false), + ); + $allowingStore = new StorageStore( + new ArrayFilesystem, + 'cache', + serializableClassPolicy: new SerializableClassPolicy(static fn (): array => [stdClass::class]), + ); + + $denyingStore->put('object', new stdClass, 60); + $allowingStore->put('object', new stdClass, 60); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $denyingStore->get('object')); + $this->assertInstanceOf(stdClass::class, $allowingStore->get('object')); + } + public function testExpiredItemsReturnNullAndGetDeleted(): void { CarbonImmutable::setTestNow($now = CarbonImmutable::now()); From b801e88604fe220e93aabe0255dba79116acd200 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:46:00 +0000 Subject: [PATCH 10/35] feat(cache): install global serializable class policy Give every manager-built PHP-serializing store the same worker-lifetime policy and expose boot-time class contributions through the cache facade. Keep Swoole's internal payloads outside the user-value policy and cover manager, direct-store, and interval paths. --- src/cache/src/CacheManager.php | 65 +++++++++--- src/cache/src/SwooleStore.php | 8 +- src/support/src/Facades/Cache.php | 1 + tests/Cache/CacheManagerTest.php | 102 +++++++++++++++++++ tests/Cache/CacheSwooleStoreIntervalTest.php | 10 +- tests/Cache/CacheSwooleStoreTest.php | 5 +- 6 files changed, 165 insertions(+), 26 deletions(-) diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php index 44a6525eb..4abf31ad8 100644 --- a/src/cache/src/CacheManager.php +++ b/src/cache/src/CacheManager.php @@ -17,6 +17,7 @@ use Hypervel\Support\RebindsCallbacksToSelf; use Hypervel\Support\Str; use InvalidArgumentException; +use LogicException; use Mockery; use Mockery\LegacyMockInterface; use ReflectionException; @@ -49,12 +50,20 @@ class CacheManager implements FactoryContract */ protected array $customCreators = []; + /** + * The serializable class policy shared by built-in cache stores. + */ + protected SerializableClassPolicy $serializableClassPolicy; + /** * Create a new Cache manager instance. */ public function __construct( protected Container $app ) { + $this->serializableClassPolicy = new SerializableClassPolicy( + fn () => $this->app->make('config')->get('cache.serializable_classes'), + ); } /** @@ -176,7 +185,7 @@ protected function createArrayDriver(array $config): Repository { return $this->repository(new ArrayStore( $config['serialize'] ?? false, - $this->getSerializableClasses($config), + serializableClassPolicy: $this->serializableClassPolicy, ), $config); } @@ -187,7 +196,7 @@ protected function createWorkerArrayDriver(array $config): Repository { return $this->repository(new WorkerArrayStore( $config['serialize'] ?? false, - $this->getSerializableClasses($config), + serializableClassPolicy: $this->serializableClassPolicy, ), $config); } @@ -206,7 +215,7 @@ protected function createDatabaseDriver(array $config): Repository $config['lock_table'] ?? 'cache_locks', $config['lock_lottery'] ?? [2, 100], $config['lock_timeout'] ?? 86400, - $this->getSerializableClasses($config), + serializableClassPolicy: $this->serializableClassPolicy, ); if ($lockConnection = $config['lock_connection'] ?? null) { @@ -238,7 +247,7 @@ protected function createFileDriver(array $config): Repository $this->app->make('files'), $config['path'], $config['permission'] ?? null, - $this->getSerializableClasses($config), + serializableClassPolicy: $this->serializableClassPolicy, )) ->setLockDirectory($config['lock_path'] ?? null), $config @@ -254,7 +263,7 @@ protected function createStorageDriver(array $config): Repository $this->app->make('filesystem')->disk($config['disk'] ?? null), $config['path'] ?? '', $this->getPrefix($config), - $this->getSerializableClasses($config), + serializableClassPolicy: $this->serializableClassPolicy, ), $config); } @@ -280,7 +289,7 @@ protected function createRedisDriver(array $config): Repository $redis, $this->getPrefix($config), $connection, - serializableClasses: $this->getSerializableClasses($config), + serializableClassPolicy: $this->serializableClassPolicy, ); $store->setTagMode($config['tag_mode'] ?? 'all'); @@ -329,7 +338,7 @@ protected function createSwooleDriver(array $config): Repository $config['memory_limit_buffer'] ?? 0.05, $config['eviction_policy'] ?? SwooleStore::EVICTION_POLICY_LRU, $config['eviction_proportion'] ?? 0.05, - $this->getSerializableClasses($config), + serializableClassPolicy: $this->serializableClassPolicy, ); return $this->repository($store, $config); @@ -401,13 +410,8 @@ protected function getPrefix(array $config): string return $config['prefix'] ?? $this->app->make('config')->string('cache.prefix'); } - /** - * Get the classes that should be allowed during unserialization. - */ - protected function getSerializableClasses(array $config): array|bool|null - { - return $this->app->make('config')->get('cache.serializable_classes'); - } + // REMOVED: Laravel's per-store getSerializableClasses() hook cannot represent + // Hypervel's shared worker-lifetime policy. /** * Get the cache connection configuration. @@ -519,6 +523,39 @@ public function setApplication(Container $app): static return $this; } + /** + * Register classes that cache stores may unserialize. + * + * Boot-only. The resolver contributes to the worker-lifetime cache policy + * and is evaluated after every provider has booted: at application boot + * completion in console processes or after configuration reload in each + * Swoole worker. An earlier cache read evaluates the current contributions + * without memoizing them. + * + * @param Closure(): array $resolver + * + * @throws LogicException + */ + public function allowSerializableClassesUsing(Closure $resolver): static + { + $this->serializableClassPolicy->allowUsing($resolver); + + return $this; + } + + /** + * Finalize the worker-lifetime serializable class policy. + * + * Boot-only. The policy is frozen for the worker lifetime; later + * contributions throw and every subsequent unserialize uses the frozen list. + * + * @internal + */ + public function finalizeSerializableClasses(): void + { + $this->serializableClassPolicy->finalize(); + } + /** * Register a callback to be invoked when an unserializable class is encountered. * diff --git a/src/cache/src/SwooleStore.php b/src/cache/src/SwooleStore.php index 311ec5f78..6f4d8844e 100644 --- a/src/cache/src/SwooleStore.php +++ b/src/cache/src/SwooleStore.php @@ -60,7 +60,7 @@ public function __construct( protected float $memoryLimitBuffer, protected string $evictionPolicy, protected float $evictionProportion, - protected array|bool|null $serializableClasses = null, + protected SerializableClassPolicy $serializableClassPolicy = new SerializableClassPolicy, ) { $this->table = $this->state->table(); } @@ -1059,11 +1059,7 @@ protected function expiration(int $seconds): float */ protected function unserialize(string $value): mixed { - if ($this->serializableClasses !== null) { - return unserialize($value, ['allowed_classes' => $this->serializableClasses]); - } - - return unserialize($value); + return $this->serializableClassPolicy->unserialize($value); } /** diff --git a/src/support/src/Facades/Cache.php b/src/support/src/Facades/Cache.php index 3d42d3a5c..c26156155 100644 --- a/src/support/src/Facades/Cache.php +++ b/src/support/src/Facades/Cache.php @@ -21,6 +21,7 @@ * @method static \Hypervel\Cache\CacheManager extend(string $driver, \Closure $callback) * @method static \Hypervel\Cache\CacheManager setApplication(\Hypervel\Contracts\Container\Container $app) * @method static void handleUnserializableClassUsing(callable|null $callback) + * @method static \Hypervel\Cache\CacheManager allowSerializableClassesUsing(\Closure $resolver) * @method static mixed pull(\UnitEnum|string $key, \Closure|mixed $default = null) * @method static bool put(\UnitEnum|array|string $key, mixed $value, \DateInterval|\DateTimeInterface|int|null $ttl = null) * @method static bool add(\UnitEnum|string $key, mixed $value, \DateInterval|\DateTimeInterface|int|null $ttl = null) diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php index d2b400aad..33c1b6e10 100644 --- a/tests/Cache/CacheManagerTest.php +++ b/tests/Cache/CacheManagerTest.php @@ -20,7 +20,9 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory; use Hypervel\Contracts\Redis\Factory as RedisFactory; +use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Events\Dispatcher as Event; +use Hypervel\Filesystem\Filesystem; use Hypervel\Redis\PhpRedisConnection; use Hypervel\Redis\Pool\PoolFactory; use Hypervel\Redis\Pool\RedisPool; @@ -30,6 +32,7 @@ use Mockery as m; use Mockery\MockInterface; use Redis; +use ReflectionProperty; use stdClass; class CacheManagerTest extends TestCase @@ -91,6 +94,105 @@ public function testItCanBuildRepositories() $this->assertInstanceOf(NullStore::class, $nullCache->getStore()); } + public function testManagerBuiltSerializingStoresShareOnePolicy(): void + { + $app = $this->getAppWithRedis([ + 'cache' => [ + 'prefix' => 'cache:', + 'serializable_classes' => false, + 'stores' => [ + 'array' => ['driver' => 'array', 'serialize' => true], + 'worker' => ['driver' => 'worker-array', 'serialize' => true], + 'database' => ['driver' => 'database', 'table' => 'cache'], + 'file' => ['driver' => 'file', 'path' => __DIR__], + 'storage' => ['driver' => 'storage', 'disk' => 'test'], + 'redis' => ['driver' => 'redis', 'connection' => 'default'], + 'swoole' => ['driver' => 'swoole', 'table' => 'default'], + ], + 'swoole_tables' => [ + 'default' => [ + 'rows' => 128, + 'bytes' => 10240, + 'conflict_proportion' => 0.2, + ], + ], + ], + ]); + $app->instance('db', m::mock(ConnectionResolverInterface::class)); + $app->instance('files', new Filesystem); + $filesystem = m::mock(FilesystemFactory::class); + $filesystem->shouldReceive('disk')->with('test')->once()->andReturn(new ArrayFilesystem); + $app->instance('filesystem', $filesystem); + $app->instance(SwooleTableManager::class, new SwooleTableManager($app)); + $manager = new CacheManager($app); + $policies = []; + + foreach (['array', 'worker', 'database', 'file', 'storage', 'redis', 'swoole'] as $name) { + $store = $manager->store($name)->getStore(); + $property = new ReflectionProperty($store, 'serializableClassPolicy'); + $policies[] = $property->getValue($store); + + if ($name !== 'swoole') { + $classesProperty = new ReflectionProperty($store, 'serializableClasses'); + + $this->assertNull($classesProperty->getValue($store)); + } + } + + foreach ($policies as $policy) { + $this->assertSame($policies[0], $policy); + } + } + + public function testStoreConstructedBeforeDeclarationSeesUpdatedPolicy(): void + { + $manager = new CacheManager($this->getApp([ + 'cache' => [ + 'serializable_classes' => false, + 'stores' => [ + 'array' => ['driver' => 'array', 'serialize' => true], + ], + ], + ])); + $store = $manager->store('array'); + $store->put('object', new stdClass, 60); + + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $store->get('object')); + + $manager->allowSerializableClassesUsing(static fn (): array => [stdClass::class]); + + $this->assertInstanceOf(stdClass::class, $store->get('object')); + } + + public function testOnDemandBuildSeesLaterDeclarations(): void + { + $manager = new CacheManager($this->getApp([ + 'cache' => ['serializable_classes' => false], + ])); + $store = $manager->build(['driver' => 'array', 'serialize' => true]); + $store->put('object', new stdClass, 60); + + $manager->allowSerializableClassesUsing(static fn (): array => [stdClass::class]); + + $this->assertInstanceOf(stdClass::class, $store->get('object')); + } + + public function testConfiguredPolicyReadsCurrentApplicationBeforeFinalization(): void + { + $manager = new CacheManager($this->getApp([ + 'cache' => ['serializable_classes' => false], + ])); + $store = $manager->build(['driver' => 'array', 'serialize' => true]); + $store->put('object', new stdClass, 60); + + $manager->setApplication($this->getApp([ + 'cache' => ['serializable_classes' => true], + ])); + $manager->finalizeSerializableClasses(); + + $this->assertInstanceOf(stdClass::class, $store->get('object')); + } + public function testItCanCreateStorageDriver(): void { $disk = new ArrayFilesystem; diff --git a/tests/Cache/CacheSwooleStoreIntervalTest.php b/tests/Cache/CacheSwooleStoreIntervalTest.php index 31ab59f38..ccca04ca9 100644 --- a/tests/Cache/CacheSwooleStoreIntervalTest.php +++ b/tests/Cache/CacheSwooleStoreIntervalTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Cache; +use Hypervel\Cache\SerializableClassPolicy; use Hypervel\Cache\SwooleStore; use Hypervel\Cache\SwooleTableManager; use Hypervel\Cache\SwooleTableState; @@ -234,8 +235,9 @@ public function testRefresherStoreRefreshesIntervalsFromSharedIndex(): void public function testSerializableClassPolicyDoesNotApplyToIntervalResolvers(): void { $state = $this->createState(); - $workerStore = $this->createStore($state, serializableClasses: false); - $refresherStore = $this->createStore($state, serializableClasses: false); + $policy = new SerializableClassPolicy(static fn (): false => false); + $workerStore = $this->createStore($state, serializableClassPolicy: $policy); + $refresherStore = $this->createStore($state, serializableClassPolicy: $policy); $workerStore->interval('foo', fn () => 'bar', 5); @@ -721,14 +723,14 @@ private function createStore( string $policy = SwooleStore::EVICTION_POLICY_TTL, float $memoryLimitBuffer = 0.05, float $evictionProportion = 0.05, - array|bool|null $serializableClasses = null, + SerializableClassPolicy $serializableClassPolicy = new SerializableClassPolicy, ): SwooleStore { return new SwooleStore( $state ?? $this->createState(), $memoryLimitBuffer, $policy, $evictionProportion, - $serializableClasses, + serializableClassPolicy: $serializableClassPolicy, ); } diff --git a/tests/Cache/CacheSwooleStoreTest.php b/tests/Cache/CacheSwooleStoreTest.php index 53e0edbba..bd6e663e5 100644 --- a/tests/Cache/CacheSwooleStoreTest.php +++ b/tests/Cache/CacheSwooleStoreTest.php @@ -8,6 +8,7 @@ use Hypervel\Cache\Exceptions\ValueTooLargeForColumnException; use Hypervel\Cache\NullSentinel; use Hypervel\Cache\Repository; +use Hypervel\Cache\SerializableClassPolicy; use Hypervel\Cache\SwooleStore; use Hypervel\Cache\SwooleTableManager; use Hypervel\Cache\SwooleTableState; @@ -116,7 +117,7 @@ public function testPutRejectsClassesDuringUnserializationWhenConfigured(): void 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05, - false, + serializableClassPolicy: new SerializableClassPolicy(static fn (): false => false), ); $store->put('foo', new stdClass, 5); @@ -131,7 +132,7 @@ public function testPutAllowsConfiguredClassesDuringUnserialization(): void 0.05, SwooleStore::EVICTION_POLICY_TTL, 0.05, - [stdClass::class], + serializableClassPolicy: new SerializableClassPolicy(static fn (): array => [stdClass::class]), ); $store->put('foo', new stdClass, 5); From 67eecdfee529080a64ed03584008efdd3a0c6d99 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:46:05 +0000 Subject: [PATCH 11/35] feat(cache): finalize class policy at process startup Freeze contributed serializable classes after providers boot in console processes and after worker configuration reload on servers. Preserve the parameterless provider boot API and verify every worker and taskworker lifecycle. --- src/cache/src/CacheServiceProvider.php | 15 ++ tests/Cache/CacheServiceProviderTest.php | 179 +++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 tests/Cache/CacheServiceProviderTest.php diff --git a/src/cache/src/CacheServiceProvider.php b/src/cache/src/CacheServiceProvider.php index c1510f743..6453a8c36 100644 --- a/src/cache/src/CacheServiceProvider.php +++ b/src/cache/src/CacheServiceProvider.php @@ -59,5 +59,20 @@ public function boot(): void $events->listen(AfterWorkerStart::class, function (AfterWorkerStart $event) { $this->app->make(CreateSwooleTimers::class)->handle($event); }); + + if ($this->app->runningInConsole()) { + $cache = $this->app->make(CacheManager::class); + + $this->app->booted( + fn () => $cache->finalizeSerializableClasses(), + ); + + return; + } + + // Worker configuration is reloaded during BeforeWorkerStart. + $events->listen(AfterWorkerStart::class, function (AfterWorkerStart $event): void { + $this->app->make(CacheManager::class)->finalizeSerializableClasses(); + }); } } diff --git a/tests/Cache/CacheServiceProviderTest.php b/tests/Cache/CacheServiceProviderTest.php new file mode 100644 index 000000000..c46492a58 --- /dev/null +++ b/tests/Cache/CacheServiceProviderTest.php @@ -0,0 +1,179 @@ +manager(); + $manager->allowSerializableClassesUsing( + static fn (): array => [CachePolicyEarlyContribution::class], + ); + $events = m::mock(Dispatcher::class); + $listeners = []; + $events->shouldReceive('listen') + ->twice() + ->andReturnUsing(function (mixed $event, mixed $listener) use (&$listeners): void { + $listeners[$event][] = $listener; + }); + $bootedCallback = null; + $application = m::mock(Application::class); + $application->shouldReceive('make') + ->once() + ->with(CacheManager::class) + ->andReturn($manager); + $application->shouldReceive('make') + ->once() + ->with('events') + ->andReturn($events); + $application->shouldReceive('runningInConsole') + ->once() + ->andReturnTrue(); + $application->shouldReceive('booted') + ->once() + ->with(m::on(function (mixed $callback) use (&$bootedCallback): bool { + $bootedCallback = $callback; + + return $callback instanceof Closure; + })); + + (new CacheServiceProvider($application))->boot(); + + $manager->allowSerializableClassesUsing( + static fn (): array => [CachePolicyLateContribution::class], + ); + + $this->assertCount(1, $listeners[BeforeServerStart::class]); + $this->assertCount(1, $listeners[AfterWorkerStart::class]); + $this->assertInstanceOf(Closure::class, $bootedCallback); + + $bootedCallback(); + + $store = $manager->build(['driver' => 'array', 'serialize' => true]); + $store->put('objects', [ + new CachePolicyEarlyContribution, + new CachePolicyLateContribution, + ], 60); + $objects = $store->get('objects'); + + $this->assertInstanceOf(CachePolicyEarlyContribution::class, $objects[0]); + $this->assertInstanceOf(CachePolicyLateContribution::class, $objects[1]); + + $this->expectException(LogicException::class); + $manager->allowSerializableClassesUsing(static fn (): array => []); + } + + public function testServerFinalizationResolvesTheWorkerManagerAtEventTime(): void + { + $workerManager = $this->manager(); + $events = m::mock(Dispatcher::class); + $listeners = []; + $events->shouldReceive('listen') + ->times(3) + ->andReturnUsing(function (mixed $event, mixed $listener) use (&$listeners): void { + $listeners[$event][] = $listener; + }); + $application = m::mock(Application::class); + $application->shouldReceive('make') + ->once() + ->with(CacheManager::class) + ->andReturn($workerManager); + $application->shouldReceive('make') + ->once() + ->with('events') + ->andReturn($events); + $application->shouldReceive('runningInConsole') + ->once() + ->andReturnFalse(); + $application->shouldNotReceive('booted'); + $provider = new CacheServiceProviderFixture($application); + + $provider->boot(); + + $this->assertTrue($provider->bootCalled); + $this->assertCount(1, $listeners[BeforeServerStart::class]); + $this->assertCount(2, $listeners[AfterWorkerStart::class]); + + $server = m::mock(SwooleServer::class); + $server->taskworker = true; + $listeners[AfterWorkerStart::class][1](new AfterWorkerStart($server, 9)); + + $this->expectException(LogicException::class); + $workerManager->allowSerializableClassesUsing(static fn (): array => []); + } + + public function testFacadeCallsTheManagerExtensionWithoutResolvingAStore(): void + { + $resolver = static fn (): array => [CachePolicyEarlyContribution::class]; + $manager = m::mock(CacheManager::class); + $manager->shouldReceive('allowSerializableClassesUsing') + ->once() + ->with($resolver) + ->andReturnSelf(); + $manager->shouldNotReceive('store'); + Cache::setFacadeApplication(null); + Cache::swap($manager); + + try { + $this->assertSame($manager, Cache::allowSerializableClassesUsing($resolver)); + } finally { + Cache::clearResolvedInstance(); + } + } + + /** + * Create a cache manager with a restricted serializable-class policy. + */ + private function manager(): CacheManager + { + $container = new Container; + $container->instance('config', new ConfigRepository([ + 'cache' => [ + 'serializable_classes' => false, + ], + ])); + + return new CacheManager($container); + } +} + +class CacheServiceProviderFixture extends CacheServiceProvider +{ + public bool $bootCalled = false; + + /** + * Bootstrap the service provider. + */ + public function boot(): void + { + $this->bootCalled = true; + + parent::boot(); + } +} + +class CachePolicyEarlyContribution +{ +} + +class CachePolicyLateContribution +{ +} From 02d51721187054e8ebed5c09d13ada069d2a1b42 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:46:13 +0000 Subject: [PATCH 12/35] fix(cache): normalize stack layers for inspection Expose normalized stack repositories for recursive validation and report stable zero-based layer indexes even when configuration uses string keys. Keep tag composition and ordinary stack behavior unchanged. --- src/cache/src/StackStore.php | 22 ++++++++++++++++++---- tests/Cache/CacheStackStoreTagsTest.php | 13 +++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/cache/src/StackStore.php b/src/cache/src/StackStore.php index f7345c558..728a7f31a 100644 --- a/src/cache/src/StackStore.php +++ b/src/cache/src/StackStore.php @@ -17,7 +17,9 @@ class StackStore extends TaggableStore implements CanFlushLocks, LockProvider { /** - * @var StackStoreProxy[] + * The ordered store layers. + * + * @var list */ protected array $stores; @@ -30,7 +32,7 @@ class StackStore extends TaggableStore implements CanFlushLocks, LockProvider protected false|string|null $tagCompositionError = false; /** - * @param array $stores + * @param array $stores * * @throws InvalidArgumentException when no layers are given */ @@ -42,7 +44,7 @@ public function __construct(array $stores) $this->stores = array_map( static fn (Store $store) => $store instanceof StackStoreProxy ? $store : new StackStoreProxy($store), - $stores + array_values($stores) ); } @@ -237,10 +239,22 @@ public function getPrefix(): string return ''; } + /** + * Get the underlying store layers. + * + * @return list + * + * @internal + */ + public function getStores(): array + { + return $this->stores; + } + /** * Get the underlying taggable layer stores, top to bottom. * - * @return array + * @return list * * @throws NotSupportedException when the layer composition cannot support tags */ diff --git a/tests/Cache/CacheStackStoreTagsTest.php b/tests/Cache/CacheStackStoreTagsTest.php index 977cbbfef..66baa1e61 100644 --- a/tests/Cache/CacheStackStoreTagsTest.php +++ b/tests/Cache/CacheStackStoreTagsTest.php @@ -68,6 +68,19 @@ public function testInvalidCompositionsDoNotSupportTags(): void } } + public function testStringKeyedLayersUseZeroBasedIndexesInCompositionErrors(): void + { + $stack = new StackStore([ + 'memory' => $this->anyModeTaggableStore(), + 'persistent' => $this->nonTaggableStore(), + ]); + + $this->expectException(NotSupportedException::class); + $this->expectExceptionMessage('Stack layer 1'); + + $stack->tags(['t']); + } + public function testInvalidNestedStackDoesNotCallGetTagMode(): void { $taggable = m::mock(TaggableStore::class); From eb40bdd57d2e497cd9a9aef1e87466700f362176 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:46:19 +0000 Subject: [PATCH 13/35] feat(cache): validate model cache stores Reject stores and Redis serializers that cannot safely preserve cached model objects, including unsupported leaves inside nested stacks. Inspect configured Redis options without opening a connection and return actionable feature and layer details. --- .../UnsupportedModelCacheStoreException.php | 11 + src/cache/src/ModelCacheStoreValidator.php | 180 +++++++++ tests/Cache/ModelCacheStoreValidatorTest.php | 342 ++++++++++++++++++ 3 files changed, 533 insertions(+) create mode 100644 src/cache/src/Exceptions/UnsupportedModelCacheStoreException.php create mode 100644 src/cache/src/ModelCacheStoreValidator.php create mode 100644 tests/Cache/ModelCacheStoreValidatorTest.php diff --git a/src/cache/src/Exceptions/UnsupportedModelCacheStoreException.php b/src/cache/src/Exceptions/UnsupportedModelCacheStoreException.php new file mode 100644 index 000000000..0d25d017f --- /dev/null +++ b/src/cache/src/Exceptions/UnsupportedModelCacheStoreException.php @@ -0,0 +1,11 @@ +validateStore($repository->getStore(), $feature); + } + + /** + * Validate a store and every nested stack layer. + * + * @param list $layerPath + * + * @throws UnsupportedModelCacheStoreException + */ + private function validateStore(Store $store, string $feature, array $layerPath = []): void + { + if ($store instanceof StackStore) { + foreach ($store->getStores() as $index => $proxy) { + $this->validateStore( + $proxy->getStore(), + $feature, + [...$layerPath, $index], + ); + } + + return; + } + + if ($store instanceof RedisStore) { + $this->validateRedisStore($store, $feature, $layerPath); + + return; + } + + if ($store instanceof DatabaseStore + || $store instanceof FileStore + || $store instanceof StorageStore + || $store instanceof SwooleStore) { + return; + } + + throw new UnsupportedModelCacheStoreException(sprintf( + '%s does not support cache store [%s]%s.', + $feature, + $store::class, + $this->stackLocation($layerPath), + )); + } + + /** + * Validate that a Redis serializer preserves model objects. + * + * @param list $layerPath + * + * @throws UnsupportedModelCacheStoreException + */ + private function validateRedisStore(RedisStore $store, string $feature, array $layerPath): void + { + $connection = $store->getContext()->connectionName(); + /** @var array $options */ + $options = $this->redisConfig->connectionConfig($connection)['options'] ?? []; + $serializer = Redis::SERIALIZER_NONE; + + foreach ($options as $option => $value) { + if ((is_string($option) && strtolower($option) === 'serializer') + || $option === Redis::OPT_SERIALIZER) { + $serializer = (int) $value; + } + } + + if ($serializer === Redis::SERIALIZER_NONE + || $serializer === Redis::SERIALIZER_PHP + || $this->isSerializer($serializer, 'SERIALIZER_IGBINARY')) { + return; + } + + if ($this->isSerializer($serializer, 'SERIALIZER_MSGPACK')) { + if (filter_var(ini_get('msgpack.php_only'), FILTER_VALIDATE_BOOL) === true) { + return; + } + + $this->rejectRedisSerializer( + $feature, + $connection, + $serializer, + $layerPath, + 'msgpack.php_only=1 is required to preserve model objects', + ); + } + + if ($serializer === Redis::SERIALIZER_JSON) { + $this->rejectRedisSerializer( + $feature, + $connection, + $serializer, + $layerPath, + 'the JSON serializer converts model objects to arrays', + ); + } + + $this->rejectRedisSerializer( + $feature, + $connection, + $serializer, + $layerPath, + 'the serializer is not verified to preserve model objects', + ); + } + + /** + * Determine whether a build-dependent Redis serializer matches. + */ + private function isSerializer(int $serializer, string $constant): bool + { + $name = Redis::class . '::' . $constant; + + return defined($name) && $serializer === constant($name); + } + + /** + * Throw an unsupported Redis serializer exception. + * + * @param list $layerPath + * + * @throws UnsupportedModelCacheStoreException + */ + private function rejectRedisSerializer( + string $feature, + string $connection, + int $serializer, + array $layerPath, + string $reason, + ): never { + throw new UnsupportedModelCacheStoreException(sprintf( + '%s does not support Redis cache connection [%s] with serializer [%d]%s because %s.', + $feature, + $connection, + $serializer, + $this->stackLocation($layerPath), + $reason, + )); + } + + /** + * Format a nested stack layer location. + * + * @param list $layerPath + */ + private function stackLocation(array $layerPath): string + { + return $layerPath === [] + ? '' + : sprintf(' at stack layer [%s]', implode('.', $layerPath)); + } +} diff --git a/tests/Cache/ModelCacheStoreValidatorTest.php b/tests/Cache/ModelCacheStoreValidatorTest.php new file mode 100644 index 000000000..2dfa7f163 --- /dev/null +++ b/tests/Cache/ModelCacheStoreValidatorTest.php @@ -0,0 +1,342 @@ +validator(); + + foreach ([ + m::mock(DatabaseStore::class), + m::mock(FileStore::class), + m::mock(StorageStore::class), + m::mock(SwooleStore::class), + $this->redisStore(), + ] as $store) { + $validator->validate($this->repository($store), 'Auth user cache'); + $this->assertTrue(true); + } + } + + public function testRejectsEveryUnsupportedStoreType(): void + { + $validator = $this->validator(); + + foreach ([ + new ArrayStore, + new WorkerArrayStore, + new NullStore, + m::mock(SessionStore::class), + m::mock(FailoverStore::class), + ] as $store) { + try { + $validator->validate($this->repository($store), 'Sanctum token cache'); + $this->fail('Expected the unsupported store to be rejected.'); + } catch (UnsupportedModelCacheStoreException $exception) { + $this->assertStringContainsString('Sanctum token cache', $exception->getMessage()); + $this->assertStringContainsString($store::class, $exception->getMessage()); + } + } + } + + public function testAcceptsEveryLeafInANestedSupportedStack(): void + { + $stack = new StackStore([ + m::mock(FileStore::class), + new StackStore([ + m::mock(StorageStore::class), + $this->redisStore(), + ]), + m::mock(DatabaseStore::class), + ]); + + $this->validator()->validate($this->repository($stack), 'Auth user cache'); + + $this->assertTrue(true); + } + + public function testReportsTheFullPathToANestedUnsupportedLayer(): void + { + $stack = new StackStore([ + m::mock(FileStore::class), + new StackStore([ + m::mock(StorageStore::class), + new ArrayStore, + ]), + ]); + + try { + $this->validator()->validate($this->repository($stack), 'Auth user cache'); + $this->fail('Expected the nested array store to be rejected.'); + } catch (UnsupportedModelCacheStoreException $exception) { + $this->assertStringContainsString('Auth user cache', $exception->getMessage()); + $this->assertStringContainsString(ArrayStore::class, $exception->getMessage()); + $this->assertStringContainsString('stack layer [1.1]', $exception->getMessage()); + } + } + + public function testRejectsFailoverInsideANestedStack(): void + { + $failover = m::mock(FailoverStore::class); + $stack = new StackStore([ + m::mock(FileStore::class), + new StackStore([ + m::mock(StorageStore::class), + $failover, + ]), + ]); + + try { + $this->validator()->validate($this->repository($stack), 'Sanctum token cache'); + $this->fail('Expected the nested failover store to be rejected.'); + } catch (UnsupportedModelCacheStoreException $exception) { + $this->assertStringContainsString($failover::class, $exception->getMessage()); + $this->assertStringContainsString('stack layer [1.1]', $exception->getMessage()); + } + } + + public function testReportsTheLayerForARejectedRedisSerializerInsideAStack(): void + { + $stack = new StackStore([ + m::mock(FileStore::class), + $this->redisStore(), + ]); + $validator = $this->validator( + connectionOptions: ['serializer' => Redis::SERIALIZER_JSON], + ); + + try { + $validator->validate($this->repository($stack), 'Auth user cache'); + $this->fail('Expected the nested Redis serializer to be rejected.'); + } catch (UnsupportedModelCacheStoreException $exception) { + $this->assertStringContainsString('connection [cache]', $exception->getMessage()); + $this->assertStringContainsString('serializer [' . Redis::SERIALIZER_JSON . ']', $exception->getMessage()); + $this->assertStringContainsString('stack layer [1]', $exception->getMessage()); + } + } + + public function testAcceptsAbsentAndDisabledRedisSerializersWithoutOpeningAConnection(): void + { + foreach ([ + [], + ['serializer' => Redis::SERIALIZER_NONE], + ['SeRiAlIzEr' => Redis::SERIALIZER_NONE, 'compression' => 1], + [Redis::OPT_SERIALIZER => Redis::SERIALIZER_NONE], + ] as $options) { + $redis = m::mock(RedisFactory::class); + $redis->shouldNotReceive('connection'); + $store = new RedisStore($redis, connection: 'cache'); + + $this->validator(connectionOptions: $options)->validate( + $this->repository($store), + 'Auth user cache', + ); + + $this->assertTrue(true); + } + } + + public function testAcceptsTheNativePhpSerializer(): void + { + $this->validator( + connectionOptions: ['serializer' => Redis::SERIALIZER_PHP], + )->validate($this->repository($this->redisStore()), 'Auth user cache'); + + $this->assertTrue(true); + } + + public function testAcceptsIgbinaryWhenTheRedisBuildSupportsIt(): void + { + if (! defined(Redis::class . '::SERIALIZER_IGBINARY')) { + $this->markTestSkipped('The phpredis build does not support igbinary.'); + } + + $this->validator( + connectionOptions: [ + 'serializer' => (int) constant(Redis::class . '::SERIALIZER_IGBINARY'), + ], + )->validate($this->repository($this->redisStore()), 'Auth user cache'); + + $this->assertTrue(true); + } + + public function testAcceptsMsgpackOnlyInPhpOnlyMode(): void + { + if (! defined(Redis::class . '::SERIALIZER_MSGPACK')) { + $this->markTestSkipped('The phpredis build does not support msgpack.'); + } + + $previous = ini_get('msgpack.php_only'); + + if (ini_set('msgpack.php_only', '1') === false) { + $this->markTestSkipped('msgpack.php_only cannot be changed in this process.'); + } + + try { + $this->validator( + connectionOptions: [ + 'serializer' => (int) constant(Redis::class . '::SERIALIZER_MSGPACK'), + ], + )->validate($this->repository($this->redisStore()), 'Sanctum token cache'); + + $this->assertTrue(true); + } finally { + if (is_string($previous)) { + ini_set('msgpack.php_only', $previous); + } + } + } + + public function testRejectsMsgpackOutsidePhpOnlyMode(): void + { + if (! defined(Redis::class . '::SERIALIZER_MSGPACK')) { + $this->markTestSkipped('The phpredis build does not support msgpack.'); + } + + $previous = ini_get('msgpack.php_only'); + + if (ini_set('msgpack.php_only', '0') === false) { + $this->markTestSkipped('msgpack.php_only cannot be changed in this process.'); + } + + try { + $validator = $this->validator(connectionOptions: [ + 'serializer' => (int) constant(Redis::class . '::SERIALIZER_MSGPACK'), + ]); + + $this->expectException(UnsupportedModelCacheStoreException::class); + $this->expectExceptionMessage('msgpack.php_only=1'); + + $validator->validate($this->repository($this->redisStore()), 'Sanctum token cache'); + } finally { + if (is_string($previous)) { + ini_set('msgpack.php_only', $previous); + } + } + } + + public function testRejectsJsonAndReportsTheConnectionAndSerializer(): void + { + $validator = $this->validator( + connectionOptions: ['serializer' => Redis::SERIALIZER_JSON], + ); + + try { + $validator->validate($this->repository($this->redisStore()), 'Auth user cache'); + $this->fail('Expected the JSON serializer to be rejected.'); + } catch (UnsupportedModelCacheStoreException $exception) { + $this->assertStringContainsString('Auth user cache', $exception->getMessage()); + $this->assertStringContainsString('connection [cache]', $exception->getMessage()); + $this->assertStringContainsString('serializer [' . Redis::SERIALIZER_JSON . ']', $exception->getMessage()); + $this->assertStringContainsString('converts model objects to arrays', $exception->getMessage()); + } + } + + public function testRejectsUnknownSerializerValues(): void + { + $validator = $this->validator(connectionOptions: ['serializer' => PHP_INT_MAX]); + + $this->expectException(UnsupportedModelCacheStoreException::class); + $this->expectExceptionMessage('not verified to preserve model objects'); + + $validator->validate($this->repository($this->redisStore()), 'Auth user cache'); + } + + public function testConnectionOptionsOverrideSharedOptions(): void + { + $this->validator( + sharedOptions: ['serializer' => Redis::SERIALIZER_JSON], + connectionOptions: ['serializer' => Redis::SERIALIZER_PHP], + )->validate($this->repository($this->redisStore()), 'Auth user cache'); + + $this->assertTrue(true); + } + + public function testLastRecognizedRedisSerializerOptionWins(): void + { + $this->validator(connectionOptions: [ + 'serializer' => Redis::SERIALIZER_JSON, + Redis::OPT_SERIALIZER => Redis::SERIALIZER_PHP, + ])->validate($this->repository($this->redisStore()), 'Auth user cache'); + + $this->assertTrue(true); + + $validator = $this->validator(connectionOptions: [ + Redis::OPT_SERIALIZER => Redis::SERIALIZER_PHP, + 'SERIALIZER' => Redis::SERIALIZER_JSON, + ]); + + $this->expectException(UnsupportedModelCacheStoreException::class); + $validator->validate($this->repository($this->redisStore()), 'Auth user cache'); + } + + /** + * Create a validator with merged Redis options. + * + * @param array $sharedOptions + * @param array $connectionOptions + */ + private function validator( + array $sharedOptions = [], + array $connectionOptions = [], + ): ModelCacheStoreValidator { + return new ModelCacheStoreValidator(new RedisConfig(new ConfigRepository([ + 'database' => [ + 'redis' => [ + 'options' => $sharedOptions, + 'cache' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'options' => $connectionOptions, + ], + ], + ], + ]))); + } + + /** + * Create a Redis store without resolving a live connection. + */ + private function redisStore(): RedisStore + { + $redis = m::mock(RedisFactory::class); + $redis->shouldNotReceive('connection'); + + return new RedisStore($redis, connection: 'cache'); + } + + /** + * Wrap a store in a cache repository. + */ + private function repository(Store $store): CacheRepository + { + return new Repository($store); + } +} From bcd2dc59f964d9847729d7f0b30976befea2508e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:46:24 +0000 Subject: [PATCH 14/35] build(auth): declare cache lifecycle dependencies Declare the cache and core packages used directly by Auth's model-cache validation and worker-start lifecycle integration. This keeps the subtree package installable without relying on monorepo-only dependency availability. --- src/auth/composer.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/auth/composer.json b/src/auth/composer.json index ad5033eb7..1a2665b0a 100644 --- a/src/auth/composer.json +++ b/src/auth/composer.json @@ -25,11 +25,13 @@ "require": { "php": "^8.4", "nesbot/carbon": "^3.13.1", + "hypervel/cache": "^0.4", "hypervel/collections": "^0.4", "hypervel/config": "^0.4", "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/core": "^0.4", "hypervel/database": "^0.4", "hypervel/hashing": "^0.4", "hypervel/http": "^0.4", From efbdba29973841a4c464edb1219f3b7151329b16 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:46:29 +0000 Subject: [PATCH 15/35] feat(auth): validate cached Eloquent providers Replace Auth's shallow store whitelist with the shared recursive model-cache validator before provider state or listeners mutate. Preserve tag validation order and cover supported, rejected, and direct-construction paths. --- src/auth/src/EloquentUserProvider.php | 65 ++++--------------- .../AuthEloquentUserProviderCacheTest.php | 54 +++++++++++++-- tests/Auth/AuthManagerTest.php | 4 ++ 3 files changed, 65 insertions(+), 58 deletions(-) diff --git a/src/auth/src/EloquentUserProvider.php b/src/auth/src/EloquentUserProvider.php index 3f7b8432b..af362bdb4 100755 --- a/src/auth/src/EloquentUserProvider.php +++ b/src/auth/src/EloquentUserProvider.php @@ -5,11 +5,7 @@ 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\Cache\ModelCacheStoreValidator; use Hypervel\Cache\TaggableStore; use Hypervel\Cache\TagMode; use Hypervel\Container\Container; @@ -25,22 +21,6 @@ class EloquentUserProvider implements UserProvider { - /** - * 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. * @@ -286,10 +266,9 @@ public function rehashPasswordIfRequired(UserContract $user, #[SensitiveParamete * ('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. + * The store is validated 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. * * Boot-only. User providers are held by cached guards; runtime use mutates * the provider used by every subsequent authentication lookup. @@ -304,8 +283,13 @@ public function enableCache( ?string $prefix = 'auth_users', ?array $tags = null, ): static { - $cache = Container::getInstance()->make('cache')->store($storeName); - $this->ensureSupportedAuthCacheStore($cache); + $container = Container::getInstance(); + $cache = $container->make('cache')->store($storeName); + + $container->make(ModelCacheStoreValidator::class)->validate( + $cache, + "Auth user cache for model [{$this->model}]", + ); if ($tags !== null && $tags !== []) { $this->ensureTaggableAnyModeStore($cache); @@ -398,33 +382,6 @@ public static function flushState(): void 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 - )); - } - /** * Ensure the resolved cache store supports tags in any-mode. * diff --git a/tests/Auth/AuthEloquentUserProviderCacheTest.php b/tests/Auth/AuthEloquentUserProviderCacheTest.php index fa9cc7fcf..c8b3d6c7c 100644 --- a/tests/Auth/AuthEloquentUserProviderCacheTest.php +++ b/tests/Auth/AuthEloquentUserProviderCacheTest.php @@ -11,10 +11,12 @@ use Hypervel\Cache\DatabaseStore; use Hypervel\Cache\FailoverStore; use Hypervel\Cache\FileStore; +use Hypervel\Cache\ModelCacheStoreValidator; use Hypervel\Cache\NullStore; use Hypervel\Cache\RedisStore; use Hypervel\Cache\SessionStore; use Hypervel\Cache\StackStore; +use Hypervel\Cache\StorageStore; use Hypervel\Cache\SwooleStore; use Hypervel\Cache\TagMode; use Hypervel\Container\Container; @@ -39,13 +41,18 @@ class AuthEloquentUserProviderCacheTest extends TestCase protected MockInterface $cacheManager; + protected MockInterface $storeValidator; + protected function setUp(): void { parent::setUp(); $container = Container::setInstance(new Container); $this->cacheManager = m::mock(CacheManager::class); + $this->storeValidator = m::mock(); + $this->storeValidator->shouldReceive('validate')->byDefault(); $container->instance('cache', $this->cacheManager); + $container->instance(ModelCacheStoreValidator::class, $this->storeValidator); } // ------------------------------------------------------------------ @@ -274,13 +281,16 @@ public function testCacheKeyAlwaysIncludesFqcnEvenWithCustomResolver() } // ------------------------------------------------------------------ - // Supported-store whitelist + // Model cache store validation // ------------------------------------------------------------------ #[DataProvider('supportedStoreProvider')] public function testEnableCacheAcceptsSupportedStores(string $storeClass) { - $this->stubCache($storeClass); + $repo = $this->stubCache($storeClass); + $this->storeValidator->shouldReceive('validate') + ->once() + ->with($repo, 'Auth user cache for model [' . self::MODEL . ']'); $provider = $this->providerWithoutDbFetch(); $provider->enableCache(null); @@ -297,6 +307,7 @@ public static function supportedStoreProvider(): iterable yield 'Redis' => [RedisStore::class]; yield 'Database' => [DatabaseStore::class]; yield 'File' => [FileStore::class]; + yield 'Storage' => [StorageStore::class]; yield 'Swoole' => [SwooleStore::class]; yield 'Stack' => [StackStore::class]; } @@ -304,7 +315,11 @@ public static function supportedStoreProvider(): iterable #[DataProvider('unsupportedStoreProvider')] public function testEnableCacheRejectsUnsupportedStores(string $storeClass) { - $this->stubCache($storeClass); + $repo = $this->stubCache($storeClass); + $this->storeValidator->shouldReceive('validate') + ->once() + ->with($repo, 'Auth user cache for model [' . self::MODEL . ']') + ->andThrow(new InvalidArgumentException('does not support cache store')); $provider = $this->providerWithoutDbFetch(); @@ -324,7 +339,11 @@ public static function unsupportedStoreProvider(): iterable public function testEnableCacheLeavesProviderInDisabledStateWhenValidationFails() { - $this->stubCache(ArrayStore::class); + $repo = $this->stubCache(ArrayStore::class); + $this->storeValidator->shouldReceive('validate') + ->once() + ->with($repo, 'Auth user cache for model [' . self::MODEL . ']') + ->andThrow(new InvalidArgumentException('does not support cache store')); $user = m::mock(Authenticatable::class); $provider = $this->providerExpectingDbFetch($user, 42); @@ -348,6 +367,33 @@ public function testEnableCacheLeavesProviderInDisabledStateWhenValidationFails( $this->assertSame($user, $provider->retrieveById(42)); } + public function testModelStoreValidationRunsBeforeAuthTagValidation(): void + { + $sequence = []; + $store = m::mock(RedisStore::class); + $store->shouldReceive('supportsTags') + ->once() + ->andReturnUsing(function () use (&$sequence): bool { + $sequence[] = 'tags'; + + return true; + }); + $store->shouldReceive('getTagMode')->once()->andReturn(TagMode::Any); + $repo = m::mock(CacheRepository::class); + $repo->shouldReceive('getStore')->andReturn($store); + $this->cacheManager->shouldReceive('store')->once()->with(null)->andReturn($repo); + $this->storeValidator->shouldReceive('validate') + ->once() + ->with($repo, 'Auth user cache for model [' . self::MODEL . ']') + ->andReturnUsing(function () use (&$sequence): void { + $sequence[] = 'model'; + }); + + $this->providerWithoutDbFetch()->enableCache(null, tags: ['auth_users']); + + $this->assertSame(['model', 'tags'], $sequence); + } + // ------------------------------------------------------------------ // Manual invalidation // ------------------------------------------------------------------ diff --git a/tests/Auth/AuthManagerTest.php b/tests/Auth/AuthManagerTest.php index ca8e82d72..c327738b8 100644 --- a/tests/Auth/AuthManagerTest.php +++ b/tests/Auth/AuthManagerTest.php @@ -13,6 +13,7 @@ use Hypervel\Auth\Middleware\RedirectIfAuthenticated; use Hypervel\Auth\RequestGuard; use Hypervel\Cache\CacheManager; +use Hypervel\Cache\ModelCacheStoreValidator; use Hypervel\Cache\RedisStore; use Hypervel\Config\Repository; use Hypervel\Container\Container; @@ -719,6 +720,9 @@ protected function getContainer(array $authConfig = []): Container $container->instance('config', new Repository([ 'auth' => $authConfig, ])); + $validator = m::mock(ModelCacheStoreValidator::class); + $validator->allows('validate'); + $container->instance(ModelCacheStoreValidator::class, $validator); return $container; } From a5034d8aa2b87016dd90b9823f68da20f4133097 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:46:37 +0000 Subject: [PATCH 16/35] feat(auth): register cached user models at startup Contribute enabled Eloquent provider models and stock Eloquent graph containers to the global class policy. Validate every configured cached provider after application boot or worker config reload without resolving guards or adding request-path work. --- src/auth/src/AuthServiceProvider.php | 126 +++++++++ tests/Auth/AuthServiceProviderTest.php | 339 +++++++++++++++++++++++++ 2 files changed, 465 insertions(+) create mode 100644 tests/Auth/AuthServiceProviderTest.php diff --git a/src/auth/src/AuthServiceProvider.php b/src/auth/src/AuthServiceProvider.php index 14c81a254..9b46be2bf 100755 --- a/src/auth/src/AuthServiceProvider.php +++ b/src/auth/src/AuthServiceProvider.php @@ -6,10 +6,19 @@ use Hypervel\Auth\Access\Gate; use Hypervel\Auth\Console\ClearResetsCommand; +use Hypervel\Cache\CacheManager; +use Hypervel\Cache\ModelCacheStoreValidator; use Hypervel\Contracts\Auth\Access\Gate as GateContract; use Hypervel\Contracts\Auth\Authenticatable as AuthenticatableContract; +use Hypervel\Contracts\Config\Repository as ConfigRepository; +use Hypervel\Core\Events\AfterWorkerStart; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\MorphPivot; +use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Http\Request; use Hypervel\Support\ServiceProvider; +use InvalidArgumentException; class AuthServiceProvider extends ServiceProvider { @@ -26,6 +35,123 @@ public function register(): void $this->commands([ClearResetsCommand::class]); } + /** + * Bootstrap the service provider. + */ + public function boot(): void + { + $cache = $this->app->make(CacheManager::class); + $config = $this->app->make(ConfigRepository::class); + + // boot() runs after every provider has registered the cache binding. + $cache->allowSerializableClassesUsing(function () use ($config): array { + $models = []; + + foreach ($this->cachedEloquentProviders($config) as $settings) { + $models[] = $settings['model']; + } + + return $models === [] ? [] : [ + ...$models, + EloquentCollection::class, + Pivot::class, + MorphPivot::class, + ]; + }); + + if ($this->app->runningInConsole()) { + $this->app->booted( + fn () => $this->validateCachedEloquentProviders($cache, $config), + ); + + return; + } + + // Worker configuration is reloaded during BeforeWorkerStart. + $events = $this->app->make('events'); + $events->listen(AfterWorkerStart::class, function (AfterWorkerStart $event): void { + $this->validateCachedEloquentProviders( + $this->app->make(CacheManager::class), + $this->app->make(ConfigRepository::class), + ); + }); + } + + /** + * Get cache-enabled Eloquent provider settings. + * + * @return list, + * store: ?string + * }> + */ + private function cachedEloquentProviders(ConfigRepository $config): array + { + $providers = []; + + foreach ($config->array('auth.providers') as $name => $provider) { + if (! is_array($provider) || ($provider['driver'] ?? null) !== 'eloquent') { + continue; + } + + $cache = $provider['cache'] ?? null; + + if (! is_array($cache) || empty($cache['enabled'])) { + continue; + } + + $model = $provider['model'] ?? null; + + if (! is_string($model) + || ! is_a($model, Model::class, true) + || ! is_a($model, AuthenticatableContract::class, true)) { + throw new InvalidArgumentException( + sprintf('Authentication provider [%s] model must be an Eloquent authenticatable class.', $name), + ); + } + + $store = $cache['store'] ?? null; + + if (! is_string($store) && $store !== null) { + throw new InvalidArgumentException( + sprintf('Authentication provider [%s] cache store must be a string or null.', $name), + ); + } + + $providers[] = [ + 'name' => (string) $name, + 'model' => $model, + 'store' => $store, + ]; + } + + return $providers; + } + + /** + * Validate every configured cached Eloquent provider. + */ + private function validateCachedEloquentProviders( + CacheManager $cache, + ConfigRepository $config, + ): void { + $providers = $this->cachedEloquentProviders($config); + + if ($providers === []) { + return; + } + + $validator = $this->app->make(ModelCacheStoreValidator::class); + + foreach ($providers as $settings) { + $validator->validate( + $cache->store($settings['store']), + "Auth user provider [{$settings['name']}]", + ); + } + } + /** * Register the authenticator services. */ diff --git a/tests/Auth/AuthServiceProviderTest.php b/tests/Auth/AuthServiceProviderTest.php new file mode 100644 index 000000000..4e417a40f --- /dev/null +++ b/tests/Auth/AuthServiceProviderTest.php @@ -0,0 +1,339 @@ + [ + 'providers' => [ + 'users' => $this->cachedProvider(AuthProviderUser::class), + 'admins' => $this->cachedProvider(AuthProviderAdmin::class, enabled: 'yes', store: 'redis'), + 'disabled' => $this->cachedProvider(AuthProviderUser::class, enabled: false), + 'database' => [ + 'driver' => 'database', + 'model' => InvalidAuthProviderModel::class, + 'cache' => ['enabled' => true], + ], + 'malformed', + ], + ], + ]); + $resolver = null; + $manager = m::mock(CacheManager::class); + $manager->shouldReceive('allowSerializableClassesUsing') + ->once() + ->with(m::on(function (mixed $callback) use (&$resolver): bool { + $resolver = $callback; + + return $callback instanceof Closure; + })) + ->andReturnSelf(); + $application = $this->consoleApplication($manager, $config); + $application->shouldReceive('booted')->once(); + + (new AuthServiceProvider($application))->boot(); + + $this->assertInstanceOf(Closure::class, $resolver); + $this->assertSame([ + AuthProviderUser::class, + AuthProviderAdmin::class, + EloquentCollection::class, + Pivot::class, + MorphPivot::class, + ], $resolver()); + } + + public function testConsoleStartupValidatesEveryEnabledProviderUsingCapturedDependencies(): void + { + $config = new ConfigRepository([ + 'auth' => [ + 'providers' => [ + 0 => $this->cachedProvider(AuthProviderUser::class), + '' => $this->cachedProvider(AuthProviderAdmin::class, store: 'redis'), + ], + ], + ]); + $defaultRepository = m::mock(CacheRepository::class); + $redisRepository = m::mock(CacheRepository::class); + $manager = m::mock(CacheManager::class); + $manager->shouldReceive('allowSerializableClassesUsing')->once()->andReturnSelf(); + $manager->shouldReceive('store')->once()->with(null)->andReturn($defaultRepository); + $manager->shouldReceive('store')->once()->with('redis')->andReturn($redisRepository); + $validator = m::mock(ModelCacheStoreValidator::class); + $validator->shouldReceive('validate') + ->once() + ->with($defaultRepository, 'Auth user provider [0]'); + $validator->shouldReceive('validate') + ->once() + ->with($redisRepository, 'Auth user provider []'); + $bootedCallback = null; + $application = $this->consoleApplication($manager, $config); + $application->shouldReceive('make') + ->once() + ->with(ModelCacheStoreValidator::class) + ->andReturn($validator); + $application->shouldReceive('booted') + ->once() + ->with(m::on(function (mixed $callback) use (&$bootedCallback): bool { + $bootedCallback = $callback; + + return $callback instanceof Closure; + })); + + (new AuthServiceProvider($application))->boot(); + + $this->assertInstanceOf(Closure::class, $bootedCallback); + $bootedCallback(); + } + + public function testDisabledProvidersResolveNeitherAStoreNorTheValidator(): void + { + $config = new ConfigRepository([ + 'auth' => [ + 'providers' => [ + 'users' => $this->cachedProvider(AuthProviderUser::class, enabled: false), + 'custom' => [ + 'driver' => 'custom', + 'cache' => ['enabled' => true], + ], + ], + ], + ]); + $manager = m::mock(CacheManager::class); + $manager->shouldReceive('allowSerializableClassesUsing')->once()->andReturnSelf(); + $manager->shouldNotReceive('store'); + $bootedCallback = null; + $application = $this->consoleApplication($manager, $config); + $application->shouldNotReceive('make')->with(ModelCacheStoreValidator::class); + $application->shouldReceive('booted') + ->once() + ->with(m::on(function (mixed $callback) use (&$bootedCallback): bool { + $bootedCallback = $callback; + + return $callback instanceof Closure; + })); + + (new AuthServiceProvider($application))->boot(); + + $this->assertSame([], $this->capturedResolver($manager)()); + $this->assertInstanceOf(Closure::class, $bootedCallback); + $bootedCallback(); + } + + public function testServerValidationUsesWorkerDependenciesAndRunsForTaskworkers(): void + { + $masterConfig = new ConfigRepository([ + 'auth' => ['providers' => []], + ]); + $workerConfig = new ConfigRepository([ + 'auth' => [ + 'providers' => [ + 'users' => $this->cachedProvider(AuthProviderUser::class, store: 'worker'), + ], + ], + ]); + $masterManager = m::mock(CacheManager::class); + $masterManager->shouldReceive('allowSerializableClassesUsing')->once()->andReturnSelf(); + $masterManager->shouldNotReceive('store'); + $workerRepository = m::mock(CacheRepository::class); + $workerManager = m::mock(CacheManager::class); + $workerManager->shouldReceive('store')->once()->with('worker')->andReturn($workerRepository); + $validator = m::mock(ModelCacheStoreValidator::class); + $validator->shouldReceive('validate') + ->once() + ->with($workerRepository, 'Auth user provider [users]'); + $listener = null; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('listen') + ->once() + ->with(AfterWorkerStart::class, m::on(function (mixed $callback) use (&$listener): bool { + $listener = $callback; + + return $callback instanceof Closure; + })); + $application = m::mock(Application::class); + $application->shouldReceive('make') + ->twice() + ->with(CacheManager::class) + ->andReturn($masterManager, $workerManager); + $application->shouldReceive('make') + ->twice() + ->with(ConfigRepositoryContract::class) + ->andReturn($masterConfig, $workerConfig); + $application->shouldReceive('make')->once()->with('events')->andReturn($events); + $application->shouldReceive('make') + ->once() + ->with(ModelCacheStoreValidator::class) + ->andReturn($validator); + $application->shouldReceive('runningInConsole')->once()->andReturnFalse(); + $application->shouldNotReceive('booted'); + + (new AuthServiceProvider($application))->boot(); + + $this->assertInstanceOf(Closure::class, $listener); + $server = m::mock(SwooleServer::class); + $server->taskworker = true; + $listener(new AfterWorkerStart($server, 8)); + } + + public function testSelectedProviderRequiresAnEloquentAuthenticatableModel(): void + { + $config = new ConfigRepository([ + 'auth' => [ + 'providers' => [ + 'users' => $this->cachedProvider(InvalidAuthProviderModel::class), + ], + ], + ]); + $manager = m::mock(CacheManager::class); + $resolver = $this->bootAndCaptureResolver($manager, $config); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Authentication provider [users] model must be an Eloquent authenticatable class.'); + + $resolver(); + } + + public function testSelectedProviderRequiresAStringOrNullStore(): void + { + $provider = $this->cachedProvider(AuthProviderUser::class); + $provider['cache']['store'] = []; + $config = new ConfigRepository([ + 'auth' => [ + 'providers' => [ + 'users' => $provider, + ], + ], + ]); + $manager = m::mock(CacheManager::class); + $resolver = $this->bootAndCaptureResolver($manager, $config); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Authentication provider [users] cache store must be a string or null.'); + + $resolver(); + } + + /** + * Create a cache-enabled Eloquent provider configuration. + * + * @param class-string $model + * @return array + */ + private function cachedProvider( + string $model, + bool|string $enabled = true, + ?string $store = null, + ): array { + return [ + 'driver' => 'eloquent', + 'model' => $model, + 'cache' => [ + 'enabled' => $enabled, + 'store' => $store, + ], + ]; + } + + /** + * Create a console application double for provider boot. + */ + private function consoleApplication( + CacheManager $manager, + ConfigRepositoryContract $config, + ): Application|m\MockInterface { + $application = m::mock(Application::class); + $application->shouldReceive('make') + ->once() + ->with(CacheManager::class) + ->andReturn($manager); + $application->shouldReceive('make') + ->once() + ->with(ConfigRepositoryContract::class) + ->andReturn($config); + $application->shouldReceive('runningInConsole')->once()->andReturnTrue(); + + return $application; + } + + /** + * Boot the provider and capture its class resolver. + */ + private function bootAndCaptureResolver( + CacheManager|m\MockInterface $manager, + ConfigRepositoryContract $config, + ): Closure { + $resolver = null; + $manager->shouldReceive('allowSerializableClassesUsing') + ->once() + ->with(m::on(function (mixed $callback) use (&$resolver): bool { + $resolver = $callback; + + return $callback instanceof Closure; + })) + ->andReturnSelf(); + $application = $this->consoleApplication($manager, $config); + $application->shouldReceive('booted')->once(); + + (new AuthServiceProvider($application))->boot(); + + $this->assertInstanceOf(Closure::class, $resolver); + + return $resolver; + } + + /** + * Capture the registered class resolver from a manager mock. + */ + private function capturedResolver(CacheManager|m\MockInterface $manager): Closure + { + $resolver = null; + $manager->shouldHaveReceived('allowSerializableClassesUsing') + ->with(m::on(function (mixed $callback) use (&$resolver): bool { + $resolver = $callback; + + return $callback instanceof Closure; + })) + ->once(); + + $this->assertInstanceOf(Closure::class, $resolver); + + return $resolver; + } +} + +class AuthProviderUser extends User +{ +} + +class AuthProviderAdmin extends User +{ +} + +class InvalidAuthProviderModel +{ +} From 8b4fa4e052cc044065de505b4e69189d4247c086 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:46:44 +0000 Subject: [PATCH 17/35] test(auth): cover serialized user graph caching Exercise real serialized cache hits with automatic root-model contributions, explicit application classes, and denied nested relations. Pin zero-query hot reads and PHP's incomplete-class behavior for undeclared graph members. --- .../Auth/EloquentUserProviderCacheTest.php | 353 ++++++++++++++++-- 1 file changed, 331 insertions(+), 22 deletions(-) diff --git a/tests/Integration/Auth/EloquentUserProviderCacheTest.php b/tests/Integration/Auth/EloquentUserProviderCacheTest.php index 99efadcdc..ccf95e33e 100644 --- a/tests/Integration/Auth/EloquentUserProviderCacheTest.php +++ b/tests/Integration/Auth/EloquentUserProviderCacheTest.php @@ -4,14 +4,25 @@ namespace Hypervel\Tests\Integration\Auth\EloquentUserProviderCacheTest; +use __PHP_Incomplete_Class; use Closure; +use Error; use Hypervel\Auth\EloquentUserProvider; use Hypervel\Cache\CacheManager; -use Hypervel\Cache\RedisStore; +use Hypervel\Cache\FileStore; use Hypervel\Contracts\Cache\Repository as CacheRepository; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Database\Eloquent\Builder; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Eloquent\Relations\HasOne; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Foundation\Auth\User; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Support\Facades\DB; +use Hypervel\Support\ServiceProvider; +use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\Attributes\WithMigration; use Hypervel\Testbench\TestCase; use Mockery as m; @@ -25,20 +36,83 @@ class EloquentUserProviderCacheTest extends TestCase protected const string DEFAULT_KEY_PREFIX = 'auth_users'; + protected CacheManager $realCacheManager; + protected MockInterface $cacheManager; protected function setUp(): void { parent::setUp(); - // Swap the cache manager with a Mockery double so we can verify - // get()/put()/forget() calls without a real backend. The mock store - // returned by the manager is a RedisStore instance, which passes - // the supported-stores whitelist. + $this->realCacheManager = $this->app->make(CacheManager::class); $this->cacheManager = m::mock(CacheManager::class); $this->app->instance('cache', $this->cacheManager); } + protected function tearDown(): void + { + try { + $this->realCacheManager->store('auth-file')->flush(); + } finally { + parent::tearDown(); + } + } + + protected function getPackageProviders(ApplicationContract $app): array + { + return [ + AuthCacheTestServiceProvider::class, + ]; + } + + protected function defineEnvironment(ApplicationContract $app): void + { + parent::defineEnvironment($app); + + $config = $app->make('config'); + $configuredClasses = $config->get('cache.testing_serializable_classes'); + + $config->set([ + 'cache.default' => 'auth-file', + 'cache.serializable_classes' => is_array($configuredClasses) + ? $configuredClasses + : false, + 'cache.stores.auth-file' => [ + 'driver' => 'file', + 'path' => $app->storagePath('framework/cache/data/auth'), + ], + 'auth.providers.users' => [ + 'driver' => 'eloquent', + 'model' => User::class, + 'cache' => [ + 'enabled' => true, + 'store' => 'auth-file', + ], + ], + 'auth.providers.relationship_users' => [ + 'driver' => 'eloquent', + 'model' => AuthCacheUser::class, + 'cache' => [ + 'enabled' => true, + 'store' => 'auth-file', + ], + ], + ]); + } + + protected function allowConfiguredSerializableClass(ApplicationContract $app): void + { + $app->make('config')->set( + 'cache.testing_serializable_classes', + [AuthConfiguredSerializableClass::class], + ); + } + + protected function allowApplicationRelationClasses(ApplicationContract $app): void + { + $app->make('config')->set('cache.testing_allow_relation_classes', true); + } + protected function afterRefreshingDatabase(): void { User::forceCreate([ @@ -52,7 +126,7 @@ protected function afterRefreshingDatabase(): void // Cache invalidation — model events // ------------------------------------------------------------------ - public function testCacheIsClearedOnUserSave() + public function testCacheIsClearedOnUserSave(): void { $user = User::query()->first(); $expectedKey = $this->buildKey($user->getAuthIdentifier()); @@ -66,7 +140,7 @@ public function testCacheIsClearedOnUserSave() $user->save(); } - public function testCacheIsClearedOnUserDelete() + public function testCacheIsClearedOnUserDelete(): void { $user = User::query()->first(); $expectedKey = $this->buildKey($user->getAuthIdentifier()); @@ -79,7 +153,7 @@ public function testCacheIsClearedOnUserDelete() $user->delete(); } - public function testDescriptorsDedupeOnIdenticalConfig() + public function testDescriptorsDedupeOnIdenticalConfig(): void { $this->stubCache(); @@ -93,7 +167,7 @@ public function testDescriptorsDedupeOnIdenticalConfig() $this->assertCount(1, $descriptors[User::class]); } - public function testModelEventInvalidatesAllDescriptorsForSameModel() + public function testModelEventInvalidatesAllDescriptorsForSameModel(): void { // Two distinct provider configurations for the same model should // produce two descriptors; saving the user should clear both keys. @@ -107,17 +181,17 @@ public function testModelEventInvalidatesAllDescriptorsForSameModel() $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 = new EloquentUserProvider($this->app->make('hash'), User::class); $providerA->enableCache('redis-a'); - $providerB = new EloquentUserProvider($this->app['hash'], User::class); + $providerB = new EloquentUserProvider($this->app->make('hash'), User::class); $providerB->enableCache('redis-b', 300, 'admin_users'); $user->name = 'Updated'; $user->save(); } - public function testModelEventListenersRegisteredOnlyOnce() + public function testModelEventListenersRegisteredOnlyOnce(): void { // Two distinct providers with different configs. If the save/deleted // listeners were attached per-enableCache, the single save below would @@ -133,10 +207,10 @@ public function testModelEventListenersRegisteredOnlyOnce() $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 = new EloquentUserProvider($this->app->make('hash'), User::class); $providerA->enableCache('redis-a'); - $providerB = new EloquentUserProvider($this->app['hash'], User::class); + $providerB = new EloquentUserProvider($this->app->make('hash'), User::class); $providerB->enableCache('redis-b', 300, 'admin_users'); $user->name = 'Updated'; @@ -147,7 +221,7 @@ public function testModelEventListenersRegisteredOnlyOnce() // Cache invalidation — provider writes // ------------------------------------------------------------------ - public function testUpdateRememberTokenClearsCache() + public function testUpdateRememberTokenClearsCache(): void { $user = User::query()->first(); $expectedKey = $this->buildKey($user->getAuthIdentifier()); @@ -160,7 +234,7 @@ public function testUpdateRememberTokenClearsCache() $provider->updateRememberToken($user, 'new-remember-token'); } - public function testRehashPasswordClearsCache() + public function testRehashPasswordClearsCache(): void { $user = User::query()->first(); $expectedKey = $this->buildKey($user->getAuthIdentifier()); @@ -177,7 +251,7 @@ public function testRehashPasswordClearsCache() // Dispatcher ordering // ------------------------------------------------------------------ - public function testEnableCacheSkipsListenerRegistrationWhenDispatcherAbsent() + public function testEnableCacheSkipsListenerRegistrationWhenDispatcherAbsent(): void { $this->stubCache(); @@ -186,7 +260,7 @@ public function testEnableCacheSkipsListenerRegistrationWhenDispatcherAbsent() // $cacheEventsRegistered untouched for this model. Model::unsetEventDispatcher(); - $provider = new EloquentUserProvider($this->app['hash'], User::class); + $provider = new EloquentUserProvider($this->app->make('hash'), User::class); $provider->enableCache(null); $reflection = new ReflectionClass(EloquentUserProvider::class); @@ -201,7 +275,7 @@ public function testEnableCacheSkipsListenerRegistrationWhenDispatcherAbsent() // withQuery() compatibility // ------------------------------------------------------------------ - public function testRetrieveByIdCachesResultWithEagerLoadedRelations() + public function testRetrieveByIdCachesResultWithEagerLoadedRelations(): void { // A withQuery callback that runs during the DB fetch should affect // the first (cache-miss) retrieval. Subsequent calls hit the cache @@ -219,7 +293,7 @@ public function testRetrieveByIdCachesResultWithEagerLoadedRelations() ); $withQueryInvocations = 0; - $provider = new EloquentUserProvider($this->app['hash'], User::class); + $provider = new EloquentUserProvider($this->app->make('hash'), User::class); $provider->enableCache(null); $provider->withQuery(function ($builder) use (&$withQueryInvocations): void { ++$withQueryInvocations; @@ -233,22 +307,197 @@ public function testRetrieveByIdCachesResultWithEagerLoadedRelations() $this->assertSame(1, $withQueryInvocations, 'withQuery callback should run only on the cache-miss fetch'); } + public function testAutomaticProviderClassCachesRootUserWithoutASecondQuery(): void + { + $provider = $this->makeRealCachedProvider(); + $user = User::query()->firstOrFail(); + + DB::enableQueryLog(); + + $first = $provider->retrieveById($user->getAuthIdentifier()); + + $this->assertInstanceOf(User::class, $first); + $this->assertSame(1, $this->countQueriesForTable('users')); + + DB::flushQueryLog(); + + $second = $provider->retrieveById($user->getAuthIdentifier()); + + $this->assertInstanceOf(User::class, $second); + $this->assertNotSame($first, $second); + $this->assertSame(0, $this->countQueriesForTable('users')); + } + + #[DefineEnvironment('allowConfiguredSerializableClass')] + public function testConfiguredClassesUnionWithAutomaticProviderClasses(): void + { + $this->app->instance('cache', $this->realCacheManager); + $store = $this->realCacheManager->build([ + 'driver' => 'array', + 'serialize' => true, + ]); + $store->put('objects', [ + new User, + new AuthConfiguredSerializableClass, + ], 60); + + $objects = $store->get('objects'); + + $this->assertInstanceOf(User::class, $objects[0]); + $this->assertInstanceOf(AuthConfiguredSerializableClass::class, $objects[1]); + } + + public function testUndeclaredToOneRelationIsIncompleteAndOmittedFromArrayOutput(): void + { + $this->createRelationTables(); + $user = AuthCacheUser::query()->firstOrFail(); + AuthCacheProfile::forceCreate([ + 'user_id' => $user->getKey(), + 'name' => 'Profile', + ]); + $provider = $this->makeRealCachedProvider(AuthCacheUser::class); + $provider->withQuery( + static fn (Builder $query): Builder => $query->with('profile'), + ); + + $first = $provider->retrieveById($user->getAuthIdentifier()); + $second = $provider->retrieveById($user->getAuthIdentifier()); + + $this->assertInstanceOf(AuthCacheProfile::class, $first?->getRelation('profile')); + $this->assertInstanceOf(AuthCacheUser::class, $second); + $this->assertTrue($second->relationLoaded('profile')); + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $second->getRelation('profile')); + + try { + $second->getRelation('profile')->getKey(); + $this->fail('Expected incomplete relation access to fail.'); + } catch (Error $exception) { + $this->assertStringContainsString(AuthCacheProfile::class, $exception->getMessage()); + } + + $this->assertArrayNotHasKey('profile', $second->toArray()); + } + + public function testUndeclaredToManyModelsRemainIncompleteInsideAutomaticCollection(): void + { + $this->createRelationTables(); + $user = AuthCacheUser::query()->firstOrFail(); + AuthCachePost::forceCreate([ + 'user_id' => $user->getKey(), + 'title' => 'First', + ]); + AuthCachePost::forceCreate([ + 'user_id' => $user->getKey(), + 'title' => 'Second', + ]); + $provider = $this->makeRealCachedProvider(AuthCacheUser::class); + $provider->withQuery( + static fn (Builder $query): Builder => $query->with('posts'), + ); + + $first = $provider->retrieveById($user->getAuthIdentifier()); + $second = $provider->retrieveById($user->getAuthIdentifier()); + + $this->assertInstanceOf(EloquentCollection::class, $first?->getRelation('posts')); + $this->assertInstanceOf(AuthCachePost::class, $first->getRelation('posts')->first()); + $this->assertInstanceOf(AuthCacheUser::class, $second); + $this->assertInstanceOf(EloquentCollection::class, $second->getRelation('posts')); + $this->assertContainsOnlyInstancesOf( + __PHP_Incomplete_Class::class, + $second->getRelation('posts')->all(), + ); + } + + #[DefineEnvironment('allowApplicationRelationClasses')] + public function testDeclaredApplicationRelationsRoundTripCompletely(): void + { + $this->createRelationTables(); + $user = AuthCacheUser::query()->firstOrFail(); + AuthCacheProfile::forceCreate([ + 'user_id' => $user->getKey(), + 'name' => 'Profile', + ]); + AuthCachePost::forceCreate([ + 'user_id' => $user->getKey(), + 'title' => 'Post', + ]); + $provider = $this->makeRealCachedProvider(AuthCacheUser::class); + $provider->withQuery( + static fn (Builder $query): Builder => $query->with(['profile', 'posts']), + ); + + $provider->retrieveById($user->getAuthIdentifier()); + $cached = $provider->retrieveById($user->getAuthIdentifier()); + + $this->assertInstanceOf(AuthCacheUser::class, $cached); + $this->assertInstanceOf(AuthCacheProfile::class, $cached->getRelation('profile')); + $this->assertInstanceOf(EloquentCollection::class, $cached->getRelation('posts')); + $this->assertInstanceOf(AuthCachePost::class, $cached->getRelation('posts')->first()); + } + // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ protected function makeCachedProvider(): EloquentUserProvider { - $provider = new EloquentUserProvider($this->app['hash'], User::class); + $provider = new EloquentUserProvider($this->app->make('hash'), User::class); $provider->enableCache(null); return $provider; } + /** + * Create a provider backed by the real serializing cache manager. + * + * @param class-string<\Hypervel\Contracts\Auth\Authenticatable&Model> $model + */ + protected function makeRealCachedProvider(string $model = User::class): EloquentUserProvider + { + $this->app->instance('cache', $this->realCacheManager); + + $provider = new EloquentUserProvider($this->app->make('hash'), $model); + $provider->enableCache('auth-file'); + + return $provider; + } + + /** + * Create the relation tables used by serialization tests. + */ + protected function createRelationTables(): void + { + $schema = $this->app->make('db')->connection()->getSchemaBuilder(); + + $schema->create('auth_cache_profiles', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('user_id'); + $table->string('name'); + }); + + $schema->create('auth_cache_posts', function (Blueprint $table): void { + $table->increments('id'); + $table->unsignedInteger('user_id'); + $table->string('title'); + }); + } + + /** + * Count logged select queries for a table. + */ + protected function countQueriesForTable(string $table): int + { + return count(array_filter( + DB::getQueryLog(), + fn (array $query): bool => str_starts_with(strtolower($query['query'] ?? ''), 'select') + && str_contains($query['query'] ?? '', $table) + )); + } + protected function stubCache(?string $name = null): MockInterface { $repo = m::mock(CacheRepository::class); - $repo->shouldReceive('getStore')->andReturn(m::mock(RedisStore::class)); + $repo->shouldReceive('getStore')->andReturn(m::mock(FileStore::class)); $this->cacheManager->shouldReceive('store')->with($name)->andReturn($repo); return $repo; @@ -259,3 +508,63 @@ protected function buildKey(mixed $identifier): string return self::DEFAULT_KEY_PREFIX . ':' . User::class . ':' . $identifier; } } + +class AuthCacheTestServiceProvider extends ServiceProvider +{ + /** + * Bootstrap the test service provider. + */ + public function boot(): void + { + $config = $this->app->make('config'); + + $this->app->make(CacheManager::class)->allowSerializableClassesUsing( + static fn (): array => $config->get('cache.testing_allow_relation_classes') === true + ? [AuthCacheProfile::class, AuthCachePost::class] + : [], + ); + } +} + +class AuthCacheUser extends User +{ + protected ?string $table = 'users'; + + /** + * Get the user's profile. + */ + public function profile(): HasOne + { + return $this->hasOne(AuthCacheProfile::class, 'user_id'); + } + + /** + * Get the user's posts. + */ + public function posts(): HasMany + { + return $this->hasMany(AuthCachePost::class, 'user_id'); + } +} + +class AuthCacheProfile extends Model +{ + protected ?string $table = 'auth_cache_profiles'; + + protected array $guarded = []; + + public bool $timestamps = false; +} + +class AuthCachePost extends Model +{ + protected ?string $table = 'auth_cache_posts'; + + protected array $guarded = []; + + public bool $timestamps = false; +} + +class AuthConfiguredSerializableClass +{ +} From 6c62d3f02dab926ef29c670988b1896401c89581 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:46:50 +0000 Subject: [PATCH 18/35] test(auth): cover automatic policy with cache tags Remove the manual user-model allowlist from tagged Auth caching so the integration suite proves provider contributions work through taggable stores. Retain cache-hit and invalidation coverage. --- .../EloquentUserProviderCacheTagsTest.php | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php b/tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php index ed79b0b3c..0f1b5d6af 100644 --- a/tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php +++ b/tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php @@ -41,12 +41,24 @@ class EloquentUserProviderCacheTagsTest extends TestCase protected function defineEnvironment(ApplicationContract $app): void { - $app->make('config')->set('cache.serializable_classes', [User::class]); - $app->make('config')->set('cache.stores.' . self::STORE_NAME, [ - 'driver' => 'redis', - 'connection' => 'default', - 'prefix' => self::STORE_PREFIX, - 'tag_mode' => 'any', + parent::defineEnvironment($app); + + $app->make('config')->set([ + 'cache.serializable_classes' => false, + 'cache.stores.' . self::STORE_NAME => [ + 'driver' => 'redis', + 'connection' => 'default', + 'prefix' => self::STORE_PREFIX, + 'tag_mode' => 'any', + ], + 'auth.providers.users' => [ + 'driver' => 'eloquent', + 'model' => User::class, + 'cache' => [ + 'enabled' => true, + 'store' => self::STORE_NAME, + ], + ], ]); } @@ -247,7 +259,7 @@ public function testDynamicTagsLetAppFlushNarrower(): void protected function makeCachedProviderWithTags(array $tags = [self::PRIMARY_TAG]): EloquentUserProvider { - $provider = new EloquentUserProvider($this->app['hash'], User::class); + $provider = new EloquentUserProvider($this->app->make('hash'), User::class); $provider->enableCache(self::STORE_NAME, tags: $tags); return $provider; From f2ec6713ca526124da9bb33fde6a80724f0f123f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:46:55 +0000 Subject: [PATCH 19/35] test(auth): cover native Redis model serializers Round-trip cached Eloquent users through Redis with policy-owned serialization and each available type-preserving native serializer. Verify every accepted mode returns a concrete model without a second database query. --- .../EloquentUserProviderRedisCacheTest.php | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tests/Integration/Auth/EloquentUserProviderRedisCacheTest.php diff --git a/tests/Integration/Auth/EloquentUserProviderRedisCacheTest.php b/tests/Integration/Auth/EloquentUserProviderRedisCacheTest.php new file mode 100644 index 000000000..e09cfefce --- /dev/null +++ b/tests/Integration/Auth/EloquentUserProviderRedisCacheTest.php @@ -0,0 +1,193 @@ +make('config'); + $database = $this->getParallelRedisDb(); + $connection = $config->array('database.redis.default'); + $connections = [ + 'auth-none' => $this->redisConnection($connection, $database, PhpRedis::SERIALIZER_NONE), + 'auth-php' => $this->redisConnection($connection, $database, PhpRedis::SERIALIZER_PHP), + ]; + $stores = [ + 'auth-redis-none' => $this->redisStore('auth-none', 'auth:none:'), + 'auth-redis-php' => $this->redisStore('auth-php', 'auth:php:'), + ]; + + if (defined('Redis::SERIALIZER_IGBINARY')) { + $connections['auth-igbinary'] = $this->redisConnection( + $connection, + $database, + (int) constant('Redis::SERIALIZER_IGBINARY'), + ); + $stores['auth-redis-igbinary'] = $this->redisStore( + 'auth-igbinary', + 'auth:igbinary:', + ); + } + + if (defined('Redis::SERIALIZER_MSGPACK')) { + $connections['auth-msgpack'] = $this->redisConnection( + $connection, + $database, + (int) constant('Redis::SERIALIZER_MSGPACK'), + ); + $stores['auth-redis-msgpack'] = $this->redisStore( + 'auth-msgpack', + 'auth:msgpack:', + ); + } + + $config->set([ + 'database.redis' => array_replace( + $config->array('database.redis'), + $connections, + ), + 'cache.serializable_classes' => false, + 'cache.stores' => array_replace( + $config->array('cache.stores'), + $stores, + ), + 'auth.providers.users' => [ + 'driver' => 'eloquent', + 'model' => User::class, + 'cache' => [ + 'enabled' => true, + 'store' => 'auth-redis-none', + ], + ], + ]); + } + + protected function afterRefreshingDatabase(): void + { + User::forceCreate([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => bcrypt('secret'), + ]); + } + + public function testSerializerNoneEnforcesThePolicyAndPreservesTheModel(): void + { + $this->assertModelRoundTrip('auth-redis-none'); + } + + public function testNativePhpSerializerPreservesTheModel(): void + { + $this->assertModelRoundTrip('auth-redis-php'); + } + + public function testIgbinarySerializerPreservesTheModelWhenAvailable(): void + { + if (! defined('Redis::SERIALIZER_IGBINARY')) { + $this->markTestSkipped('The installed phpredis build does not support igbinary.'); + } + + $this->assertModelRoundTrip('auth-redis-igbinary'); + } + + public function testPhpOnlyMsgpackSerializerPreservesTheModelWhenAvailable(): void + { + if (! defined('Redis::SERIALIZER_MSGPACK')) { + $this->markTestSkipped('The installed phpredis build does not support msgpack.'); + } + + if (filter_var(ini_get('msgpack.php_only'), FILTER_VALIDATE_BOOL) !== true) { + $this->markTestSkipped('msgpack.php_only=1 is required for model caching.'); + } + + $this->assertModelRoundTrip('auth-redis-msgpack'); + } + + /** + * Assert a provider returns its concrete model without a second database query. + */ + protected function assertModelRoundTrip(string $store): void + { + $user = User::query()->firstOrFail(); + $provider = new EloquentUserProvider($this->app->make('hash'), User::class); + $provider->enableCache($store); + + DB::enableQueryLog(); + + $first = $provider->retrieveById($user->getAuthIdentifier()); + + $this->assertInstanceOf(User::class, $first); + $this->assertSame(1, $this->countQueriesForTable('users')); + + DB::flushQueryLog(); + + $second = $provider->retrieveById($user->getAuthIdentifier()); + + $this->assertInstanceOf(User::class, $second); + $this->assertSame(0, $this->countQueriesForTable('users')); + } + + /** + * Build a Redis connection using the assigned parallel-test database. + * + * @param array $connection + * @return array + */ + protected function redisConnection( + array $connection, + int $database, + int $serializer, + ): array { + return array_replace($connection, [ + 'database' => $database, + 'options' => [ + 'serializer' => $serializer, + ], + ]); + } + + /** + * Build a Redis cache store configuration. + * + * @return array + */ + protected function redisStore(string $connection, string $prefix): array + { + return [ + 'driver' => 'redis', + 'connection' => $connection, + 'prefix' => $prefix, + ]; + } + + /** + * Count logged select queries for a table. + */ + protected function countQueriesForTable(string $table): int + { + return count(array_filter( + DB::getQueryLog(), + fn (array $query): bool => str_starts_with(strtolower($query['query'] ?? ''), 'select') + && str_contains($query['query'] ?? '', $table) + )); + } +} From 313c36f2c70255333172fa62bd57d6a38d5ad6d0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:47:01 +0000 Subject: [PATCH 20/35] build(sanctum): declare worker lifecycle dependency Declare the core package used directly by Sanctum's worker-start cache validation listener. This keeps the subtree package dependency graph complete outside the monorepo. --- src/sanctum/composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sanctum/composer.json b/src/sanctum/composer.json index 13f91af59..748f6ec93 100644 --- a/src/sanctum/composer.json +++ b/src/sanctum/composer.json @@ -32,6 +32,7 @@ "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/core": "^0.4", "hypervel/database": "^0.4", "hypervel/http": "^0.4", "hypervel/macroable": "^0.4", From 17695d790993be7e4d4f86f5490529f90e4718aa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:47:09 +0000 Subject: [PATCH 21/35] fix(sanctum): centralize the default token model Use one typed constant for initial and flushed personal-access-token model state. This keeps worker and test resets aligned with the framework default. --- src/sanctum/src/Sanctum.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/sanctum/src/Sanctum.php b/src/sanctum/src/Sanctum.php index 3e5dd26ac..cd3b066bf 100644 --- a/src/sanctum/src/Sanctum.php +++ b/src/sanctum/src/Sanctum.php @@ -13,12 +13,15 @@ */ class Sanctum { + /** @var class-string */ + protected const string DEFAULT_PERSONAL_ACCESS_TOKEN_MODEL = PersonalAccessToken::class; + /** * The personal access client model class name. * * @var class-string */ - public static string $personalAccessTokenModel = PersonalAccessToken::class; + public static string $personalAccessTokenModel = self::DEFAULT_PERSONAL_ACCESS_TOKEN_MODEL; /** * A callback that can get the token from the request. @@ -141,7 +144,7 @@ public static function personalAccessTokenModel(): string */ public static function flushState(): void { - static::$personalAccessTokenModel = PersonalAccessToken::class; + static::$personalAccessTokenModel = self::DEFAULT_PERSONAL_ACCESS_TOKEN_MODEL; static::$accessTokenRetrievalCallback = null; static::$accessTokenAuthenticationCallback = null; } From 3d55835f6ee2c30bf2527b33af0b0d96d202bf61 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:47:16 +0000 Subject: [PATCH 22/35] feat(sanctum): register cached identity models Contribute the selected access-token model, Sanctum guard provider models, and stock Eloquent graph containers to the global policy. Validate the configured store at process startup while preserving the parameterless provider boot API. --- src/sanctum/src/SanctumServiceProvider.php | 93 ++++ tests/Sanctum/SanctumServiceProviderTest.php | 442 +++++++++++++++++++ 2 files changed, 535 insertions(+) create mode 100644 tests/Sanctum/SanctumServiceProviderTest.php diff --git a/src/sanctum/src/SanctumServiceProvider.php b/src/sanctum/src/SanctumServiceProvider.php index 4aa9412f3..9ef4e94f7 100644 --- a/src/sanctum/src/SanctumServiceProvider.php +++ b/src/sanctum/src/SanctumServiceProvider.php @@ -5,6 +5,15 @@ namespace Hypervel\Sanctum; use Hypervel\Auth\AuthManager; +use Hypervel\Cache\CacheManager; +use Hypervel\Cache\ModelCacheStoreValidator; +use Hypervel\Contracts\Auth\Authenticatable; +use Hypervel\Contracts\Config\Repository as ConfigRepository; +use Hypervel\Core\Events\AfterWorkerStart; +use Hypervel\Database\Eloquent\Collection as EloquentCollection; +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\MorphPivot; +use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Http\Request; use Hypervel\Sanctum\Console\Commands\PruneExpired; use Hypervel\Session\Middleware\StartSession; @@ -30,6 +39,69 @@ public function register(): void */ public function boot(): void { + $cache = $this->app->make(CacheManager::class); + $config = $this->app->make(ConfigRepository::class); + + $cache->allowSerializableClassesUsing(function () use ($config): array { + if (! $config->boolean('sanctum.cache.enabled')) { + return []; + } + + $models = [Sanctum::personalAccessTokenModel()]; + + foreach ($config->array('auth.guards') as $guard) { + if (! is_array($guard) || ($guard['driver'] ?? null) !== 'sanctum') { + continue; + } + + $providerName = $guard['provider'] ?? null; + + if (! is_string($providerName)) { + continue; + } + + $provider = $config->get("auth.providers.{$providerName}"); + + if (! is_array($provider) || ($provider['driver'] ?? null) !== 'eloquent') { + continue; + } + + $model = $provider['model'] ?? null; + + if (! is_string($model) + || ! is_a($model, Model::class, true) + || ! is_a($model, Authenticatable::class, true)) { + throw new InvalidArgumentException( + "Authentication provider [{$providerName}] model must be an Eloquent authenticatable class.", + ); + } + + $models[] = $model; + } + + return [ + ...$models, + EloquentCollection::class, + Pivot::class, + MorphPivot::class, + ]; + }); + + if ($this->app->runningInConsole()) { + $this->app->booted( + fn () => $this->validateCacheStore($cache, $config), + ); + } else { + // Worker configuration is reloaded during BeforeWorkerStart. + $events = $this->app->make('events'); + $events->listen(AfterWorkerStart::class, function (AfterWorkerStart $event): void { + $this->validateCacheStore( + $this->app->make(CacheManager::class), + $this->app->make(ConfigRepository::class), + ); + }); + } + $this->registerSanctumGuard(); $this->configureSessionCookies(); @@ -41,6 +113,27 @@ public function boot(): void $this->registerRoutes(); } + /** + * Validate the configured token cache store. + */ + private function validateCacheStore(CacheManager $cache, ConfigRepository $config): void + { + if (! $config->boolean('sanctum.cache.enabled')) { + return; + } + + $store = $config->get('sanctum.cache.store'); + + if (! is_string($store) && $store !== null) { + throw new InvalidArgumentException('Sanctum cache store must be a string or null.'); + } + + $this->app->make(ModelCacheStoreValidator::class)->validate( + $cache->store($store === '' ? null : $store), + 'Sanctum token cache', + ); + } + /** * Register the Sanctum authentication guard. */ diff --git a/tests/Sanctum/SanctumServiceProviderTest.php b/tests/Sanctum/SanctumServiceProviderTest.php new file mode 100644 index 000000000..41fe75382 --- /dev/null +++ b/tests/Sanctum/SanctumServiceProviderTest.php @@ -0,0 +1,442 @@ + [ + 'cache' => ['enabled' => true], + ], + 'auth' => [ + 'guards' => [ + 'api' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + ], + 'custom' => [ + 'driver' => 'sanctum', + 'provider' => 'custom', + ], + 'web' => [ + 'driver' => 'session', + 'provider' => 'invalid', + ], + 'malformed', + ], + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => SanctumProviderUser::class, + ], + 'custom' => [ + 'driver' => 'custom', + 'model' => InvalidSanctumProviderModel::class, + ], + 'invalid' => [ + 'driver' => 'eloquent', + 'model' => InvalidSanctumProviderModel::class, + ], + ], + ], + ]); + $manager = m::mock(CacheManager::class); + $resolver = $this->bootAndCaptureResolver($manager, $config); + + Sanctum::usePersonalAccessTokenModel(SanctumProviderToken::class); + + $this->assertSame([ + SanctumProviderToken::class, + SanctumProviderUser::class, + EloquentCollection::class, + Pivot::class, + MorphPivot::class, + ], $resolver()); + } + + public function testConsoleStartupValidatesTheConfiguredStoreUsingCapturedDependencies(): void + { + $config = new ConfigRepository([ + 'sanctum' => [ + 'cache' => [ + 'enabled' => true, + 'store' => '', + ], + ], + 'auth' => [ + 'guards' => [], + ], + ]); + $repository = m::mock(CacheRepository::class); + $manager = m::mock(CacheManager::class); + $manager->shouldReceive('allowSerializableClassesUsing')->once()->andReturnSelf(); + $manager->shouldReceive('store')->once()->with(null)->andReturn($repository); + $validator = m::mock(ModelCacheStoreValidator::class); + $validator->shouldReceive('validate') + ->once() + ->with($repository, 'Sanctum token cache'); + $bootedCallback = null; + $application = $this->consoleApplication($manager, $config); + $application->shouldReceive('make') + ->once() + ->with(ModelCacheStoreValidator::class) + ->andReturn($validator); + $application->shouldReceive('booted') + ->once() + ->with(m::on(function (mixed $callback) use (&$bootedCallback): bool { + $bootedCallback = $callback; + + return $callback instanceof Closure; + })); + + $provider = new SanctumServiceProviderFixture($application); + $provider->boot(); + + $this->assertTrue($provider->bootCalled); + $this->assertTrue($provider->sanctumGuardRegistered); + $this->assertTrue($provider->sessionCookiesConfigured); + $this->assertTrue($provider->routesRegistered); + $this->assertTrue($provider->publishingRegistered); + $this->assertTrue($provider->commandsRegistered); + $this->assertInstanceOf(Closure::class, $bootedCallback); + + $bootedCallback(); + } + + public function testDisabledCachingResolvesNeitherAStoreNorTheValidator(): void + { + $config = new ConfigRepository([ + 'sanctum' => [ + 'cache' => ['enabled' => false], + ], + 'auth' => [ + 'guards' => [], + ], + ]); + $manager = m::mock(CacheManager::class); + $manager->shouldReceive('allowSerializableClassesUsing')->once()->andReturnSelf(); + $manager->shouldNotReceive('store'); + $bootedCallback = null; + $application = $this->consoleApplication($manager, $config); + $application->shouldNotReceive('make')->with(ModelCacheStoreValidator::class); + $application->shouldReceive('booted') + ->once() + ->with(m::on(function (mixed $callback) use (&$bootedCallback): bool { + $bootedCallback = $callback; + + return $callback instanceof Closure; + })); + + (new SanctumServiceProviderFixture($application))->boot(); + + $this->assertSame([], $this->capturedResolver($manager)()); + $this->assertInstanceOf(Closure::class, $bootedCallback); + + $bootedCallback(); + } + + public function testServerValidationUsesWorkerDependenciesAndRunsForTaskworkers(): void + { + $masterConfig = new ConfigRepository([ + 'sanctum' => [ + 'cache' => ['enabled' => false], + ], + 'auth' => [ + 'guards' => [], + ], + ]); + $workerConfig = new ConfigRepository([ + 'sanctum' => [ + 'cache' => [ + 'enabled' => true, + 'store' => 'worker', + ], + ], + 'auth' => [ + 'guards' => [], + ], + ]); + $masterManager = m::mock(CacheManager::class); + $masterManager->shouldReceive('allowSerializableClassesUsing')->once()->andReturnSelf(); + $masterManager->shouldNotReceive('store'); + $workerRepository = m::mock(CacheRepository::class); + $workerManager = m::mock(CacheManager::class); + $workerManager->shouldReceive('store')->once()->with('worker')->andReturn($workerRepository); + $validator = m::mock(ModelCacheStoreValidator::class); + $validator->shouldReceive('validate') + ->once() + ->with($workerRepository, 'Sanctum token cache'); + $listener = null; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('listen') + ->once() + ->with(AfterWorkerStart::class, m::on(function (mixed $callback) use (&$listener): bool { + $listener = $callback; + + return $callback instanceof Closure; + })); + $application = m::mock(Application::class); + $application->shouldReceive('make') + ->twice() + ->with(CacheManager::class) + ->andReturn($masterManager, $workerManager); + $application->shouldReceive('make') + ->twice() + ->with(ConfigRepositoryContract::class) + ->andReturn($masterConfig, $workerConfig); + $application->shouldReceive('make')->once()->with('events')->andReturn($events); + $application->shouldReceive('make') + ->once() + ->with(ModelCacheStoreValidator::class) + ->andReturn($validator); + $application->shouldReceive('runningInConsole')->twice()->andReturnFalse(); + $application->shouldNotReceive('booted'); + + $provider = new SanctumServiceProviderFixture($application); + $provider->boot(); + + $this->assertTrue($provider->bootCalled); + $this->assertFalse($provider->publishingRegistered); + $this->assertFalse($provider->commandsRegistered); + $this->assertInstanceOf(Closure::class, $listener); + + $server = m::mock(SwooleServer::class); + $server->taskworker = true; + $listener(new AfterWorkerStart($server, 8)); + } + + public function testSelectedProviderRequiresAnEloquentAuthenticatableModel(): void + { + $config = new ConfigRepository([ + 'sanctum' => [ + 'cache' => ['enabled' => true], + ], + 'auth' => [ + 'guards' => [ + 'api' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + ], + ], + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => InvalidSanctumProviderModel::class, + ], + ], + ], + ]); + $manager = m::mock(CacheManager::class); + $resolver = $this->bootAndCaptureResolver($manager, $config); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Authentication provider [users] model must be an Eloquent authenticatable class.' + ); + + $resolver(); + } + + public function testConfiguredStoreMustBeAStringOrNull(): void + { + $config = new ConfigRepository([ + 'sanctum' => [ + 'cache' => [ + 'enabled' => true, + 'store' => [], + ], + ], + 'auth' => [ + 'guards' => [], + ], + ]); + $manager = m::mock(CacheManager::class); + $manager->shouldReceive('allowSerializableClassesUsing')->once()->andReturnSelf(); + $manager->shouldNotReceive('store'); + $bootedCallback = null; + $application = $this->consoleApplication($manager, $config); + $application->shouldReceive('booted') + ->once() + ->with(m::on(function (mixed $callback) use (&$bootedCallback): bool { + $bootedCallback = $callback; + + return $callback instanceof Closure; + })); + + (new SanctumServiceProviderFixture($application))->boot(); + + $this->assertInstanceOf(Closure::class, $bootedCallback); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Sanctum cache store must be a string or null.'); + + $bootedCallback(); + } + + /** + * Create a console application double for provider boot. + */ + private function consoleApplication( + CacheManager $manager, + ConfigRepositoryContract $config, + ): Application|m\MockInterface { + $application = m::mock(Application::class); + $application->shouldReceive('make') + ->once() + ->with(CacheManager::class) + ->andReturn($manager); + $application->shouldReceive('make') + ->once() + ->with(ConfigRepositoryContract::class) + ->andReturn($config); + $application->shouldReceive('runningInConsole')->twice()->andReturnTrue(); + + return $application; + } + + /** + * Boot the provider and capture its class resolver. + */ + private function bootAndCaptureResolver( + CacheManager|m\MockInterface $manager, + ConfigRepositoryContract $config, + ): Closure { + $resolver = null; + $manager->shouldReceive('allowSerializableClassesUsing') + ->once() + ->with(m::on(function (mixed $callback) use (&$resolver): bool { + $resolver = $callback; + + return $callback instanceof Closure; + })) + ->andReturnSelf(); + $application = $this->consoleApplication($manager, $config); + $application->shouldReceive('booted')->once(); + + (new SanctumServiceProviderFixture($application))->boot(); + + $this->assertInstanceOf(Closure::class, $resolver); + + return $resolver; + } + + /** + * Capture the registered class resolver from a manager mock. + */ + private function capturedResolver(CacheManager|m\MockInterface $manager): Closure + { + $resolver = null; + $manager->shouldHaveReceived('allowSerializableClassesUsing') + ->with(m::on(function (mixed $callback) use (&$resolver): bool { + $resolver = $callback; + + return $callback instanceof Closure; + })) + ->once(); + + $this->assertInstanceOf(Closure::class, $resolver); + + return $resolver; + } +} + +class SanctumServiceProviderFixture extends SanctumServiceProvider +{ + public bool $bootCalled = false; + + public bool $sanctumGuardRegistered = false; + + public bool $sessionCookiesConfigured = false; + + public bool $routesRegistered = false; + + public bool $publishingRegistered = false; + + public bool $commandsRegistered = false; + + /** + * Bootstrap any package services. + */ + public function boot(): void + { + $this->bootCalled = true; + + parent::boot(); + } + + /** + * Register the Sanctum authentication guard. + */ + protected function registerSanctumGuard(): void + { + $this->sanctumGuardRegistered = true; + } + + /** + * Configure session cookies for stateful frontend requests. + */ + protected function configureSessionCookies(): void + { + $this->sessionCookiesConfigured = true; + } + + /** + * Register the package routes. + */ + protected function registerRoutes(): void + { + $this->routesRegistered = true; + } + + /** + * Register the package's publishable resources. + */ + protected function registerPublishing(): void + { + $this->publishingRegistered = true; + } + + /** + * Register the console commands for the package. + */ + protected function registerCommands(): void + { + $this->commandsRegistered = true; + } +} + +class SanctumProviderUser extends User +{ +} + +class SanctumProviderToken extends PersonalAccessToken +{ +} + +class InvalidSanctumProviderModel +{ +} From 979fc08ac3c7e3a725f073874f4ca26b6a3347e4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:47:24 +0000 Subject: [PATCH 23/35] fix(sanctum): keep token caches coherent Keep tokenable relations out of cached access-token snapshots, preserve other eager relations, and refresh audit timestamps from a clean clone. Let internal last-used writes evict only the token entry while application-visible updates and deletion still clear both caches. --- src/sanctum/src/PersonalAccessToken.php | 42 +++- .../Sanctum/PersonalAccessTokenCacheTest.php | 219 +++++++++++++++--- 2 files changed, 224 insertions(+), 37 deletions(-) diff --git a/src/sanctum/src/PersonalAccessToken.php b/src/sanctum/src/PersonalAccessToken.php index 89917d99e..77590b35e 100644 --- a/src/sanctum/src/PersonalAccessToken.php +++ b/src/sanctum/src/PersonalAccessToken.php @@ -67,13 +67,23 @@ protected static function boot(): void { parent::boot(); - static::updating(function ($model) { - if (config('sanctum.cache.enabled')) { - self::clearTokenCache($model->id); + static::updating(function (PersonalAccessToken $model): void { + if (! config('sanctum.cache.enabled')) { + return; + } + + // Eloquent fires updating before adding updated_at, so this exact + // dirty set identifies Sanctum's internal audit write. + if (array_keys($model->getDirty()) === ['last_used_at']) { + self::forgetTokenEntry(self::getCache(), $model->id); + + return; } + + self::clearTokenCache($model->id); }); - static::deleting(function ($model) { + static::deleting(function (PersonalAccessToken $model): void { if (config('sanctum.cache.enabled')) { self::clearTokenCache($model->id); } @@ -131,7 +141,7 @@ protected static function findTokenUsingCache(string $id): ?static return $cache->rememberNullable( self::getCacheKey($id), config('sanctum.cache.ttl'), - fn () => static::find($id) + fn () => static::find($id)?->unsetRelation('tokenable') ); } @@ -140,6 +150,10 @@ protected static function findTokenUsingCache(string $id): ?static */ public static function findTokenable(PersonalAccessToken $accessToken): ?Authenticatable { + if ($accessToken->relationLoaded('tokenable')) { + return $accessToken->getRelation('tokenable'); + } + if (! config('sanctum.cache.enabled')) { return $accessToken->getAttribute('tokenable'); } @@ -147,11 +161,15 @@ public static function findTokenable(PersonalAccessToken $accessToken): ?Authent $cache = self::getCache(); $cacheKey = self::getCacheKey($accessToken->id) . ':tokenable'; - return $cache->rememberNullable( + /** @var ?Authenticatable $tokenable */ + $tokenable = $cache->rememberNullable( $cacheKey, config('sanctum.cache.ttl'), fn () => $accessToken->getAttribute('tokenable') ); + $accessToken->setRelation('tokenable', $tokenable); + + return $tokenable; } /** @@ -191,10 +209,18 @@ public function cant(UnitEnum|string $ability): bool public static function clearTokenCache(int|string $tokenId): void { $cache = self::getCache(); - $cache->forget(self::getCacheKey($tokenId)); + self::forgetTokenEntry($cache, $tokenId); $cache->forget(self::getCacheKey($tokenId) . ':tokenable'); } + /** + * Forget the cached personal access token entry. + */ + protected static function forgetTokenEntry(CacheRepository $cache, int|string $tokenId): void + { + $cache->forget(self::getCacheKey($tokenId)); + } + /** * Store the time the token was last used. */ @@ -230,7 +256,7 @@ public function updateLastUsedAt(): void if ($cacheEnabled) { static::getCache()->put( static::getCacheKey($this->id), - $this, + $this->withoutRelation('tokenable'), config('sanctum.cache.ttl'), ); } diff --git a/tests/Sanctum/PersonalAccessTokenCacheTest.php b/tests/Sanctum/PersonalAccessTokenCacheTest.php index bbb6f345e..b83eb12c7 100644 --- a/tests/Sanctum/PersonalAccessTokenCacheTest.php +++ b/tests/Sanctum/PersonalAccessTokenCacheTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Sanctum; +use Hypervel\Cache\CacheManager; use Hypervel\Cache\NullSentinel; use Hypervel\Cache\Repository as CacheRepository; use Hypervel\Contracts\Foundation\Application as ApplicationContract; @@ -13,8 +14,11 @@ use Hypervel\Sanctum\Sanctum; use Hypervel\Sanctum\SanctumServiceProvider; use Hypervel\Support\Facades\DB; +use Hypervel\Support\ServiceProvider; +use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Sanctum\Fixtures\TestUser; +use Mockery as m; class PersonalAccessTokenCacheTest extends TestCase { @@ -22,6 +26,8 @@ class PersonalAccessTokenCacheTest extends TestCase protected bool $migrateRefresh = true; + protected bool $tokenCacheEnabled = true; + protected function setUp(): void { parent::setUp(); @@ -29,10 +35,21 @@ protected function setUp(): void $this->createUsersTable(); } + protected function tearDown(): void + { + try { + $this->app->make('cache')->store('sanctum-file')->flush(); + $this->app->make('cache')->store('0')->flush(); + } finally { + parent::tearDown(); + } + } + protected function getPackageProviders(ApplicationContract $app): array { return [ SanctumServiceProvider::class, + PersonalAccessTokenCacheTestServiceProvider::class, ]; } @@ -40,20 +57,63 @@ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); - $app->make('config')->set([ - 'cache.default' => 'array', - 'cache.stores.array' => [ - 'driver' => 'array', - 'serialize' => false, + $config = $app->make('config'); + + $config->set([ + 'cache.default' => 'sanctum-file', + 'cache.serializable_classes' => false, + 'cache.stores.sanctum-file' => [ + 'driver' => 'file', + 'path' => $app->storagePath('framework/cache/data/sanctum'), ], 'cache.stores.0' => [ - 'driver' => 'array', - 'serialize' => false, + 'driver' => 'file', + 'path' => $app->storagePath('framework/cache/data/sanctum-zero'), + ], + 'auth.guards.sanctum' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + 'session_guards' => [], ], - 'sanctum.cache.enabled' => true, + 'auth.providers.users' => [ + 'driver' => 'eloquent', + 'model' => TestUser::class, + ], + 'sanctum.cache.enabled' => $this->tokenCacheEnabled, ]); } + protected function useStringKeyPersonalAccessTokenModel(ApplicationContract $app): void + { + $app->make('config')->set( + 'sanctum.testing_personal_access_token_model', + StringKeyPersonalAccessToken::class, + ); + } + + protected function useEagerTokenablePersonalAccessTokenModel(ApplicationContract $app): void + { + $app->make('config')->set( + 'sanctum.testing_personal_access_token_model', + EagerTokenablePersonalAccessToken::class, + ); + } + + protected function disableTokenCache(ApplicationContract $app): void + { + $this->tokenCacheEnabled = false; + } + + protected function useZeroTokenCacheStore(ApplicationContract $app): void + { + $app->make('config')->set('sanctum.cache.store', '0'); + } + + protected function useDefaultTokenCacheStoreFromEmptyName(ApplicationContract $app): void + { + $app->make('config')->set('sanctum.cache.store', ''); + } + /** * Get the migrations to run for the test. */ @@ -102,12 +162,11 @@ public function testEmptyTokenIdDoesNotQueryOrCache(): void $this->assertNull($this->cacheRepository()->getRaw('sanctum:')); } + #[DefineEnvironment('useStringKeyPersonalAccessTokenModel')] public function testStringKeyTokenModelAcceptsNonNumericTokenId(): void { $this->createStringKeyTokenTable(); - Sanctum::usePersonalAccessTokenModel(StringKeyPersonalAccessToken::class); - $token = StringKeyPersonalAccessToken::forceCreate([ 'id' => 'token_01', 'tokenable_type' => TestUser::class, @@ -148,13 +207,45 @@ public function testValidTokenIsCached(): void DB::enableQueryLog(); - $this->assertTrue($token->is(PersonalAccessToken::findToken($token->id . '|secret'))); + $foundToken = PersonalAccessToken::findToken($token->id . '|secret'); + + $this->assertNotNull($foundToken); + $this->assertTrue($token->is($foundToken)); + $this->assertFalse($foundToken->relationLoaded('tokenable')); $this->assertSame(1, $this->countQueriesForTable('personal_access_tokens')); DB::flushQueryLog(); - $this->assertTrue($token->is(PersonalAccessToken::findToken($token->id . '|secret'))); + $foundToken = PersonalAccessToken::findToken($token->id . '|secret'); + + $this->assertNotNull($foundToken); + $this->assertTrue($token->is($foundToken)); + $this->assertFalse($foundToken->relationLoaded('tokenable')); + $this->assertSame(0, $this->countQueriesForTable('personal_access_tokens')); + } + + #[DefineEnvironment('useEagerTokenablePersonalAccessTokenModel')] + public function testCachedTokenNeverRetainsEagerLoadedTokenable(): void + { + $token = $this->createToken(); + + DB::enableQueryLog(); + + $foundToken = EagerTokenablePersonalAccessToken::findToken($token->id . '|secret'); + + $this->assertNotNull($foundToken); + $this->assertFalse($foundToken->relationLoaded('tokenable')); + $this->assertSame(1, $this->countQueriesForTable('personal_access_tokens')); + $this->assertSame(1, $this->countQueriesForTable('users')); + + DB::flushQueryLog(); + + $foundToken = EagerTokenablePersonalAccessToken::findToken($token->id . '|secret'); + + $this->assertNotNull($foundToken); + $this->assertFalse($foundToken->relationLoaded('tokenable')); $this->assertSame(0, $this->countQueriesForTable('personal_access_tokens')); + $this->assertSame(0, $this->countQueriesForTable('users')); } public function testFindingValidTokenDoesNotUpdateLastUsedAt(): void @@ -176,19 +267,28 @@ public function testFindingTokenWithBadHashDoesNotUpdateLastUsedAt(): void $this->assertNull($token->fresh()->last_used_at); } + #[DefineEnvironment('disableTokenCache')] public function testLastUsedAtIsWrittenEveryTimeWhenCachingIsDisabled(): void { - $this->app->make('config')->set('sanctum.cache.enabled', false); $this->freezeTime(); $token = $this->createToken(); - $token->updateLastUsedAt(); - $firstLastUsedAt = $token->fresh()->last_used_at; - - $this->travel(1)->second(); - $token->updateLastUsedAt(); - - $this->assertTrue($token->fresh()->last_used_at->isAfter($firstLastUsedAt)); + $cacheManager = $this->app->make(CacheManager::class); + $cacheDouble = m::mock(CacheManager::class); + $cacheDouble->shouldNotReceive('store'); + $this->app->instance('cache', $cacheDouble); + + try { + $token->updateLastUsedAt(); + $firstLastUsedAt = $token->fresh()->last_used_at; + + $this->travel(1)->second(); + $token->updateLastUsedAt(); + + $this->assertTrue($token->fresh()->last_used_at->isAfter($firstLastUsedAt)); + } finally { + $this->app->instance('cache', $cacheManager); + } } public function testCachedLastUsedAtUpdateIsSkippedWithinIntervalWithoutTouchingConnection(): void @@ -220,6 +320,10 @@ public function testCachedLastUsedAtUpdateRunsAfterIntervalAndRefreshesCache(): $token = $this->createToken(); $token->forceFill(['last_used_at' => $now->subSeconds(301)])->save(); $token = $token->fresh(); + $tokenable = PersonalAccessToken::findTokenable($token); + + $this->assertInstanceOf(TestUser::class, $tokenable); + $token->setRelation('customRelation', $tokenable); $connection = $token->getConnection(); $connection->setRecordModificationState(false); @@ -227,6 +331,7 @@ public function testCachedLastUsedAtUpdateRunsAfterIntervalAndRefreshesCache(): $token->updateLastUsedAt(); $this->assertFalse($connection->hasModifiedRecords()); + $this->assertSame($tokenable, $token->getRelation('tokenable')); $this->assertSame( $now->format('Y-m-d H:i:s'), $token->fresh()->last_used_at->format('Y-m-d H:i:s'), @@ -234,12 +339,32 @@ public function testCachedLastUsedAtUpdateRunsAfterIntervalAndRefreshesCache(): $cachedToken = $this->cacheRepository()->get("sanctum:{$token->id}"); $this->assertInstanceOf(PersonalAccessToken::class, $cachedToken); + $this->assertFalse($cachedToken->relationLoaded('tokenable')); + $this->assertTrue($cachedToken->relationLoaded('customRelation')); + $this->assertInstanceOf(TestUser::class, $cachedToken->getRelation('customRelation')); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); $this->assertSame( $now->format('Y-m-d H:i:s'), $cachedToken->last_used_at->format('Y-m-d H:i:s'), ); } + public function testLastUsedAtAuditWriteForgetsOnlyTokenEntry(): void + { + $token = $this->createToken(); + $foundToken = PersonalAccessToken::findToken($token->id . '|secret'); + + $this->assertNotNull($foundToken); + $this->assertInstanceOf(TestUser::class, PersonalAccessToken::findTokenable($foundToken)); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + + $token->forceFill(['last_used_at' => now()])->save(); + + $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); + $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); + } + public function testSuccessfulLastUsedAtUpdatePreservesModifiedConnectionState(): void { $token = $this->createToken(); @@ -299,28 +424,37 @@ public function testMissingTokenableCachesNullResult(): void public function testValidTokenableIsCached(): void { $token = $this->createToken(); + $accessToken = PersonalAccessToken::findOrFail($token->id); DB::enableQueryLog(); - $tokenable = PersonalAccessToken::findTokenable(PersonalAccessToken::findOrFail($token->id)); + $tokenable = PersonalAccessToken::findTokenable($accessToken); $this->assertInstanceOf(TestUser::class, $tokenable); $this->assertSame($token->tokenable_id, $tokenable->getKey()); + $this->assertSame($tokenable, $accessToken->getRelation('tokenable')); $this->assertSame(1, $this->countQueriesForTable('users')); DB::flushQueryLog(); - $tokenable = PersonalAccessToken::findTokenable(PersonalAccessToken::findOrFail($token->id)); - $this->assertInstanceOf(TestUser::class, $tokenable); - $this->assertSame($token->tokenable_id, $tokenable->getKey()); + $this->assertSame($tokenable, PersonalAccessToken::findTokenable($accessToken)); + $this->assertSame(0, $this->countQueriesForTable('users')); + + $accessToken = PersonalAccessToken::findOrFail($token->id); + $cachedTokenable = PersonalAccessToken::findTokenable($accessToken); + + $this->assertInstanceOf(TestUser::class, $cachedTokenable); + $this->assertSame($token->tokenable_id, $cachedTokenable->getKey()); + $this->assertSame($cachedTokenable, $accessToken->getRelation('tokenable')); $this->assertSame(0, $this->countQueriesForTable('users')); } public function testClearTokenCacheForgetsTokenAndTokenableEntries(): void { $token = $this->createToken(); + $foundToken = PersonalAccessToken::findOrFail($token->id); $this->assertTrue($token->is(PersonalAccessToken::findToken($token->id . '|secret'))); - $this->assertTrue($token->tokenable->is(PersonalAccessToken::findTokenable($token))); + $this->assertTrue($token->tokenable->is(PersonalAccessToken::findTokenable($foundToken))); PersonalAccessToken::clearTokenCache($token->id); @@ -328,12 +462,12 @@ public function testClearTokenCacheForgetsTokenAndTokenableEntries(): void $this->assertNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); } - public function testTokenCacheStorePreservesZeroAndEmptyFallback(): void + #[DefineEnvironment('useZeroTokenCacheStore')] + public function testTokenCacheStorePreservesZeroName(): void { $defaultStore = $this->app->make('cache')->store(); $zeroStore = $this->app->make('cache')->store('0'); - $this->app->make('config')->set('sanctum.cache.store', '0'); $defaultStore->put('sanctum:1', 'default', 60); $zeroStore->put('sanctum:1', 'zero', 60); @@ -341,8 +475,14 @@ public function testTokenCacheStorePreservesZeroAndEmptyFallback(): void $this->assertSame('default', $defaultStore->get('sanctum:1')); $this->assertNull($zeroStore->get('sanctum:1')); + } + + #[DefineEnvironment('useDefaultTokenCacheStoreFromEmptyName')] + public function testEmptyTokenCacheStoreNameUsesTheDefaultStore(): void + { + $defaultStore = $this->app->make('cache')->store(); + $zeroStore = $this->app->make('cache')->store('0'); - $this->app->make('config')->set('sanctum.cache.store', ''); $defaultStore->put('sanctum:2', 'default', 60); $zeroStore->put('sanctum:2', 'zero', 60); @@ -373,9 +513,10 @@ public function testUpdatingTokenForgetsTokenAndTokenableCacheEntries(): void public function testDeletingTokenForgetsTokenAndTokenableCacheEntries(): void { $token = $this->createToken(); + $foundToken = PersonalAccessToken::findOrFail($token->id); $this->assertTrue($token->is(PersonalAccessToken::findToken($token->id . '|secret'))); - $this->assertTrue($token->tokenable->is(PersonalAccessToken::findTokenable($token))); + $this->assertTrue($token->tokenable->is(PersonalAccessToken::findTokenable($foundToken))); $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}")); $this->assertNotNull($this->cacheRepository()->getRaw("sanctum:{$token->id}:tokenable")); @@ -467,3 +608,23 @@ class StringKeyPersonalAccessToken extends PersonalAccessToken public bool $incrementing = false; } + +class EagerTokenablePersonalAccessToken extends PersonalAccessToken +{ + protected array $with = ['tokenable']; +} + +class PersonalAccessTokenCacheTestServiceProvider extends ServiceProvider +{ + /** + * Bootstrap the test service provider. + */ + public function boot(): void + { + $model = $this->app->make('config')->get('sanctum.testing_personal_access_token_model'); + + if (is_string($model)) { + Sanctum::usePersonalAccessTokenModel($model); + } + } +} From 2a0b71cd796f8e593f1f0ef957c183e367164b2c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:47:30 +0000 Subject: [PATCH 24/35] fix(sanctum): reuse the resolved tokenable Resolve and attach one tokenable instance for provider validation, callbacks, events, and withAccessToken instead of fetching a second object. Preserve the protected Laravel validation hook and zero-query hot authentication. --- src/sanctum/src/SanctumGuard.php | 3 +- tests/Sanctum/GuardTest.php | 181 ++++++++++++++++++++++++++++++- 2 files changed, 181 insertions(+), 3 deletions(-) diff --git a/src/sanctum/src/SanctumGuard.php b/src/sanctum/src/SanctumGuard.php index 78ec353d9..9c502c9bc 100644 --- a/src/sanctum/src/SanctumGuard.php +++ b/src/sanctum/src/SanctumGuard.php @@ -223,10 +223,11 @@ protected function isValidAccessToken(?PersonalAccessToken $accessToken): bool return false; } + $model = Sanctum::$personalAccessTokenModel; $isValid = (! $this->expiration || $accessToken->getAttribute('created_at')->gt(now()->subMinutes($this->expiration))) && (! $accessToken->getAttribute('expires_at') || ! $accessToken->getAttribute('expires_at')->isPast()) - && $this->hasValidProvider($accessToken->getAttribute('tokenable')); + && $this->hasValidProvider($model::findTokenable($accessToken)); if (is_callable(Sanctum::$accessTokenAuthenticationCallback)) { $isValid = (bool) (Sanctum::$accessTokenAuthenticationCallback)($accessToken, $isValid); diff --git a/tests/Sanctum/GuardTest.php b/tests/Sanctum/GuardTest.php index da6a60eab..f042aebad 100644 --- a/tests/Sanctum/GuardTest.php +++ b/tests/Sanctum/GuardTest.php @@ -16,7 +16,10 @@ use Hypervel\Sanctum\Sanctum; use Hypervel\Sanctum\SanctumServiceProvider; use Hypervel\Sanctum\TransientToken; +use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Route; +use Hypervel\Support\ServiceProvider; +use Hypervel\Testbench\Attributes\DefineEnvironment; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Sanctum\Fixtures\TestUser; use Hypervel\Tests\Sanctum\Fixtures\User as SanctumTestUser; @@ -30,6 +33,8 @@ class GuardTest extends TestCase protected bool $migrateRefresh = true; + protected bool $tokenCacheEnabled = true; + protected function setUp(): void { parent::setUp(); @@ -38,16 +43,34 @@ protected function setUp(): void $this->defineTestRoutes(); } + protected function tearDown(): void + { + try { + $this->app->make('cache')->store('sanctum-guard-file')->flush(); + } finally { + parent::tearDown(); + } + } + protected function getPackageProviders(ApplicationContract $app): array { return [ SanctumServiceProvider::class, + GuardTestServiceProvider::class, ]; } protected function defineEnvironment(ApplicationContract $app): void { - $app->make('config')->set([ + $config = $app->make('config'); + + $config->set([ + 'cache.default' => 'sanctum-guard-file', + 'cache.serializable_classes' => false, + 'cache.stores.sanctum-guard-file' => [ + 'driver' => 'file', + 'path' => $app->storagePath('framework/cache/data/sanctum-guard'), + ], 'auth.guards.sanctum' => [ 'driver' => 'sanctum', 'provider' => 'users', @@ -59,9 +82,23 @@ protected function defineEnvironment(ApplicationContract $app): void ], 'auth.providers.users.model' => TestUser::class, 'auth.providers.users.driver' => 'eloquent', + 'sanctum.cache.enabled' => $this->tokenCacheEnabled, ]); } + protected function disableTokenCache(ApplicationContract $app): void + { + $this->tokenCacheEnabled = false; + } + + protected function useCustomTrackingPersonalAccessTokenModel(ApplicationContract $app): void + { + $app->make('config')->set( + 'sanctum.testing_personal_access_token_model', + CustomTrackingPersonalAccessToken::class, + ); + } + /** * Get the migrations to run for the test. */ @@ -158,6 +195,18 @@ protected function createUserWithToken(array $abilities = ['*'], ?string $plainT return [$user, $token, $token->id . '|' . $plainTextToken]; } + /** + * Count logged select queries for a table. + */ + protected function countQueriesForTable(string $table): int + { + return count(array_filter( + DB::getQueryLog(), + fn (array $query): bool => str_starts_with(strtolower($query['query'] ?? ''), 'select') + && str_contains($query['query'] ?? '', $table) + )); + } + /** * Expect the invalid session guards configuration exception. */ @@ -213,6 +262,118 @@ public function testAuthenticationWithTokenIfNoSessionPresent(): void ]); } + public function testHotTokenAuthenticationDoesNotQueryTokenOrUserTables(): void + { + [$user, $token, $plainToken] = $this->createUserWithToken(); + + DB::enableQueryLog(); + + $this->withHeaders([ + 'Authorization' => 'Bearer ' . $plainToken, + ])->getJson('/test/user') + ->assertOk() + ->assertJson([ + 'authenticated' => true, + 'user_id' => $user->id, + 'token_id' => $token->id, + ]); + + $this->assertSame(1, $this->countQueriesForTable('personal_access_tokens')); + $this->assertSame(1, $this->countQueriesForTable('users')); + + DB::flushQueryLog(); + + $this->withHeaders([ + 'Authorization' => 'Bearer ' . $plainToken, + ])->getJson('/test/user') + ->assertOk() + ->assertJson([ + 'authenticated' => true, + 'user_id' => $user->id, + 'token_id' => $token->id, + ]); + + $this->assertSame(0, $this->countQueriesForTable('personal_access_tokens')); + $this->assertSame(0, $this->countQueriesForTable('users')); + } + + public function testProviderValidationCallbackAndAuthenticatedUserShareTokenableInstance(): void + { + $callbackTokenable = null; + $authenticatedUser = null; + $eventToken = null; + + Sanctum::authenticateAccessTokensUsing( + function (PersonalAccessToken $accessToken, bool $isValid) use (&$callbackTokenable): bool { + $callbackTokenable = $accessToken->getRelation('tokenable'); + + return $isValid; + } + ); + + $this->app->make(Dispatcher::class)->listen( + TokenAuthenticated::class, + function (TokenAuthenticated $event) use (&$eventToken): void { + $eventToken = $event->token; + } + ); + + Route::get('/test/tokenable-identity', function () use (&$authenticatedUser) { + $authenticatedUser = auth('sanctum')->user(); + + return response()->noContent(); + }); + + [$user, $token, $plainToken] = $this->createUserWithToken(); + + DB::enableQueryLog(); + + $this->withHeaders([ + 'Authorization' => 'Bearer ' . $plainToken, + ])->get('/test/tokenable-identity')->assertNoContent(); + + $this->assertInstanceOf(TestUser::class, $callbackTokenable); + $this->assertSame($callbackTokenable, $authenticatedUser); + $this->assertInstanceOf(PersonalAccessToken::class, $eventToken); + $this->assertSame($eventToken, $authenticatedUser->currentAccessToken()); + $this->assertSame($callbackTokenable, $eventToken->getRelation('tokenable')); + $this->assertSame($user->id, $authenticatedUser->id); + $this->assertSame($token->id, $eventToken->id); + $this->assertSame(1, $this->countQueriesForTable('users')); + } + + public function testCachedUsersAreIndependentAcrossTokenAuthentications(): void + { + [$user, $firstToken, $firstPlainToken] = $this->createUserWithToken( + plainTextToken: 'first-token' + ); + $secondToken = $user->tokens()->create([ + 'name' => 'Second Token', + 'token' => hash('sha256', 'second-token'), + 'abilities' => ['*'], + ]); + $authenticatedUsers = []; + + Route::get('/test/cached-user-identity', function () use (&$authenticatedUsers) { + $authenticatedUsers[] = auth('sanctum')->user(); + + return response()->noContent(); + }); + + $this->withHeaders([ + 'Authorization' => 'Bearer ' . $firstPlainToken, + ])->get('/test/cached-user-identity')->assertNoContent(); + + $this->withHeaders([ + 'Authorization' => 'Bearer ' . $secondToken->id . '|second-token', + ])->get('/test/cached-user-identity')->assertNoContent(); + + $this->assertCount(2, $authenticatedUsers); + $this->assertNotSame($authenticatedUsers[0], $authenticatedUsers[1]); + $this->assertSame($firstToken->id, $authenticatedUsers[0]->currentAccessToken()->id); + $this->assertSame($secondToken->id, $authenticatedUsers[1]->currentAccessToken()->id); + } + public function testEmptySessionGuardsIsTokenOnly(): void { $this->app->make('config')->set('auth.guards.sanctum.session_guards', []); @@ -636,6 +797,7 @@ public function testSuccessfulAuthenticationDoesNotTrackLastUsedAtWhenDisabled() $this->assertNull($token->fresh()->last_used_at); } + #[DefineEnvironment('disableTokenCache')] public function testSuccessfulAuthenticationWritesLastUsedAtOnEachRequestWhenCachingIsDisabled(): void { $this->freezeTime(); @@ -707,9 +869,9 @@ static function (): never { $this->assertNull($token->fresh()->last_used_at); } + #[DefineEnvironment('useCustomTrackingPersonalAccessTokenModel')] public function testCustomTokenModelCanOverrideLastUsedAtUpdate(): void { - Sanctum::usePersonalAccessTokenModel(CustomTrackingPersonalAccessToken::class); [$user, $token, $plainToken] = $this->createUserWithToken(); $this->withHeaders([ @@ -859,6 +1021,21 @@ public function testActingAsUserAuthentication(): void } } +class GuardTestServiceProvider extends ServiceProvider +{ + /** + * Bootstrap the test service provider. + */ + public function boot(): void + { + $model = $this->app->make('config')->get('sanctum.testing_personal_access_token_model'); + + if (is_string($model)) { + Sanctum::usePersonalAccessTokenModel($model); + } + } +} + class CustomTrackingPersonalAccessToken extends PersonalAccessToken { public function updateLastUsedAt(): void From b0ef1c0c2fe2237bfec71b050c56f03a272940ea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:47:38 +0000 Subject: [PATCH 25/35] fix(cache): invalidate every failover store Run forget and flush against every configured leaf so stale lower-priority values cannot reappear. Preserve first-success behavior elsewhere, existing failure events and dedupe, and last-exception semantics when every store fails. --- src/cache/src/FailoverStore.php | 76 ++++++- tests/Integration/Cache/FailoverStoreTest.php | 191 +++++++++++++++++- 2 files changed, 252 insertions(+), 15 deletions(-) diff --git a/src/cache/src/FailoverStore.php b/src/cache/src/FailoverStore.php index f1337ef62..00199b02f 100644 --- a/src/cache/src/FailoverStore.php +++ b/src/cache/src/FailoverStore.php @@ -158,7 +158,14 @@ public function touch(string $key, int $seconds): bool */ public function forget(string $key): bool { - return $this->attemptOnAllStores(__FUNCTION__, func_get_args()); + // First-success invalidation lets stale values reappear from lower-priority stores. + [$results, $exception] = $this->attemptOnEveryStore(__FUNCTION__, func_get_args()); + + if ($results === []) { + throw $exception ?? new RuntimeException('All failover cache stores failed.'); + } + + return $exception === null; } /** @@ -166,7 +173,15 @@ public function forget(string $key): bool */ public function flush(): bool { - return $this->attemptOnAllStores(__FUNCTION__, func_get_args()); + // First-success invalidation lets stale values reappear from lower-priority stores. + [$results, $exception] = $this->attemptOnEveryStore(__FUNCTION__, func_get_args()); + + if ($results === []) { + throw $exception ?? new RuntimeException('All failover cache stores failed.'); + } + + return $exception === null + && ! in_array(false, $results, true); } /** @@ -279,7 +294,7 @@ public function getPrefix(): string } /** - * Attempt the given method on all stores. + * Attempt the given method until a store call does not throw. * * @throws Throwable */ @@ -295,14 +310,12 @@ protected function attemptOnAllStores(string $method, array $arguments): mixed foreach ($this->stores as $store) { try { return $this->store($store)->{$method}(...$arguments); - } catch (Throwable $e) { - $lastException = $e; + } catch (Throwable $exception) { + $lastException = $exception; $failedCaches[] = $store; - if (! in_array($store, $failingCaches, true) && $this->events->hasListeners(CacheFailedOver::class)) { - $this->events->dispatch(new CacheFailedOver($store, $e)); - } + $this->recordStoreFailure($store, $exception, $failingCaches); } } } finally { @@ -312,6 +325,53 @@ protected function attemptOnAllStores(string $method, array $arguments): mixed throw $lastException ?? new RuntimeException('All failover cache stores failed.'); } + /** + * Attempt the given method on every store. + * + * @return array{0: list, 1: ?Throwable} + */ + protected function attemptOnEveryStore(string $method, array $arguments): array + { + $contextKey = self::FAILING_CACHES_CONTEXT_PREFIX . spl_object_id($this); + $failingCaches = CoroutineContext::get($contextKey, []); + $results = []; + $lastException = null; + $failedCaches = []; + + try { + foreach ($this->stores as $store) { + try { + $results[] = $this->store($store)->{$method}(...$arguments); + } catch (Throwable $exception) { + $lastException = $exception; + $failedCaches[] = $store; + + $this->recordStoreFailure($store, $exception, $failingCaches); + } + } + } finally { + CoroutineContext::set($contextKey, $failedCaches); + } + + return [$results, $lastException]; + } + + /** + * Dispatch a newly observed store failure. + * + * @param list $failingCaches + */ + protected function recordStoreFailure( + string $store, + Throwable $exception, + array $failingCaches, + ): void { + if (! in_array($store, $failingCaches, true) + && $this->events->hasListeners(CacheFailedOver::class)) { + $this->events->dispatch(new CacheFailedOver($store, $exception)); + } + } + /** * Get the cache store for the given store name. */ diff --git a/tests/Integration/Cache/FailoverStoreTest.php b/tests/Integration/Cache/FailoverStoreTest.php index d4b7fb520..ec9f7b676 100644 --- a/tests/Integration/Cache/FailoverStoreTest.php +++ b/tests/Integration/Cache/FailoverStoreTest.php @@ -32,7 +32,7 @@ protected function setUp(): void CantSerialize::$throwException = true; } - public function testFailoverCacheDispatchesEventOnlyOnce() + public function testFailoverCacheDispatchesEventOnlyOnce(): void { config([ 'cache.stores.failing_array' => array_merge(config('cache.stores.array'), ['serialize' => true]), @@ -61,7 +61,7 @@ public function testFailoverCacheDispatchesEventOnlyOnce() Event::assertDispatchedTimes(CacheFailedOver::class, 2); } - public function testSeparateFailoverStoresDoNotShareFailureEventsForTheSameFailingStore() + public function testSeparateFailoverStoresDoNotShareFailureEventsForTheSameFailingStore(): void { $events = m::mock(Dispatcher::class); $events->shouldReceive('hasListeners') @@ -172,7 +172,7 @@ public function testPutManyWithEmptyInputReturnsSelectedStoreResult(): void $this->assertFalse($store->putMany([], 60)); } - public function testNullSentinelRoundTripsThroughFailoverStorePrimary() + public function testNullSentinelRoundTripsThroughFailoverStorePrimary(): void { $primaryRepo = new Repository(new ArrayStore(serializesValues: true)); $fallbackRepo = new Repository(new ArrayStore(serializesValues: true)); @@ -198,7 +198,7 @@ public function testNullSentinelRoundTripsThroughFailoverStorePrimary() $this->assertNull($fallbackRepo->getStore()->get('k')); } - public function testPlainRememberTreatsCachedSentinelAsHitThroughFailoverStack() + public function testPlainRememberTreatsCachedSentinelAsHitThroughFailoverStack(): void { $primaryRepo = new Repository(new ArrayStore(serializesValues: true)); $fallbackRepo = new Repository(new ArrayStore(serializesValues: true)); @@ -220,7 +220,7 @@ public function testPlainRememberTreatsCachedSentinelAsHitThroughFailoverStack() $this->assertFalse($invoked); } - public function testPlainFlexibleTreatsCachedSentinelAsHitThroughFailoverStack() + public function testPlainFlexibleTreatsCachedSentinelAsHitThroughFailoverStack(): void { $primaryRepo = new Repository(new ArrayStore(serializesValues: true)); $fallbackRepo = new Repository(new ArrayStore(serializesValues: true)); @@ -242,7 +242,7 @@ public function testPlainFlexibleTreatsCachedSentinelAsHitThroughFailoverStack() $this->assertFalse($invoked); } - public function testManyFiresCacheHitNotCacheMissedForSentinelThroughFailoverStack() + public function testManyFiresCacheHitNotCacheMissedForSentinelThroughFailoverStack(): void { $primaryRepo = new Repository(new ArrayStore(serializesValues: true)); $fallbackRepo = new Repository(new ArrayStore(serializesValues: true)); @@ -270,7 +270,157 @@ public function testManyFiresCacheHitNotCacheMissedForSentinelThroughFailoverSta $this->assertInstanceOf(CacheHit::class, $captured[1]); // Null, not the sentinel value. $this->assertNull($captured[1]->value); - $this->assertEmpty(array_filter($captured, fn ($e) => $e instanceof CacheMissed)); + $this->assertEmpty(array_filter($captured, fn ($event) => $event instanceof CacheMissed)); + } + + public function testForgetAndFlushReachEveryStore(): void + { + $primary = m::mock(CacheRepository::class); + $primary->shouldReceive('forget')->once()->with('key')->andReturn(false); + $primary->shouldReceive('flush')->once()->andReturn(true); + $fallback = m::mock(CacheRepository::class); + $fallback->shouldReceive('forget')->once()->with('key')->andReturn(true); + $fallback->shouldReceive('flush')->once()->andReturn(true); + $store = $this->failoverStore([ + 'primary' => $primary, + 'fallback' => $fallback, + ]); + + $this->assertTrue($store->forget('key')); + $this->assertTrue($store->flush()); + } + + public function testPartialInvalidationFailuresReturnFalseAfterTryingEveryStore(): void + { + $primary = m::mock(CacheRepository::class); + $primary->shouldReceive('forget') + ->once() + ->with('key') + ->andThrow(new Exception('Primary forget failed.')); + $primary->shouldReceive('flush') + ->once() + ->andThrow(new Exception('Primary flush failed.')); + $fallback = m::mock(CacheRepository::class); + $fallback->shouldReceive('forget')->once()->with('key')->andReturn(false); + $fallback->shouldReceive('flush')->once()->andReturn(true); + $store = $this->failoverStore([ + 'primary' => $primary, + 'fallback' => $fallback, + ]); + + $this->assertFalse($store->forget('key')); + $this->assertFalse($store->flush()); + } + + public function testFlushReturnsFalseWhenAnyCompletedStoreReturnsFalse(): void + { + $primary = m::mock(CacheRepository::class); + $primary->shouldReceive('flush')->once()->andReturn(false); + $fallback = m::mock(CacheRepository::class); + $fallback->shouldReceive('flush')->once()->andReturn(true); + $store = $this->failoverStore([ + 'primary' => $primary, + 'fallback' => $fallback, + ]); + + $this->assertFalse($store->flush()); + } + + public function testAllFailingInvalidationRethrowsTheLastExceptionAfterTryingEveryStore(): void + { + $primary = m::mock(CacheRepository::class); + $primary->shouldReceive('forget') + ->once() + ->with('key') + ->andThrow(new Exception('Primary failed.')); + $fallback = m::mock(CacheRepository::class); + $fallback->shouldReceive('forget') + ->once() + ->with('key') + ->andThrow(new Exception('Fallback failed.')); + $store = $this->failoverStore([ + 'primary' => $primary, + 'fallback' => $fallback, + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Fallback failed.'); + + $store->forget('key'); + } + + public function testAllFailingFirstSuccessOperationStillRethrowsTheLastException(): void + { + $primary = m::mock(CacheRepository::class); + $primary->shouldReceive('getRaw') + ->once() + ->with('key') + ->andThrow(new Exception('Primary failed.')); + $fallback = m::mock(CacheRepository::class); + $fallback->shouldReceive('getRaw') + ->once() + ->with('key') + ->andThrow(new Exception('Fallback failed.')); + $store = $this->failoverStore([ + 'primary' => $primary, + 'fallback' => $fallback, + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Fallback failed.'); + + $store->getRaw('key'); + } + + public function testFailedInvalidationUpdatesFailureContextForLaterFirstSuccessWrites(): void + { + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners') + ->once() + ->with(CacheFailedOver::class) + ->andReturn(true); + $events->shouldReceive('dispatch') + ->once() + ->with(m::on( + fn (object $event): bool => $event instanceof CacheFailedOver + && $event->storeName === 'primary' + )); + $primary = m::mock(CacheRepository::class); + $primary->shouldReceive('forget') + ->once() + ->with('key') + ->andThrow(new Exception('Primary failed.')); + $primary->shouldReceive('put') + ->once() + ->with('key', 'value', 60) + ->andThrow(new Exception('Primary still failed.')); + $fallback = m::mock(CacheRepository::class); + $fallback->shouldReceive('forget')->once()->with('key')->andReturn(true); + $fallback->shouldReceive('put')->once()->with('key', 'value', 60)->andReturn(true); + $store = $this->failoverStore([ + 'primary' => $primary, + 'fallback' => $fallback, + ], $events); + + $this->assertFalse($store->forget('key')); + $this->assertTrue($store->put('key', 'value', 60)); + } + + public function testSuccessfulReadsAndWritesRemainFirstSuccessOperations(): void + { + $primary = m::mock(CacheRepository::class); + $primary->shouldReceive('getRaw')->once()->with('key')->andReturn('value'); + $primary->shouldReceive('put')->once()->with('key', 'updated', 60)->andReturn(true); + $fallback = m::mock(CacheRepository::class); + $fallback->shouldNotReceive('getRaw'); + $fallback->shouldNotReceive('put'); + $store = $this->failoverStore([ + 'primary' => $primary, + 'fallback' => $fallback, + ]); + + $this->assertSame('value', $store->getRaw('key')); + $this->assertTrue($store->put('key', 'updated', 60)); } private function buildFailoverRepository(Repository $primary, Repository $fallback): Repository @@ -284,6 +434,33 @@ private function buildFailoverRepository(Repository $primary, Repository $fallba return new Repository(new FailoverStore($cacheManager, $events, ['primary', 'fallback'])); } + + /** + * Build a failover store from named repository doubles. + * + * @param array $repositories + */ + private function failoverStore( + array $repositories, + ?Dispatcher $events = null, + ): FailoverStore { + $cacheManager = m::mock(CacheManager::class); + $cacheManager->shouldReceive('store') + ->andReturnUsing( + fn (string $store): CacheRepository => $repositories[$store], + ); + + if ($events === null) { + $events = m::mock(Dispatcher::class); + $events->allows('hasListeners')->andReturn(false); + } + + return new FailoverStore( + $cacheManager, + $events, + array_keys($repositories), + ); + } } class CantSerialize From 2d990cf2de85211d09f8b58c1ed54d41f9c43b91 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:47:43 +0000 Subject: [PATCH 26/35] docs(cache): explain serialized class policy defaults Describe the global false, array, and unrestricted policy modes at the configuration source. Clarify provider contributions, forged-payload safety, and native PhpRedis serializer bypass. --- src/foundation/config/cache.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/foundation/config/cache.php b/src/foundation/config/cache.php index 2d9d2dbbf..dffc2157a 100644 --- a/src/foundation/config/cache.php +++ b/src/foundation/config/cache.php @@ -124,11 +124,13 @@ | Serializable Classes |-------------------------------------------------------------------------- | - | This value determines the classes that can be unserialized from cache - | storage. By default, no PHP classes will be unserialized from your - | cache to prevent gadget chain attacks if your APP_KEY is leaked. - | If the Redis cache connection uses a native PhpRedis serializer, that - | serializer handles deserialization and this setting does not apply. + | This global value determines the classes that PHP cache stores may + | unserialize. False allows only classes contributed by framework and + | package providers, an array adds application classes, and null or true + | allows every class. False is the secure default because unserializing + | arbitrary classes can expose gadget chains when cache payloads are forged. + | Native PhpRedis serializers handle deserialization themselves, so this + | policy does not apply to those connections. | */ From 50dcd313f170d1dd6f5cc862f4c2a46abc632c3d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:47:51 +0000 Subject: [PATCH 27/35] docs(cache): document serialized object caching Document boot-time class contributions, nested application objects, denied-class behavior, native Redis serializer rules, and model-store validation. Explain failover invalidation behavior and the Session serializer boundary without exposing internal implementation details. --- src/boost/docs/cache.md | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/boost/docs/cache.md b/src/boost/docs/cache.md index b024bc314..0b0e3f10c 100644 --- a/src/boost/docs/cache.md +++ b/src/boost/docs/cache.md @@ -82,7 +82,9 @@ Use `array` for request-local test and scratch data. Use `worker-array` only whe ### Serializable Cached Objects -By default, Hypervel cache stores that serialize values do not instantiate PHP classes while unserializing them. If your application intentionally caches objects, list the classes that may be unserialized using the `serializable_classes` option: +Hypervel applies one global class policy to PHP-serialized cache values. By default, `serializable_classes` is `false`, so PHP may instantiate only classes contributed by framework and package providers. Auth and Sanctum contribute their configured root models and Hypervel contributes the standard Eloquent collection and pivot classes they use. + +Set this option to an array to add application-owned classes, or to `null` / `true` to allow every class: ```php 'serializable_classes' => [ @@ -90,9 +92,29 @@ By default, Hypervel cache stores that serialize values do not instantiate PHP c ], ``` -This applies to database, file, Redis, storage, and Swoole stores, as well as stacks containing them. Array and worker-array stores configured with `'serialize' => false` keep values in memory and do not use this setting. +Packages and applications may contribute classes lazily from a service provider's `boot` method. Contributions are combined with the configured array in registration order, and duplicates are removed: + +```php +use App\Models\Organization; +use App\Models\Team; +use Hypervel\Support\Facades\Cache; -You may register a callback during application boot to observe cached objects whose classes are not allowed or available: +public function boot(): void +{ + Cache::allowSerializableClassesUsing(fn (): array => [ + Organization::class, + Team::class, + ]); +} +``` + +The resolver is evaluated after every provider has booted and must be registered during process startup. Declare application relation models, morph targets, custom collections and pivots, and any other nested objects that may be cached. Unknown class names may be declared without loading them immediately. + +The policy applies to serializing array and worker-array stores, database, file, storage, Redis using `Redis::SERIALIZER_NONE`, and Swoole. A stack applies the same policy through each serializing layer. Array stores with serialization disabled keep live values, while the session cache follows the separate serializer selected in `config/session.php`. + +When a denied root object is read, PHP returns an `__PHP_Incomplete_Class`. Nested denied objects follow normal PHP behavior: using the object directly raises an error containing `The script tried to` and names the denied class. Eloquent's `toArray` and `toJson` omit an incomplete relation instead of returning it as `null`. Add the named class to the configured array or a boot-time resolver. If the class was removed from the application, clear the stale cache entry instead. + +You may register an optional boot-time callback for top-level incomplete objects. Without a callback, Hypervel leaves PHP's incomplete object unchanged: ```php use Hypervel\Support\Facades\Cache; @@ -102,7 +124,9 @@ Cache::handleUnserializableClassUsing(function (string $key, ?string $class) { }); ``` -If you use Redis and rely on this allowlist, configure the connection used by your cache store with the default `Redis::SERIALIZER_NONE` serializer. Native PhpRedis serializers handle cached values before Hypervel can apply the allowlist; `Redis::SERIALIZER_PHP` and `Redis::SERIALIZER_IGBINARY` may instantiate PHP classes when reading cached values. With a native serializer, the callback may still report unavailable classes, but it cannot report classes excluded by the allowlist. Other Redis connections may continue using any supported serializer. +Native PhpRedis serializers restore values before Hypervel can apply the PHP class policy. Use `Redis::SERIALIZER_NONE` when policy enforcement is required. Native PHP and igbinary serializers preserve PHP object types while bypassing the policy. Msgpack also preserves classes and references when `msgpack.php_only=1`; without PHP-only mode, and with the JSON serializer, objects become arrays. Configure `msgpack.php_only` in `php.ini` before workers start rather than changing this process-wide setting while requests are running. + +Igbinary maintains a table of repeated strings and class names, which can suit Eloquent graphs with repeated attribute keys. Msgpack provides a compact binary encoding but repeats string and class names. Benchmark your own values and access patterns before choosing a serializer. ### Driver Prerequisites @@ -256,6 +280,8 @@ CACHE_STORE=failover When a cache store operation fails and failover is activated, Hypervel will dispatch the `Hypervel\Cache\Events\CacheFailedOver` event, allowing you to report or log that a cache store has failed. +Reads, writes, increments, locks, and other ordinary operations stop after the first store call that does not throw. `forget` and `flush` instead attempt every configured store so a stale lower-priority value cannot reappear later. They return `false` after a partial failure; if every store throws, the last exception is rethrown. + ## Cache Usage From 3751a1140d1788e02733fcead58283a806cdbf4b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:47:56 +0000 Subject: [PATCH 28/35] docs(auth): explain cached model requirements State which provider models and Eloquent containers Auth contributes automatically and which application-owned graph classes remain explicit. Document supported stores, Redis serializer rules, startup-only configuration, and cache staleness behavior. --- src/auth/README.md | 54 +++++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/src/auth/README.md b/src/auth/README.md index 3a4a02270..f445eba9b 100644 --- a/src/auth/README.md +++ b/src/auth/README.md @@ -38,15 +38,25 @@ Per-provider config in `config/auth.php`: ], ``` -Allow every configured user model to be unserialized in `config/cache.php`: +Cache configuration is read during process startup and must not be changed while a worker is serving requests. + +Enabled Eloquent provider models and Hypervel's standard Eloquent collection and pivot classes are added to the cache class policy automatically. Declare application-owned relations, custom collections or pivots, and other nested objects from a service provider: ```php -'serializable_classes' => [ - App\Models\User::class, -], +use App\Models\Organization; +use App\Models\Team; +use Hypervel\Support\Facades\Cache; + +public function boot(): void +{ + Cache::allowSerializableClassesUsing(fn (): array => [ + Organization::class, + Team::class, + ]); +} ``` -For supported stores and Redis serializer requirements, see [Serializable Cached Objects](https://hypervel.org/docs/cache#serializable-cached-objects). +Providers constructed directly and not represented in `auth.providers` must also declare their root model. These declarations apply to PHP-policy serialization paths. Accepted native Redis serializers preserve model types but bypass the class policy. See [Serializable Cached Objects](https://hypervel.org/docs/cache#serializable-cached-objects) for denied nested-class behavior and remedies. Minimum env setup for single Redis node: @@ -64,14 +74,11 @@ 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()`. +At high request volume, every authenticated request otherwise reads and hydrates the user from its provider. -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: +The `stack = [swoole → redis]` topology can keep hot lookups in each node's Swoole table for a short period. Repeated requests for the same user can then skip the shared Redis round trip. This reduces shared-cache traffic and latency, with bounded L1 staleness as the trade-off. -- 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 +Choose the L1 TTL from the application's consistency requirements and measure the real workload rather than assuming a fixed hit rate or latency. ### Invalidation model @@ -87,7 +94,7 @@ Four layers, most-automatic to most-manual: **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. +**Across nodes:** only shared tiers propagate invalidation. If you use `stack = [swoole, redis]`, invalidation clears the origin node's L1 and shared Redis, while other nodes' Swoole L1s can serve their entry until its TTL expires. Applications that require strict global consistency should skip the node-local L1 tier. ### Manual invalidation API @@ -113,7 +120,7 @@ The cache key includes the provider's model FQCN, so `Auth::clearUserCache(42, ' **Multi-guard / multi-model apps:** -| Setup | Behaviour | +| Setup | Behavior | |---|---| | 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')`. | @@ -197,7 +204,7 @@ Cache::store('auth')->tags(['auth_users'])->flush(); Tags are additive — per-user reads, writes, and the automatic invalidation listener keep working as before. -**Tag mode requirement.** The configured store must implement `TaggableStore` and be in `TagMode::Any` — Redis is the only stock driver that supports configurable tag modes, via its `tag_mode` config key. `enableCache()` throws at boot if these conditions aren't met. All-mode is rejected because its tag-namespaced storage keys would force every read and forget to carry tag context, which doesn't fit the auth-cache access pattern. +**Tag mode requirement.** The configured store must implement `TaggableStore` and be in `TagMode::Any` — Redis is the only stock driver that supports configurable tag modes, via its `tag_mode` config key. Tag validation runs when the user provider is created. All-mode is rejected because its tag-namespaced storage keys would force every read and forget to carry tag context, which doesn't fit the auth-cache access pattern. For per-request tag scoping (e.g. tagging each cached user with their tenant), see **Dynamic tag resolvers** below. @@ -206,7 +213,7 @@ For per-request tag scoping (e.g. tagging each cached user with their tenant), s | 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. | +| Password or other direct model change | Eloquent model writes clear the cached user. Eventless writes require explicit invalidation or acceptance of the TTL bound. | | 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. | @@ -218,17 +225,21 @@ For per-request tag scoping (e.g. tagging each cached user with their tenant), s | `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. | +| `storage` | depends | Shared only when the configured filesystem disk is shared by every node. | | `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. +- `array` — coroutine-local; nothing persists across requests. +- `worker-array` — worker-local copies diverge across workers and nodes. - `null` — discards writes. -- `failover` — ambiguous fallback semantics; silently degrades onto an unsafe tier when the primary is down. +- `failover` — an unavailable primary can retain a stale identity and serve it after recovery. + +Stack layers are validated recursively, including nested stacks. -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. +For Redis, `SERIALIZER_NONE`, native PHP, and available igbinary serializers preserve model types. Msgpack is accepted only with `msgpack.php_only=1`. JSON, non-PHP msgpack, and unknown serializer modes are rejected because they can return arrays instead of models. Native serializers bypass `cache.serializable_classes`; use `SERIALIZER_NONE` when class-policy enforcement is required. ### Tenant-aware cache keys @@ -283,13 +294,12 @@ Cache::store('auth')->tags(['tenant:5'])->flush(); // just tenant 5's users ### 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. +- **`withQuery()` caches the first-seen shape.** If the provider eager-loads relations, the first uncached call stores that graph. Declare every application relation and custom container class in the cache class policy so later hits can restore the full shape. - **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 -Keep `cache.serializable_classes` limited to the user model classes this cache needs. Broad allowlists expand PHP's unserialization surface. +Configured provider models and stock Eloquent graph containers are allowed automatically. Keep manual declarations limited to the application-owned classes the cached graph needs. Broad allowlists expand PHP's unserialization surface. Native Redis serializers bypass this policy. For auth-sensitive contexts (admin panels, financial actions), consider: @@ -297,4 +307,4 @@ For auth-sensitive contexts (admin panels, financial actions), consider: - 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. +Eloquent user model saves and deletes clear the cached entry. Writes that suppress or bypass model events require explicit invalidation or acceptance of the configured TTL bound. From 4e253998b606003e5ac578f9a1c60b522cabc797 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:48:02 +0000 Subject: [PATCH 29/35] docs(auth): document user cache serialization Update application guidance for automatic user-model contributions, explicit nested graph classes, recursive store validation, and type-preserving Redis serializers. Clarify invalidation and maximum-staleness behavior without requiring manual root-model configuration. --- src/boost/docs/authentication.md | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/boost/docs/authentication.md b/src/boost/docs/authentication.md index 0b832e59a..ecdc0ccce 100644 --- a/src/boost/docs/authentication.md +++ b/src/boost/docs/authentication.md @@ -207,15 +207,23 @@ You may enable the cache per Eloquent provider in your application's `config/aut ], ``` -Because the supported stores serialize cached user models, add every configured user model to the `serializable_classes` option in your application's `config/cache.php` file: +Enabled configured provider models and Hypervel's standard Eloquent collection and pivot classes are added to the cache class policy automatically. Declare application-owned relations, custom collections or pivots, and other nested objects from a service provider: ```php -'serializable_classes' => [ - App\Models\User::class, -], +use App\Models\Organization; +use App\Models\Team; +use Hypervel\Support\Facades\Cache; + +public function boot(): void +{ + Cache::allowSerializableClassesUsing(fn (): array => [ + Organization::class, + Team::class, + ]); +} ``` -Keep the allowlist limited to the user model classes your authentication cache needs. For supported stores and Redis serializer requirements, see [Serializable Cached Objects](/docs/{{version}}/cache#serializable-cached-objects). +Providers constructed directly and not represented in `auth.providers` must also declare their root model. These declarations apply to PHP-policy serialization paths. Accepted native Redis serializers preserve model types but bypass the class policy. See [Serializable Cached Objects](/docs/{{version}}/cache#serializable-cached-objects) for denied nested-class behavior and remedies. When `store` is `null`, Hypervel uses your default cache store. For a single Redis-backed deployment, you may enable the cache like this: @@ -231,9 +239,13 @@ AUTH_USERS_CACHE_ENABLED=true AUTH_USERS_CACHE_STORE=stack ``` -Supported stores are `redis`, `database`, `file`, `swoole`, and `stack`. The `array`, `null`, `session`, and `failover` stores are rejected when the guard is resolved because they are either request-local, user-local, discard writes, or have fallback behavior that is not appropriate for authentication data. For untagged auth caching, Hypervel validates the outer stack store; unsupported inner stores such as `array`, `null`, `session`, or `failover` can still cause stale, missing, or unsafe auth cache behavior. Choose supported inner stores such as `swoole` and `redis`. When auth cache tags are configured, the stack's tag composition is also validated. +Supported stores are `redis`, `database`, `file`, `storage`, `swoole`, and stacks containing only supported stores. Stack layers are validated recursively. The `array`, `worker-array`, `null`, `session`, and `failover` stores are rejected. Failover is unsuitable because an unavailable primary can retain a stale identity and serve it after recovery. + +For Redis, `SERIALIZER_NONE`, native PHP, and available igbinary serializers preserve model types. Msgpack is accepted only with `msgpack.php_only=1`. JSON, non-PHP msgpack, and unknown modes are rejected because they can return arrays instead of models. Native serializers bypass `cache.serializable_classes`; use `SERIALIZER_NONE` when class-policy enforcement is required. -When using a node-local store such as `swoole` or `file`, invalidation is local to that node. In a multi-node deployment using `stack` with a Swoole L1, a user update clears the current node's L1 and the shared backing store, while other nodes may serve their L1 entry until its short TTL expires. Use plain `redis` or `database` if you need strict cross-node consistency. +When using a node-local store such as `swoole` or `file`, invalidation is local to that node. A storage store is shared only when its configured filesystem disk is shared. In a multi-node deployment using `stack` with a Swoole L1, a user update clears the current node's L1 and the shared backing store, while other nodes may serve their L1 entry until its short TTL expires. Use a shared store without a node-local L1 when strict cross-node consistency is required. + +Auth cache configuration is read during process startup and must not be changed while a worker is serving requests. The default cache key format is `{prefix}:{user-model-fqcn}:{identifier}`, such as `auth_users:App\Models\User:42`. Including the model class prevents collisions when different guards use different user models. If the same user identifier can resolve to different records depending on request context, such as in a multi-tenant application, register a cache key resolver in a service provider: @@ -264,6 +276,8 @@ Auth::clearUserCache($user->getAuthIdentifier()); Auth::clearUserCache($admin->getAuthIdentifier(), guard: 'admin'); ``` +If `withQuery()` eager-loads relations, the first uncached lookup stores that graph. Declare every application relation and custom container class so later cache hits restore the complete shape. + If multiple guards share the same Eloquent provider and user model, one clear call against any of those guards clears that provider's cache keyspace. If different guards use different user models, pass the guard name so Hypervel can clear the correct provider. When a custom key resolver is registered, `clearUserCache` uses that same resolver and clears the cache entry for the current request context. If you need to clear many cached users at once, use a dedicated cache store for auth, point `AUTH_USERS_CACHE_STORE` at that store, and flush it: From 6101859af767a15baa82680cce6505b097977643 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:48:08 +0000 Subject: [PATCH 30/35] docs(auth): update cache configuration guidance Keep framework and Testbench defaults aligned while documenting storage support, recursive stack validation, shared-cache topology, and boot-only cache configuration. Replace the stale outer-stack caveat with the validated behavior. --- src/foundation/config/auth.php | 24 ++++++++++-------------- src/testbench/hypervel/config/auth.php | 24 ++++++++++-------------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/src/foundation/config/auth.php b/src/foundation/config/auth.php index 661b31ad6..1e838d483 100644 --- a/src/foundation/config/auth.php +++ b/src/foundation/config/auth.php @@ -91,12 +91,14 @@ | 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. + | Supported stores: 'redis', 'database', 'file', 'storage', + | 'swoole', and stacks containing only supported stores. Array, + | worker-array, null, session, and failover stores are rejected. + | Stack layers are validated recursively. | - | Cross-node behaviour: + | Cross-node behavior: | - 'redis' / 'database': fully shared — invalidation is global. + | - 'storage': shared only when its configured disk is shared. | - 'file' / 'swoole': node-local, no cross-node invalidation | (single-instance deployments only). | - 'stack' with a node-local upper tier (e.g. [swoole, redis]): @@ -104,16 +106,10 @@ | 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: without tags, only the outer store is validated. A stack - | with an unsupported inner tier (e.g. [array, redis]) won't be caught. - | When cache tags are enabled, the stack's tag composition is also - | validated. + | A short-lived node-local L1 over a shared L2 can reduce shared + | cache traffic, with bounded L1 staleness as the trade-off. Cache + | configuration is read during process startup and must not change + | while a worker is serving requests. | | Cache tags (optional): | Set 'tags' to an array of tag names (e.g. ['auth_users']) diff --git a/src/testbench/hypervel/config/auth.php b/src/testbench/hypervel/config/auth.php index a736b2026..e63f6af5a 100644 --- a/src/testbench/hypervel/config/auth.php +++ b/src/testbench/hypervel/config/auth.php @@ -17,12 +17,14 @@ | 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. + | Supported stores: 'redis', 'database', 'file', 'storage', + | 'swoole', and stacks containing only supported stores. Array, + | worker-array, null, session, and failover stores are rejected. + | Stack layers are validated recursively. | - | Cross-node behaviour: + | Cross-node behavior: | - 'redis' / 'database': fully shared — invalidation is global. + | - 'storage': shared only when its configured disk is shared. | - 'file' / 'swoole': node-local, no cross-node invalidation | (single-instance deployments only). | - 'stack' with a node-local upper tier (e.g. [swoole, redis]): @@ -30,16 +32,10 @@ | 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: without tags, only the outer store is validated. A stack - | with an unsupported inner tier (e.g. [array, redis]) won't be caught. - | When cache tags are enabled, the stack's tag composition is also - | validated. + | A short-lived node-local L1 over a shared L2 can reduce shared + | cache traffic, with bounded L1 staleness as the trade-off. Cache + | configuration is read during process startup and must not change + | while a worker is serving requests. | | Cache tags (optional): | Set 'tags' to an array of tag names (e.g. ['auth_users']) From f75407a2aeeece7c38f9cbfa9ecb546db1dff6ca Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:48:12 +0000 Subject: [PATCH 31/35] docs(sanctum): explain identity cache behavior Document automatic token and provider model contributions, explicit application graph classes, supported serializers and stores, and startup-only configuration. Clarify tokenable separation, invalidation, and staleness guarantees. --- src/sanctum/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sanctum/README.md b/src/sanctum/README.md index f13e30747..6a7ffe2e8 100644 --- a/src/sanctum/README.md +++ b/src/sanctum/README.md @@ -9,6 +9,9 @@ Ported from: https://github.com/laravel/sanctum - Hypervel only supports the `id|token` format returned by `createToken()`. Laravel's legacy plain-token lookup is omitted because Hypervel's token cache and invalidation paths are keyed by token ID. - Hypervel includes optional token and tokenable lookup caching for Swoole workers. Missing token IDs and missing tokenable models are cached as `null` for the configured TTL to avoid repeated database reads. +- When caching is enabled, the selected personal-access-token model, configured Sanctum guard provider models, and stock Eloquent graph containers are added to the cache class policy automatically. Applications declare custom-provider morph targets, nested relations, custom containers, and other application-owned objects with `Cache::allowSerializableClassesUsing()` during provider boot. +- Sanctum validates its cache store during process startup. Redis, database, file, storage, Swoole, and supported-only stacks are accepted; array, worker-array, null, session, failover, and type-destroying Redis serializer modes are rejected. Accepted native Redis serializers preserve model types but bypass the PHP class policy. +- Cached token entries never embed `tokenable`. The live token receives the exact resolved tokenable before callbacks and events. Deletion and application-visible token updates clear both entries, while Sanctum's internal `last_used_at` write clears only the token entry. The tokenable TTL is its maximum staleness bound. - The global `sanctum.guard` accept-list is removed. Each sanctum-driver guard declares its trusted session guards with `auth.guards.{guard}.session_guards`; `[]` means bearer tokens only, and a missing key is a config error. Stateful session users must also match the sanctum guard's provider; Laravel returns any listed guard's user unchecked. - `sanctum.stateful` is renamed `sanctum.stateful_domains`, matching the `SANCTUM_STATEFUL_DOMAINS` environment variable and the key's actual contents. - Sanctum's session password-hash artifacts are HMAC-only. Laravel's raw-hash fallback for legacy sessions is intentionally omitted because Hypervel 0.4 has no released legacy sessions. From 11c42c54edaf121d4c049682a362298cf53734ba Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:48:17 +0000 Subject: [PATCH 32/35] docs(sanctum): document serialized token caching Explain automatic identity-model contributions, application-owned nested classes, recursive store validation, and Redis serializer requirements. Describe the cache shapes and invalidation rules that preserve zero-query hot authentication. --- src/boost/docs/sanctum.md | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/boost/docs/sanctum.md b/src/boost/docs/sanctum.md index 39ba51c23..320651646 100644 --- a/src/boost/docs/sanctum.md +++ b/src/boost/docs/sanctum.md @@ -181,26 +181,41 @@ Token caching is disabled by default. You may enable and configure it in your ap ], ``` -Because token caching stores both personal access token and tokenable model objects, add the configured model classes to the `serializable_classes` option in your application's `config/cache.php` file: +When caching is enabled, Sanctum adds the selected personal access token model, Eloquent models used by configured Sanctum guard providers, and Hypervel's standard Eloquent collection and pivot classes to the cache class policy automatically. Declare custom-provider morph targets, nested `$with` relations, custom collections or pivots, and other application-owned objects from a service provider: ```php -'serializable_classes' => [ - App\Models\User::class, - Hypervel\Sanctum\PersonalAccessToken::class, -], +use App\Models\Organization; +use App\Models\Team; +use Hypervel\Support\Facades\Cache; + +public function boot(): void +{ + Cache::allowSerializableClassesUsing(fn (): array => [ + Organization::class, + Team::class, + ]); +} ``` -If you use a custom personal access token model, list it instead of the default model. For supported stores and Redis serializer requirements, see [Serializable Cached Objects](/docs/{{version}}/cache#serializable-cached-objects). +These declarations apply to PHP-policy serialization paths. Accepted native Redis serializers preserve model types but bypass the class policy. See [Serializable Cached Objects](/docs/{{version}}/cache#serializable-cached-objects) for denied nested-class behavior and remedies. The `store` option determines which cache store is used. When this value is `null`, Sanctum uses your application's default cache store. The `ttl` option controls how long token and tokenable entries remain cached, in seconds. The `prefix` option is prepended to Sanctum's cache keys. +Redis, database, file, storage, Swoole, and stacks containing only supported stores may be used. Stack layers are validated recursively. Array, worker-array, null, session, and failover stores are rejected. Failover is unsuitable because an unavailable primary can retain a stale identity and serve it after recovery. + +For Redis, `SERIALIZER_NONE`, native PHP, and available igbinary serializers preserve model types. Msgpack is accepted only with `msgpack.php_only=1`. JSON, non-PHP msgpack, and unknown modes are rejected because they can return arrays instead of models. Native serializers bypass `cache.serializable_classes`; use `SERIALIZER_NONE` when class-policy enforcement is required. + +Sanctum cache settings and `sanctum.last_used_at` are read during process startup and must not be changed while a worker is serving requests. + Sanctum also caches missing token IDs and missing tokenable models as `null` results for the configured TTL. This protects your database from repeated lookups for the same revoked, deleted, or orphaned token data. Because token IDs come from request input, use a cache store with bounded memory or an eviction policy when enabling token caching on public endpoints. The `last_used_at_update_interval` option controls how frequently Sanctum writes a cached token's `last_used_at` timestamp back to the database. The default value is `300`, so the timestamp is updated at most once every five minutes for each token while caching is enabled. The cache TTL should be greater than or equal to this interval so active cached tokens do not expire before the next allowed timestamp write. Sanctum token caching pairs well with the authentication package's [user lookup cache](/docs/{{version}}/authentication#user-lookup-cache). Token-authenticated routes often need both the personal access token and its user model, so enabling both caches can reduce repeated database reads on hot authenticated endpoints. -When a personal access token model is updated or deleted, Sanctum automatically clears that token's cached token and tokenable entries. You may also clear a token's cache manually using the `clearTokenCache` method on the personal access token model: +The cached token entry never embeds its `tokenable` relation. During authentication, the live token receives the exact tokenable instance used for provider validation before authentication callbacks and events run. + +Deleting a personal access token or making an application-visible update clears both cached entries. Sanctum's internal `last_used_at` write clears only the token entry, so it does not defeat the tokenable cache. You may also clear both entries manually using the `clearTokenCache` method: ```php use Hypervel\Sanctum\PersonalAccessToken; @@ -208,7 +223,7 @@ use Hypervel\Sanctum\PersonalAccessToken; PersonalAccessToken::clearTokenCache($tokenId); ``` -If you change data on the tokenable model that must be reflected immediately during token authentication, you should either use a short cache TTL or clear the cache for that model's tokens: +Tokenable model changes do not automatically evict token-ID-keyed entries. The cache TTL is therefore the maximum staleness bound. If a change must be reflected immediately during token authentication, clear the cache for that model's tokens: ```php use Hypervel\Sanctum\PersonalAccessToken; From 319537b9d7ce512a6e978251f46a38cbcb1e98a0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:48:25 +0000 Subject: [PATCH 33/35] docs(sanctum): clarify tokenable cache staleness State that the configured token cache TTL is also the maximum time a cached tokenable identity may remain stale. Keep the existing last-used update interval guidance intact. --- src/sanctum/config/sanctum.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sanctum/config/sanctum.php b/src/sanctum/config/sanctum.php index 5405697db..b757af176 100644 --- a/src/sanctum/config/sanctum.php +++ b/src/sanctum/config/sanctum.php @@ -84,7 +84,8 @@ | | When enabled, Sanctum will cache token and tokenable lookups to improve | performance. The last_used_at timestamp will be updated at the specified - | interval instead of on every request to reduce database writes. + | interval instead of on every request to reduce database writes. The TTL + | is the maximum time a cached tokenable identity may remain stale. | */ From 7eb69f9a9a44950e38dfff67cdabb63c7d0b39c5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:18:14 +0000 Subject: [PATCH 34/35] test(cache): tighten serialized policy coverage Pin idempotent finalization and Redis serializer option precedence, and type the remaining validator test double.\n\nClarify provider contributions, node-local store guidance, and deliberate taskworker coverage without changing runtime behavior. --- src/boost/docs/cache.md | 2 +- src/foundation/config/auth.php | 4 ++-- src/foundation/config/cache.php | 12 +++++----- src/testbench/hypervel/config/auth.php | 4 ++-- .../AuthEloquentUserProviderCacheTest.php | 2 +- tests/Auth/AuthServiceProviderTest.php | 1 + tests/Cache/CacheServiceProviderTest.php | 1 + tests/Cache/ModelCacheStoreValidatorTest.php | 7 +++++- tests/Cache/SerializableClassPolicyTest.php | 24 +++++++++++++++++++ tests/Sanctum/SanctumServiceProviderTest.php | 1 + 10 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/boost/docs/cache.md b/src/boost/docs/cache.md index 0b0e3f10c..25b85e6d3 100644 --- a/src/boost/docs/cache.md +++ b/src/boost/docs/cache.md @@ -82,7 +82,7 @@ Use `array` for request-local test and scratch data. Use `worker-array` only whe ### Serializable Cached Objects -Hypervel applies one global class policy to PHP-serialized cache values. By default, `serializable_classes` is `false`, so PHP may instantiate only classes contributed by framework and package providers. Auth and Sanctum contribute their configured root models and Hypervel contributes the standard Eloquent collection and pivot classes they use. +Hypervel applies one global class policy to PHP-serialized cache values. By default, `serializable_classes` is `false`, so PHP may instantiate only classes contributed by framework, package, and application providers. Auth and Sanctum contribute their configured root models and Hypervel contributes the standard Eloquent collection and pivot classes they use. Set this option to an array to add application-owned classes, or to `null` / `true` to allow every class: diff --git a/src/foundation/config/auth.php b/src/foundation/config/auth.php index 1e838d483..9a8df6319 100644 --- a/src/foundation/config/auth.php +++ b/src/foundation/config/auth.php @@ -99,8 +99,8 @@ | Cross-node behavior: | - 'redis' / 'database': fully shared — invalidation is global. | - 'storage': shared only when its configured disk is shared. - | - 'file' / 'swoole': node-local, no cross-node invalidation - | (single-instance deployments only). + | - 'file' / 'swoole' used as the only store: node-local, with 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 diff --git a/src/foundation/config/cache.php b/src/foundation/config/cache.php index dffc2157a..141a1deca 100644 --- a/src/foundation/config/cache.php +++ b/src/foundation/config/cache.php @@ -125,12 +125,12 @@ |-------------------------------------------------------------------------- | | This global value determines the classes that PHP cache stores may - | unserialize. False allows only classes contributed by framework and - | package providers, an array adds application classes, and null or true - | allows every class. False is the secure default because unserializing - | arbitrary classes can expose gadget chains when cache payloads are forged. - | Native PhpRedis serializers handle deserialization themselves, so this - | policy does not apply to those connections. + | unserialize. False allows only classes contributed by framework, package, + | and application providers; an array also allows the classes listed here; + | null or true allows every class. False is the secure default because + | unserializing arbitrary classes can expose gadget chains when cache + | payloads are forged. Native PhpRedis serializers handle deserialization + | themselves, so this policy does not apply to those connections. | */ diff --git a/src/testbench/hypervel/config/auth.php b/src/testbench/hypervel/config/auth.php index e63f6af5a..60fa395f2 100644 --- a/src/testbench/hypervel/config/auth.php +++ b/src/testbench/hypervel/config/auth.php @@ -25,8 +25,8 @@ | Cross-node behavior: | - 'redis' / 'database': fully shared — invalidation is global. | - 'storage': shared only when its configured disk is shared. - | - 'file' / 'swoole': node-local, no cross-node invalidation - | (single-instance deployments only). + | - 'file' / 'swoole' used as the only store: node-local, with 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 diff --git a/tests/Auth/AuthEloquentUserProviderCacheTest.php b/tests/Auth/AuthEloquentUserProviderCacheTest.php index c8b3d6c7c..6a69a5844 100644 --- a/tests/Auth/AuthEloquentUserProviderCacheTest.php +++ b/tests/Auth/AuthEloquentUserProviderCacheTest.php @@ -49,7 +49,7 @@ protected function setUp(): void $container = Container::setInstance(new Container); $this->cacheManager = m::mock(CacheManager::class); - $this->storeValidator = m::mock(); + $this->storeValidator = m::mock(ModelCacheStoreValidator::class); $this->storeValidator->shouldReceive('validate')->byDefault(); $container->instance('cache', $this->cacheManager); $container->instance(ModelCacheStoreValidator::class, $this->storeValidator); diff --git a/tests/Auth/AuthServiceProviderTest.php b/tests/Auth/AuthServiceProviderTest.php index 4e417a40f..b26284305 100644 --- a/tests/Auth/AuthServiceProviderTest.php +++ b/tests/Auth/AuthServiceProviderTest.php @@ -196,6 +196,7 @@ public function testServerValidationUsesWorkerDependenciesAndRunsForTaskworkers( $this->assertInstanceOf(Closure::class, $listener); $server = m::mock(SwooleServer::class); + // Store validation runs in request workers and taskworkers, unlike Swoole timer registration. $server->taskworker = true; $listener(new AfterWorkerStart($server, 8)); } diff --git a/tests/Cache/CacheServiceProviderTest.php b/tests/Cache/CacheServiceProviderTest.php index c46492a58..2c731d13c 100644 --- a/tests/Cache/CacheServiceProviderTest.php +++ b/tests/Cache/CacheServiceProviderTest.php @@ -113,6 +113,7 @@ public function testServerFinalizationResolvesTheWorkerManagerAtEventTime(): voi $this->assertCount(2, $listeners[AfterWorkerStart::class]); $server = m::mock(SwooleServer::class); + // Policy finalization runs in request workers and taskworkers, unlike Swoole timer registration. $server->taskworker = true; $listeners[AfterWorkerStart::class][1](new AfterWorkerStart($server, 9)); diff --git a/tests/Cache/ModelCacheStoreValidatorTest.php b/tests/Cache/ModelCacheStoreValidatorTest.php index 2dfa7f163..c43d303b6 100644 --- a/tests/Cache/ModelCacheStoreValidatorTest.php +++ b/tests/Cache/ModelCacheStoreValidatorTest.php @@ -279,7 +279,7 @@ public function testConnectionOptionsOverrideSharedOptions(): void $this->assertTrue(true); } - public function testLastRecognizedRedisSerializerOptionWins(): void + public function testLastNumericRedisSerializerOptionWinsOverStringOption(): void { $this->validator(connectionOptions: [ 'serializer' => Redis::SERIALIZER_JSON, @@ -287,13 +287,18 @@ public function testLastRecognizedRedisSerializerOptionWins(): void ])->validate($this->repository($this->redisStore()), 'Auth user cache'); $this->assertTrue(true); + } + public function testLastStringRedisSerializerOptionWinsOverNumericOption(): void + { $validator = $this->validator(connectionOptions: [ Redis::OPT_SERIALIZER => Redis::SERIALIZER_PHP, 'SERIALIZER' => Redis::SERIALIZER_JSON, ]); $this->expectException(UnsupportedModelCacheStoreException::class); + $this->expectExceptionMessage('serializer [' . Redis::SERIALIZER_JSON . ']'); + $validator->validate($this->repository($this->redisStore()), 'Auth user cache'); } diff --git a/tests/Cache/SerializableClassPolicyTest.php b/tests/Cache/SerializableClassPolicyTest.php index 8db616d41..0a61a5a0b 100644 --- a/tests/Cache/SerializableClassPolicyTest.php +++ b/tests/Cache/SerializableClassPolicyTest.php @@ -140,6 +140,30 @@ public function testUnserializeRecomputesBeforeFinalizationAndFreezesAfterward() $this->assertSame(3, $resolverRuns); } + public function testFinalizationIsIdempotentAndDoesNotWidenThePolicy(): void + { + $resolverRuns = 0; + $policy = new SerializableClassPolicy(static fn (): false => false); + $policy->allowUsing(static function () use (&$resolverRuns): array { + ++$resolverRuns; + + return [SerializablePolicyAllowedClass::class]; + }); + + $policy->finalize(); + $policy->finalize(); + + $this->assertSame(1, $resolverRuns); + $this->assertInstanceOf( + SerializablePolicyAllowedClass::class, + $policy->unserialize(serialize(new SerializablePolicyAllowedClass)), + ); + $this->assertInstanceOf( + __PHP_Incomplete_Class::class, + $policy->unserialize(serialize(new SerializablePolicyDeclaredClass)), + ); + } + public function testFinalizationClearsResolverCaptures(): void { $capture = new stdClass; diff --git a/tests/Sanctum/SanctumServiceProviderTest.php b/tests/Sanctum/SanctumServiceProviderTest.php index 41fe75382..c07592e6c 100644 --- a/tests/Sanctum/SanctumServiceProviderTest.php +++ b/tests/Sanctum/SanctumServiceProviderTest.php @@ -226,6 +226,7 @@ public function testServerValidationUsesWorkerDependenciesAndRunsForTaskworkers( $this->assertInstanceOf(Closure::class, $listener); $server = m::mock(SwooleServer::class); + // Store validation runs in request workers and taskworkers, unlike Swoole timer registration. $server->taskworker = true; $listener(new AfterWorkerStart($server, 8)); } From 216cfd6a7a84de27d7c31dbfd6723031756dc364 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:18:25 +0000 Subject: [PATCH 35/35] docs(cache): record serialized class policy design Document the final architecture, worker lifecycle, performance invariants, model cache rules, and verification coverage for serialized cache values. --- ...ble-class-policy-and-model-cache-safety.md | 1905 +++++++++++++++++ 1 file changed, 1905 insertions(+) create mode 100644 docs/plans/2026-07-27-0615-cache-serializable-class-policy-and-model-cache-safety.md diff --git a/docs/plans/2026-07-27-0615-cache-serializable-class-policy-and-model-cache-safety.md b/docs/plans/2026-07-27-0615-cache-serializable-class-policy-and-model-cache-safety.md new file mode 100644 index 000000000..2c7f757ee --- /dev/null +++ b/docs/plans/2026-07-27-0615-cache-serializable-class-policy-and-model-cache-safety.md @@ -0,0 +1,1905 @@ +# Cache Serializable-Class Policy and Model-Cache Safety + +## Status + +The design is signed off. + +## Objective + +Make PHP object caching secure by default without requiring applications to repeat +framework-owned model classes in `cache.serializable_classes`. + +The finished design must: + +- preserve freshly reloaded package configuration in replacement Swoole workers; +- keep one global cache unserialization policy across every manager-built + built-in direct PHP-serializing cache store; +- let framework and package providers contribute lazily resolved classes during boot; +- automatically support the ordinary Auth and Sanctum model classes; +- accept every type-preserving native Redis serializer for first-party model + caches while rejecting type-destroying modes; +- correct the Sanctum token/tokenable cache flow; +- remove stale values from every reachable failover leaf on `forget()` and `flush()`; +- retain no per-store allowlist machinery, compatibility layer, duplicate validator, + stale documentation, model-graph preprocessing, extra backend request, or + additional serialization pass. + +Hypervel-specific backwards compatibility and minimizing churn are not design +constraints. Laravel API parity remains a design constraint; every planned +difference has explicit approval and is described at its relevant section. The +final tree should look as though object caching, first-party identity caches, +composite stores, and long-lived workers were designed together. + +## Verified Problem + +### Hypervel cannot copy Laravel's static configuration model + +Laravel reads cache configuration during a short-lived request and does not ship +Hypervel's Auth user cache or Sanctum token/tokenable cache. Hypervel: + +- memoizes cache stores for the worker lifetime; +- can construct a serializing store during another provider's `boot()`; +- permits an application provider to select a custom Sanctum token model later in + the same boot sequence; +- caches Eloquent models across requests in Auth and Sanctum. + +Passing the current `cache.serializable_classes` value into a store constructor +therefore freezes an incomplete list. A per-store declaration API would not fix +this cleanly: `stack` and `failover` themselves do not unserialize, while their +transitive leaves do, and a `null` store name depends on `cache.default`. The +allowlist is consequently global and shared by all serializing cache stores. + +Application boot is not the final configuration boundary for a Swoole worker. +The `serve` command boots providers in the master process, then +`ReloadDotenvAndConfig` rebuilds configuration during each worker's +`BeforeWorkerStart`. `LoadConfiguration` copies the rebuilt items into the same +configuration repository object, so provider-captured configuration repositories +can observe the worker values. Application `booted()` callbacks do not run again. + +`ConfigMutationTracker` currently records the result of every boot-time +`Repository::set()`. Package `mergeConfigFrom()` and +`replaceConfigRecursivelyFrom()` calls therefore record complete arrays after +their `env()` expressions and application overrides have been evaluated in the +master. Worker reload briefly installs the fresh values, then mutation replay +writes those master snapshots over them. An unpublished package config is restored +only from the stale snapshot; a published package config is likewise overwritten. + +The tracker must preserve two different semantics: + +- replay an authoritative value when the mutation result itself must match master + state, such as `server.servers` after Swoole listeners have been created; +- replay an operation when its result depends on configuration and environment + state rebuilt for the worker, as both package config merge helpers do. + +Package merge operations must consequently be re-evaluated against the fresh +worker repository without rerunning providers or changing ordinary boot-mutation +replay. + +Policy finalization and configured model-store validation must consequently use +two explicit schedules: + +- console and test processes finalize and validate from application `booted()` + callbacks because they never dispatch worker-start events; +- Swoole processes finalize and validate from independent, unguarded + `AfterWorkerStart` listeners in every ordinary worker and taskworker, after + configuration reload and before the worker-start coordinator resumes. + +The server master deliberately remains unfinalized and retains its small resolver +closures for the server lifetime. It serves no requests. Do not place finalization +or validation inside `CreateSwooleTimers`: that listener intentionally runs only +on worker zero and never on taskworkers. + +The dual schedule is required because Auth and Sanctum cache enablement can change +with the worker environment. Finalizing from `booted()` in server mode would +freeze the master's contribution list and skip validation when freshly reloaded +worker configuration enables a feature. + +Local Laravel references remain useful for public cache behavior, but not for this +worker-lifetime policy. These paths are relative to the monorepo root +(`../../../examples/laravel/framework` from this worktree): + +- `examples/laravel/framework/src/Illuminate/Cache/CacheManager.php`; +- `examples/laravel/framework/src/Illuminate/Cache/Repository.php`; +- `examples/laravel/framework/src/Illuminate/Cache/SessionStore.php`; +- `examples/laravel/framework/src/Illuminate/Cache/FailoverStore.php`. + +### Framework-known classes should not require application declarations + +`cache.serializable_classes` defaults to `false`. Auth and Sanctum documentation +currently tells every application to add its root models manually. This has three +problems: + +1. The framework already knows the configured Eloquent provider and Sanctum token + model classes. +2. `EloquentUserProvider::withQuery()` and custom model `$with` properties can add + nested classes that configuration cannot derive. +3. `Repository::handleIncompleteClass()` checks only a top-level + `__PHP_Incomplete_Class`. A valid root model can contain denied relation objects + and pass that check. + +A local round-trip probe confirmed the classes in an ordinary to-many graph: + +1. root model only: missing `Hypervel\Database\Eloquent\Collection`; +2. root plus collection: missing the related application model; +3. many-to-many relations can additionally contain the stock `Pivot` or + `MorphPivot`. + +`Model::__sleep()` merges cached casts and clears cast caches before returning the +serialized property list, so Carbon cast objects are not an additional automatic +allowlist requirement. + +The cache write always contains a valid serialized payload. PHP creates incomplete +objects only when a later restricted read encounters a denied class. A denied root +reaches `Repository::handleIncompleteClass()`. A denied nested object follows PHP +and Laravel behavior: direct property or method use fails and names the class, +while Eloquent `toArray()` / `toJson()` omits an incomplete relation entirely +rather than returning it as `null`. The framework must document those signals and +the declaration remedies without adding model preprocessing to ordinary cache +operations. + +### Sanctum's current cache flow defeats its tokenable cache + +`SanctumGuard::user()` calls `isValidAccessToken()` before +`PersonalAccessToken::findTokenable()`. Provider validation inside +`isValidAccessToken()` reads `$accessToken->tokenable`, causing a database query. +`findTokenable()` then returns a second instance from its cache. The object used +for provider validation is not necessarily the one passed to `withAccessToken()`. + +The existing cache suite uses a coroutine-local `array` store with +`serialize => false` without crossing a coroutine/request boundary. Repeated +lookups therefore return the same live token object and mask both the query and +object-identity defects. `withAccessToken()` also mutates the user model, so a +worker-shared store that retains live objects would be unsafe across coroutines. + +Token refresh has two further defects: + +- `updateLastUsedAt()` writes the live PAT back to cache after `tokenable` may have + been loaded. The PAT entry can then carry a stale tokenable and repopulate the + dedicated tokenable entry after it expires. +- the PAT `updating` listener clears both the PAT and tokenable entries before + every update. With last-used tracking enabled, which is the default, a fresh + token cannot take the interval early return because `last_used_at` is `null`. + Its first successful authentication therefore populates the tokenable entry and + deterministically deletes it during the audit write in the same request. The + next request queries the user again. + +The guard's last-used setting is fixed when the guard is constructed, so changing +`sanctum.last_used_at` at runtime does not alter an existing guard. + +### Store suitability is semantic, not just serializability + +Auth has a private outer-store whitelist; Sanctum has no equivalent. The correct +shared rules are: + +| Store | Model cache decision | Reason | +|---|---|---| +| Redis | allow conditionally | Shared/persistent; the serializer must preserve PHP object types. | +| Database | allow | Shared/persistent and policy-aware. | +| File | allow | Persistent; node-local deployments must accept node-local invalidation. | +| Storage | allow | Persistent and policy-aware. | +| Swoole | allow | Node-local; valid for explicitly single-node use. | +| Stack | recurse | Every leaf must be suitable. | +| Array | reject | Coroutine-local, so it is not a cross-request cache. | +| Worker array | reject | Per-worker copies diverge; unserialized mode also shares mutable objects across coroutines. | +| Null | reject | Does not cache. | +| Session | reject | Per-session identity is not a cross-request shared identity cache. | +| Failover | reject | First-success writes and unreachable leaves can later expose stale identities. | + +A failover `[redis, database]` can write only Redis, fall back to an old database +value during an outage, or fail to delete an unavailable Redis value and serve it +after recovery. Even an all-leaf best-effort delete cannot revoke an unreachable +primary. Nesting failover inside a stack must not bypass rejection. + +For Redis model caches, a native phpredis serializer bypasses PHP's +`allowed_classes` policy, but bypass is not itself a functional failure: + +- PHP and igbinary preserve the Eloquent runtime type; +- msgpack preserves PHP classes and references when `msgpack.php_only=1`; +- JSON and msgpack with `msgpack.php_only=0` return arrays instead of models. + +The validator therefore rejects type-destroying and unknown modes while accepting +type-preserving native serializers. Compression does not affect the decision. + +### The session cache belongs to the Session serialization boundary + +`Hypervel\Cache\SessionStore` places live values into the session and does not +serialize or unserialize them itself. `Hypervel\Session\Store` serializes the whole +session later using the explicitly configured Session strategy. + +Session serialization defaults to JSON and its JSON value semantics are +documented. `SessionStore` preserves literal dotted cache keys by storing the +`_cache` collection as one flat map. Inner PHP framing would contradict the +selected Session serializer, add permanent work, and break the literal-key +design. `SessionStore`, its tests, and the existing Session documentation are not +changed by this work. + +Do not add a session-wide class-policy entry to `docs/todo.md`. JSON is the +deliberate default, and the PHP option already warns that object deserialization +increases gadget-chain risk when the application key is compromised. Session's +policy bypass is therefore a documented boundary, not an unfinished framework +gap. + +### Failover invalidation currently stops after the first non-throwing leaf + +`FailoverStore::forget()` and `flush()` use `attemptOnAllStores()`, whose name is +misleading: it returns on the first non-throwing call. Stale lower-priority values +therefore survive successful invalidation. Laravel currently has the same behavior; +Hypervel should deliberately diverge. + +Leaf `forget()` booleans cannot be folded as success: + +- Redis, file, array, session, and Swoole commonly return `false` when a key was + absent; +- database returns `true` regardless of affected rows. + +For `forget()`, reaching a leaf without an exception is successful invalidation +regardless of that boolean. `flush()` booleans do represent a real operation result +and remain foldable. + +## Final Architecture + +```text +cache.serializable_classes + │ + ▼ + SerializableClassPolicy ◄── lazy provider contributions during boot + │ + ├── shared by every built-in direct PHP-serializing cache store + └── finalized once per console process or Swoole worker + + AuthServiceProvider ───────► configured cached provider models + SanctumServiceProvider ────► PAT model + Sanctum guard provider models + App/package providers ─────► application-specific relations and morph targets +``` + +The policy is an instance owned by `CacheManager`. It is not static, not in the +cache factory contract, not per store, and has no interface. Every built-in +direct PHP-serializing store created by that manager receives the same object. + +### Performance invariants + +- Package config merge operations are captured during master boot and re-evaluated + once during each worker's existing config replay. This adds no request-time + config or cache work. +- The serializable-class feature adds no additional serialization/deserialization + pass, graph walk, provider resolution, backend request, or container lookup to + Auth/Sanctum cache operations. Sanctum's correctness fixes add only an + in-memory relation assignment after tokenable resolution, an in-place relation + removal on a cold PAT miss, and one clone with that relation removed on the + existing audit refresh write. +- Native Redis deserialization returns before the shared PHP policy. Policy-aware + direct stores add one nullable-policy check and, after finalization, a method + call plus two in-memory conditionals before the same `unserialize()`; a + tiny-string microbenchmark measured roughly 70–80 ns, while real model + deserialization and backend I/O dominate. +- Configured store/stack/serializer/msgpack-ini inspection runs once at process + start. `EloquentUserProvider::enableCache()` repeats the same validation once + when a provider is constructed so direct construction cannot bypass the + invariant; cached guards do not repeat it on user lookups. Automatic + contributed class names are resolved only at startup. Resolver closures are + cleared when each console process or Swoole worker finalizes; the non-serving + server master keeps its inherited copies. +- `SessionStore` gains no framing, policy call, or serialization work. +- Sanctum's audit-write predicate runs only when the existing last-used interval + permits a database write; ordinary hot hits still return before model events. + That write performs one small dirty-key comparison and one fewer cache delete. +- Failover's extra all-leaf work is limited to correctness-critical `forget()` and + `flush()`; reads and writes remain first-success. + +## 1. Preserve Fresh Package Configuration During Worker Reload + +### Record derived package config as operations + +Update `Hypervel\Foundation\Configuration\ConfigMutationTracker` without adding a +second tracker or changing the Config repository. Replace its inaccurate +class-level `@internal` annotation with prose that identifies it as +framework-internal boot-mutation infrastructure rather than a userland API. + +Allow the existing ordered log to hold raw mutations and semantic operations: + +```php +/** + * @var list|Closure(Repository): void> + */ +protected array $mutations = []; +``` + +Add one public method for the cross-package framework consumer: + +```php +/** + * Apply and record a configuration operation for worker replay. + * + * Boot-only. The operation is retained for worker-start replay and is + * re-evaluated against the freshly rebuilt configuration repository. + * + * @param Closure(Repository): void $mutation + */ +public function applyAndRecord(Repository $config, Closure $mutation): void +{ + $wasRecording = $this->recording; + $this->recording = false; + + try { + $mutation($config); + } finally { + $this->recording = $wasRecording; + } + + if ($wasRecording) { + $this->mutations[] = $mutation; + } +} +``` + +This applies the operation during master boot while suppressing the raw +`Repository::set()` notifications it produces. A failed operation restores the +prior recording state and exits before appending. + +Keep one ordered replay loop. It must seal recording before invoking any entry so +semantic closures cannot append their internal `set()` calls to the log while it +is being replayed: + +```php +public function replay(Repository $config): void +{ + $this->recording = false; + + foreach ($this->mutations as $mutation) { + if ($mutation instanceof Closure) { + $mutation($config); + + continue; + } + + $config->set($mutation); + } +} +``` + +Do not add a public contract, DTO, enum/mode discriminator, retry, rollback layer, +or second observer. Raw mutations keep exact value replay in their existing order. + +### Re-evaluate the two package config merge helpers + +In `Hypervel\Support\ServiceProvider`, import +`Hypervel\Config\Repository as ConcreteConfigRepository` and the Foundation +tracker. `hypervel/support` already declares its Foundation dependency, so no +package metadata changes are required. + +After each existing cached-config early return, resolve the tracker through the +container so tests and framework boot retain normal container behavior. Keep one +concise WHY comment at the resolution: package config merges depend on worker +environment/config reload, so Foundation replays the operation rather than its +master-process result. + +`mergeConfigFrom()` captures only its path, key, and provider-derived +`mergeableOptions()` list in a static closure: + +```php +$config = $this->app->make('config'); +$mergeableOptions = $this->mergeableOptions($key); + +$this->app->make(ConfigMutationTracker::class)->applyAndRecord( + $config, + static function (ConcreteConfigRepository $config) use ($path, $key, $mergeableOptions): void { + $packageDefaults = require $path; + $appConfig = $config->array($key, []); + $merged = array_merge($packageDefaults, $appConfig); + + foreach ($mergeableOptions as $option) { + if (isset($packageDefaults[$option], $appConfig[$option])) { + $merged[$option] = array_merge( + $packageDefaults[$option], + $appConfig[$option], + ); + } + } + + $config->set($key, $merged); + }, +); +``` + +Capturing the list instead of the provider avoids retaining service-provider +instances while preserving overridden `mergeableOptions()` declarations. + +`replaceConfigRecursivelyFrom()` uses the same tracker API with its existing, +distinct merge rule: + +```php +static function (ConcreteConfigRepository $config) use ($path, $key): void { + $config->set($key, array_replace_recursive( + require $path, + $config->array($key, []), + )); +} +``` + +Do not route ordinary provider `Config::set()` calls through semantic replay. +Their values may intentionally describe master-created resources. + +## 2. Add the Shared Serializable-Class Policy + +### Policy class + +Add `src/cache/src/SerializableClassPolicy.php`. + +`SerializableClassPolicy` is final because finalization is the security invariant +and there is no alternate implementation. Its normalized result is +`list|false|null`, where `null` means unrestricted. + +```php +final class SerializableClassPolicy +{ + /** + * @var list> + */ + private array $resolvers = []; + + /** @var list|false|null */ + private array|false|null $resolvedClasses = null; + + private bool $finalized = false; + + /** + * Create a serializable class policy. + * + * @param null|(Closure(): (null|array|bool)) $configuredClassesResolver + */ + public function __construct( + private ?Closure $configuredClassesResolver = null, + ) { + } + + /** + * Register classes that PHP may unserialize. + * + * Boot-only. Contributions are held for the process-wide policy and cannot + * change after finalization. + * + * @param Closure(): array $resolver + * + * @throws LogicException + */ + public function allowUsing(Closure $resolver): void + { + if ($this->finalized) { + throw new LogicException( + 'Serializable cache classes must be declared from a service provider boot() method before the cache policy is finalized during process startup.' + ); + } + + $this->resolvers[] = $resolver; + } + + /** + * Finalize the serializable class policy. + * + * Boot-only. The result is frozen for every subsequent cache read in the + * process. + * + * @internal + */ + public function finalize(): void + { + if ($this->finalized) { + return; + } + + $this->resolvedClasses = $this->resolve(); + $this->finalized = true; + $this->configuredClassesResolver = null; + $this->resolvers = []; + } + + /** + * Unserialize a cached value through the effective policy. + */ + public function unserialize(string $value): mixed + { + $classes = $this->finalized + ? $this->resolvedClasses + : $this->resolve(); + + return $classes === null + ? unserialize($value) + : unserialize($value, ['allowed_classes' => $classes]); + } +} +``` + +`resolve()` must implement these exact semantics: + +| Configured resolver/value | Effective result | +|---|---| +| no configured resolver | unrestricted `null`; do not execute any resolver | +| `null` or `true` | unrestricted `null`; do not execute any resolver | +| `false`, no declarations | `false` | +| `false`, declarations | declaration list | +| explicit array | configured list followed by declarations | + +Normalize and validate each source before unioning it. PHP ignores allowlist keys +and does not autoload names merely because they appear in `allowed_classes`, so +accept arbitrary array keys and unknown class names from both sources. Discard +each source's keys before spreading it; otherwise matching string keys across +configuration and a resolver silently overwrite an explicitly allowed class: + +```php +foreach ($classes as $key => $class) { + if (! is_string($class)) { + throw new InvalidArgumentException(/* identify source and actual key */); + } +} + +return array_values($classes); +``` + +Invalid config types, non-array resolver results, and non-string entries fail during +process-start finalization. Associative arrays are accepted and normalized per +source before the union. This prevents matching associative keys in independently +supported sources from silently discarding an operator-configured class and +producing an incomplete object on read. Invalid-entry messages name the source and +the entry's actual integer or string key. After all normalized sources have been +appended, stable dedupe with `array_values(array_unique($classes))`. Unknown names +are retained without eager autoloading and simply never match unless that class is +available when PHP unserializes a value. Keep the +`false`-with-no-contributions return before final normalization. Resolver +exceptions propagate unchanged. Clearing the closures after finalization avoids +retaining dead provider captures for the worker lifetime. The existing actionable +`LogicException` remains sufficient for late registration. + +### CacheManager API and lifecycle + +Construct one policy in `CacheManager::__construct()`. The configuration resolver +must read `$this->app` at evaluation time, not capture the constructor argument, +so the existing tests-only `setApplication()` behavior remains coherent before +finalization: + +```php +$this->serializableClassPolicy = new SerializableClassPolicy( + fn () => $this->app->make('config')->get('cache.serializable_classes'), +); +``` + +Add: + +```php +/** + * Register classes that cache stores may unserialize. + * + * Boot-only. The resolver contributes to the worker-lifetime cache policy and + * is evaluated after every provider has booted: at application boot completion + * in console processes or after configuration reload in each Swoole worker. An + * earlier cache read evaluates the current contributions without memoizing them. + * + * @param Closure(): array $resolver + * + * @throws LogicException + */ +public function allowSerializableClassesUsing(Closure $resolver): static +{ + $this->serializableClassPolicy->allowUsing($resolver); + + return $this; +} + +/** + * Finalize the worker-lifetime serializable class policy. + * + * Boot-only. The policy is frozen for the worker lifetime; later + * contributions throw and every subsequent unserialize uses the frozen list. + * + * @internal + */ +public function finalizeSerializableClasses(): void +{ + $this->serializableClassPolicy->finalize(); +} +``` + +Add only the extension API to the Cache facade metadata: + +```php + * @method static \Hypervel\Cache\CacheManager allowSerializableClassesUsing(\Closure $resolver) +``` + +Place it beside the manager-level +`handleUnserializableClassUsing()` entry in +`Hypervel\Support\Facades\Cache`, before the repository-forwarded methods. + +Keep the method off `Hypervel\Contracts\Cache\Factory`: third-party factories do +not have to implement Hypervel's manager capability. Defining it directly on the +manager is mandatory because an unknown method would otherwise reach `__call()`, +resolve the default store, and silently invoke a repository method. + +Delete `CacheManager::getSerializableClasses(array $config)`. This is an approved +Laravel API difference: the correct Hypervel policy is global and live, while the +protected Laravel hook is evaluated per store. Preserving that meaning would +require a shared contribution registry plus per-store policy views and +finalization; calling it with fabricated config would retain the signature while +silently breaking subclass behavior. Record the removal concisely in +`src/cache/README.md` and at the method's natural source location. + +Keep `CacheServiceProvider::boot(): void` parameterless. Resolve +`CacheManager::class` only in the console branch, then schedule finalization +according to the process: + +```php +if ($this->app->runningInConsole()) { + $cache = $this->app->make(CacheManager::class); + + $this->app->booted( + fn () => $cache->finalizeSerializableClasses(), + ); +} else { + // Worker configuration is reloaded during BeforeWorkerStart. + $events->listen(AfterWorkerStart::class, function (AfterWorkerStart $event): void { + $this->app->make(CacheManager::class)->finalizeSerializableClasses(); + }); +} +``` + +The console callback captures the boot-resolved worker-safe manager. The server +listener resolves the manager at event time, following the event-listener +rebinding rule. +Register it as a distinct listener after the existing timer listener, with no +worker-ID or taskworker guard. The timer listener registers lazy callbacks whose +store resolution happens after worker start. Any store another startup listener +resolves earlier already holds the shared policy object and observes finalization, +so listener order does not affect correctness; letting timer registration failures +surface first is clearer. + +Do not add an injected parameter, including an optional one. PHP treats both as +incompatible with a downstream parameterless override and fails while declaring +the subclass. Boot-time container resolution reaches the same aliased singleton +and has no functional or hot-path cost, so changing this Hypervel-owned public +surface has no benefit. + +No `AfterEachTestSubscriber` entry is needed. The policy, validator, and manager +hold instance state discarded with the test container, and +`Sanctum::flushState()` already resets its static model and callbacks. + +All providers are registered before any provider boots. Auth may therefore declare +classes from its new `boot()` method even though `AuthServiceProvider` precedes +`CacheServiceProvider` in the alphabetical provider list. Do not reorder providers. +Auth/Sanctum structural store validation may run before Cache finalization because +it does not read the class policy and resolved stores share the policy object. + +### Add the live policy without replacing Laravel store APIs + +Preserve every Laravel-compatible +`array|bool|null $serializableClasses = null` constructor parameter, its name, +position, default, and protected property. Add a separate nullable trailing +`SerializableClassPolicy` parameter/property for manager-created stores. The +unserializer selects the live policy when injected and otherwise retains the +existing scalar path byte-for-byte: + +```php +if ($this->serializableClassPolicy !== null) { + return $this->serializableClassPolicy->unserialize($value); +} + +if ($this->serializableClasses !== null) { + return unserialize($value, ['allowed_classes' => $this->serializableClasses]); +} + +return unserialize($value); +``` + +Apply this dual path to Laravel-compatible `ArrayStore`, `DatabaseStore`, +`FileStore`, `StorageStore`, and `RedisStore`. Keep the scalar property on +Hypervel's `AbstractArrayStore` so `ArrayStore` subclasses retain the inherited +Laravel extension surface; `WorkerArrayStore` inherits it. Both the scalar and +policy properties, constructor parameters, and dual-path `unserialize()` live on +`AbstractArrayStore`. `ArrayStore::__construct()` keeps Laravel's two parameters, +adds the trailing nullable policy, and forwards both serialization inputs to the +parent. Hypervel-only `SwooleStore` receives a promoted non-null policy directly, +with a fresh unrestricted policy as its constructor default. Do not add a trait, +mode flag, policy factory, or constructor union that exposes a policy object +through Laravel's protected scalar property. + +`StorageStore::path()` uses stable, unseeded `xxh128` over +`$this->prefix . $key`. Keep the real prefix in the digest input so shared disks +preserve store namespace separation across workers, restarts, and nodes. + +At every store-construction site, the manager passes its single policy instance +through the named trailing `serializableClassPolicy:` argument and never passes +the scalar argument. This prevents the policy from binding positionally to the +restored Laravel scalar parameter, especially in constructors with several +earlier arguments. Direct Laravel-style construction continues to accept +positional and named scalar arguments and follows PHP's native allowlist behavior +without policy resolution. + +Redis keeps both inputs through its existing helper because +`Redis\Support\Serialization::phpUnserialize()` is the actual unserialization +boundary and `RedisStore` has no unserializer. Retain the helper's scalar +constructor parameter and add a separate trailing nullable policy: + +```php +public function __construct( + protected array|bool|null $serializableClasses = null, + protected ?SerializableClassPolicy $serializableClassPolicy = null, +) { +} +``` + +`RedisStore::getSerialization()` passes both fields to the helper. Its +`phpUnserialize()` retains the numeric fast return, then selects the injected live +policy, the existing scalar allowlist, or unrestricted PHP unserialization in that +order. Do not wrap a scalar in a policy: an unfinalized wrapper would re-resolve it +on every direct-store read. The helper's native-serializer return remains before +`phpUnserialize()`, so native phpredis serializers bypass both paths exactly as +before and remain available for ordinary caches and first-party model caches when +they preserve object types. + +Keep `FileStore::lock()` on Laravel's `new static(...)` extension path and pass +both serialization inputs positionally. The lock store can address the same +directory and key namespace as ordinary file-cache values, so it must not lose +the manager-injected live policy. The trailing positional policy remains +compatible with subclasses that declare Laravel's four-argument constructor, +because PHP accepts extra positional arguments for user-defined methods. + +Do not inject the policy into `SessionStore`; Session owns that value boundary. +Do not route Swoole's internal lock arrays, interval indexes, metadata, or +`SerializableClosure` resolver payload through the user-value policy. The resolver +is framework-created internal metadata, signed by `laravel/serializable-closure` +when the application key is configured, and may legitimately capture arbitrary +application objects. It is not reachable through a public user cache key and +cannot use the user-value class list. Only the existing cached-user-value +unserializer changes. In particular, keep the increment path's internal +`unserialize($record['value'], ['allowed_classes' => false])` hardcoded deny-all; +it reads a numeric record before casting and is not the user-value boundary. + +Retain direct-construction scalar tests and add live-policy tests separately. +Ordinary direct construction remains unrestricted by default. + +## 3. Contribute Framework-Owned Model Classes Automatically + +### Auth + +Add `hypervel/cache: ^0.4` and `hypervel/core: ^0.4` to +`src/auth/composer.json`. Auth directly imports cache classes but does not declare +the dependency; its server lifecycle listener directly imports +`AfterWorkerStart`. + +Add a parameterless `AuthServiceProvider::boot(): void`, importing +`Hypervel\Contracts\Config\Repository as ConfigRepository`. Resolve +`CacheManager::class` and `ConfigRepository::class` once at the top through the +provider's container. Capture these worker-safe, unconditionally needed +dependencies in its lazy resolver and console booted callback. The Swoole event +callback resolves its dependencies from the container at event time. Required or +optional injected parameters buy nothing and can make a downstream subclass that +already declares `boot(): void` fail at class declaration. Give the provider one +private `cachedEloquentProviders()` parser. Use that parser for both class +contribution and startup store validation so the two paths cannot drift: + +```php +/** + * @return list, + * store: ?string + * }> + */ +private function cachedEloquentProviders(ConfigRepository $config): array; +``` + +The list carries normalized provider names explicitly so a usable numeric +configuration key such as `0` is represented as `"0"` rather than being coerced +back to an integer array key. From `boot()`, call the manager extension with a +lazy resolver. It returns models only for Eloquent providers whose own cache is +enabled: + +```php +$cache->allowSerializableClassesUsing(function () use ($config): array { + $models = []; + + foreach ($this->cachedEloquentProviders($config) as $settings) { + $models[] = $settings['model']; + } + + return $models === [] ? [] : [ + ...$models, + EloquentCollection::class, + Pivot::class, + MorphPivot::class, + ]; +}); +``` + +Read providers with `$config->array('auth.providers')`. Ignore non-array entries +because they cannot select a cache-enabled Eloquent provider. For array entries, +selection exactly matches `CreatesUserProviders::createEloquentProvider()`: the +driver must be `eloquent`, caching is enabled with +`! empty($provider['cache']['enabled'])`, and the store is read with +`$provider['cache']['store'] ?? null`. Cast each selected PHP array key to its +string provider identifier, including usable numeric and empty-string keys. For +each selected provider, require a class-string implementing both Eloquent `Model` +and the Auth +`Authenticatable` contract, and require the store to be a string or `null`. Throw +an `InvalidArgumentException` naming the provider and invalid field. The boot +validator and runtime provider must select the same provider set. The three +framework graph containers cover ordinary Eloquent relation shapes. Custom +collection/pivot subclasses and every application relation model remain manual +declarations. + +Keep one concise source comment that declaration happens in `boot()`: all providers +have registered the cache binding by then, while the resolver remains lazy until +policy finalization. Do not encode this dependency by reordering providers. + +### Sanctum + +Keep Laravel Sanctum's public parameterless +`SanctumServiceProvider::boot(): void`. Resolve `CacheManager` and +`Hypervel\Contracts\Config\Repository as ConfigRepository` once at the top through +the provider's container, then capture those worker-safe dependencies in the +resolver and console booted validator. Resolve dependencies from the container +inside the Swoole event callback, as required for event listeners. Retain the same +pattern for existing event/listener callbacks. Add `hypervel/core: ^0.4` to +`src/sanctum/composer.json` for the direct `AfterWorkerStart` import. + +Register a lazy resolver that returns an empty list unless +`sanctum.cache.enabled` is true. When enabled, it contributes: + +- `Sanctum::personalAccessTokenModel()`, evaluated only at policy finalization; +- Eloquent provider models referenced by guards whose driver is `sanctum`; +- the three stock Eloquent graph containers when at least one model was found. + +```php +$cache->allowSerializableClassesUsing(function () use ($config): array { + if (! $config->boolean('sanctum.cache.enabled')) { + return []; + } + + $models = [Sanctum::personalAccessTokenModel()]; + + foreach ($config->array('auth.guards') as $guardName => $guard) { + if (! is_array($guard) || ($guard['driver'] ?? null) !== 'sanctum') { + continue; + } + + $providerName = $guard['provider'] ?? null; + + if (! is_string($providerName)) { + continue; + } + + $provider = $config->get("auth.providers.{$providerName}"); + + if (! is_array($provider) || ($provider['driver'] ?? null) !== 'eloquent') { + continue; + } + + $model = $provider['model'] ?? null; + + if (! is_string($model) + || ! is_a($model, Model::class, true) + || ! is_a($model, Authenticatable::class, true)) { + throw new InvalidArgumentException( + "Authentication provider [{$providerName}] model must be an Eloquent authenticatable class." + ); + } + + $models[] = $model; + } + + return [ + ...$models, + EloquentCollection::class, + Pivot::class, + MorphPivot::class, + ]; +}); +``` + +Ignore entries that cannot select a Sanctum guard backed by an Eloquent provider; +automatic discovery must not make unused malformed/custom Auth configuration fail +worker startup. Validate the model only after an Eloquent provider is selected. +Do not guess arbitrary morph targets or models produced by custom providers. +Applications declare those, nested `withQuery()` / `$with` relation models, +custom collections, custom pivots, other application-owned nested objects, and +Auth providers constructed directly rather than represented by +`auth.providers.*` through the same public API: + +```php +Cache::allowSerializableClassesUsing(fn (): array => [ + Team::class, + Organization::class, +]); +``` + +This is additive to an explicit configured array and dedupes across Auth, Sanctum, +and application/package providers. + +## 4. Add One Shared Model-Cache Store Validator + +Add: + +- `src/cache/src/ModelCacheStoreValidator.php`; +- `src/cache/src/Exceptions/UnsupportedModelCacheStoreException.php`. + +`UnsupportedModelCacheStoreException` extends `InvalidArgumentException`, matching +the configuration-error contract already exposed by +`EloquentUserProvider::enableCache()`. + +The validator is an ordinary open service with no mutable state apart from its +injected `RedisConfig`. Applications can already replace container services, so +`final` would not protect the gate and would only prevent faithful typed test +doubles. It accepts a resolved cache repository and a feature description through +one public method: + +```php +/** + * Validate that the store can safely cache models. + * + * @throws UnsupportedModelCacheStoreException + */ +public function validate( + CacheRepository $repository, + string $feature, +): void +``` + +Import `Hypervel\Contracts\Cache\Repository as CacheRepository`; the validator +uses only the contract's `getStore()` method and must not narrow callers to +Hypervel's concrete repository. + +Do not add a second traversal method, capability result, result object, or enum. +The recursive operation: + +1. recursively validates every `StackStore` layer; +2. never short-circuits stack validation; +3. applies the Redis serializer/type-preservation rule to `RedisStore`; +4. accepts database, file, storage, and Swoole leaves; +5. rejects every other store, including failover. + +Implement the recursion as one private store method carrying a zero-based layer +path for errors. Normalize `StackStore`'s constructor result with `array_values()` +so its existing ordered-layer invariant becomes an actual list. This also fixes +tag-composition errors assigning duplicate or incorrect layer numbers when +manager-built configuration contains string keys. Correct the constructor input +docblock to `array` because those keyed inputs +are valid. A stack visits every proxy returned by `getStores()` and recurses through +`StackStoreProxy::getStore()`. Supported leaves return; a rejected leaf throws +`UnsupportedModelCacheStoreException` naming the feature, concrete store class, +and full stack path. Do not use reflection, unwrap repositories more than once, +or add accessors to stores that are always rejected. + +Expose the stack layers without leaking its mutable property: + +```php +/** + * Get the underlying store layers. + * + * @return list + * + * @internal + */ +public function getStores(): array +{ + return $this->stores; +} +``` + +`StackStoreProxy::getStore()` already exists. No accessor is required on failover +because failover is always rejected. + +### Redis serializer inspection + +For Redis, obtain the connection name from: + +```php +$store->getContext()->connectionName() +``` + +Read only: + +```php +$options = $this->redisConfig + ->connectionConfig($connectionName)['options'] ?? []; +``` + +`connectionConfig()` already merges shared and connection options with connection +options winning. `RedisConnection::CONNECTION_LEVEL_PHPREDIS_OPTIONS` excludes the +serializer, so there is no second location to reproduce. + +Scan the merged array in iteration order. Recognize both: + +- case-insensitive string key `serializer`; +- numeric key `Redis::OPT_SERIALIZER`. + +Both keys can coexist because they are distinct PHP array keys. +`RedisConnection::setOptions()` applies them in array order, so the last recognized +entry is the effective serializer: + +```php +$serializer = Redis::SERIALIZER_NONE; + +foreach ($options as $option => $value) { + if ((is_string($option) && strtolower($option) === 'serializer') + || $option === Redis::OPT_SERIALIZER) { + $serializer = (int) $value; + } +} +``` + +Apply these exact decisions: + +| Effective serializer | Accept | Class-policy behavior | +|---|---:|---| +| `SERIALIZER_NONE` | yes | PHP applies the shared policy. | +| `SERIALIZER_PHP` | yes | Native serializer bypasses the policy. | +| `SERIALIZER_IGBINARY` when defined | yes | Native serializer bypasses the policy. | +| `SERIALIZER_MSGPACK` when defined and `msgpack.php_only=1` | yes | Native serializer bypasses the policy. | +| `SERIALIZER_JSON` | no | Models become arrays. | +| msgpack with `msgpack.php_only=0` | no | Models become arrays. | +| unknown/future values | no | Type preservation is unverified. | + +Conditionally inspect igbinary/msgpack constants with `defined()` / `constant()`; +these constants depend on the phpredis build. For msgpack, read +`ini_get('msgpack.php_only')` during boot validation and accept it only when +`filter_var($value, FILTER_VALIDATE_BOOL)` returns `true`. Document +`msgpack.php_only` as a `php.ini` worker-start setting: request-time `ini_set()` +mutates process-global behavior across Swoole coroutines. Do not add a behavioral +probe or connect to Redis. A rejected Redis store error names the connection, +effective serializer value, and the type-preservation reason; msgpack errors name +the required `msgpack.php_only=1` setting. + +The msgpack decision was verified in an isolated PHP 8.4 / msgpack 3.0.1 / +phpredis 6.3.0 build through both phpredis helpers and an actual Redis round trip. +PHP-only mode preserved concrete Eloquent graphs, protected/private properties, +enums, dates, shared references, cycles, and serialization hooks; non-PHP mode +returned arrays. MessagePack does not deduplicate repeated strings: its PHP +extension emits every string/class name in full and tracks references only for +objects and arrays. Igbinary does intern repeated strings and class names, which +particularly suits Eloquent graphs with repeated attribute keys. User docs give +the structural distinction and say to benchmark the real workload; exact +environment-dependent multipliers remain non-contractual plan research only. + +`SERIALIZER_PHP` preserves types but bypasses the class policy. Recommend +`SERIALIZER_NONE` when choosing only for cache serialization; native PHP remains +valid when uniform connection-wide native serialization is intentional. +Compression remains unrestricted. Comment that config inspection deliberately +avoids `RedisConnection::serialized()`, which would open a live connection at +boot. + +This gate applies only to first-party Auth/Sanctum Eloquent identity caches. +Ordinary Redis cache usage remains unrestricted, and its existing documented +native-serializer policy bypass remains unchanged. + +### Validation call sites and timing + +Replace `EloquentUserProvider::SUPPORTED_AUTH_CACHE_STORES` and +`ensureSupportedAuthCacheStore()` with the shared validator. `enableCache()` calls +it before mutating provider state. Tag validation remains Auth-specific and follows +the structural validation: + +```php +$container = Container::getInstance(); +$cache = $container->make('cache')->store($storeName); + +$container->make(ModelCacheStoreValidator::class)->validate( + $cache, + "Auth user cache for model [{$this->model}]", +); +``` + +Both feature providers validate configured stores at the same process-ready +boundary as policy finalization: + +- Auth validates every cache-enabled Eloquent provider store; +- Sanctum validates its configured/default store when token caching is enabled. + +Auth reuses `cachedEloquentProviders()` so its callback has the same configuration +selection rules as the runtime consumer. Extract one private validation method +that receives the manager and configuration repository. Resolve the validator +once inside it, but only after finding an enabled provider: + +```php +private function validateCachedEloquentProviders( + CacheManager $cache, + ConfigRepository $config, +): void { + $providers = $this->cachedEloquentProviders($config); + + if ($providers === []) { + return; + } + + $validator = $this->app->make(ModelCacheStoreValidator::class); + + foreach ($providers as $settings) { + $validator->validate( + $cache->store($settings['store']), + "Auth user provider [{$settings['name']}]", + ); + } +} +``` + +In console/test processes, an application `booted()` callback invokes this method +with the boot-resolved manager and configuration repository. In server mode, an +unguarded `AfterWorkerStart` listener invokes it with dependencies resolved from +the container at event time. Give both Auth and Sanctum scheduling branches the +same concise source comment: worker configuration is reloaded during +`BeforeWorkerStart`, so server validation must follow it. Do not introduce a +shared lifecycle helper or cache-owned readiness event for three small explicit +branches. + +`EloquentUserProvider::enableCache()` calls the same method before enabling the +cache. Keep both validation sites: startup validation fails a bad deployment +before traffic, while `enableCache()` preserves the provider's invariant when it +is constructed or used directly. Tag validation remains subsequent and +Auth-specific. + +Sanctum normalizes `null` and `''` to the default store exactly as `getCache()` +does. Its private validation method checks the supplied config repository first, +resolves the validator once, and validates the repository without retaining any +result. It resolves neither the validator nor a cache store when the feature is +disabled. The console callback captures the boot-resolved manager/config +repository; the server listener resolves both at event time. + +This guarded resolution is deliberate: the callback fires only once, whereas the +lazy policy resolvers may run repeatedly before finalization and therefore capture +their dependencies. Hoisting the validator would eagerly construct it and its +`RedisConfig` dependency for every application even when both model caches retain +their disabled defaults. + +This resolves configured stores even when no request authenticates. Redis and +database store construction opens no backend connection, and failing at boot is +intentional. The policy need not be finalized before this structural validation. +Every ordinary worker and taskworker validates after its own configuration reload; +no validation shares `CreateSwooleTimers`' worker-zero guard. All startup listeners +finish before `WorkerStartCallback` resumes the worker-start coordinator. + +Configuration is boot-only. Sanctum currently re-reads its store name on every +operation, so the docs explicitly prohibit runtime changes. Do not add per-operation +revalidation or static capability state for unsupported runtime configuration +mutation. + +Unit tests that construct a bare container bind a validator double. Integration +tests exercise the real validator and all recursive compositions. + +## 5. Correct Sanctum Token and Tokenable Caching + +### One tokenable instance per authentication + +Preserve Laravel Sanctum's protected `isValidAccessToken()` name, signature, +aggregate semantics, short-circuiting, and authentication callback. Correct only +its tokenable resolution: when the temporal checks pass, call the cache-aware +`findTokenable()` instead of lazy-loading the relation directly. + +```php +protected function isValidAccessToken(?PersonalAccessToken $accessToken): bool +{ + if (! $accessToken) { + return false; + } + + $model = Sanctum::$personalAccessTokenModel; + $isValid + = (! $this->expiration || $accessToken->getAttribute('created_at')->gt(now()->subMinutes($this->expiration))) + && (! $accessToken->getAttribute('expires_at') || ! $accessToken->getAttribute('expires_at')->isPast()) + && $this->hasValidProvider($model::findTokenable($accessToken)); + + if (is_callable(Sanctum::$accessTokenAuthenticationCallback)) { + $isValid = (bool) (Sanctum::$accessTokenAuthenticationCallback)($accessToken, $isValid); + } + + return $isValid; +} +``` + +`user()` continues to call this hook, then calls `findTokenable()` after a true +result. The default implementation has already loaded the exact relation in +memory, so that second method call performs no cache or database operation. A +subclass override that returns true without resolving the relation causes exactly +one resolution afterward. The existing `&&` chain still avoids tokenable +resolution for an expired token unless the callback overrides the failure. + +A hash-verified token whose tokenable fails provider validation may populate the +tokenable cache before rejection. That is deliberate: only a holder of the valid +token reaches this path, and caching the resolved identity does not change the +rejection or authorization result. + +`findTokenable()` first returns an already-loaded relation, including a loaded +`null`, then otherwise resolves and sets the relation on the passed live PAT on +both cache hits and misses: + +```php +if ($accessToken->relationLoaded('tokenable')) { + return $accessToken->getRelation('tokenable'); +} + +if (! config('sanctum.cache.enabled')) { + return $accessToken->getAttribute('tokenable'); +} + +$cache = self::getCache(); +$tokenable = $cache->rememberNullable(/* ... */); +$accessToken->setRelation('tokenable', $tokenable); + +return $tokenable; +``` + +This keeps the live token's relation shape identical on hits and misses, lets +`Sanctum::$accessTokenAuthenticationCallback` read the already-resolved exact +tokenable without a second query, and preserves the current event-facing shape. +It does not put the relation into the PAT cache because every PAT write strips it. +When Sanctum caching is disabled and no relation is loaded, the method keeps its +existing direct `getAttribute('tokenable')` return; Eloquent lazy loading already +sets the relation, so do not add a redundant `setRelation()` to that branch. + +### Never cache tokenable inside the PAT entry + +Inside `findTokenUsingCache()`: + +```php +return static::find($id)?->unsetRelation('tokenable'); +``` + +The miss callback owns the freshly loaded model, so strip it in place rather than +cloning it. This ensures the first miss and later hits have the same PAT shape, +including when a custom PAT has `tokenable` in `$with`. + +When a custom PAT eager-loads `tokenable` through `$with`, Eloquent has already +issued a relation query before the miss callback strips it, so the later +`findTokenable()` call issues a second tokenable query. Accept that nonstandard +cold-miss cost: the hot path is unchanged, while preserving the eager-loaded +instance would require special-case cache plumbing or a per-miss allocation. + +After a successful `updateLastUsedAt()`, write a clone stripped of only +`tokenable`: + +```php +$cache->put( + $cacheKey, + $this->withoutRelation('tokenable'), + $ttl, +); +``` + +Preserve every other custom PAT relation. Those relations remain subject to graph +class declarations and may become incomplete on a restricted read when undeclared. +Never call `withoutRelations()`: it would silently change custom model shape. +The refresh clone is required because the original PAT is attached to the +authenticated user through `withAccessToken()` and remains request-visible through +`currentAccessToken()`. Mutating it would discard the exact resolved tokenable and +can cause a later lazy-load query. +The tokenable cache miss keeps its existing `rememberNullable()` behavior without +any model preprocessing. + +### Preserve tokenable cache across internal audit writes + +Keep full cache invalidation for deletion and every application-visible PAT +update. Narrow only Sanctum's own `last_used_at` audit write. Retain the existing +cache-enabled guard. The `updating` event fires before Eloquent adds `updated_at`, +so the listener can identify that write without adding state: + +```php +static::updating(function (PersonalAccessToken $model): void { + if (! config('sanctum.cache.enabled')) { + return; + } + + $dirty = $model->getDirty(); + + // Eloquent fires updating before adding updated_at, so this exact dirty set + // identifies Sanctum's internal audit write. + if (array_keys($dirty) === ['last_used_at']) { + self::forgetTokenEntry(self::getCache(), $model->id); + + return; + } + + self::clearTokenCache($model->id); +}); +``` + +Extract a protected static +`forgetTokenEntry(CacheRepository $cache, int|string $tokenId): void` helper for +the PAT entry. `clearTokenCache()` resolves its repository once, passes it to the +helper, then forgets the tokenable entry through the same repository. Its public +behavior is unchanged and no path gains a duplicate container/config lookup. The +`deleting` listener continues to call `clearTokenCache()`. + +Do not generalize the exception to every update that leaves tokenable identity +fields unchanged. Abilities, expiry, and other application-visible mutations stay +on the conservative full-invalidation path; only the framework's proven +audit-write defect needs narrower behavior. Do not add listener-order machinery: +application event listeners that mutate the model after this listener are an +explicit Eloquent extension point, not an invariant this cache layer must +preserve. + +This makes the configured tokenable TTL the real maximum staleness bound. Changes +to a tokenable model do not automatically evict token-id-keyed entries; immediate +reflection requires explicit `clearTokenCache()` calls for that model's tokens. +Document that contract instead of adding a reverse tokenable-to-token index. + +### Normalize Sanctum's static model default + +`Sanctum` repeats `PersonalAccessToken::class` in the static property initializer +and `flushState()`. Follow the repository invariant by adding one protected typed +`DEFAULT_PERSONAL_ACCESS_TOKEN_MODEL` constant and referencing it from both +places. Preserve the property's `class-string` type; if PHPStan loses the +class-string refinement through the typed constant, add the correct class-string +docblock to the constant rather than widening the property. + +## 6. Make Failover Invalidations Reach Every Leaf + +Keep first-success behavior for reads, writes, increments, locks, touch, and prefix +resolution. Change only `forget()` and `flush()`. + +Keep Laravel's protected `attemptOnAllStores()` name and last-exception behavior +for first-success operations. Correct its inaccurate docblock to state that it +returns after the first store call that does not throw; naming cleanup does not +justify breaking the protected extension point. + +Add one focused helper that: + +- invokes the named method on every configured repository; +- collects successful return values in order; +- retains the last thrown exception, matching Laravel's first-success primitive; +- dispatches `CacheFailedOver` for newly failing leaves using the existing + per-coroutine dedupe; +- updates the existing failing-cache context with all leaves that threw; +- returns results plus the last exception. + +Factor the existing event/dedupe check into one small failure-recording method used +by both first-success and every-store loops. Do not duplicate the +`CacheFailedOver` decision or create a generalized operation strategy: + +```php +protected function recordStoreFailure( + string $store, + Throwable $exception, + array $failingCaches, +): void { + if (! in_array($store, $failingCaches, true) + && $this->events->hasListeners(CacheFailedOver::class)) { + $this->events->dispatch(new CacheFailedOver($store, $exception)); + } +} +``` + +Each catch block records the shared failure behavior before retaining the +exception and leaf in its own operation state: + +```php +} catch (Throwable $exception) { + $this->recordStoreFailure($store, $exception, $failingCaches); + + $lastException = $exception; + $failedCaches[] = $store; +} +``` + +The caller's existing `finally` still replaces the per-coroutine failing-cache +set, so the healthy leaf is not retained as failing. + +```php +/** + * @return array{0: list, 1: ?Throwable} + */ +protected function attemptOnEveryStore(string $method, array $arguments): array; +``` + +Do not add a boolean mode flag. Each caller maps the primitive to its own contract. + +`forget()`: + +```php +[$results, $exception] = $this->attemptOnEveryStore(__FUNCTION__, func_get_args()); + +if ($results === []) { + throw $exception ?? new RuntimeException('All failover cache stores failed.'); +} + +return $exception === null; +``` + +Ignore completed leaf booleans. Return `true` when every leaf was reached without +an exception, including an absent key. Return `false` after partial exceptions. + +`flush()`: + +```php +[$results, $exception] = $this->attemptOnEveryStore(__FUNCTION__, func_get_args()); + +if ($results === []) { + throw $exception ?? new RuntimeException('All failover cache stores failed.'); +} + +return $exception === null + && ! in_array(false, $results, true); +``` + +Thus: + +- all leaves throw: rethrow the last exception, matching Laravel failover; +- partial exception: both methods return `false` after trying all leaves; +- `forget()` ignores an absent-key `false`; +- `flush()` returns `false` for any completed false. + +This improves generic failover invalidation but does not make failover suitable for +identity caches: an inaccessible primary can still retain and later serve stale +identity data. + +Keep a concise source explanation on `forget()` / `flush()` that Hypervel +deliberately reaches every leaf because first-success invalidation resurrects stale +values. Do not add historical commentary to unrelated methods. + +## 7. Configuration, Documentation, and Cleanup + +### Configuration + +Update `src/foundation/config/cache.php` to describe: + +- the global policy; +- `false`, array, and unrestricted `null`/`true` semantics; +- why `false` is the secure default for forged cache payloads; +- provider-contributed framework classes; +- native Redis serializer bypass for general caches. + +Update the matching Auth cache blocks in `src/foundation/config/auth.php` and the +committed Testbench skeleton at `src/testbench/hypervel/config/auth.php`: + +- supported leaves include storage; +- stacks are recursively validated; +- array, worker-array, null, session, and failover are rejected; +- delete the stale "only the outer store is validated" caveat; +- retain honest node-local and L1 TTL behavior; +- state that storage invalidation is shared only when its configured disk is + shared; +- correct the touched comments to the repository's American English spelling. + +Keep Sanctum cache configuration concise. State that the TTL is the maximum +staleness bound for cached tokenable identity. Put the fuller boot-only and +invalidation explanation in the Sanctum documentation rather than duplicating a +long config comment. + +### Cache documentation + +Update `src/boost/docs/cache.md`: + +- explain automatic union and stable dedupe; +- document `Cache::allowSerializableClassesUsing()` as a boot-only lazy resolver; +- show application-owned nested object, relation, and morph declarations rather + than root Auth/Sanctum declarations; +- document the existing optional + `Cache::handleUnserializableClassUsing()` callback while preserving Laravel's + default no-op behavior; +- explain that a denied nested class follows PHP/Laravel behavior: direct use + reports an incomplete object and names the class, while Eloquent + `toArray()` / `toJson()` omits an incomplete relation rather than returning it + as `null`; identify the stable error phrase contained in both method-call and + property-access failures and explain that it normally means the class was + denied by this policy rather than missing from the autoloader, while removed + classes require clearing stale entries; +- list the policy-aware direct serializing drivers and explicitly exclude + `SessionStore`, whose values use the Session serializer; +- explain that native Redis serializers bypass the class policy; +- distinguish igbinary's repeated-string/class table from msgpack's compact + binary encoding, require PHP-only msgpack for models, and recommend workload + benchmarks without publishing environment-dependent multipliers; +- document all-leaf failover `forget()` / `flush()` behavior. + +Do not change the Session cache documentation. It already states that the store +follows the configured Session serializer and documents the intentional JSON +object-fidelity semantics. + +### Auth documentation + +Update `src/auth/README.md` and +`src/boost/docs/authentication.md`: + +- delete instructions to list the root user model manually; +- state that enabled configured provider models and stock Eloquent graph + containers are automatic; +- explain manual nested relation/custom collection/custom pivot/other object + declarations, including directly constructed providers not represented in + `auth.providers.*`; +- make clear that those declarations apply to PHP-policy serialization paths; + accepted native Redis serializers preserve types while bypassing the policy; +- link to the cache documentation's denied-nested-class behavior and remedies; +- state that Auth cache configuration is boot-only; +- document recursive store validation and storage support; +- explain why failover is rejected; +- document accepted type-preserving Redis modes and the policy bypass; +- require `msgpack.php_only=1` for msgpack and reject JSON/unknown modes; +- remove the stale outer-stack caveat; +- correct the `withQuery()` promise: its cached shape is retained only when all + application relation classes are declared; +- describe node-local microcaching benefits without unsupported fixed hit-rate or + nanosecond-latency claims, and state the bounded L1 staleness tradeoff directly + without an “out of scope” label; +- replace the stale “array after the upcoming rewrite” wording with its current + coroutine-local behavior; +- say Auth-specific tag-mode validation runs when the provider is created, not at + application boot; +- replace the claim that password changes are irrelevant with the real + invalidation rule: Eloquent model writes clear the user entry, while eventless + writes require explicit invalidation or accept the TTL bound; +- describe config callbacks as worker-start configuration rather than request-time + state; +- correct the touched README table heading to American English. + +### Sanctum documentation + +Update `src/sanctum/README.md` and `src/boost/docs/sanctum.md`: + +- delete manual root PAT/user allowlist instructions; +- state automatic lazy PAT and configured Sanctum guard provider declarations; +- explain manual custom-provider morph targets, nested `$with` relations, custom + Eloquent containers, and other application-owned nested objects; +- make clear that those declarations apply to PHP-policy serialization paths; + accepted native Redis serializers preserve types while bypassing the policy; +- link to the cache documentation's denied-nested-class behavior and remedies; +- document recursive supported-store validation, failover rejection, and the same + type-preserving Redis rules; +- state cache configuration, including `sanctum.last_used_at`, is boot-only; +- explain that the PAT cache never embeds `tokenable`, while the live token receives + the exact resolved tokenable before callbacks/events; +- explain that deletion and application-visible PAT updates clear both entries, + while Sanctum's internal `last_used_at` write clears only the PAT entry; +- state that the tokenable TTL is its actual maximum staleness bound, tokenable + model changes do not automatically evict token-id-keyed entries, and immediate + reflection requires explicit clearing; +- retain the guidance that cache TTL should be greater than or equal to the + last-used update interval; +- keep custom PAT selection documented as a provider-boot operation. + +### Stale-source cleanup + +After implementation, whole-tree searches must find no live: + +- class-level `@internal` annotation on `ConfigMutationTracker`; +- package config helper that records an evaluated merge result as a raw mutation; +- `getSerializableClasses()` method; +- `SUPPORTED_AUTH_CACHE_STORES`; +- docs telling users to add ordinary Auth/Sanctum root models manually; +- docs claiming only the outer stack store is validated; +- docs claiming every PAT update clears both PAT and tokenable entries; +- Sanctum cache test using `array` with `serialize => false`; +- runtime custom PAT selection in cache tests; +- cache policy injection or inner framing in `SessionStore`. + +Include test configuration in this audit, not only documentation. In +`tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php`, remove the manual +`[User::class]` config so the test proves the automatic provider declaration +works. Retain +`tests/Integration/Cache/Redis/BasicOperationsIntegrationTest.php`'s +`[stdClass::class]`: that is a genuine application-owned class and remains the +explicit configured-array path. Keep a separate Auth assertion for the additive +union with explicit application classes. + +Do not edit local Laravel examples. + +## 8. Testing Plan + +### Worker configuration replay coverage + +Add `tests/Foundation/Configuration/ConfigMutationTrackerTest.php`: + +- raw and semantic entries replay in their exact shared order; +- a semantic operation sees the fresh repository state; +- internal `set()` calls are not recorded as duplicate raw mutations; +- recording is sealed before replaying closures, so the log cannot grow during + iteration; +- a failed initial operation restores the prior recording state and is not + appended; +- a raw child-key override recorded after a semantic root merge still wins; +- post-replay mutations retain the existing sealed behavior. + +Extend `tests/Support/SupportServiceProviderTest.php` with a real tracker bound +through its existing application double. Preserve all current merge assertions and +add replay coverage for: + +- fresh package defaults and application overrides; +- overridden `mergeableOptions()` nested merging without retaining the provider; +- `replaceConfigRecursivelyFrom()` using its distinct recursive rule; +- cached configuration resolving neither Config nor the tracker. + +Extend `tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php` with an isolated +temporary package config whose value uses the fixture environment. Register a +small inline provider after the master load, switch to `.env.testing`, run the +real reload listener, and prove the post-replay package value is +`testing_value`, not the master `default_value`. Also prove an unpublished package +config is restored by semantic replay. Existing Reverb/server tests remain the +regression for exact raw value replay. + +### SerializableClassPolicy unit coverage + +Add `tests/Cache/SerializableClassPolicyTest.php`: + +- a policy with no configured resolver resolves to unrestricted; +- no configured resolver does not invoke declarations; +- `null` and `true` normalize to unrestricted and never invoke resolvers; +- `false` with no declarations remains `false`; +- `false` plus declarations becomes the declaration list; +- configured and declared classes are both accepted in one graph; +- multiple feature resolvers union their classes; +- pre-finalization `unserialize()` recomputes and does not memoize; +- finalization freezes once and clears resolver captures; +- post-finalization declaration throws the actionable `LogicException`; +- resolver exceptions propagate; +- invalid config type, non-array resolver result, and non-string entries fail with + the source and actual entry key; +- associative configured and resolver arrays are accepted; +- colliding string keys in a configured array and contributed resolver preserve + both values; +- unknown names are retained without autoloading and do not match unrelated + serialized objects; +- duplicates across normalized sources still restore every distinct declared + class; +- unrestricted `unserialize()` restores an object; +- `false` restores a denied object as `__PHP_Incomplete_Class`; +- configured and contributed classes restore their concrete objects. + +### Manager/provider lifecycle coverage + +Extend `tests/Cache/CacheManagerTest.php`; add focused provider lifecycle coverage +in `tests/Cache/CacheServiceProviderTest.php`, +`tests/Auth/AuthServiceProviderTest.php`, and +`tests/Sanctum/SanctumServiceProviderTest.php`: + +- every direct serializing driver receives the same policy object; +- a store constructed before a later declaration sees the final policy; +- `build()` outside `$stores` also sees later declarations; +- `setApplication()` is read at evaluation time before finalization; +- providers booted before and after `CacheServiceProvider` can both contribute + before process finalization; +- a custom PAT selected in an application provider's `boot()` is included; +- the Auth provider may declare before Cache's `boot()` without provider reordering; +- Cache, Auth, and Sanctum console callbacks capture their boot-resolved + worker-safe manager/config repository, while server event callbacks resolve + dependencies at event time; +- a Cache provider fixture overrides parameterless `boot()`, calls + `parent::boot()`, and proves the existing server listeners and new lifecycle + registration still occur; +- a Sanctum provider fixture overrides parameterless `boot()`, calls + `parent::boot()`, and proves its real boot registrations still occur; +- console/test processes finalize and validate from application `booted()`; +- server application boot leaves the inherited master policy unfinalized until a + worker-start event; +- server workers use config reloaded into the same captured repository object; +- policy finalization and Auth/Sanctum validation run for worker IDs beyond zero + and taskworkers, independently of Swoole timer eligibility; +- disabled features never resolve the guarded validator; +- facade metadata reaches the manager method rather than resolving a store. + +Do not add reflection-only signature assertions or an Auth provider subclass +fixture. The two behavior fixtures pin the existing non-empty parent boot methods +and fail loudly at class declaration if a parameter is added; Auth has no parent +boot behavior for such a fixture to test. + +Leave +`tests/Cache/CacheFailoverStoreTest.php::testIncompleteClassHandlerRunsOnceAcrossFailoverRepositories()` +unchanged. Do not introduce a framework default handler or exception-specific +failover behavior. + +Retain the existing scalar allowed-class suites for every Laravel-compatible +direct store, including positional and named `serializableClasses` construction. +Keep direct-store coverage proving that a store constructed with neither a scalar +nor a policy uses native unrestricted unserialization. +Add separate manager-injected live-policy coverage. Hypervel-only `SwooleStore` +takes a promoted non-null policy with a fresh unrestricted policy as its default; +the Redis-support helper keeps its scalar input and adds the named trailing +policy. On one direct store, construct both a scalar and a policy and prove the +policy takes precedence. Add or retain denied/allowed object round trips for: + +- serializing array and worker-array; +- database; +- file, while its lock clone retains both the scalar Laravel constructor input + and the manager-injected live policy; +- storage; +- Redis's PHP-owned serialization path when the native serializer is disabled; +- Swoole public values. + +In `tests/Cache/Redis/Support/SerializationTest.php`, pass the live policy with the +named trailing `serializableClassPolicy:` argument. Keep separate scalar-constructor +coverage; do not let a policy test bind to the retained scalar parameter by +position. + +Extend `tests/Cache/CacheStorageStoreTest.php` with an exact path assertion derived +from `hash('xxh128', $prefix . $key)` using a non-empty prefix. + +Extend `tests/Cache/CacheStackStoreTest.php` with the regression that a +string-keyed layer configuration reports the correct zero-based layer index in +tag-composition errors. + +### ModelCacheStoreValidator coverage + +Add `tests/Cache/ModelCacheStoreValidatorTest.php`: + +- accepts Redis/Database/File/Storage/Swoole and subclasses; +- rejects Array/WorkerArray/Null/Session/Failover; +- accepts an all-supported stack recursively; +- rejects unsupported and failover leaves at any nesting depth; +- reports the feature/store/layer clearly; +- scans merged Redis options without resolving a live connection; +- accepts an absent serializer and numeric-key or string/case-varied + `serializer` entries set to `SERIALIZER_NONE`, regardless of compression; +- accepts native PHP, defined igbinary, and defined PHP-only msgpack while + documenting their class-policy bypass; +- rejects JSON, msgpack with PHP-only disabled, and unknown/future modes; +- reports the Redis connection, effective serializer, and actionable reason for + every rejection; +- conditionally covers build-dependent igbinary/msgpack constants; +- reads msgpack PHP-only configuration without probing behavior or resolving a + live connection; +- validates every nested stack leaf, including a later unsupported leaf after + earlier supported leaves; +- reports the full layer path for nested failover stores and rejected Redis + serializers; +- honors shared-versus-connection option merge; +- honors last occurrence when string/case-varied and numeric serializer keys + coexist. + +Auth unit tests prove `enableCache()` validates before any state, descriptors, or +listeners mutate, while tag validation remains subsequent and Auth-specific. +Startup lifecycle integration tests prove every configured enabled provider store +is validated without resolving a guard, using the exact runtime `! empty()` +enablement and `store ?? null` normalization. They also cover malformed provider +entries being ignored, accepted numeric/empty provider names, and selected invalid +models/store values through the parser's named configuration exceptions. + +Sanctum startup lifecycle tests prove disabled caching resolves no store and +enabled caching validates the configured/default store before requests without +retaining runtime state. With a restricted class policy, they also prove malformed +or unrelated guard/provider entries are ignored by automatic discovery, custom +providers remain manual, and an invalid selected Eloquent model fails with the +intended configuration exception rather than a subscript/type error. + +### Auth cache coverage + +Use a real serializing supported store in integration tests: + +- `cache.serializable_classes = false` plus automatic provider declaration caches + and returns the root user with zero second lookup query; +- explicit configured classes union with automatic classes; +- automatic contributions contain the configured root and stock Eloquent + collection/pivot classes but not application relation or custom container + classes; +- an undeclared to-one relation becomes incomplete on the cache hit, direct use + names the denied class, and `toArray()` omits the relation key rather than + returning `null`; +- a to-many relation restores the stock Eloquent collection while undeclared + related application models remain incomplete; +- declaring relation classes through the facade makes the full graph round-trip; +- isolated real Redis integration in + `tests/Integration/Auth/EloquentUserProviderRedisCacheTest.php` conditionally + round-trips a cached user under serializer-none policy enforcement plus native + PHP, igbinary, and PHP-only msgpack, proving each available accepted mode + returns the concrete model and avoids a second database query. Use + `InteractsWithRedis`; do not make the general Auth integration suite depend on + Redis. + +### Sanctum cache and guard coverage + +Replace the `array`/unserialized fixture in +`tests/Sanctum/PersonalAccessTokenCacheTest.php` with an isolated real file store, +`cache.serializable_classes = false`, and a configured Eloquent provider for +`TestUser`. + +Cover: + +- automatic default PAT and provider model declarations; +- custom PAT chosen from provider boot is included; +- missing token and tokenable nullable sentinels; +- second token lookup performs no PAT query; +- second tokenable lookup performs no user query; +- guard authentication in two fresh coroutine/request contexts performs zero + PAT/user queries in the second context; +- provider validation, callback relation, and `withAccessToken()` use the exact + same tokenable instance; +- the authentication callback can read `$accessToken->tokenable` without another + query; +- cached PAT copies never have `tokenable` loaded; +- miss and hit PAT shapes match even when custom PAT `$with` includes tokenable; +- `updateLastUsedAt()` refresh strips tokenable and cannot reseed stale user state; +- with last-used tracking enabled, a fresh token's first full guard authentication + leaves its tokenable entry cached, and a second fresh request/coroutine performs + zero PAT and user queries; +- other custom PAT `$with` relations are preserved; +- automatic declarations include the PAT and configured guard provider models but + not custom-provider morph targets or application relation classes; +- declaring custom provider/morph targets and custom PAT relation classes restores + the complete graph; +- cached users are independent objects, preventing cross-coroutine access-token + mutation leakage; +- internal audit writes forget only the PAT entry; +- application-visible PAT updates and deletion still forget both PAT and + tokenable entries; +- PAT updates with caching disabled resolve no cache repository and perform no + invalidation; +- public `clearTokenCache()` still forgets both entries. + +Rewrite tests that mutate `sanctum.cache.store`, cache enablement, or token model +after boot as separate boot configurations/providers. Do not bless runtime config +mutation. + +### Failover coverage + +Extend `tests/Integration/Cache/FailoverStoreTest.php`: + +- `forget()` and `flush()` invoke every leaf; +- absent-key false from Redis-style leaves does not make `forget()` false; +- one throwing leaf plus one completion returns false without throwing; +- partial failures dispatch/dedupe `CacheFailedOver` and update context; +- all leaves throwing rethrows the last exception after trying all; +- first-success operations also rethrow the last exception when every leaf + fails, preserving Laravel's existing failover behavior; +- `flush()` returns false for a completed false; +- `flush()` returns false for partial exception; +- all successful flushes return true; +- reads and writes remain first-success; +- the same partial-outage store can continue first-success writes after a failed + all-leaf invalidation. + +### Documentation/config tests + +Update config defaults and package documentation assertions where present. +`tests/Testbench/DefaultConfigurationTest.php` continues to assert the default +`false` policy. + +## 9. Verification and Review + +Run focused tests immediately after each subsystem: + +```bash +./vendor/bin/phpunit --no-progress tests/Foundation/Configuration/ConfigMutationTrackerTest.php +./vendor/bin/phpunit --no-progress tests/Foundation/Listeners/ReloadDotenvAndConfigTest.php +./vendor/bin/phpunit --no-progress tests/Support/SupportServiceProviderTest.php +./vendor/bin/phpunit --no-progress tests/Cache/SerializableClassPolicyTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheManagerTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheServiceProviderTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheArrayStoreTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheWorkerArrayStoreTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheDatabaseStoreTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheFileStoreTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheRepositoryTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheRedisStoreTest.php +./vendor/bin/phpunit --no-progress tests/Cache/Redis/Support/SerializationTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheStorageStoreTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheSwooleStoreIntervalTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheSwooleStoreTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheStackStoreTest.php +./vendor/bin/phpunit --no-progress tests/Cache/CacheFailoverStoreTest.php +./vendor/bin/phpunit --no-progress tests/Cache/ModelCacheStoreValidatorTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Cache/FailoverStoreTest.php +./vendor/bin/phpunit --no-progress tests/Auth/AuthEloquentUserProviderCacheTest.php +./vendor/bin/phpunit --no-progress tests/Auth/AuthServiceProviderTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Auth/EloquentUserProviderCacheTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Auth/EloquentUserProviderRedisCacheTest.php +./vendor/bin/phpunit --no-progress tests/Sanctum/PersonalAccessTokenCacheTest.php +./vendor/bin/phpunit --no-progress tests/Sanctum/GuardTest.php +./vendor/bin/phpunit --no-progress tests/Sanctum/SanctumServiceProviderTest.php +``` + +Then run package/static checks and the required complete suite from repository root: + +```bash +composer validate --strict src/auth/composer.json +composer validate --strict src/sanctum/composer.json +composer fix +git diff --check +``` + +`composer fix` runs formatting, both PHPStan configurations, the full parallel +suite, the scoped Testbench package suite, and the dogfood package suite in the +repository-defined order. Do not replace it with a partial check sequence. + +After tests pass: + +1. re-read `AGENTS.md` and this plan in full; +2. inspect the complete diff for config replay ordering, worker-lifetime state, + provider timing, hot-read overhead, stale docs/comments, and duplicated policy + logic; +3. run the stale-source searches from Section 7; +4. compare deliberate Laravel differences again against the local reference; +5. request independent source and test review; +6. resolve every finding and rerun affected focused tests plus the full required + suite.