├── 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 |

2 |
3 |
4 |
5 |
6 |
7 |
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 |
--------------------------------------------------------------------------------