diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8c16dc6..449e075 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,12 +17,9 @@ jobs: strategy: fail-fast: true matrix: - php: [ 8.2, 8.3, 8.4 ] - laravel: [ 11.*, 12.*, 13.* ] + php: [ 8.3, 8.4 ] + laravel: [ 12.*, 13.* ] stability: [ prefer-lowest, prefer-stable ] - exclude: - - php: 8.2 - laravel: 13.* name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.stability }} diff --git a/README.md b/README.md index e894754..bf26692 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ flags, database-driven toggles, or both. ## Features - **Two storage drivers**: Config (environment variables) or Database (runtime toggleable) +- **Per-flag driver routing**: Mix config and database flags in the same app via `database_flags` - **Layered approach**: Database driver falls back to config, allowing gradual migration - **Built-in caching**: Configurable cache store and TTL for performance - **Blade directives**: `@toggle`, `@elsetoggle`, `@endtoggle` for clean templates @@ -102,6 +103,34 @@ The **config driver** is read-only at runtime - values come from environment var The **database driver** checks the database first, then falls back to config values. This allows you to define defaults in config while overriding specific toggles at runtime. +### Per-flag driver routing + +You can mix both drivers in the same application. Define config-driven flags in `flags` and list database-driven flags +in `database_flags`: + +```php +// config/toggle.php + +'flags' => [ + // Config-driven, read-only — controlled by .env + 'new-checkout' => env('TOGGLE_NEW_CHECKOUT', false), + 'dark-mode' => env('TOGGLE_DARK_MODE', true), +], + +'database_flags' => [ + // Database-driven, mutable at runtime via Toggle::enable() / Toggle::disable() + 'maintenance-banner', + 'beta-access', +], +``` + +Resolution logic: +- Flags in `database_flags` always use the database driver (with config fallback) +- Flags in `flags` always use the config driver (read-only) +- Unlisted flags use the global `driver` setting + +This lets you keep stable flags in `.env` while allowing runtime control over flags that need to change without a deploy. + ### Default behavior for undefined toggles ```env diff --git a/config/toggle.php b/config/toggle.php index 543de40..9d65996 100644 --- a/config/toggle.php +++ b/config/toggle.php @@ -61,11 +61,12 @@ /* |-------------------------------------------------------------------------- - | Feature Flags + | Feature Flags (Config-driven) |-------------------------------------------------------------------------- | - | Define your feature flags here. Each flag should have a unique key - | and a boolean value (typically from an environment variable). + | Define your config-driven feature flags here. When used alongside + | "database_flags", these will always resolve from the config driver + | (read-only). Otherwise, the global "driver" setting applies. | | Example: | 'new-checkout' => env('TOGGLE_NEW_CHECKOUT', false), @@ -76,4 +77,26 @@ // 'example-flag' => env('TOGGLE_EXAMPLE_FLAG', false), ], + /* + |-------------------------------------------------------------------------- + | Database-driven Flags + |-------------------------------------------------------------------------- + | + | List flag names that should be resolved from the database. These flags + | are mutable at runtime via Toggle::enable() and Toggle::disable(). + | If a flag is not found in the database, it will fall back to config. + | + | Flags listed here will always use the database driver regardless of the + | global driver setting. + | + | Example: + | 'maintenance-banner', + | 'beta-access', + | + */ + + 'database_flags' => [ + // + ], + ]; diff --git a/src/Drivers/PerFlagDriver.php b/src/Drivers/PerFlagDriver.php new file mode 100644 index 0000000..a166022 --- /dev/null +++ b/src/Drivers/PerFlagDriver.php @@ -0,0 +1,101 @@ + $databaseFlags + * @param array $configFlags + */ + public function __construct( + protected ConfigDriver $configDriver, + protected DatabaseDriver $databaseDriver, + protected Driver $defaultDriver, + protected array $databaseFlags, + protected array $configFlags, + ) {} + + public function get(string $name): ?bool + { + return $this->driverFor($name)->get($name); + } + + public function set(string $name, bool $active): bool + { + return $this->driverFor($name)->set($name, $active); + } + + public function delete(string $name): bool + { + return $this->driverFor($name)->delete($name); + } + + public function has(string $name): bool + { + return $this->driverFor($name)->has($name); + } + + public function all(): array + { + $configResults = $this->configDriver->all(); + $dbResults = $this->databaseDriver->all(); + $databaseFlagSet = $this->databaseFlagSet(); + + $result = []; + + // Config flags from config driver only + foreach ($this->configFlags as $flag => $value) { + if (! isset($databaseFlagSet[$flag])) { + $result[$flag] = $configResults[$flag] ?? (bool) $value; + } + } + + // Database flags from database driver + foreach ($this->databaseFlags as $flag) { + if (isset($dbResults[$flag])) { + $result[$flag] = $dbResults[$flag]; + } elseif (isset($configResults[$flag])) { + $result[$flag] = $configResults[$flag]; + } + } + + // Unlisted flags from the default driver (reuse already-fetched results) + $defaultAll = ($this->defaultDriver === $this->databaseDriver) + ? $dbResults + : (($this->defaultDriver === $this->configDriver) ? $configResults : $this->defaultDriver->all()); + + foreach ($defaultAll as $flag => $value) { + if (! array_key_exists($flag, $result)) { + $result[$flag] = $value; + } + } + + return $result; + } + + protected function driverFor(string $name): Driver + { + if (isset($this->databaseFlagSet()[$name])) { + return $this->databaseDriver; + } + + if (array_key_exists($name, $this->configFlags)) { + return $this->configDriver; + } + + return $this->defaultDriver; + } + + /** + * @return array + */ + protected function databaseFlagSet(): array + { + return array_fill_keys($this->databaseFlags, true); + } +} diff --git a/src/ToggleServiceProvider.php b/src/ToggleServiceProvider.php index a2db884..2dbfb0d 100644 --- a/src/ToggleServiceProvider.php +++ b/src/ToggleServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Contracts\Config\Repository as ConfigRepository; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Log; use Illuminate\Support\ServiceProvider; use OffloadProject\Toggle\Commands\CacheClearCommand; use OffloadProject\Toggle\Commands\CreateCommand; @@ -14,6 +15,7 @@ use OffloadProject\Toggle\Contracts\Driver; use OffloadProject\Toggle\Drivers\ConfigDriver; use OffloadProject\Toggle\Drivers\DatabaseDriver; +use OffloadProject\Toggle\Drivers\PerFlagDriver; class ToggleServiceProvider extends ServiceProvider { @@ -40,6 +42,7 @@ public function register(): void public function boot(): void { + $this->validateFlagOverlap(); $this->registerPublishing(); $this->registerBladeDirectives(); $this->registerCommands(); @@ -47,9 +50,33 @@ public function boot(): void protected function createDriver(Application $app, ConfigRepository $config): Driver { - $driver = $config->get('toggle.driver', 'config'); + $databaseFlags = $config->get('toggle.database_flags', []); + $configFlags = $config->get('toggle.flags', []); + $driverName = $config->get('toggle.driver', 'config'); + + // If database_flags are configured, use the PerFlagDriver for routing + if (! empty($databaseFlags)) { + $configDriver = new ConfigDriver($config); + $databaseDriver = new DatabaseDriver( + $app->make('db.connection'), + $config, + ); + + $defaultDriver = match ($driverName) { + 'database' => $databaseDriver, + default => $configDriver, + }; + + return new PerFlagDriver( + $configDriver, + $databaseDriver, + $defaultDriver, + $databaseFlags, + $configFlags, + ); + } - return match ($driver) { + return match ($driverName) { 'database' => new DatabaseDriver( $app->make('db.connection'), $config, @@ -58,6 +85,25 @@ protected function createDriver(Application $app, ConfigRepository $config): Dri }; } + protected function validateFlagOverlap(): void + { + if (! $this->app->hasDebugModeEnabled()) { + return; + } + + /** @var ConfigRepository $config */ + $config = $this->app->make(ConfigRepository::class); + + $databaseFlags = $config->get('toggle.database_flags', []); + $configFlags = array_keys($config->get('toggle.flags', [])); + + $overlap = array_intersect($databaseFlags, $configFlags); + + if (! empty($overlap)) { + Log::warning('Toggle: The following flags are defined in both "flags" and "database_flags". The database_flags entry will take precedence: '.implode(', ', $overlap)); + } + } + protected function registerPublishing(): void { if (! $this->app->runningInConsole()) { diff --git a/tests/Feature/PerFlagDriverTest.php b/tests/Feature/PerFlagDriverTest.php new file mode 100644 index 0000000..8984477 --- /dev/null +++ b/tests/Feature/PerFlagDriverTest.php @@ -0,0 +1,128 @@ + 'config', + 'toggle.flags' => [ + 'config-flag' => true, + 'shared-flag' => false, + ], + 'toggle.database_flags' => [ + 'db-flag', + 'shared-flag', + ], + ]); + + // Rebind the toggle manager so the PerFlagDriver is created + app()->forgetInstance(ToggleManager::class); + app()->forgetInstance('toggle'); +}); + +it('resolves database flags from the database', function () { + DB::table('toggles')->insert([ + 'name' => 'db-flag', + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $manager = app(ToggleManager::class); + + expect($manager->active('db-flag'))->toBeTrue(); +}); + +it('resolves config flags from config', function () { + $manager = app(ToggleManager::class); + + expect($manager->active('config-flag'))->toBeTrue(); +}); + +it('config flags remain read-only even with database_flags configured', function () { + $manager = app(ToggleManager::class); + + $manager->enable('config-flag'); +})->throws(ReadOnlyDriverException::class); + +it('database flags can be set at runtime', function () { + $manager = app(ToggleManager::class); + + expect($manager->enable('db-flag'))->toBeTrue(); + expect(DB::table('toggles')->where('name', 'db-flag')->value('active'))->toBe(1); +}); + +it('database flags fall back to config when not in database', function () { + // shared-flag is in both arrays, has value false in config, not in DB + $manager = app(ToggleManager::class); + + expect($manager->active('shared-flag'))->toBeFalse(); +}); + +it('database flags take precedence over config when present in database', function () { + // shared-flag is false in config but true in DB + DB::table('toggles')->insert([ + 'name' => 'shared-flag', + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $manager = app(ToggleManager::class); + + expect($manager->active('shared-flag'))->toBeTrue(); +}); + +it('unlisted flags use the global default driver', function () { + // Global driver is 'config', so unlisted flags resolve via config + config(['toggle.flags' => [ + 'config-flag' => true, + 'shared-flag' => false, + 'unlisted-via-config' => true, + ]]); + + app()->forgetInstance(ToggleManager::class); + app()->forgetInstance('toggle'); + + $manager = app(ToggleManager::class); + + expect($manager->active('unlisted-via-config'))->toBeTrue(); +}); + +it('all() merges results from both drivers', function () { + DB::table('toggles')->insert([ + 'name' => 'db-flag', + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $manager = app(ToggleManager::class); + $all = $manager->all(); + + expect($all)->toHaveKey('config-flag') + ->and($all['config-flag'])->toBeTrue() + ->and($all)->toHaveKey('db-flag') + ->and($all['db-flag'])->toBeTrue(); +}); + +it('logs warning when flags overlap between config and database_flags', function () { + config(['app.debug' => true]); + + Log::shouldReceive('warning') + ->once() + ->withArgs(fn (string $message) => str_contains($message, 'shared-flag')); + + // Trigger boot on the existing provider instance + app()->forgetInstance(ToggleManager::class); + app()->forgetInstance('toggle'); + + /** @var OffloadProject\Toggle\ToggleServiceProvider $provider */ + $provider = app()->getProvider(OffloadProject\Toggle\ToggleServiceProvider::class); + $provider->boot(); +});