diff --git a/src/boost/docs/providers.md b/src/boost/docs/providers.md index cd27d9005..0b10d399c 100644 --- a/src/boost/docs/providers.md +++ b/src/boost/docs/providers.md @@ -3,8 +3,11 @@ - [Introduction](#introduction) - [Writing Service Providers](#writing-service-providers) - [The Register Method](#the-register-method) + - [Merging Configuration](#merging-configuration) - [The Boot Method](#the-boot-method) + - [Conditionally Loading Providers](#conditionally-loading-providers) - [Registering Providers](#registering-providers) + - [Provider Priority](#provider-priority) - [Deferred Providers](#deferred-providers) @@ -104,6 +107,39 @@ class AppServiceProvider extends ServiceProvider } ``` + +#### Merging Configuration + +Package service providers may merge their default configuration into the application's configuration using the `mergeConfigFrom` method. This is typically done within the `register` method: + +```php +/** + * Register any application services. + */ +public function register(): void +{ + $this->mergeConfigFrom( + __DIR__.'/../config/riak.php', 'riak' + ); +} +``` + +By default, configuration is merged at the top level. If your configuration contains arrays that should allow applications to add new entries without replacing the entire array, override the `mergeableOptions` method: + +```php +/** + * Get the options within the configuration that should be merged. + * + * @return array + */ +protected function mergeableOptions(string $name): array +{ + return $name === 'riak' ? ['connections'] : []; +} +``` + +In this example, the application's `riak.connections` entries will be merged with the package's default connections. Entries with the same key will still be replaced by the application's configuration. + ### The Boot Method @@ -150,6 +186,23 @@ public function boot(ResponseFactory $response): void } ``` + +### Conditionally Loading Providers + +You may prevent a service provider from being registered or booted by overriding the `isEnabled` method. This is useful for packages that are installed in many applications but should only load in some of them: + +```php +/** + * Determine whether this provider should be registered and booted. + */ +public function isEnabled(): bool +{ + return (bool) config('modules.riak.enabled'); +} +``` + +When this method returns `false`, the provider's `register` and `boot` methods will not be called, its `bindings` and `singletons` properties will not be processed, and the provider will not be marked as loaded. + ## Registering Providers @@ -174,6 +227,25 @@ return [ ]; ``` + +### Provider Priority + +Auto-discovered package providers may define a `priority` property to control their registration order relative to other discovered package providers. Providers with a higher priority are registered first: + +```php +use Hypervel\Support\ServiceProvider; + +class RiakServiceProvider extends ServiceProvider +{ + /** + * The registration priority for this provider. + */ + public int $priority = 10; +} +``` + +Provider priority only applies to auto-discovered package providers. Framework providers are always registered before discovered package providers, and application providers are registered after them. + ## Deferred Providers diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index f095b2d98..5cbbb2e66 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -950,6 +950,14 @@ public function register(ServiceProvider|string $provider, bool $force = false): $provider = $this->resolveProvider($provider); } + // Hypervel-specific: providers may opt out of registration via isEnabled() + // returning false (e.g. gated on runtime config/env). A disabled provider + // is instantiated but never registered or booted, its bindings/singletons + // properties are skipped, and it is not tracked as a registered provider. + if (! $provider->isEnabled()) { + return $provider; + } + $provider->register(); // If there are bindings / singletons set as properties on the provider we diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php index 90b9a5abb..26bc042e5 100644 --- a/src/support/src/ServiceProvider.php +++ b/src/support/src/ServiceProvider.php @@ -82,6 +82,20 @@ public function __construct( ) { } + /** + * Determine whether this provider should be registered and booted. + * + * Hypervel-specific extension (not in Laravel). Override on a subclass to + * gate registration on runtime config / env / feature flags. When this + * returns false the provider is instantiated but neither register() nor + * boot() is called, its bindings/singletons properties are not processed, + * and it is not tracked in the application's provider list. + */ + public function isEnabled(): bool + { + return true; + } + /** * Register any application services. */ diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index 8c185b57b..e2b586a8e 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -14,6 +14,7 @@ use Hypervel\Support\ServiceProvider; use Hypervel\Tests\TestCase; use Mockery as m; +use RuntimeException; use stdClass; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -63,6 +64,7 @@ public function testServiceProvidersAreCorrectlyRegistered() { $provider = m::mock(ApplicationBasicServiceProviderStub::class); $class = get_class($provider); + $provider->shouldReceive('isEnabled')->andReturn(true); $provider->shouldReceive('register')->once(); $app = new Application; $app->register($provider); @@ -114,6 +116,7 @@ public function testServiceProvidersAreCorrectlyRegisteredWhenRegisterMethodIsNo { $provider = m::mock(ServiceProvider::class); $class = get_class($provider); + $provider->shouldReceive('isEnabled')->andReturn(true); $provider->shouldReceive('register')->once(); $app = new Application; $app->register($provider); @@ -125,6 +128,7 @@ public function testServiceProvidersCouldBeLoaded() { $provider = m::mock(ServiceProvider::class); $class = get_class($provider); + $provider->shouldReceive('isEnabled')->andReturn(true); $provider->shouldReceive('register')->once(); $app = new Application; $app->register($provider); @@ -133,6 +137,63 @@ public function testServiceProvidersCouldBeLoaded() $this->assertFalse($app->providerIsLoaded(ApplicationBasicServiceProviderStub::class)); } + public function testDisabledServiceProviderIsNotRegisteredOrTracked() + { + $app = new Application; + $app->register($provider = new ApplicationDisabledServiceProviderStub($app)); + + $this->assertArrayNotHasKey(get_class($provider), $app->getLoadedProviders()); + $this->assertFalse($app->providerIsLoaded(get_class($provider))); + $this->assertNull($app->getProvider(get_class($provider))); + $this->assertSame([], $app->getProviders(get_class($provider))); + } + + public function testDisabledServiceProviderBindingsArrayIsSkipped() + { + $app = new Application; + $app->register(new class($app) extends ServiceProvider { + public $bindings = [ + AbstractClass::class => ConcreteClass::class, + ]; + + public function isEnabled(): bool + { + return false; + } + }); + + $this->assertFalse($app->bound(AbstractClass::class)); + } + + public function testDisabledServiceProviderSingletonsArrayIsSkipped() + { + $app = new Application; + $app->register(new class($app) extends ServiceProvider { + public $singletons = [ + AbstractClass::class => ConcreteClass::class, + ]; + + public function isEnabled(): bool + { + return false; + } + }); + + $this->assertFalse($app->bound(AbstractClass::class)); + } + + public function testDisabledServiceProviderIsNotBootedWhenAppAlreadyBooted() + { + $app = new Application; + $app->boot(); + + // boot() throws if called — passing the late-register branch without + // the isEnabled() check would call bootProvider() and trigger it. + $app->register(new ApplicationDisabledServiceProviderStub($app)); + + $this->assertTrue($app->isBooted()); + } + public function testEnvironment() { $app = new Application; @@ -669,6 +730,24 @@ public function register(): void } } +class ApplicationDisabledServiceProviderStub extends ServiceProvider +{ + public function isEnabled(): bool + { + return false; + } + + public function register(): void + { + throw new RuntimeException('register() must not be called on a disabled provider'); + } + + public function boot(): void + { + throw new RuntimeException('boot() must not be called on a disabled provider'); + } +} + abstract class AbstractClass { } diff --git a/tests/Support/SupportServiceProviderTest.php b/tests/Support/SupportServiceProviderTest.php index 83dcb224c..4f5b9c849 100644 --- a/tests/Support/SupportServiceProviderTest.php +++ b/tests/Support/SupportServiceProviderTest.php @@ -7,8 +7,8 @@ use Hypervel\Config\Repository as ConfigRepository; use Hypervel\Foundation\Application; use Hypervel\Support\ServiceProvider; +use Hypervel\Tests\TestCase; use Mockery as m; -use PHPUnit\Framework\TestCase; class SupportServiceProviderTest extends TestCase { @@ -16,6 +16,8 @@ class SupportServiceProviderTest extends TestCase protected function setUp(): void { + parent::setUp(); + $this->app = $app = m::mock(Application::class)->makePartial(); $config = new ConfigRepository([ @@ -29,6 +31,33 @@ protected function setUp(): void $two->boot(); } + public function testIsEnabledReturnsTrueByDefault() + { + $provider = new ServiceProviderForTestingOne($this->app); + + $this->assertTrue($provider->isEnabled()); + } + + public function testIsEnabledCanBeOverriddenToReturnFalse() + { + $provider = new ServiceProviderForTestingDisabled($this->app); + + $this->assertFalse($provider->isEnabled()); + } + + public function testIsEnabledCanReadFromContainerAndConfig() + { + $config = new ConfigRepository(['package' => ['enabled' => true]]); + $app = m::mock(Application::class)->makePartial(); + $app->shouldReceive('make')->with('config')->andReturn($config); + + $provider = new ServiceProviderForTestingConditionallyEnabled($app); + $this->assertTrue($provider->isEnabled()); + + $config->set('package.enabled', false); + $this->assertFalse($provider->isEnabled()); + } + public function testPublishableServiceProviders() { $toPublish = ServiceProvider::publishableProviders(); @@ -476,6 +505,22 @@ public function boot() } } +class ServiceProviderForTestingDisabled extends ServiceProvider +{ + public function isEnabled(): bool + { + return false; + } +} + +class ServiceProviderForTestingConditionallyEnabled extends ServiceProvider +{ + public function isEnabled(): bool + { + return (bool) $this->app->make('config')->get('package.enabled', false); + } +} + class ServiceProviderForTestingFlat extends ServiceProvider { public function register(): void