├── LICENSE.md ├── README.md ├── composer.json ├── config └── pennant.php ├── database └── migrations │ └── 2022_11_01_000001_create_features_table.php ├── ide.json ├── src ├── Commands │ ├── FeatureMakeCommand.php │ └── PurgeCommand.php ├── Concerns │ └── HasFeatures.php ├── Contracts │ ├── CanListStoredFeatures.php │ ├── DefinesFeaturesExternally.php │ ├── Driver.php │ ├── FeatureScopeSerializeable.php │ ├── FeatureScopeable.php │ └── HasFlushableCache.php ├── Drivers │ ├── ArrayDriver.php │ ├── DatabaseDriver.php │ └── Decorator.php ├── Events │ ├── AllFeaturesPurged.php │ ├── DynamicallyRegisteringFeatureClass.php │ ├── FeatureDeleted.php │ ├── FeatureResolved.php │ ├── FeatureRetrieved.php │ ├── FeatureUpdated.php │ ├── FeatureUpdatedForAllScopes.php │ ├── FeaturesPurged.php │ ├── UnexpectedNullScopeEncountered.php │ └── UnknownFeatureResolved.php ├── Feature.php ├── FeatureManager.php ├── LazilyResolvedFeature.php ├── Middleware │ └── EnsureFeaturesAreActive.php ├── Migrations │ └── PennantMigration.php ├── PendingScopedFeatureInteraction.php ├── PennantServiceProvider.php └── helpers.php ├── stubs └── feature.stub ├── testbench.yaml └── workbench ├── app ├── Features │ └── NewApi.php └── Models │ ├── Team.php │ └── User.php └── database └── factories └── UserFactory.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Taylor Otwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Laravel Pennant Package Logo

2 | 3 |

4 | Build Status 5 | Total Downloads 6 | Latest Stable Version 7 | License 8 |

9 | 10 | ## Introduction 11 | 12 | Laravel Pennant is a simple, lightweight library for managing feature flags. 13 | 14 | ## Official Documentation 15 | 16 | Documentation for Laravel Pennant can be found on the [Laravel website](https://laravel.com/docs/pennant). 17 | 18 | ## Contributing 19 | 20 | Thank you for considering contributing to Laravel Pennant! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 21 | 22 | ## Code of Conduct 23 | 24 | In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). 25 | 26 | ## Security Vulnerabilities 27 | 28 | Please review [our security policy](https://github.com/laravel/pennant/security/policy) on how to report security vulnerabilities. 29 | 30 | ## License 31 | 32 | Laravel Pennant is open-sourced software licensed under the [MIT license](LICENSE.md). 33 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel/pennant", 3 | "description": "A simple, lightweight library for managing feature flags.", 4 | "keywords": ["laravel", "pennant", "feature", "flags"], 5 | "homepage": "https://github.com/laravel/pennant", 6 | "license": "MIT", 7 | "support": { 8 | "issues": "https://github.com/laravel/pennant/issues", 9 | "source": "https://github.com/laravel/pennant" 10 | }, 11 | "authors": [ 12 | { 13 | "name": "Taylor Otwell", 14 | "email": "taylor@laravel.com" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.1", 19 | "illuminate/console": "^10.0|^11.0|^12.0", 20 | "illuminate/container": "^10.0|^11.0|^12.0", 21 | "illuminate/contracts": "^10.0|^11.0|^12.0", 22 | "illuminate/database": "^10.0|^11.0|^12.0", 23 | "illuminate/queue": "^10.0|^11.0|^12.0", 24 | "illuminate/support": "^10.0|^11.0|^12.0", 25 | "symfony/console": "^6.0|^7.0", 26 | "symfony/finder": "^6.0|^7.0" 27 | }, 28 | "require-dev": { 29 | "laravel/octane": "^1.4|^2.0", 30 | "orchestra/testbench": "^8.0|^9.0|^10.0", 31 | "phpstan/phpstan": "^1.10", 32 | "phpunit/phpunit": "^9.0|^10.4|^11.5" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Laravel\\Pennant\\": "src/" 37 | }, 38 | "files": [ 39 | "src/helpers.php" 40 | ] 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Tests\\": "tests/", 45 | "Workbench\\App\\": "workbench/app/", 46 | "Workbench\\Database\\Factories\\": "workbench/database/factories/" 47 | } 48 | }, 49 | "extra": { 50 | "branch-alias": { 51 | "dev-master": "1.x-dev" 52 | }, 53 | "laravel": { 54 | "providers": [ 55 | "Laravel\\Pennant\\PennantServiceProvider" 56 | ], 57 | "aliases": { 58 | "Feature": "Laravel\\Pennant\\Feature" 59 | } 60 | } 61 | }, 62 | "config": { 63 | "sort-packages": true 64 | }, 65 | "scripts": { 66 | "post-autoload-dump": "@prepare", 67 | "prepare": "@php vendor/bin/testbench package:discover --ansi" 68 | }, 69 | "minimum-stability": "dev", 70 | "prefer-stable": true 71 | } 72 | -------------------------------------------------------------------------------- /config/pennant.php: -------------------------------------------------------------------------------- 1 | env('PENNANT_STORE', 'database'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Pennant Stores 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may configure each of the stores that should be available to 26 | | Pennant. These stores shall be used to store resolved feature flag 27 | | values - you may configure as many as your application requires. 28 | | 29 | */ 30 | 31 | 'stores' => [ 32 | 33 | 'array' => [ 34 | 'driver' => 'array', 35 | ], 36 | 37 | 'database' => [ 38 | 'driver' => 'database', 39 | 'connection' => null, 40 | 'table' => 'features', 41 | ], 42 | 43 | ], 44 | ]; 45 | -------------------------------------------------------------------------------- /database/migrations/2022_11_01_000001_create_features_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('name'); 17 | $table->string('scope'); 18 | $table->text('value'); 19 | $table->timestamps(); 20 | 21 | $table->unique(['name', 'scope']); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | */ 28 | public function down(): void 29 | { 30 | Schema::dropIfExists('features'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /ide.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://laravel-ide.com/schema/laravel-ide-v2.json", 3 | "blade": { 4 | "ifDirectives": [ 5 | { 6 | "name": "feature", 7 | "prefix": "" 9 | } 10 | ] 11 | }, 12 | "codeGenerations": [ 13 | { 14 | "name": "Create Pennant Feature", 15 | "id": "laravel/pennant:feature", 16 | "files": [ 17 | { 18 | "appNamespace": "Features", 19 | "template": { 20 | "path": "stubs/feature.stub", 21 | "parameters": { 22 | "{{ namespace }}": "${INPUT_FQN|namespace}", 23 | "{{ class }}": "${INPUT_FQN|className}" 24 | } 25 | } 26 | } 27 | ] 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/Commands/FeatureMakeCommand.php: -------------------------------------------------------------------------------- 1 | laravel->basePath('stubs/feature.stub')) 41 | ? $customPath 42 | : __DIR__.'/../../stubs/feature.stub'; 43 | } 44 | 45 | /** 46 | * Get the default namespace for the class. 47 | * 48 | * @param string $rootNamespace 49 | * @return string 50 | */ 51 | protected function getDefaultNamespace($rootNamespace) 52 | { 53 | return "{$rootNamespace}\\Features"; 54 | } 55 | 56 | /** 57 | * Get the console command arguments. 58 | * 59 | * @return array 60 | */ 61 | protected function getOptions() 62 | { 63 | return [ 64 | ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the feature already exists'], 65 | ]; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Commands/PurgeCommand.php: -------------------------------------------------------------------------------- 1 | store($this->option('store')); 45 | 46 | $features = $this->argument('features') ?: null; 47 | 48 | $except = collect($this->option('except')) 49 | ->when($this->option('except-registered'), fn ($except) => $except->merge($store->defined())) 50 | ->unique() 51 | ->all(); 52 | 53 | if ($except) { 54 | $features = collect($features ?: $store->stored()) 55 | ->flip() 56 | ->forget($except) 57 | ->flip() 58 | ->values() 59 | ->all(); 60 | } 61 | 62 | $store->purge($features); 63 | 64 | if ($features) { 65 | $this->components->info(implode(', ', $features).' successfully purged from storage.'); 66 | } elseif ($except) { 67 | $this->components->info('No features to purge from storage.'); 68 | } else { 69 | $this->components->info('All features successfully purged from storage.'); 70 | } 71 | 72 | return self::SUCCESS; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Concerns/HasFeatures.php: -------------------------------------------------------------------------------- 1 | for($this); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Contracts/CanListStoredFeatures.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function stored(): array; 13 | } 14 | -------------------------------------------------------------------------------- /src/Contracts/DefinesFeaturesExternally.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | public function definedFeaturesForScope(mixed $scope): array; 13 | } 14 | -------------------------------------------------------------------------------- /src/Contracts/Driver.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | public function defined(): array; 20 | 21 | /** 22 | * Get multiple feature flag values. 23 | * 24 | * @param array> $features 25 | * @return array> 26 | */ 27 | public function getAll(array $features): array; 28 | 29 | /** 30 | * Retrieve a feature flag's value. 31 | */ 32 | public function get(string $feature, mixed $scope): mixed; 33 | 34 | /** 35 | * Set a feature flag's value. 36 | */ 37 | public function set(string $feature, mixed $scope, mixed $value): void; 38 | 39 | /** 40 | * Set a feature flag's value for all scopes. 41 | */ 42 | public function setForAllScopes(string $feature, mixed $value): void; 43 | 44 | /** 45 | * Delete a feature flag's value. 46 | */ 47 | public function delete(string $feature, mixed $scope): void; 48 | 49 | /** 50 | * Purge the given features from storage. 51 | */ 52 | public function purge(?array $features): void; 53 | } 54 | -------------------------------------------------------------------------------- /src/Contracts/FeatureScopeSerializeable.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | protected $featureStateResolvers; 29 | 30 | /** 31 | * The resolved feature states. 32 | * 33 | * @var array> 34 | */ 35 | protected $resolvedFeatureStates = []; 36 | 37 | /** 38 | * The sentinel value for unknown features. 39 | * 40 | * @var \stdClass 41 | */ 42 | protected $unknownFeatureValue; 43 | 44 | /** 45 | * Create a new driver instance. 46 | * 47 | * @param array $featureStateResolvers 48 | * @return void 49 | */ 50 | public function __construct(Dispatcher $events, $featureStateResolvers) 51 | { 52 | $this->events = $events; 53 | $this->featureStateResolvers = $featureStateResolvers; 54 | 55 | $this->unknownFeatureValue = new stdClass; 56 | } 57 | 58 | /** 59 | * Define an initial feature flag state resolver. 60 | * 61 | * @param string $feature 62 | * @param (callable(mixed $scope): mixed) $resolver 63 | */ 64 | public function define($feature, $resolver): void 65 | { 66 | $this->featureStateResolvers[$feature] = $resolver; 67 | } 68 | 69 | /** 70 | * Retrieve the names of all defined features. 71 | * 72 | * @return array 73 | */ 74 | public function defined(): array 75 | { 76 | return array_keys($this->featureStateResolvers); 77 | } 78 | 79 | /** 80 | * Retrieve the names of all stored features. 81 | * 82 | * @return array 83 | */ 84 | public function stored(): array 85 | { 86 | return array_keys($this->resolvedFeatureStates); 87 | } 88 | 89 | /** 90 | * Get multiple feature flag values. 91 | * 92 | * @param array> $features 93 | * @return array> 94 | */ 95 | public function getAll($features): array 96 | { 97 | return Collection::make($features) 98 | ->map(fn ($scopes, $feature) => Collection::make($scopes) 99 | ->map(fn ($scope) => $this->get($feature, $scope)) 100 | ->all()) 101 | ->all(); 102 | } 103 | 104 | /** 105 | * Retrieve a feature flag's value. 106 | * 107 | * @param string $feature 108 | * @param mixed $scope 109 | */ 110 | public function get($feature, $scope): mixed 111 | { 112 | $scopeKey = Feature::serializeScope($scope); 113 | 114 | if (isset($this->resolvedFeatureStates[$feature][$scopeKey])) { 115 | return $this->resolvedFeatureStates[$feature][$scopeKey]; 116 | } 117 | 118 | return with($this->resolveValue($feature, $scope), function ($value) use ($feature, $scopeKey) { 119 | if ($value === $this->unknownFeatureValue) { 120 | return false; 121 | } 122 | 123 | $this->set($feature, $scopeKey, $value); 124 | 125 | return $value; 126 | }); 127 | } 128 | 129 | /** 130 | * Determine the initial value for a given feature and scope. 131 | * 132 | * @param string $feature 133 | * @param mixed $scope 134 | * @return mixed 135 | */ 136 | protected function resolveValue($feature, $scope) 137 | { 138 | if ($this->missingResolver($feature)) { 139 | $this->events->dispatch(new UnknownFeatureResolved($feature, $scope)); 140 | 141 | return $this->unknownFeatureValue; 142 | } 143 | 144 | return $this->featureStateResolvers[$feature]($scope); 145 | } 146 | 147 | /** 148 | * Set a feature flag's value. 149 | * 150 | * @param string $feature 151 | * @param mixed $scope 152 | * @param mixed $value 153 | */ 154 | public function set($feature, $scope, $value): void 155 | { 156 | $this->resolvedFeatureStates[$feature] ??= []; 157 | 158 | $this->resolvedFeatureStates[$feature][Feature::serializeScope($scope)] = $value; 159 | } 160 | 161 | /** 162 | * Set a feature flag's value for all scopes. 163 | * 164 | * @param string $feature 165 | * @param mixed $value 166 | */ 167 | public function setForAllScopes($feature, $value): void 168 | { 169 | $this->resolvedFeatureStates[$feature] ??= []; 170 | 171 | foreach ($this->resolvedFeatureStates[$feature] as $scope => $currentValue) { 172 | $this->resolvedFeatureStates[$feature][$scope] = $value; 173 | } 174 | } 175 | 176 | /** 177 | * Delete a feature flag's value. 178 | * 179 | * @param string $feature 180 | * @param mixed $scope 181 | */ 182 | public function delete($feature, $scope): void 183 | { 184 | unset($this->resolvedFeatureStates[$feature][Feature::serializeScope($scope)]); 185 | } 186 | 187 | /** 188 | * Purge the given feature from storage. 189 | * 190 | * @param array|null $features 191 | */ 192 | public function purge($features): void 193 | { 194 | if ($features === null) { 195 | $this->resolvedFeatureStates = []; 196 | } else { 197 | foreach ($features as $feature) { 198 | unset($this->resolvedFeatureStates[$feature]); 199 | } 200 | } 201 | } 202 | 203 | /** 204 | * Determine if the feature does not have a resolver available. 205 | * 206 | * @param string $feature 207 | * @return bool 208 | */ 209 | protected function missingResolver($feature) 210 | { 211 | return ! array_key_exists($feature, $this->featureStateResolvers); 212 | } 213 | 214 | /** 215 | * Flush the resolved feature states. 216 | * 217 | * @return void 218 | */ 219 | public function flushCache() 220 | { 221 | $this->resolvedFeatureStates = []; 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/Drivers/DatabaseDriver.php: -------------------------------------------------------------------------------- 1 | 53 | */ 54 | protected $featureStateResolvers; 55 | 56 | /** 57 | * The sentinel value for unknown features. 58 | * 59 | * @var \stdClass 60 | */ 61 | protected $unknownFeatureValue; 62 | 63 | /** 64 | * The current retry depth for retrieving values from the database. 65 | * 66 | * @var int 67 | */ 68 | protected $retryDepth = 0; 69 | 70 | /** 71 | * The name of the "created at" column. 72 | * 73 | * @var string 74 | */ 75 | const CREATED_AT = 'created_at'; 76 | 77 | /** 78 | * The name of the "updated at" column. 79 | * 80 | * @var string 81 | */ 82 | const UPDATED_AT = 'updated_at'; 83 | 84 | /** 85 | * Create a new driver instance. 86 | * 87 | * @param array $featureStateResolvers 88 | * @return void 89 | */ 90 | public function __construct(DatabaseManager $db, Dispatcher $events, Repository $config, string $name, $featureStateResolvers) 91 | { 92 | $this->db = $db; 93 | $this->events = $events; 94 | $this->config = $config; 95 | $this->name = $name; 96 | $this->featureStateResolvers = $featureStateResolvers; 97 | 98 | $this->unknownFeatureValue = new stdClass; 99 | } 100 | 101 | /** 102 | * Define an initial feature flag state resolver. 103 | * 104 | * @param string $feature 105 | * @param (callable(mixed $scope): mixed) $resolver 106 | */ 107 | public function define($feature, $resolver): void 108 | { 109 | $this->featureStateResolvers[$feature] = $resolver; 110 | } 111 | 112 | /** 113 | * Retrieve the names of all defined features. 114 | * 115 | * @return array 116 | */ 117 | public function defined(): array 118 | { 119 | return array_keys($this->featureStateResolvers); 120 | } 121 | 122 | /** 123 | * Retrieve the names of all stored features. 124 | * 125 | * @return array 126 | */ 127 | public function stored(): array 128 | { 129 | return $this->newQuery() 130 | ->select('name') 131 | ->distinct() 132 | ->get() 133 | ->pluck('name') 134 | ->all(); 135 | } 136 | 137 | /** 138 | * Get multiple feature flag values. 139 | * 140 | * @param array> $features 141 | * @return array> 142 | */ 143 | public function getAll($features): array 144 | { 145 | $query = $this->newQuery(); 146 | 147 | $resolved = Collection::make($features) 148 | ->map(fn ($scopes, $feature) => Collection::make($scopes) 149 | ->each(fn ($scope) => $query->orWhere( 150 | fn ($q) => $q->where('name', $feature)->where('scope', Feature::serializeScope($scope)) 151 | ))); 152 | 153 | $records = $query->get(); 154 | 155 | $inserts = new Collection; 156 | 157 | $results = $resolved->map(fn ($scopes, $feature) => $scopes->map(function ($scope) use ($feature, $records, $inserts) { 158 | $filtered = $records->where('name', $feature)->where('scope', Feature::serializeScope($scope)); 159 | 160 | if ($filtered->isNotEmpty()) { 161 | return json_decode($filtered->first()->value, flags: JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR); 162 | } 163 | 164 | return with($this->resolveValue($feature, $scope), function ($value) use ($feature, $scope, $inserts) { 165 | if ($value === $this->unknownFeatureValue) { 166 | return false; 167 | } 168 | 169 | $inserts[] = [ // @phpstan-ignore offsetAssign.valueType 170 | 'name' => $feature, 171 | 'scope' => $scope, 172 | 'value' => $value, 173 | ]; 174 | 175 | return $value; 176 | }); 177 | })->all())->all(); 178 | 179 | if ($inserts->isNotEmpty()) { // @phpstan-ignore method.impossibleType 180 | try { 181 | $this->insertMany($inserts->all()); 182 | } catch (UniqueConstraintViolationException $e) { 183 | if ($this->retryDepth === 2) { 184 | throw new RuntimeException('Unable to insert feature values into the database.', previous: $e); 185 | } 186 | 187 | $this->retryDepth++; 188 | 189 | return $this->getAll($features); 190 | } finally { 191 | $this->retryDepth = 0; 192 | } 193 | } 194 | 195 | return $results; 196 | } 197 | 198 | /** 199 | * Retrieve a feature flag's value. 200 | * 201 | * @param string $feature 202 | * @param mixed $scope 203 | */ 204 | public function get($feature, $scope): mixed 205 | { 206 | if (($record = $this->retrieve($feature, $scope)) !== null) { 207 | return json_decode($record->value, flags: JSON_OBJECT_AS_ARRAY | JSON_THROW_ON_ERROR); 208 | } 209 | 210 | return with($this->resolveValue($feature, $scope), function ($value) use ($feature, $scope) { 211 | if ($value === $this->unknownFeatureValue) { 212 | return false; 213 | } 214 | 215 | try { 216 | $this->insert($feature, $scope, $value); 217 | } catch (UniqueConstraintViolationException $e) { 218 | if ($this->retryDepth === 1) { 219 | throw new RuntimeException('Unable to insert feature value into the database.', previous: $e); 220 | } 221 | 222 | $this->retryDepth++; 223 | 224 | return $this->get($feature, $scope); 225 | } finally { 226 | $this->retryDepth = 0; 227 | } 228 | 229 | return $value; 230 | }); 231 | } 232 | 233 | /** 234 | * Retrieve the value for the given feature and scope from storage. 235 | * 236 | * @param string $feature 237 | * @param mixed $scope 238 | * @return object|null 239 | */ 240 | protected function retrieve($feature, $scope) 241 | { 242 | return $this->newQuery() 243 | ->where('name', $feature) 244 | ->where('scope', Feature::serializeScope($scope)) 245 | ->first(); 246 | } 247 | 248 | /** 249 | * Determine the initial value for a given feature and scope. 250 | * 251 | * @param string $feature 252 | * @param mixed $scope 253 | * @return mixed 254 | */ 255 | protected function resolveValue($feature, $scope) 256 | { 257 | if (! array_key_exists($feature, $this->featureStateResolvers)) { 258 | $this->events->dispatch(new UnknownFeatureResolved($feature, $scope)); 259 | 260 | return $this->unknownFeatureValue; 261 | } 262 | 263 | return $this->featureStateResolvers[$feature]($scope); 264 | } 265 | 266 | /** 267 | * Set a feature flag's value. 268 | * 269 | * @param string $feature 270 | * @param mixed $scope 271 | * @param mixed $value 272 | */ 273 | public function set($feature, $scope, $value): void 274 | { 275 | $this->newQuery()->upsert([ 276 | 'name' => $feature, 277 | 'scope' => Feature::serializeScope($scope), 278 | 'value' => json_encode($value, flags: JSON_THROW_ON_ERROR), 279 | static::CREATED_AT => $now = Carbon::now(), 280 | static::UPDATED_AT => $now, 281 | ], uniqueBy: ['name', 'scope'], update: ['value', static::UPDATED_AT]); 282 | } 283 | 284 | /** 285 | * Set a feature flag's value for all scopes. 286 | * 287 | * @param string $feature 288 | * @param mixed $value 289 | */ 290 | public function setForAllScopes($feature, $value): void 291 | { 292 | $this->newQuery() 293 | ->where('name', $feature) 294 | ->update([ 295 | 'value' => json_encode($value, flags: JSON_THROW_ON_ERROR), 296 | static::UPDATED_AT => Carbon::now(), 297 | ]); 298 | } 299 | 300 | /** 301 | * Update the value for the given feature and scope in storage. 302 | * 303 | * @param string $feature 304 | * @param mixed $scope 305 | * @param mixed $value 306 | * @return bool 307 | */ 308 | protected function update($feature, $scope, $value) 309 | { 310 | return (bool) $this->newQuery() 311 | ->where('name', $feature) 312 | ->where('scope', Feature::serializeScope($scope)) 313 | ->update([ 314 | 'value' => json_encode($value, flags: JSON_THROW_ON_ERROR), 315 | static::UPDATED_AT => Carbon::now(), 316 | ]); 317 | } 318 | 319 | /** 320 | * Insert the value for the given feature and scope into storage. 321 | * 322 | * @param string $feature 323 | * @param mixed $scope 324 | * @param mixed $value 325 | * @return bool 326 | */ 327 | protected function insert($feature, $scope, $value) 328 | { 329 | return $this->insertMany([[ 330 | 'name' => $feature, 331 | 'scope' => $scope, 332 | 'value' => $value, 333 | ]]); 334 | } 335 | 336 | /** 337 | * Insert the given feature values into storage. 338 | * 339 | * @param array $inserts 340 | * @return bool 341 | */ 342 | protected function insertMany($inserts) 343 | { 344 | $now = Carbon::now(); 345 | 346 | return $this->newQuery()->insert(array_map(fn ($insert) => [ 347 | 'name' => $insert['name'], 348 | 'scope' => Feature::serializeScope($insert['scope']), 349 | 'value' => json_encode($insert['value'], flags: JSON_THROW_ON_ERROR), 350 | static::CREATED_AT => $now, 351 | static::UPDATED_AT => $now, 352 | ], $inserts)); 353 | } 354 | 355 | /** 356 | * Delete a feature flag's value. 357 | * 358 | * @param string $feature 359 | * @param mixed $scope 360 | */ 361 | public function delete($feature, $scope): void 362 | { 363 | $this->newQuery() 364 | ->where('name', $feature) 365 | ->where('scope', Feature::serializeScope($scope)) 366 | ->delete(); 367 | } 368 | 369 | /** 370 | * Purge the given feature from storage. 371 | * 372 | * @param array|null $features 373 | */ 374 | public function purge($features): void 375 | { 376 | if ($features === null) { 377 | $this->newQuery()->delete(); 378 | } else { 379 | $this->newQuery() 380 | ->whereIn('name', $features) 381 | ->delete(); 382 | } 383 | } 384 | 385 | /** 386 | * Create a new table query. 387 | * 388 | * @return \Illuminate\Database\Query\Builder 389 | */ 390 | protected function newQuery() 391 | { 392 | return $this->connection()->table( 393 | $this->config->get("pennant.stores.{$this->name}.table") ?? 'features' 394 | ); 395 | } 396 | 397 | /** 398 | * The database connection. 399 | * 400 | * @return \Illuminate\Database\Connection 401 | */ 402 | protected function connection() 403 | { 404 | return $this->db->connection( 405 | $this->config->get("pennant.stores.{$this->name}.connection") ?? null 406 | ); 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /src/Drivers/Decorator.php: -------------------------------------------------------------------------------- 1 | 78 | */ 79 | protected $cache; 80 | 81 | /** 82 | * Map of feature names to their implementations. 83 | * 84 | * @var array 85 | */ 86 | protected $nameMap = []; 87 | 88 | /** 89 | * Create a new driver decorator instance. 90 | * 91 | * @param string $name 92 | * @param \Laravel\Pennant\Contracts\Driver $driver 93 | * @param (callable(): mixed) $defaultScopeResolver 94 | * @param \Illuminate\Contracts\Container\Container $container 95 | * @param \Illuminate\Support\Collection $cache 96 | */ 97 | public function __construct($name, $driver, $defaultScopeResolver, $container, $cache) 98 | { 99 | $this->name = $name; 100 | $this->driver = $driver; 101 | $this->defaultScopeResolver = $defaultScopeResolver; 102 | $this->container = $container; 103 | $this->cache = $cache; 104 | } 105 | 106 | /** 107 | * Discover and register the application's feature classes. 108 | * 109 | * @param string $namespace 110 | * @param string|null $path 111 | * @return void 112 | */ 113 | public function discover($namespace = 'App\\Features', $path = null) 114 | { 115 | $namespace = Str::finish($namespace, '\\'); 116 | 117 | Collection::make((new Finder) 118 | ->files() 119 | ->name('*.php') 120 | ->depth(0) 121 | ->in($path ?? base_path('app/Features'))) 122 | ->each(fn ($file) => $this->define("{$namespace}{$file->getBasename('.php')}")); 123 | } 124 | 125 | /** 126 | * Define an initial feature flag state resolver. 127 | * 128 | * @param string|class-string $feature 129 | * @param mixed $resolver 130 | */ 131 | public function define($feature, $resolver = null): void 132 | { 133 | if (func_num_args() === 1) { 134 | [$feature, $resolver] = [ 135 | $this->container->make($feature)->name ?? $feature, 136 | new LazilyResolvedFeature($feature), 137 | ]; 138 | 139 | $this->nameMap[$feature] = $resolver->feature; 140 | } else { 141 | $this->nameMap[$feature] = $resolver; 142 | } 143 | 144 | $this->driver->define($feature, function ($scope) use ($feature, $resolver) { 145 | if ($resolver instanceof LazilyResolvedFeature) { 146 | $resolver = with($this->container[$resolver->feature], fn ($instance) => method_exists($instance, 'resolve') 147 | ? $instance->resolve(...) // @phpstan-ignore callable.nonNativeMethod 148 | : $instance(...)); 149 | } 150 | 151 | if (! $resolver instanceof Closure) { 152 | return $this->resolve($feature, fn () => $resolver, $scope); 153 | } 154 | 155 | if ($scope !== null) { 156 | return $this->resolve($feature, $resolver, $scope); 157 | } 158 | 159 | if ($this->canHandleNullScope($resolver)) { 160 | return $this->resolve($feature, $resolver, $scope); 161 | } 162 | 163 | Event::dispatch(new UnexpectedNullScopeEncountered($feature)); 164 | 165 | return $this->resolve($feature, fn () => false, $scope); 166 | }); 167 | } 168 | 169 | /** 170 | * Resolve the feature value. 171 | * 172 | * @param string $feature 173 | * @param callable $resolver 174 | * @param mixed $scope 175 | * @return mixed 176 | */ 177 | protected function resolve($feature, $resolver, $scope) 178 | { 179 | $value = $resolver($scope); 180 | 181 | $value = $value instanceof Lottery ? $value() : $value; 182 | 183 | Event::dispatch(new FeatureResolved($feature, $scope, $value)); 184 | 185 | return $value; 186 | } 187 | 188 | /** 189 | * Determine if the resolver can handle the scope. 190 | * 191 | * @param callable|class-string $resolver 192 | * @param mixed $scope 193 | * @return bool 194 | */ 195 | public function isResolverValidForScope($resolver, $scope) 196 | { 197 | if (is_string($resolver) && class_exists($resolver)) { 198 | $class = new ReflectionClass($resolver); 199 | 200 | $function = $class->hasMethod('resolve') 201 | ? $class->getMethod('resolve') 202 | : $class->getMethod('__invoke'); 203 | } else { 204 | $function = new ReflectionFunction(Closure::fromCallable($resolver)); 205 | } 206 | 207 | if ($function->getNumberOfParameters() === 0) { 208 | return true; 209 | } 210 | 211 | $type = $function->getParameters()[0]->getType(); 212 | 213 | if ($type === null) { 214 | return true; 215 | } 216 | 217 | return $this->typeAllowsScope($type, $scope, $function); 218 | } 219 | 220 | /** 221 | * Determine if the type can handle the scope. 222 | * 223 | * @param \ReflectionType $type 224 | * @param mixed $scope 225 | * @param \ReflectionMethod|\ReflectionFunction $function 226 | * @return bool 227 | */ 228 | protected function typeAllowsScope($type, $scope, $function) 229 | { 230 | if ($type instanceof ReflectionUnionType) { 231 | foreach ($type->getTypes() as $type) { 232 | if ($this->typeAllowsScope($type, $scope, $function)) { 233 | return true; 234 | } 235 | } 236 | 237 | return false; 238 | } 239 | 240 | if ($type instanceof ReflectionIntersectionType) { 241 | foreach ($type->getTypes() as $type) { 242 | if (! $this->typeAllowsScope($type, $scope, $function)) { 243 | return false; 244 | } 245 | } 246 | 247 | return true; 248 | } 249 | 250 | if ($type instanceof ReflectionNamedType) { 251 | if ($type->getName() === 'mixed') { 252 | return true; 253 | } 254 | 255 | return match (gettype($scope)) { 256 | 'boolean', 257 | 'integer', 258 | 'double', 259 | 'string', 260 | 'array', 261 | 'resource', 262 | 'resource (closed)' => gettype($scope) === $type->getName(), 263 | 'NULL' => $this->canHandleNullScope($function), 264 | 'object' => $scope instanceof ($type->getName()), 265 | 'unknown type' => false, 266 | }; 267 | } 268 | 269 | throw new RuntimeException('Unknown reflection type encoutered.'); 270 | } 271 | 272 | /** 273 | * Determine if the resolver accepts null scope. 274 | * 275 | * @param callable|\ReflectionFunction|\ReflectionMethod $resolver 276 | * @return bool 277 | */ 278 | protected function canHandleNullScope($resolver) 279 | { 280 | $function = is_callable($resolver) 281 | ? new ReflectionFunction(Closure::fromCallable($resolver)) 282 | : $resolver; 283 | 284 | return $function->getNumberOfParameters() === 0 || 285 | ! $function->getParameters()[0]->hasType() || 286 | $function->getParameters()[0]->getType()->allowsNull(); 287 | } 288 | 289 | /** 290 | * Retrieve the names of all defined features. 291 | * 292 | * @return array 293 | */ 294 | public function defined(): array 295 | { 296 | return $this->driver->defined(); 297 | } 298 | 299 | /** 300 | * Retrieve the names of all stored features. 301 | * 302 | * @return array 303 | */ 304 | public function stored(): array 305 | { 306 | if (! $this->driver instanceof CanListStoredFeatures) { 307 | throw new RuntimeException("The [{$this->name}] driver does not support listing stored features."); 308 | } 309 | 310 | return $this->driver->stored(); 311 | } 312 | 313 | /** 314 | * Get multiple feature flag values. 315 | * 316 | * @internal 317 | * 318 | * @param string|array $features 319 | * @return array> 320 | */ 321 | public function getAll($features): array 322 | { 323 | $features = $this->normalizeFeaturesToLoad($features); 324 | 325 | if ($features->isEmpty()) { 326 | return []; 327 | } 328 | 329 | $hasUnresolvedFeatures = false; 330 | 331 | $resolvedBefore = $features->reduce(function ($resolved, $scopes, $feature) use (&$hasUnresolvedFeatures) { 332 | $resolved[$feature] = []; 333 | 334 | if (! $this->hasBeforeHook($feature)) { 335 | $hasUnresolvedFeatures = true; 336 | 337 | return $resolved; 338 | } 339 | 340 | $before = $this->container->make($this->implementationClass($feature))->before(...); 341 | 342 | foreach ($scopes as $index => $scope) { 343 | $value = $this->resolveBeforeHook($feature, $scope, $before); 344 | 345 | if ($value !== null) { 346 | $resolved[$feature][$index] = $value; 347 | } else { 348 | $hasUnresolvedFeatures = true; 349 | } 350 | } 351 | 352 | return $resolved; 353 | }, []); 354 | 355 | $results = array_replace_recursive( 356 | $features->all(), 357 | $resolvedBefore, 358 | $hasUnresolvedFeatures ? $this->driver->getAll($features->map(function ($scopes, $feature) use ($resolvedBefore) { 359 | return array_diff_key($scopes, $resolvedBefore[$feature]); 360 | })->all()) : [], 361 | ); 362 | 363 | $features->flatMap(fn ($scopes, $key) => Collection::make($scopes) 364 | ->zip($results[$key]) 365 | ->map(fn ($scopes) => $scopes->push($key))) 366 | ->each(fn ($value) => $this->putInCache($value[2], $value[0], $value[1])); 367 | 368 | return $results; 369 | } 370 | 371 | /** 372 | * Get multiple feature flag values that are missing. 373 | * 374 | * @internal 375 | * 376 | * @param string|array $features 377 | * @return array> 378 | */ 379 | public function getAllMissing($features) 380 | { 381 | return $this->normalizeFeaturesToLoad($features) 382 | ->map(fn ($scopes, $feature) => Collection::make($scopes) 383 | ->reject(fn ($scope) => $this->isCached($feature, $scope)) 384 | ->all()) 385 | ->reject(fn ($scopes) => $scopes === []) 386 | ->pipe(fn ($features) => $this->getAll($features->all())); 387 | } 388 | 389 | /** 390 | * Normalize the features to load. 391 | * 392 | * @param string|array $features 393 | * @return \Illuminate\Support\Collection> 394 | */ 395 | protected function normalizeFeaturesToLoad($features) 396 | { 397 | return Collection::wrap($features) 398 | ->mapWithKeys(fn ($value, $key) => is_int($key) 399 | ? [$value => Collection::make([$this->defaultScope()])] 400 | : [$key => Collection::wrap($value)]) 401 | ->mapWithKeys(fn ($scopes, $feature) => [ 402 | $this->resolveFeature($feature) => $scopes, 403 | ]) 404 | ->map( 405 | fn ($scopes) => $scopes->map(fn ($scope) => $this->resolveScope($scope))->all() 406 | ); 407 | } 408 | 409 | /** 410 | * Retrieve a feature flag's value. 411 | * 412 | * @internal 413 | * 414 | * @param string $feature 415 | * @param mixed $scope 416 | */ 417 | public function get($feature, $scope): mixed 418 | { 419 | $feature = $this->resolveFeature($feature); 420 | 421 | $scope = $this->resolveScope($scope); 422 | 423 | $item = $this->cache 424 | ->whereStrict('scope', Feature::serializeScope($scope)) 425 | ->whereStrict('feature', $feature) 426 | ->first(); 427 | 428 | if ($item !== null) { 429 | Event::dispatch(new FeatureRetrieved($feature, $scope, $item['value'])); 430 | 431 | return $item['value']; 432 | } 433 | 434 | $before = $this->hasBeforeHook($feature) 435 | ? $this->container->make($this->implementationClass($feature))->before(...) 436 | : fn () => null; 437 | 438 | $value = $this->resolveBeforeHook($feature, $scope, $before) ?? $this->driver->get($feature, $scope); 439 | 440 | $this->putInCache($feature, $scope, $value); 441 | 442 | Event::dispatch(new FeatureRetrieved($feature, $scope, $value)); 443 | 444 | return $value; 445 | } 446 | 447 | /** 448 | * Resolve the before hook value. 449 | * 450 | * @param string $feature 451 | * @param mixed $scope 452 | * @param callable $hook 453 | * @return mixed 454 | */ 455 | protected function resolveBeforeHook($feature, $scope, $hook) 456 | { 457 | if ($scope === null && ! $this->canHandleNullScope($hook)) { 458 | Event::dispatch(new UnexpectedNullScopeEncountered($feature)); 459 | 460 | return null; 461 | } 462 | 463 | return $hook($scope); 464 | } 465 | 466 | /** 467 | * Set a feature flag's value. 468 | * 469 | * @internal 470 | * 471 | * @param string $feature 472 | * @param mixed $scope 473 | * @param mixed $value 474 | */ 475 | public function set($feature, $scope, $value): void 476 | { 477 | $feature = $this->resolveFeature($feature); 478 | 479 | $scope = $this->resolveScope($scope); 480 | 481 | $this->driver->set($feature, $scope, $value); 482 | 483 | $this->putInCache($feature, $scope, $value); 484 | 485 | Event::dispatch(new FeatureUpdated($feature, $scope, $value)); 486 | } 487 | 488 | /** 489 | * Activate the feature for everyone. 490 | * 491 | * @param string|array $feature 492 | * @param mixed $value 493 | * @return void 494 | */ 495 | public function activateForEveryone($feature, $value = true) 496 | { 497 | Collection::wrap($feature) 498 | ->each(fn ($name) => $this->setForAllScopes($name, $value)); 499 | } 500 | 501 | /** 502 | * Deactivate the feature for everyone. 503 | * 504 | * @param string|array $feature 505 | * @return void 506 | */ 507 | public function deactivateForEveryone($feature) 508 | { 509 | Collection::wrap($feature) 510 | ->each(fn ($name) => $this->setForAllScopes($name, false)); 511 | } 512 | 513 | /** 514 | * Set a feature flag's value for all scopes. 515 | * 516 | * @internal 517 | * 518 | * @param string $feature 519 | * @param mixed $value 520 | */ 521 | public function setForAllScopes($feature, $value): void 522 | { 523 | $feature = $this->resolveFeature($feature); 524 | 525 | $this->driver->setForAllScopes($feature, $value); 526 | 527 | $this->cache = $this->cache->reject( 528 | fn ($item) => $item['feature'] === $feature 529 | ); 530 | 531 | Event::dispatch(new FeatureUpdatedForAllScopes($feature, $value)); 532 | } 533 | 534 | /** 535 | * Delete a feature flag's value. 536 | * 537 | * @internal 538 | * 539 | * @param string $feature 540 | * @param mixed $scope 541 | */ 542 | public function delete($feature, $scope): void 543 | { 544 | $feature = $this->resolveFeature($feature); 545 | 546 | $scope = $this->resolveScope($scope); 547 | 548 | $this->driver->delete($feature, $scope); 549 | 550 | $this->removeFromCache($feature, $scope); 551 | 552 | Event::dispatch(new FeatureDeleted($feature, $scope)); 553 | } 554 | 555 | /** 556 | * Purge the given feature from storage. 557 | * 558 | * @param string|array|null $features 559 | */ 560 | public function purge($features = null): void 561 | { 562 | if ($features === null) { 563 | $this->driver->purge(null); 564 | 565 | $this->cache = new Collection; 566 | 567 | Event::dispatch(new AllFeaturesPurged); 568 | } else { 569 | Collection::wrap($features) 570 | ->map($this->resolveFeature(...)) 571 | ->pipe(function ($features) { 572 | $this->driver->purge($features->all()); 573 | 574 | $this->cache->forget( 575 | $this->cache->whereInStrict('feature', $features)->keys()->all() 576 | ); 577 | 578 | Event::dispatch(new FeaturesPurged($features->all())); 579 | }); 580 | } 581 | } 582 | 583 | /** 584 | * Retrieve the feature's name. 585 | * 586 | * @param string $feature 587 | * @return string 588 | */ 589 | public function name($feature) 590 | { 591 | return $this->resolveFeature($feature); 592 | } 593 | 594 | /** 595 | * Retrieve the feature's class. 596 | * 597 | * @param string $name 598 | * @return mixed 599 | */ 600 | public function instance($name) 601 | { 602 | $feature = $this->nameMap[$name] ?? $name; 603 | 604 | if (is_string($feature) && class_exists($feature)) { 605 | return $this->container->make($feature); 606 | } 607 | 608 | if ($feature instanceof Closure || $feature instanceof Lottery) { 609 | return $feature; 610 | } 611 | 612 | return fn () => $feature; 613 | } 614 | 615 | /** 616 | * Retrieve the defined features for the given scope. 617 | * 618 | * @internal 619 | * 620 | * @param mixed $scope 621 | * @return \Illuminate\Support\Collection 622 | */ 623 | public function definedFeaturesForScope($scope) 624 | { 625 | $scope = $this->resolveScope($scope); 626 | 627 | if ($this->driver instanceof DefinesFeaturesExternally) { 628 | return collect($this->driver->definedFeaturesForScope($scope)); 629 | } 630 | 631 | return collect($this->nameMap) 632 | ->only($this->defined()) 633 | ->filter(function ($resolver) use ($scope) { 634 | if (is_callable($resolver) || (is_string($resolver) && class_exists($resolver))) { 635 | return $this->isResolverValidForScope($resolver, $scope); 636 | } 637 | 638 | return true; 639 | }) 640 | ->keys(); 641 | } 642 | 643 | /** 644 | * Resolve the feature name and ensure it is defined. 645 | * 646 | * @param string $feature 647 | * @return string 648 | */ 649 | protected function resolveFeature($feature) 650 | { 651 | return $this->shouldDynamicallyDefine($feature) 652 | ? $this->ensureDynamicFeatureIsDefined($feature) 653 | : $feature; 654 | } 655 | 656 | /** 657 | * Determine if the feature should be dynamically defined. 658 | * 659 | * @param string $feature 660 | * @return bool 661 | */ 662 | protected function shouldDynamicallyDefine($feature) 663 | { 664 | return ! in_array($feature, $this->defined()) 665 | && class_exists($feature) 666 | && (method_exists($feature, 'resolve') || method_exists($feature, '__invoke')); 667 | } 668 | 669 | /** 670 | * Dynamically define the feature. 671 | * 672 | * @param string $feature 673 | * @return string 674 | */ 675 | protected function ensureDynamicFeatureIsDefined($feature) 676 | { 677 | return tap($this->container->make($feature)->name ?? $feature, function ($name) use ($feature) { 678 | if (! in_array($name, $this->defined())) { 679 | Event::dispatch(new DynamicallyRegisteringFeatureClass($feature)); 680 | 681 | $this->define($feature); 682 | } 683 | }); 684 | } 685 | 686 | /** 687 | * Determine if the given feature has a before hook. 688 | * 689 | * @param string $feature 690 | * @return bool 691 | */ 692 | protected function hasBeforeHook($feature) 693 | { 694 | $implementation = $this->implementationClass($feature); 695 | 696 | return is_string($implementation) && class_exists($implementation) && method_exists($implementation, 'before'); 697 | } 698 | 699 | /** 700 | * Retrieve the implementation feature class for the given feature name. 701 | * 702 | * @return ?string 703 | */ 704 | protected function implementationClass($feature) 705 | { 706 | $class = $this->nameMap[$feature] ?? $feature; 707 | 708 | if (is_string($class) && class_exists($class)) { 709 | return $class; 710 | } 711 | 712 | return null; 713 | } 714 | 715 | /** 716 | * Resolve the scope. 717 | * 718 | * @param mixed $scope 719 | * @return mixed 720 | */ 721 | protected function resolveScope($scope) 722 | { 723 | return $scope instanceof FeatureScopeable 724 | ? $scope->toFeatureIdentifier($this->name) 725 | : $scope; 726 | } 727 | 728 | /** 729 | * Determine if a feature's value is in the cache for the given scope. 730 | * 731 | * @param string $feature 732 | * @param mixed $scope 733 | * @return bool 734 | */ 735 | protected function isCached($feature, $scope) 736 | { 737 | $scope = Feature::serializeScope($scope); 738 | 739 | return $this->cache->search( 740 | fn ($item) => $item['feature'] === $feature && $item['scope'] === $scope 741 | ) !== false; 742 | } 743 | 744 | /** 745 | * Put the given feature's value into the cache. 746 | * 747 | * @param string $feature 748 | * @param mixed $scope 749 | * @param mixed $value 750 | * @return void 751 | */ 752 | protected function putInCache($feature, $scope, $value) 753 | { 754 | $scope = Feature::serializeScope($scope); 755 | 756 | $position = $this->cache->search( 757 | fn ($item) => $item['feature'] === $feature && $item['scope'] === $scope 758 | ); 759 | 760 | if ($position === false) { 761 | $this->cache[] = ['feature' => $feature, 'scope' => $scope, 'value' => $value]; 762 | } else { 763 | $this->cache[$position] = ['feature' => $feature, 'scope' => $scope, 'value' => $value]; 764 | } 765 | } 766 | 767 | /** 768 | * Remove the given feature's value from the cache. 769 | * 770 | * @param string $feature 771 | * @param mixed $scope 772 | * @return void 773 | */ 774 | protected function removeFromCache($feature, $scope) 775 | { 776 | $scope = Feature::serializeScope($scope); 777 | 778 | $position = $this->cache->search( 779 | fn ($item) => $item['feature'] === $feature && $item['scope'] === $scope 780 | ); 781 | 782 | if ($position !== false) { 783 | unset($this->cache[$position]); 784 | } 785 | } 786 | 787 | /** 788 | * Retrieve the default scope. 789 | * 790 | * @return mixed 791 | */ 792 | protected function defaultScope() 793 | { 794 | return ($this->defaultScopeResolver)(); 795 | } 796 | 797 | /** 798 | * Flush the in-memory cache of feature values. 799 | * 800 | * @return void 801 | */ 802 | public function flushCache() 803 | { 804 | $this->cache = new Collection; 805 | 806 | if ($this->driver instanceof HasFlushableCache) { 807 | $this->driver->flushCache(); 808 | } 809 | } 810 | 811 | /** 812 | * Get the underlying feature driver. 813 | * 814 | * @return \Laravel\Pennant\Contracts\Driver 815 | */ 816 | public function getDriver() 817 | { 818 | return $this->driver; 819 | } 820 | 821 | /** 822 | * Set the container instance used by the decorator. 823 | * 824 | * @return $this 825 | */ 826 | public function setContainer(Container $container) 827 | { 828 | $this->container = $container; 829 | 830 | return $this; 831 | } 832 | 833 | /** 834 | * Dynamically create a pending feature interaction. 835 | * 836 | * @param string $name 837 | * @param array $parameters 838 | * @return mixed 839 | */ 840 | public function __call($name, $parameters) 841 | { 842 | if (static::hasMacro($name)) { 843 | return $this->macroCall($name, $parameters); 844 | } 845 | 846 | return tap(new PendingScopedFeatureInteraction($this), function ($interaction) use ($name) { 847 | if ($name !== 'for' && ($this->defaultScopeResolver)() !== null) { 848 | $interaction->for(($this->defaultScopeResolver)()); 849 | } 850 | })->{$name}(...$parameters); 851 | } 852 | } 853 | -------------------------------------------------------------------------------- /src/Events/AllFeaturesPurged.php: -------------------------------------------------------------------------------- 1 | feature = $feature; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Events/FeatureDeleted.php: -------------------------------------------------------------------------------- 1 | feature = $feature; 34 | $this->scope = $scope; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/FeatureResolved.php: -------------------------------------------------------------------------------- 1 | feature = $feature; 42 | $this->scope = $scope; 43 | $this->value = $value; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Events/FeatureRetrieved.php: -------------------------------------------------------------------------------- 1 | feature = $feature; 42 | $this->scope = $scope; 43 | $this->value = $value; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Events/FeatureUpdated.php: -------------------------------------------------------------------------------- 1 | feature = $feature; 42 | $this->scope = $scope; 43 | $this->value = $value; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Events/FeatureUpdatedForAllScopes.php: -------------------------------------------------------------------------------- 1 | feature = $feature; 34 | $this->value = $value; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/FeaturesPurged.php: -------------------------------------------------------------------------------- 1 | features = $features; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Events/UnexpectedNullScopeEncountered.php: -------------------------------------------------------------------------------- 1 | feature = $feature; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Events/UnknownFeatureResolved.php: -------------------------------------------------------------------------------- 1 | feature = $feature; 34 | $this->scope = $scope; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Feature.php: -------------------------------------------------------------------------------- 1 | container = $container; 64 | } 65 | 66 | /** 67 | * Get a Pennant store instance. 68 | * 69 | * @param string|null $store 70 | * @return \Laravel\Pennant\Drivers\Decorator 71 | * 72 | * @throws \InvalidArgumentException 73 | */ 74 | public function store($store = null) 75 | { 76 | return $this->driver($store); 77 | } 78 | 79 | /** 80 | * Get a Pennant store instance by name. 81 | * 82 | * @param string|null $name 83 | * @return \Laravel\Pennant\Drivers\Decorator 84 | * 85 | * @throws \InvalidArgumentException 86 | */ 87 | public function driver($name = null) 88 | { 89 | $name = $name ?: $this->getDefaultDriver(); 90 | 91 | return $this->stores[$name] = $this->get($name); 92 | } 93 | 94 | /** 95 | * Attempt to get the store from the local cache. 96 | * 97 | * @param string $name 98 | * @return \Laravel\Pennant\Drivers\Decorator 99 | */ 100 | protected function get($name) 101 | { 102 | return $this->stores[$name] ?? $this->resolve($name); 103 | } 104 | 105 | /** 106 | * Resolve the given store. 107 | * 108 | * @param string $name 109 | * @return \Laravel\Pennant\Drivers\Decorator 110 | * 111 | * @throws \InvalidArgumentException 112 | */ 113 | protected function resolve($name) 114 | { 115 | $config = $this->getConfig($name); 116 | 117 | if (is_null($config)) { 118 | throw new InvalidArgumentException("Pennant store [{$name}] is not defined."); 119 | } 120 | 121 | if (isset($this->customCreators[$config['driver']])) { 122 | $driver = $this->callCustomCreator($config); 123 | } else { 124 | $driverMethod = 'create'.ucfirst($config['driver']).'Driver'; 125 | 126 | if (method_exists($this, $driverMethod)) { 127 | $driver = $this->{$driverMethod}($config, $name); 128 | } else { 129 | throw new InvalidArgumentException("Driver [{$config['driver']}] is not supported."); 130 | } 131 | } 132 | 133 | return new Decorator( 134 | $name, 135 | $driver, 136 | $this->defaultScopeResolver($name), 137 | $this->container, 138 | new Collection 139 | ); 140 | } 141 | 142 | /** 143 | * Call a custom driver creator. 144 | * 145 | * @return mixed 146 | */ 147 | protected function callCustomCreator(array $config) 148 | { 149 | return $this->customCreators[$config['driver']]($this->container, $config); 150 | } 151 | 152 | /** 153 | * Create an instance of the array driver. 154 | * 155 | * @return \Laravel\Pennant\Drivers\ArrayDriver 156 | */ 157 | public function createArrayDriver() 158 | { 159 | return new ArrayDriver($this->container['events'], []); 160 | } 161 | 162 | /** 163 | * Create an instance of the database driver. 164 | * 165 | * @return \Laravel\Pennant\Drivers\DatabaseDriver 166 | */ 167 | public function createDatabaseDriver(array $config, string $name) 168 | { 169 | return new DatabaseDriver( 170 | $this->container['db'], 171 | $this->container['events'], 172 | $this->container['config'], 173 | $name, 174 | [] 175 | ); 176 | } 177 | 178 | /** 179 | * Serialize the given scope for storage. 180 | * 181 | * @param mixed $scope 182 | * @return string 183 | */ 184 | public function serializeScope($scope) 185 | { 186 | return match (true) { 187 | $scope instanceof FeatureScopeSerializeable => $scope->featureScopeSerialize(), 188 | $scope === null => '__laravel_null', 189 | is_string($scope) => $scope, 190 | is_numeric($scope) => (string) $scope, 191 | $scope instanceof Model && $this->useMorphMap => $scope->getMorphClass().'|'.$scope->getKey(), 192 | $scope instanceof Model && ! $this->useMorphMap => $scope::class.'|'.$scope->getKey(), 193 | default => throw new RuntimeException('Unable to serialize the feature scope to a string. You should implement the FeatureScopeSerializeable contract.') 194 | }; 195 | } 196 | 197 | /** 198 | * Specify that the Eloquent morph map should be used when serializing. 199 | * 200 | * @param bool $value 201 | * @return $this 202 | */ 203 | public function useMorphMap($value = true) 204 | { 205 | $this->useMorphMap = $value; 206 | 207 | return $this; 208 | } 209 | 210 | /** 211 | * Flush the driver caches. 212 | * 213 | * @return void 214 | */ 215 | public function flushCache() 216 | { 217 | foreach ($this->stores as $driver) { 218 | $driver->flushCache(); 219 | } 220 | } 221 | 222 | /** 223 | * The default scope resolver. 224 | * 225 | * @param string $driver 226 | * @return callable(): mixed 227 | */ 228 | protected function defaultScopeResolver($driver) 229 | { 230 | return function () use ($driver) { 231 | if ($this->defaultScopeResolver !== null) { 232 | return ($this->defaultScopeResolver)($driver); 233 | } 234 | 235 | return $this->container['auth']->guard()->user(); 236 | }; 237 | } 238 | 239 | /** 240 | * Set the default scope resolver. 241 | * 242 | * @param (callable(string): mixed) $resolver 243 | * @return void 244 | */ 245 | public function resolveScopeUsing($resolver) 246 | { 247 | $this->defaultScopeResolver = $resolver; 248 | } 249 | 250 | /** 251 | * Get the Pennant store configuration. 252 | * 253 | * @param string $name 254 | * @return array|null 255 | */ 256 | protected function getConfig($name) 257 | { 258 | return $this->container['config']["pennant.stores.{$name}"]; 259 | } 260 | 261 | /** 262 | * Get the default store name. 263 | * 264 | * @return string 265 | */ 266 | public function getDefaultDriver() 267 | { 268 | return $this->container['config']->get('pennant.default') ?? 'database'; 269 | } 270 | 271 | /** 272 | * Set the default store name. 273 | * 274 | * @param string $name 275 | * @return void 276 | */ 277 | public function setDefaultDriver($name) 278 | { 279 | $this->container['config']->set('pennant.default', $name); 280 | } 281 | 282 | /** 283 | * Unset the given store instances. 284 | * 285 | * @param array|string|null $name 286 | * @return $this 287 | */ 288 | public function forgetDriver($name = null) 289 | { 290 | $name ??= $this->getDefaultDriver(); 291 | 292 | foreach ((array) $name as $storeName) { 293 | if (isset($this->stores[$storeName])) { 294 | unset($this->stores[$storeName]); 295 | } 296 | } 297 | 298 | return $this; 299 | } 300 | 301 | /** 302 | * Forget all of the resolved store instances. 303 | * 304 | * @return $this 305 | */ 306 | public function forgetDrivers() 307 | { 308 | $this->stores = []; 309 | 310 | return $this; 311 | } 312 | 313 | /** 314 | * Register a custom driver creator Closure. 315 | * 316 | * @param string $driver 317 | * @return $this 318 | */ 319 | public function extend($driver, Closure $callback) 320 | { 321 | $this->customCreators[$driver] = $callback->bindTo($this, $this); 322 | 323 | return $this; 324 | } 325 | 326 | /** 327 | * Set the container instance used by the manager. 328 | * 329 | * @param \Illuminate\Container\Container $container 330 | * @return $this 331 | */ 332 | public function setContainer(Container $container) 333 | { 334 | $this->container = $container; 335 | 336 | foreach ($this->stores as $store) { 337 | $store->setContainer($container); 338 | } 339 | 340 | return $this; 341 | } 342 | 343 | /** 344 | * Dynamically call the default store instance. 345 | * 346 | * @param string $method 347 | * @param array $parameters 348 | * @return mixed 349 | */ 350 | public function __call($method, $parameters) 351 | { 352 | return $this->store()->$method(...$parameters); 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/LazilyResolvedFeature.php: -------------------------------------------------------------------------------- 1 | feature = $feature; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Middleware/EnsureFeaturesAreActive.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | protected $scope = []; 23 | 24 | /** 25 | * Create a new Pending Scoped Feature Interaction instance. 26 | * 27 | * @param \Laravel\Pennant\Drivers\Decorator $driver 28 | */ 29 | public function __construct($driver) 30 | { 31 | $this->driver = $driver; 32 | } 33 | 34 | /** 35 | * Add scope to the feature interaction. 36 | * 37 | * @param mixed $scope 38 | * @return $this 39 | */ 40 | public function for($scope) 41 | { 42 | $this->scope = array_merge($this->scope, Collection::wrap($scope)->all()); 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Load the feature into memory. 49 | * 50 | * @param string|array $features 51 | * @return array> 52 | */ 53 | public function load($features) 54 | { 55 | return Collection::wrap($features) 56 | ->mapWithKeys(fn ($feature) => [$feature => $this->scope()]) 57 | ->pipe(fn ($features) => $this->driver->getAll($features->all())); 58 | } 59 | 60 | /** 61 | * Load the missing features into memory. 62 | * 63 | * @param string|array $features 64 | * @return array> 65 | */ 66 | public function loadMissing($features) 67 | { 68 | return Collection::wrap($features) 69 | ->mapWithKeys(fn ($feature) => [$feature => $this->scope()]) 70 | ->pipe(fn ($features) => $this->driver->getAllMissing($features->all())); 71 | } 72 | 73 | /** 74 | * Load all defined features into memory. 75 | * 76 | * @return array> 77 | */ 78 | public function loadAll() 79 | { 80 | return $this->load( 81 | $this->driver->definedFeaturesForScope($this->scope()[0])->all() 82 | ); 83 | } 84 | 85 | /** 86 | * Get the value of the flag. 87 | * 88 | * @param string $feature 89 | * @return mixed 90 | */ 91 | public function value($feature) 92 | { 93 | return head($this->values([$feature])); 94 | } 95 | 96 | /** 97 | * Get the values of the flag. 98 | * 99 | * @param array $features 100 | * @return array 101 | */ 102 | public function values($features) 103 | { 104 | if (count($this->scope()) > 1) { 105 | throw new RuntimeException('It is not possible to retrieve the values for multiple scopes.'); 106 | } 107 | 108 | $this->loadMissing($features); 109 | 110 | return Collection::make($features) 111 | ->mapWithKeys(fn ($feature) => [ 112 | $this->driver->name($feature) => $this->driver->get($feature, $this->scope()[0]), 113 | ]) 114 | ->all(); 115 | } 116 | 117 | /** 118 | * Retrieve all the features and their values. 119 | * 120 | * @return array 121 | */ 122 | public function all() 123 | { 124 | return $this->values( 125 | $this->driver->definedFeaturesForScope($this->scope()[0])->all() 126 | ); 127 | } 128 | 129 | /** 130 | * Determine if the feature is active. 131 | * 132 | * @param string $feature 133 | * @return bool 134 | */ 135 | public function active($feature) 136 | { 137 | return $this->allAreActive([$feature]); 138 | } 139 | 140 | /** 141 | * Determine if all the features are active. 142 | * 143 | * @param array $features 144 | * @return bool 145 | */ 146 | public function allAreActive($features) 147 | { 148 | $this->loadMissing($features); 149 | 150 | return Collection::make($features) 151 | ->crossJoin($this->scope()) 152 | ->every(fn ($bits) => $this->driver->get(...$bits) !== false); 153 | } 154 | 155 | /** 156 | * Determine if any of the features are active. 157 | * 158 | * @param array $features 159 | * @return bool 160 | */ 161 | public function someAreActive($features) 162 | { 163 | $this->loadMissing($features); 164 | 165 | return Collection::make($this->scope()) 166 | ->every(fn ($scope) => Collection::make($features) 167 | ->some(fn ($feature) => $this->driver->get($feature, $scope) !== false)); 168 | } 169 | 170 | /** 171 | * Determine if the feature is inactive. 172 | * 173 | * @param string $feature 174 | * @return bool 175 | */ 176 | public function inactive($feature) 177 | { 178 | return $this->allAreInactive([$feature]); 179 | } 180 | 181 | /** 182 | * Determine if all the features are inactive. 183 | * 184 | * @param array $features 185 | * @return bool 186 | */ 187 | public function allAreInactive($features) 188 | { 189 | $this->loadMissing($features); 190 | 191 | return Collection::make($features) 192 | ->crossJoin($this->scope()) 193 | ->every(fn ($bits) => $this->driver->get(...$bits) === false); 194 | } 195 | 196 | /** 197 | * Determine if any of the features are inactive. 198 | * 199 | * @param array $features 200 | * @return bool 201 | */ 202 | public function someAreInactive($features) 203 | { 204 | $this->loadMissing($features); 205 | 206 | return Collection::make($this->scope()) 207 | ->every(fn ($scope) => Collection::make($features) 208 | ->some(fn ($feature) => $this->driver->get($feature, $scope) === false)); 209 | } 210 | 211 | /** 212 | * Apply the callback if the feature is active. 213 | * 214 | * @param string $feature 215 | * @param \Closure $whenActive 216 | * @param \Closure|null $whenInactive 217 | * @return mixed 218 | */ 219 | public function when($feature, $whenActive, $whenInactive = null) 220 | { 221 | if ($this->active($feature)) { 222 | return $whenActive($this->value($feature), $this); 223 | } 224 | 225 | if ($whenInactive !== null) { 226 | return $whenInactive($this); 227 | } 228 | } 229 | 230 | /** 231 | * Apply the callback if the feature is inactive. 232 | * 233 | * @param string $feature 234 | * @param \Closure $whenInactive 235 | * @param \Closure|null $whenActive 236 | * @return mixed 237 | */ 238 | public function unless($feature, $whenInactive, $whenActive = null) 239 | { 240 | return $this->when($feature, $whenActive ?? fn () => null, $whenInactive); 241 | } 242 | 243 | /** 244 | * Activate the feature. 245 | * 246 | * @param string|array $feature 247 | * @param mixed $value 248 | * @return void 249 | */ 250 | public function activate($feature, $value = true) 251 | { 252 | Collection::wrap($feature) 253 | ->crossJoin($this->scope()) 254 | ->each(fn ($bits) => $this->driver->set($bits[0], $bits[1], $value)); 255 | } 256 | 257 | /** 258 | * Deactivate the feature. 259 | * 260 | * @param string|array $feature 261 | * @return void 262 | */ 263 | public function deactivate($feature) 264 | { 265 | Collection::wrap($feature) 266 | ->crossJoin($this->scope()) 267 | ->each(fn ($bits) => $this->driver->set($bits[0], $bits[1], false)); 268 | } 269 | 270 | /** 271 | * Forget the flags value. 272 | * 273 | * @param string|array $features 274 | * @return void 275 | */ 276 | public function forget($features) 277 | { 278 | Collection::wrap($features) 279 | ->crossJoin($this->scope()) 280 | ->each(fn ($bits) => $this->driver->delete(...$bits)); 281 | } 282 | 283 | /** 284 | * The scope to pass to the driver. 285 | * 286 | * @return array 287 | */ 288 | protected function scope() 289 | { 290 | return $this->scope ?: [null]; 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/PennantServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->singleton(FeatureManager::class, fn ($app) => new FeatureManager($app)); 18 | 19 | $this->mergeConfigFrom(__DIR__.'/../config/pennant.php', 'pennant'); 20 | } 21 | 22 | /** 23 | * Bootstrap the package's services. 24 | * 25 | * @return void 26 | */ 27 | public function boot() 28 | { 29 | if ($this->app->runningInConsole()) { 30 | $this->offerPublishing(); 31 | 32 | $this->commands([ 33 | \Laravel\Pennant\Commands\FeatureMakeCommand::class, 34 | \Laravel\Pennant\Commands\PurgeCommand::class, 35 | ]); 36 | } 37 | 38 | $this->callAfterResolving('blade.compiler', function ($blade) { 39 | $blade->if('feature', function ($feature, $value = null) { 40 | if (func_num_args() === 2) { 41 | return Feature::value($feature) === $value; 42 | } 43 | 44 | return Feature::active($feature); 45 | }); 46 | 47 | $blade->if('featureany', function ($features) { 48 | return Feature::someAreActive($features); 49 | }); 50 | }); 51 | 52 | $this->listenForEvents(); 53 | } 54 | 55 | /** 56 | * Listen for the events relevant to the package. 57 | * 58 | * @return void 59 | */ 60 | protected function listenForEvents() 61 | { 62 | $this->app['events']->listen([ 63 | \Laravel\Octane\Events\RequestReceived::class, 64 | \Laravel\Octane\Events\TaskReceived::class, 65 | \Laravel\Octane\Events\TickReceived::class, 66 | ], fn () => $this->app[FeatureManager::class] 67 | ->setContainer(Container::getInstance()) 68 | ->flushCache()); 69 | 70 | $this->app['events']->listen([ 71 | \Illuminate\Queue\Events\JobProcessed::class, 72 | ], fn () => $this->app[FeatureManager::class]->flushCache()); 73 | 74 | $this->app['events']->listen([ 75 | \Illuminate\Foundation\Events\PublishingStubs::class, 76 | ], fn ($event) => $event->add(__DIR__.'/../stubs/feature.stub', 'feature.stub')); 77 | } 78 | 79 | /** 80 | * Register the migrations and publishing for the package. 81 | * 82 | * @return void 83 | */ 84 | protected function offerPublishing() 85 | { 86 | $this->publishes([ 87 | __DIR__.'/../config/pennant.php' => config_path('pennant.php'), 88 | ], 'pennant-config'); 89 | 90 | $method = method_exists($this, 'publishesMigrations') ? 'publishesMigrations' : 'publishes'; 91 | 92 | $this->{$method}([ 93 | __DIR__.'/../database/migrations' => $this->app->databasePath('migrations'), 94 | ], 'pennant-migrations'); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | make(FeatureManager::class); 18 | 19 | return match (func_num_args()) { 20 | 0 => $manager, 21 | 1 => $manager->value(...func_get_args()), 22 | default => $manager->when(...func_get_args()), 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /stubs/feature.stub: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class UserFactory extends \Orchestra\Testbench\Factories\UserFactory 13 | { 14 | /** 15 | * The name of the factory's corresponding model. 16 | * 17 | * @var class-string 18 | */ 19 | protected $model = User::class; 20 | } 21 | --------------------------------------------------------------------------------