Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 26 additions & 3 deletions config/toggle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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' => [
//
],

];
101 changes: 101 additions & 0 deletions src/Drivers/PerFlagDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

namespace OffloadProject\Toggle\Drivers;

use OffloadProject\Toggle\Contracts\Driver;

class PerFlagDriver implements Driver
{
/**
* @param array<int, string> $databaseFlags
* @param array<string, mixed> $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();

Comment thread
shavonn marked this conversation as resolved.
$result = [];

// Config flags from config driver only
foreach ($this->configFlags as $flag => $value) {
if (! isset($databaseFlagSet[$flag])) {
$result[$flag] = $configResults[$flag] ?? (bool) $value;
}
Comment thread
shavonn marked this conversation as resolved.
}

// 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<string, true>
*/
protected function databaseFlagSet(): array
{
return array_fill_keys($this->databaseFlags, true);
}
}
50 changes: 48 additions & 2 deletions src/ToggleServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
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;
use OffloadProject\Toggle\Commands\ListCommand;
use OffloadProject\Toggle\Contracts\Driver;
use OffloadProject\Toggle\Drivers\ConfigDriver;
use OffloadProject\Toggle\Drivers\DatabaseDriver;
use OffloadProject\Toggle\Drivers\PerFlagDriver;

class ToggleServiceProvider extends ServiceProvider
{
Expand All @@ -40,16 +42,41 @@ public function register(): void

public function boot(): void
{
$this->validateFlagOverlap();
$this->registerPublishing();
$this->registerBladeDirectives();
$this->registerCommands();
}

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,
Expand All @@ -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));
}
Comment thread
shavonn marked this conversation as resolved.
}

protected function registerPublishing(): void
{
if (! $this->app->runningInConsole()) {
Expand Down
Loading