From 8c3a822b0d81a9e9ad721297687f0e92c1fd7688 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Fri, 8 May 2026 15:51:55 +0600 Subject: [PATCH 1/2] Add ghostable providers for lazy-loading framework services --- src/Phaseolies/Application.php | 183 +++++++++++++++++- src/Phaseolies/DI/Container.php | 8 + .../Providers/CacheServiceProvider.php | 18 +- .../Providers/GhostableProvider.php | 13 ++ .../Providers/LanguageServiceProvider.php | 16 +- .../Providers/RateLimiterServiceProvider.php | 15 +- tests/Application/ApplicationTest.php | 52 +++++ .../Mock/Providers/GhostableTestProvider.php | 39 ++++ 8 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 src/Phaseolies/Providers/GhostableProvider.php create mode 100644 tests/Application/Mock/Providers/GhostableTestProvider.php diff --git a/src/Phaseolies/Application.php b/src/Phaseolies/Application.php index b6cfd32e..a07ed08c 100644 --- a/src/Phaseolies/Application.php +++ b/src/Phaseolies/Application.php @@ -4,6 +4,7 @@ use Phaseolies\Auth\ActorManager; use Phaseolies\Support\Router; +use Phaseolies\Providers\GhostableProvider; use Phaseolies\Providers\ServiceProvider; use Phaseolies\Http\DispatchResult; use Phaseolies\Http\Response; @@ -127,6 +128,34 @@ class Application extends Container */ protected $serviceProviders = []; + /** + * The queued ghost providers keyed by class name. + * + * @var array, ServiceProvider> + */ + protected array $ghostProviders = []; + + /** + * Map of service identifiers to ghost provider classes. + * + * @var array> + */ + protected array $ghostServices = []; + + /** + * Tracks ghost providers that have already been loaded. + * + * @var array, true> + */ + protected array $loadedGhostProviders = []; + + /** + * Tracks ghost providers currently being loaded. + * + * @var array, true> + */ + protected array $loadingGhostProviders = []; + /** * Indicates if the providers has been booted * @@ -134,6 +163,20 @@ class Application extends Container */ protected $providersBooted = false; + /** + * Indicates if the application is currently booting eager providers. + * + * @var bool + */ + protected bool $bootingProviders = false; + + /** + * Tracks providers whose boot method has already run. + * + * @var array, true> + */ + protected array $bootedProviderClasses = []; + /** * @var Router */ @@ -304,13 +347,64 @@ protected function registerProviders(array $providers = []): void { foreach ($providers as $provider) { $providerInstance = new $provider($this); + if ($providerInstance instanceof ServiceProvider) { - $providerInstance->register(); - $this->serviceProviders[] = $providerInstance; + if ($this->shouldQueueGhostProvider($providerInstance)) { + $this->queueGhostProvider($providerInstance); + continue; + } + + $this->registerProviderInstance($providerInstance); } } } + /** + * Determine if the provider should be queued as a ghost provider + * + * @param ServiceProvider $providerInstance + * @return bool + */ + protected function shouldQueueGhostProvider(ServiceProvider $providerInstance): bool + { + return !$this->runningInConsole() && $providerInstance instanceof GhostableProvider; + } + + /** + * Register and track an eager provider instance. + * + * @param ServiceProvider $providerInstance + * @return void + */ + protected function registerProviderInstance(ServiceProvider $providerInstance): void + { + $providerInstance->register(); + + $this->serviceProviders[] = $providerInstance; + } + + /** + * Queue a ghost provider until one of its services is requested. + * + * @param ServiceProvider $providerInstance + * @return void + */ + protected function queueGhostProvider(ServiceProvider $providerInstance): void + { + /** @var GhostableProvider $providerInstance */ + $providerClass = $providerInstance::class; + + $this->ghostProviders[$providerClass] = $providerInstance; + + foreach ($providerInstance->ghosts() as $ghost) { + if (!is_string($ghost) || $ghost === '') { + continue; + } + + $this->ghostServices[$ghost] = $providerClass; + } + } + /** * Boots core service providers. * @@ -334,8 +428,14 @@ protected function bootCoreProviders(): self */ protected function bootProviders(): void { - foreach ($this->serviceProviders as $providerInstance) { - $providerInstance->boot(); + $this->bootingProviders = true; + + try { + foreach ($this->serviceProviders as $providerInstance) { + $this->bootProviderInstance($providerInstance); + } + } finally { + $this->bootingProviders = false; } $this->bootstrap(); @@ -730,6 +830,81 @@ public function getProvider(string $provider): ?ServiceProvider return null; } + /** + * Determine if the application has a binding or queued ghost for the given service. + * + * @param string $key + * @return bool + */ + public function has(string $key): bool + { + return parent::has($key) || isset($this->ghostServices[$key]); + } + + /** + * Load a queued ghost provider when one of its services is requested. + * + * @param string $abstract + * @return bool + */ + public function loadGhostProvider(string $abstract): bool + { + $providerClass = $this->ghostServices[$abstract] ?? null; + + if ($providerClass === null) { + return false; + } + + if (isset($this->loadedGhostProviders[$providerClass]) || isset($this->loadingGhostProviders[$providerClass])) { + return true; + } + + $providerInstance = $this->ghostProviders[$providerClass] ?? null; + + if (!$providerInstance instanceof ServiceProvider) { + return false; + } + + $this->loadingGhostProviders[$providerClass] = true; + + foreach (($providerInstance instanceof GhostableProvider ? $providerInstance->ghosts() : []) as $ghost) { + unset($this->ghostServices[$ghost]); + } + + try { + $this->registerProviderInstance($providerInstance); + + if ($this->providersBooted || $this->bootingProviders) { + $this->bootProviderInstance($providerInstance); + } + + $this->loadedGhostProviders[$providerClass] = true; + + return true; + } finally { + unset($this->loadingGhostProviders[$providerClass], $this->ghostProviders[$providerClass]); + } + } + + /** + * Boot a provider instance once. + * + * @param ServiceProvider $providerInstance + * @return void + */ + protected function bootProviderInstance(ServiceProvider $providerInstance): void + { + $providerClass = $providerInstance::class; + + if (isset($this->bootedProviderClasses[$providerClass])) { + return; + } + + $providerInstance->boot(); + + $this->bootedProviderClasses[$providerClass] = true; + } + /** * Determine if the application is in the given environment. * diff --git a/src/Phaseolies/DI/Container.php b/src/Phaseolies/DI/Container.php index 50c77b02..dd2d9d59 100644 --- a/src/Phaseolies/DI/Container.php +++ b/src/Phaseolies/DI/Container.php @@ -166,6 +166,14 @@ public function get(string $abstract, array $parameters = []): mixed $this->resolving[$abstract] = true; try { + if ( + !isset(self::$bindings[$abstract]) && + !array_key_exists($abstract, self::$instances) && + method_exists($this, 'loadGhostProvider') + ) { + $this->loadGhostProvider($abstract); + } + if (isset(self::$instances[$abstract]) && self::$instances[$abstract] !== null) { return self::$instances[$abstract]; } diff --git a/src/Phaseolies/Providers/CacheServiceProvider.php b/src/Phaseolies/Providers/CacheServiceProvider.php index ce17c845..113343df 100644 --- a/src/Phaseolies/Providers/CacheServiceProvider.php +++ b/src/Phaseolies/Providers/CacheServiceProvider.php @@ -8,10 +8,11 @@ use Symfony\Component\Cache\Adapter\ApcuAdapter; use Psr\SimpleCache\CacheInterface; use Phaseolies\Providers\ServiceProvider; +use Phaseolies\Providers\GhostableProvider; use Phaseolies\Cache\CacheStore; use Phaseolies\Cache\IncrementableCacheInterface; -class CacheServiceProvider extends ServiceProvider +class CacheServiceProvider extends ServiceProvider implements GhostableProvider { /** * @var \Closure[] Custom adapter factories @@ -158,4 +159,19 @@ public function boot() { // } + + /** + * Get the services that should ghost-load this provider. + * + * @return array + */ + public function ghosts(): array + { + return [ + CacheStore::class, + IncrementableCacheInterface::class, + CacheInterface::class, + 'cache', + ]; + } } diff --git a/src/Phaseolies/Providers/GhostableProvider.php b/src/Phaseolies/Providers/GhostableProvider.php new file mode 100644 index 00000000..b878c16d --- /dev/null +++ b/src/Phaseolies/Providers/GhostableProvider.php @@ -0,0 +1,13 @@ + + */ + public function ghosts(): array; +} diff --git a/src/Phaseolies/Providers/LanguageServiceProvider.php b/src/Phaseolies/Providers/LanguageServiceProvider.php index 2640cbcb..4b29f21e 100644 --- a/src/Phaseolies/Providers/LanguageServiceProvider.php +++ b/src/Phaseolies/Providers/LanguageServiceProvider.php @@ -6,8 +6,9 @@ use Phaseolies\Translation\FileLoader; use Phaseolies\Translation\Translator; use Phaseolies\Providers\ServiceProvider; +use Phaseolies\Providers\GhostableProvider; -class LanguageServiceProvider extends ServiceProvider +class LanguageServiceProvider extends ServiceProvider implements GhostableProvider { /** * Register the service provider. @@ -55,4 +56,17 @@ public function boot() { Lang::setFacadeApplication($this->app); } + + /** + * Get the services that should ghost-load this provider. + * + * @return array + */ + public function ghosts(): array + { + return [ + 'translation.loader', + 'translator', + ]; + } } diff --git a/src/Phaseolies/Providers/RateLimiterServiceProvider.php b/src/Phaseolies/Providers/RateLimiterServiceProvider.php index bc5d206c..ef546c4c 100644 --- a/src/Phaseolies/Providers/RateLimiterServiceProvider.php +++ b/src/Phaseolies/Providers/RateLimiterServiceProvider.php @@ -3,10 +3,11 @@ namespace Phaseolies\Providers; use Phaseolies\Providers\ServiceProvider; +use Phaseolies\Providers\GhostableProvider; use Phaseolies\Cache\IncrementableCacheInterface; use Phaseolies\Cache\RateLimiter; -class RateLimiterServiceProvider extends ServiceProvider +class RateLimiterServiceProvider extends ServiceProvider implements GhostableProvider { /** * Register the service provider. @@ -29,4 +30,16 @@ public function boot() { // } + + /** + * Get the services that should ghost-load this provider. + * + * @return array + */ + public function ghosts(): array + { + return [ + RateLimiter::class, + ]; + } } diff --git a/tests/Application/ApplicationTest.php b/tests/Application/ApplicationTest.php index 4e26e9aa..689c65f5 100644 --- a/tests/Application/ApplicationTest.php +++ b/tests/Application/ApplicationTest.php @@ -16,6 +16,7 @@ use Phaseolies\Support\Router; use Phaseolies\Support\Session; use Phaseolies\Console\Console; +use Tests\Application\Mock\Providers\GhostableTestProvider; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use Phaseolies\Support\StringService; @@ -96,6 +97,7 @@ protected function setUp(): void protected function tearDown(): void { + GhostableTestProvider::resetState(); $this->deleteDirectory($this->tempBasePath); } @@ -216,6 +218,56 @@ public function testCoreProvidersAreLoaded(): void $this->assertContains(\Phaseolies\Providers\LanguageServiceProvider::class, $providers); } + public function testGhostableProvidersAreQueuedOutsideConsole(): void + { + GhostableTestProvider::resetState(); + + $this->setProtectedProperty($this->app, 'isRunningInConsole', false); + + $this->callProtectedMethod($this->app, 'registerProviders', [ + [GhostableTestProvider::class], + ]); + + $this->assertTrue($this->app->has('ghost.service')); + $this->assertSame([], $this->app->getProviders()); + $this->assertSame(0, GhostableTestProvider::$registerCount); + } + + public function testGhostServiceResolutionLoadsQueuedProviderAndBootsIt(): void + { + GhostableTestProvider::resetState(); + + $this->setProtectedProperty($this->app, 'isRunningInConsole', false); + $this->setProtectedProperty($this->app, 'providersBooted', true); + + $this->callProtectedMethod($this->app, 'registerProviders', [ + [GhostableTestProvider::class], + ]); + + $this->assertSame('ghost-value', $this->app->make('ghost.service')); + $this->assertSame('booted', $this->app->make('ghost.booted')); + $this->assertSame(1, GhostableTestProvider::$registerCount); + $this->assertSame(1, GhostableTestProvider::$bootCount); + $this->assertInstanceOf( + GhostableTestProvider::class, + $this->app->getProvider(GhostableTestProvider::class) + ); + } + + public function testGhostableProvidersRemainEagerInConsole(): void + { + GhostableTestProvider::resetState(); + + $this->setProtectedProperty($this->app, 'isRunningInConsole', true); + + $this->callProtectedMethod($this->app, 'registerProviders', [ + [GhostableTestProvider::class], + ]); + + $this->assertSame(1, GhostableTestProvider::$registerCount); + $this->assertCount(1, $this->app->getProviders()); + } + public function testSingletonBindings(): void { $this->callProtectedMethod($this->app, 'bindSingletonClasses'); diff --git a/tests/Application/Mock/Providers/GhostableTestProvider.php b/tests/Application/Mock/Providers/GhostableTestProvider.php new file mode 100644 index 00000000..78bbd031 --- /dev/null +++ b/tests/Application/Mock/Providers/GhostableTestProvider.php @@ -0,0 +1,39 @@ +app->singleton('ghost.service', fn() => 'ghost-value'); + } + + public function boot(): void + { + self::$bootCount++; + + $this->app->singleton('ghost.booted', fn() => 'booted'); + } + + public function ghosts(): array + { + return [ + 'ghost.service', + ]; + } +} From 5a5437ddc2ccc61a1bcd5ec393d43976954e5e8e Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Fri, 8 May 2026 16:08:02 +0600 Subject: [PATCH 2/2] fix: circular dependency for providers: --- src/Phaseolies/DI/Container.php | 16 ++++++++-------- tests/Application/ApplicationTest.php | 18 ++++++++++++++++++ .../Mock/Providers/GhostableTestProvider.php | 6 ++++++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/Phaseolies/DI/Container.php b/src/Phaseolies/DI/Container.php index dd2d9d59..33e5ac1c 100644 --- a/src/Phaseolies/DI/Container.php +++ b/src/Phaseolies/DI/Container.php @@ -163,17 +163,17 @@ public function get(string $abstract, array $parameters = []): mixed throw new \RuntimeException("Circular dependency detected while resolving [{$abstract}]"); } + if ( + !isset(self::$bindings[$abstract]) && + !array_key_exists($abstract, self::$instances) && + method_exists($this, 'loadGhostProvider') + ) { + $this->loadGhostProvider($abstract); + } + $this->resolving[$abstract] = true; try { - if ( - !isset(self::$bindings[$abstract]) && - !array_key_exists($abstract, self::$instances) && - method_exists($this, 'loadGhostProvider') - ) { - $this->loadGhostProvider($abstract); - } - if (isset(self::$instances[$abstract]) && self::$instances[$abstract] !== null) { return self::$instances[$abstract]; } diff --git a/tests/Application/ApplicationTest.php b/tests/Application/ApplicationTest.php index 689c65f5..35ed2fc5 100644 --- a/tests/Application/ApplicationTest.php +++ b/tests/Application/ApplicationTest.php @@ -63,6 +63,7 @@ final class ApplicationTest extends TestCase protected function setUp(): void { $container = new Container(); + $container->flush(); $container->bind('config', fn() => Config::class); // Create a temporary directory structure @@ -98,6 +99,8 @@ protected function setUp(): void protected function tearDown(): void { GhostableTestProvider::resetState(); + $this->app->flush(); + Container::forgetInstance(); $this->deleteDirectory($this->tempBasePath); } @@ -254,6 +257,21 @@ public function testGhostServiceResolutionLoadsQueuedProviderAndBootsIt(): void ); } + public function testGhostServiceCanBeResolvedDuringProviderRegistration(): void + { + GhostableTestProvider::resetState(); + GhostableTestProvider::$resolveDuringRegister = true; + + $this->setProtectedProperty($this->app, 'isRunningInConsole', false); + + $this->callProtectedMethod($this->app, 'registerProviders', [ + [GhostableTestProvider::class], + ]); + + $this->assertSame('ghost-value', $this->app->make('ghost.service')); + $this->assertSame(1, GhostableTestProvider::$registerCount); + } + public function testGhostableProvidersRemainEagerInConsole(): void { GhostableTestProvider::resetState(); diff --git a/tests/Application/Mock/Providers/GhostableTestProvider.php b/tests/Application/Mock/Providers/GhostableTestProvider.php index 78bbd031..b7f1cfd4 100644 --- a/tests/Application/Mock/Providers/GhostableTestProvider.php +++ b/tests/Application/Mock/Providers/GhostableTestProvider.php @@ -9,11 +9,13 @@ class GhostableTestProvider extends ServiceProvider implements GhostableProvider { public static int $registerCount = 0; public static int $bootCount = 0; + public static bool $resolveDuringRegister = false; public static function resetState(): void { self::$registerCount = 0; self::$bootCount = 0; + self::$resolveDuringRegister = false; } public function register(): void @@ -21,6 +23,10 @@ public function register(): void self::$registerCount++; $this->app->singleton('ghost.service', fn() => 'ghost-value'); + + if (self::$resolveDuringRegister) { + $this->app->make('ghost.service'); + } } public function boot(): void