├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── banner.png ├── composer.json ├── config └── feature-flags.php ├── database └── migrations │ ├── 2021_04_15_084248_create_feature_flags_table.php │ ├── 2021_04_15_093138_create_feature_groups_table.php │ ├── 2021_04_15_094248_create_feature_feature_group_table.php │ ├── 2021_04_15_094256_create_feature_user_table.php │ ├── 2021_04_15_094286_create_feature_group_user_table.php │ └── 2022_04_27_090000_add_feature_flag_expiry.php └── src ├── Concerns └── HasFeatures.php ├── Console ├── ActivateFeature.php ├── ActivateFeatureGroup.php ├── AddFeature.php ├── AddFeatureGroup.php ├── AddFeatureToGroup.php ├── DeactivateFeature.php ├── DeactivateFeatureGroup.php ├── ExtendFeature.php ├── ViewFeatureGroups.php ├── ViewFeatures.php └── ViewGroupsWithFeatures.php ├── Exceptions └── ExpiredFeatureException.php ├── FeatureFlagsServiceProvider.php ├── Http └── Middleware │ ├── API │ ├── FeatureMiddleware.php │ └── GroupMiddleware.php │ ├── FeatureMiddleware.php │ └── GroupMiddleware.php └── Models ├── Builders ├── Concerns │ ├── HasActiveAndInactive.php │ └── QueryByName.php ├── FeatureBuilder.php └── FeatureGroupBuilder.php ├── Concerns └── NormaliseName.php ├── Feature.php └── FeatureGroup.php /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at `juststevemcd@gmail.com`. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/JustSteveKing/eloquent-log-driver). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - Check the code style with ``$ composer style:check`` and fix it with ``$ composer style:fix``. 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ composer run test 29 | ``` 30 | 31 | 32 | **Happy coding**! 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Steve McDougall 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 Feature Flags 2 | 3 | [![Software License][ico-license]](LICENSE.md) 4 | [![PHP Version](https://img.shields.io/packagist/php-v/juststeveking/laravel-feature-flags.svg?style=flat-square)](https://php.net) 5 | [![Run Tests](https://github.com/JustSteveKing/laravel-feature-flags/actions/workflows/tests.yml/badge.svg)](https://github.com/JustSteveKing/laravel-feature-flags/actions/workflows/tests.yml) 6 | [![Latest Version on Packagist][ico-version]][link-packagist] 7 | [![Total Downloads][ico-downloads]][link-downloads] 8 | 9 |

10 | 11 | ![](banner.png) 12 | 13 |

14 | 15 | **I recommend using laravel/pennant for any future Feature Flag needs. This package will be frozen as is with no updates planned.** 16 | 17 | A simple to use Feature Flag package for Laravel, allowing you to create Feature Groups and assign Users to them - while also being able to give users override access to given features outside of their groups. 18 | 19 | ## Installation 20 | 21 | You can install the package via composer: 22 | 23 | ```bash 24 | composer require juststeveking/laravel-feature-flags 25 | ``` 26 | 27 | You can publish the migrations files with: 28 | 29 | ```bash 30 | php artisan vendor:publish --provider="JustSteveKing\Laravel\FeatureFlags\FeatureFlagsServiceProvider" --tag="migrations" 31 | ``` 32 | 33 | You can publish the config file with: 34 | 35 | ```bash 36 | php artisan vendor:publish --provider="JustSteveKing\Laravel\FeatureFlags\FeatureFlagsServiceProvider" --tag="config" 37 | ``` 38 | 39 | This is the contents of the published config file: 40 | 41 | ```php 42 | return [ 43 | 'middleware' => [ 44 | 'mode' => 'abort', 45 | 46 | 'redirect_route' => '/', 47 | 48 | 'status_code' => 404, 49 | ], 50 | 51 | 'enable_time_bombs' => false, 52 | 53 | 'time_bomb_environments' => ['production'] 54 | ]; 55 | ``` 56 | 57 | You will then need to migrate the database changes: 58 | 59 | ```bash 60 | php artisan migrate 61 | ``` 62 | 63 | ## Usage 64 | 65 | This package allows you to manage user features and feature groups in a database. 66 | 67 | 68 | **All Feature and Feature Group names will be normalised to lower case on save.** 69 | 70 | 71 | To use this package your User model needs to have the `HasFeatures` trait: 72 | 73 | ```php 74 | user()->addToGroup('beta testers'); 96 | 97 | // Alternatively you can use the following syntax 98 | auth()->user()->joinGroup('beta testers'); 99 | 100 | // You can check if a user is a member of a feature group 101 | auth()->user()->inGroup('beta testers'); 102 | 103 | // You can also get a user to leave a feature group 104 | auth()->user()->leaveGroup('beta testers'); 105 | 106 | // You can also pass in more than one group name 107 | auth()->user()->joinGroup('beta testers', 'api testers'); 108 | ``` 109 | 110 | ### Working with Features 111 | 112 | ```php 113 | // This will create the Feature if not already created and attach the user to it. 114 | auth()->user()->giveFeature('run reports'); 115 | 116 | // You can check if a user has a specific feature 117 | auth()->user()->hasFeature('run reports'); 118 | 119 | // You can also remove a feature for a user 120 | auth()->user()->removeFeature('run reports'); 121 | 122 | // Like with Feature Groups you can pass in more than one option 123 | // These will return if any are matched. 124 | auth()->user()->hasFeature('run reports', 'admin'); 125 | ``` 126 | 127 | ### Putting it together 128 | 129 | To use the package as a whole: 130 | 131 | ```php 132 | // Create a Feature Group 133 | $group = FeatureGroup::create([ 134 | 'name' => 'Beta Testers' 135 | ]); 136 | 137 | // Create a Feature 138 | $feature = Feature::create([ 139 | 'name' => 'API Access' 140 | ]); 141 | 142 | // Add the Feature to the Feature Group 143 | $group->addFeature($feature); 144 | 145 | // Assign a User to the Group 146 | auth()->user()->joinGroup($group->name); 147 | 148 | if (auth()->user()->groupHasFeature('api access')) { 149 | // The user belongs to a group that has access to this feature. 150 | } 151 | 152 | if (auth()->user()->hasFeature('run reports')) { 153 | // The user has been given access to this feature outside of group features 154 | } 155 | 156 | if (auth()->user()->hasFeature('user level feature')) { 157 | // The user has access to this feature as a user or through a group. 158 | } 159 | ``` 160 | 161 | ## Timebombs for Features 162 | 163 | A common use case for Feature Flags is to allow developers to add new functionality without breaking existing code. 164 | 165 | This process is great when paired with a solid CI/CD pipeline. But the biggest drawback to this is residual technical debt that can 166 | occur when developers forget about removing implemented flags across a code base. 167 | 168 | To handle this, users of this package can utilise Timebombs! Timebombs are used to cause Feature Flags to throw an exception 169 | when a flag should have been removed from the code base. 170 | 171 | To use Timebombs, you will need to explicitly enable them within the config ('enable_time_bombs' => true). 172 | And define which environments you do not want exceptions to be thrown. (This is particularly useful with CI/CD, as you will want to throw exceptions locally, in CI and on staging environments but NOT on production). 173 | 174 | ### Defining when a timebomb should throw an exception 175 | 176 | Once Timebombs are enabled, when creating a new Flag, you will be asked when you want your flag to expire (This is number of days). 177 | When the current time surpasses that expiration date, then your feature flag will throw an exception. 178 | 179 | To extend a flag, you can use the handy command 180 | 181 | ```php 182 | php artisan feature-flags:extend-feature 183 | ``` 184 | 185 | Where you will be prompted to define how many more days are required before the flag should throw an exception again. 186 | 187 | ### Further reading 188 | 189 | To learn more on Feature flags and Timebombs, there is a great article by Martin Fowler [Here](https://martinfowler.com/articles/feature-toggles.html). 190 | 191 | ## Template Usage 192 | 193 | There are some Blade Directives to help control access to features in your UI: 194 | 195 | ```php 196 | // You can check if a user has a specific feature 197 | @feature('api access') 198 | 199 | @endfeature 200 | 201 | // You can check if a user is a member of a feature group 202 | @featuregroup('beta testers') 203 | 204 | @endfeaturegroup 205 | 206 | // You can check if a user is a member of a group with access to a feature 207 | @groupfeature('api access') 208 | 209 | @endgroupfeature 210 | ``` 211 | 212 | ## Middleware 213 | 214 | There are some middleware classes that you can use: 215 | 216 | By default you can use: 217 | 218 | - `\JustSteveKing\Laravel\FeatureFlags\Http\Middleware\FeatureMiddleware::class` 219 | - `\JustSteveKing\Laravel\FeatureFlags\Http\Middleware\GroupMiddleware::class` 220 | 221 | There 2 middleware classes will either abort on failure, or redirect. The way these work can be managed in the config file for the package. It allows you to set a mode for the middleware (either `abort` or `redirect`) and also allows you to set a `redirect_route` or `status_code`. 222 | 223 | Then there is also: 224 | 225 | - `\JustSteveKing\Laravel\FeatureFlags\Http\Middleware\API\FeatureMiddleware::class` 226 | - `\JustSteveKing\Laravel\FeatureFlags\Http\Middleware\API\GroupMiddleware::class` 227 | 228 | These 2 middleware classes only have the one mode of `abort` but will ready from your config file for the package to know what status code to return, these classes are made specifically for APIs. 229 | 230 | ### To limit access to users with specific features 231 | 232 | Add the following to your `app/Http/Kernel.php` 233 | 234 | ```php 235 | protected $routeMiddleware = [ 236 | 'feature' => \JustSteveKing\Laravel\FeatureFlags\Http\Middleware\FeatureMiddleware::class, 237 | ]; 238 | ``` 239 | 240 | You can pass through more than one feature name, and pass them in a friendlier format or as they are: 241 | 242 | ```php 243 | Route::middleware(['feature:run-reports,print reports'])->group(/* */); 244 | ``` 245 | 246 | ### To limit access to users who are part of a feature group 247 | 248 | Add the following to your `app/Http/Kernel.php` 249 | 250 | ```php 251 | protected $routeMiddleware = [ 252 | 'feature-group' => \JustSteveKing\Laravel\FeatureFlags\Http\Middleware\GroupMiddleware::class, 253 | ]; 254 | ``` 255 | 256 | You can pass through more than one feature group name, and pass them in a friendlier format or as they are: 257 | 258 | ```php 259 | Route::middleware(['feature-group:beta-testers,internal,developer advocates'])->group(/* */); 260 | ``` 261 | 262 | ## Artisan Commands 263 | 264 | There are a number of artisan commands available for interacting with feature flags. 265 | ```bash 266 | feature-flags:activate-feature Activates a feature 267 | feature-flags:activate-feature-group Activates a feature group 268 | feature-flags:add-feature Add a new feature 269 | feature-flags:add-feature-group Add a new feature group 270 | feature-flags:add-feature-to-group Add a feature to a group 271 | feature-flags:deactivate-feature Deactivates a feature 272 | feature-flags:deactivate-feature-group Deactivates a feature group 273 | feature-flags:view-feature-groups View feature groups 274 | feature-flags:view-features View features 275 | feature-flags:view-groups-with-features View groups with features 276 | ``` 277 | 278 | ## Testing 279 | 280 | ``` bash 281 | $ composer run test 282 | ``` 283 | 284 | ## Contributing 285 | 286 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) for details. 287 | 288 | ## Security 289 | 290 | If you discover any security related issues, please email juststevemcd@gmail.com instead of using the issue tracker. 291 | 292 | ## Credits 293 | 294 | - [Steve McDougall][link-author] 295 | - [All Contributors][link-contributors] 296 | 297 | ## License 298 | 299 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 300 | 301 | [ico-version]: https://img.shields.io/packagist/v/juststeveking/laravel-feature-flags.svg?style=flat-square 302 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 303 | [ico-downloads]: https://img.shields.io/packagist/dt/juststeveking/laravel-feature-flags.svg?style=flat-square 304 | 305 | [link-packagist]: https://packagist.org/packages/juststeveking/laravel-feature-flags 306 | [link-downloads]: https://packagist.org/packages/juststeveking/laravel-feature-flags 307 | 308 | [link-author]: https://github.com/JustSteveKing 309 | [link-contributors]: ../../contributors 310 | -------------------------------------------------------------------------------- /banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustSteveKing/laravel-feature-flags/a76cda7b4a1e2d94cd6ab010a586a22a206eafbb/banner.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "juststeveking/laravel-feature-flags", 3 | "description": "A simple to use Feature Flags package for Laravel", 4 | "keywords": [ 5 | "JustSteveKing", 6 | "laravel", 7 | "laravel-feature-flags" 8 | ], 9 | "homepage": "https://github.com/JustSteveKing/laravel-feature-flags", 10 | "type": "library", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Steve McDougall", 15 | "email": "juststevemcd@gmail.com" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.1", 20 | "illuminate/support": "^10.0|^11.0" 21 | }, 22 | "require-dev": { 23 | "doctrine/dbal": "^3.3", 24 | "nunomaduro/collision": "^6.0|^8.0", 25 | "orchestra/testbench": "^8.0.0|^9.0", 26 | "pestphp/pest": "^1.21.1|^2.34", 27 | "pestphp/pest-plugin-laravel": "^1.4|^2.3", 28 | "phpunit/phpunit": "^9.3|^10.5", 29 | "vimeo/psalm": "^4.4|^5.22" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "JustSteveKing\\Laravel\\FeatureFlags\\": "src/" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "JustSteveKing\\Laravel\\FeatureFlags\\Tests\\": "tests/" 39 | } 40 | }, 41 | "scripts": { 42 | "psalm": "./vendor/bin/psalm", 43 | "test": "./vendor/bin/pest", 44 | "test-coverage": "./vendor/bin/pest --coverage" 45 | }, 46 | "config": { 47 | "sort-packages": true, 48 | "optimize-autoloader": true, 49 | "allow-plugins": { 50 | "pestphp/pest-plugin": true 51 | } 52 | }, 53 | "extra": { 54 | "laravel": { 55 | "providers": [ 56 | "JustSteveKing\\Laravel\\FeatureFlags\\FeatureFlagsServiceProvider" 57 | ] 58 | } 59 | }, 60 | "minimum-stability": "dev", 61 | "prefer-stable": true 62 | } 63 | -------------------------------------------------------------------------------- /config/feature-flags.php: -------------------------------------------------------------------------------- 1 | [ 5 | /* 6 | |-------------------------------------------------------------------------- 7 | | Default Return Mode 8 | |-------------------------------------------------------------------------- 9 | | 10 | | This option controls the default return mode from the middleware. 11 | | 12 | | Supported: "abort", "redirect" 13 | | 14 | */ 15 | 'mode' => 'abort', 16 | 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | Default Redirect Route 20 | |-------------------------------------------------------------------------- 21 | | 22 | | This option controls the default redirect route from the middleware 23 | | when using the "redirect" mode. 24 | | 25 | */ 26 | 'redirect_route' => '/', 27 | 28 | /* 29 | |-------------------------------------------------------------------------- 30 | | Default Status Code 31 | |-------------------------------------------------------------------------- 32 | | 33 | | This option controls the default status code from the middleware 34 | | when using the "abort" mode. 35 | | 36 | */ 37 | 'status_code' => 404, 38 | ], 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Enabling Time bombs for Features 43 | |-------------------------------------------------------------------------- 44 | | 45 | | This option controls whether an exception will be thrown if a feature 46 | | has expired. See Martin Fowler's blog post on this: 47 | | https://martinfowler.com/articles/feature-toggles.html#WorkingWithFeature-flaggedSystems 48 | | 49 | */ 50 | 'enable_time_bombs' => false, 51 | 52 | /* 53 | |-------------------------------------------------------------------------- 54 | | Environments that will NOT trigger Time Bombs 55 | |-------------------------------------------------------------------------- 56 | | 57 | | This option controls which environment settings will prevent time bomb 58 | | exceptions from being thrown. To trigger in all environments, leave 59 | | the array as empty. 60 | | 61 | */ 62 | 'time_bomb_environments' => ['production'] 63 | ]; 64 | -------------------------------------------------------------------------------- /database/migrations/2021_04_15_084248_create_feature_flags_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name'); 14 | $table->text('description')->nullable(); 15 | $table->boolean('active')->default(true); 16 | $table->timestamps(); 17 | }); 18 | } 19 | 20 | public function down() 21 | { 22 | Schema::dropIfExists('features'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /database/migrations/2021_04_15_093138_create_feature_groups_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name'); 14 | $table->text('description')->nullable(); 15 | $table->boolean('active')->default(true); 16 | $table->timestamps(); 17 | }); 18 | } 19 | 20 | public function down() 21 | { 22 | Schema::dropIfExists('feature_groups'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /database/migrations/2021_04_15_094248_create_feature_feature_group_table.php: -------------------------------------------------------------------------------- 1 | unsignedBigInteger('feature_id'); 18 | $table->unsignedBigInteger('feature_group_id'); 19 | 20 | $table->foreign('feature_id')->references('id')->on('features')->onDelete('CASCADE'); 21 | $table->foreign('feature_group_id')->references('id')->on('feature_groups')->onDelete('CASCADE'); 22 | 23 | $table->primary(['feature_id', 'feature_group_id']); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('feature_feature_group'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/2021_04_15_094256_create_feature_user_table.php: -------------------------------------------------------------------------------- 1 | unsignedBigInteger('feature_id'); 18 | $table->unsignedBigInteger('user_id'); 19 | 20 | $table->foreign('feature_id')->references('id')->on('features')->onDelete('CASCADE'); 21 | $table->foreign('user_id')->references('id')->on('users')->onDelete('CASCADE'); 22 | 23 | $table->primary(['feature_id', 'user_id']); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('feature_user'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/2021_04_15_094286_create_feature_group_user_table.php: -------------------------------------------------------------------------------- 1 | unsignedBigInteger('feature_group_id'); 18 | $table->unsignedBigInteger('user_id'); 19 | 20 | $table->foreign('feature_group_id')->references('id')->on('feature_groups')->onDelete('CASCADE'); 21 | $table->foreign('user_id')->references('id')->on('users')->onDelete('CASCADE'); 22 | 23 | $table->primary(['feature_group_id', 'user_id']); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | * 30 | * @return void 31 | */ 32 | public function down() 33 | { 34 | Schema::dropIfExists('feature_group_user'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/migrations/2022_04_27_090000_add_feature_flag_expiry.php: -------------------------------------------------------------------------------- 1 | datetime('expires_at')->nullable(); 14 | }); 15 | } 16 | } 17 | 18 | public function down() 19 | { 20 | if (Schema::hasTable('features')) { 21 | Schema::table('features', function (Blueprint $table) { 22 | $table->dropColumn('expires_at'); 23 | }); 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/Concerns/HasFeatures.php: -------------------------------------------------------------------------------- 1 | getAllFeatures( 18 | features: Arr::flatten($features) 19 | ); 20 | 21 | if (is_null($features)) { 22 | return $this; 23 | } 24 | 25 | $this->features()->saveMany($features); 26 | 27 | return $this; 28 | } 29 | 30 | public function removeFeature(...$features): self 31 | { 32 | $features = $this->getAllFeatures( 33 | features: Arr::flatten($features) 34 | ); 35 | 36 | $this->features()->detach($features); 37 | 38 | return $this; 39 | } 40 | 41 | public function updateFeatures(...$features): self 42 | { 43 | $this->features()->detach(); 44 | 45 | return $this->giveFeature($features); 46 | } 47 | 48 | public function hasFeature(string $feature): bool 49 | { 50 | return $this->hasFeatureThroughGroup( 51 | feature: $feature, 52 | ) || $this->hasFeatureDirect( 53 | feature: $feature, 54 | ); 55 | } 56 | 57 | public function hasFeatureDirect(string $feature): bool 58 | { 59 | $feature = Feature::active()->name($feature)->first(); 60 | 61 | if (is_null($feature)) { 62 | return false; 63 | } 64 | 65 | return $this->features->contains($feature); 66 | } 67 | 68 | public function hasFeatureThroughGroup(string $feature): bool 69 | { 70 | $feature = Feature::with(['groups'])->active() 71 | ->name($feature)->first(); 72 | 73 | if (is_null($feature)) { 74 | return false; 75 | } 76 | 77 | foreach ($feature->groups as $group) { 78 | if ($this->groups->contains($group)) { 79 | return true; 80 | } 81 | } 82 | 83 | return false; 84 | } 85 | 86 | public function inGroup(...$groups): bool 87 | { 88 | foreach ($groups as $group) 89 | { 90 | $group = strtolower($group); 91 | } 92 | 93 | return !! FeatureGroup::active() 94 | ->whereIn('name', $groups) 95 | ->count(); 96 | } 97 | 98 | public function leaveGroup(...$groups): self 99 | { 100 | $groups = $this->getAllGroups( 101 | groups: Arr::flatten($groups) 102 | ); 103 | 104 | $this->groups()->detach($groups); 105 | 106 | return $this; 107 | } 108 | 109 | public function joinGroup(...$groups): self 110 | { 111 | $groups = $this->getAllGroups( 112 | groups: Arr::flatten($groups) 113 | ); 114 | 115 | if (is_null($groups)) { 116 | return $this; 117 | } 118 | 119 | $this->groups()->saveMany($groups); 120 | 121 | return $this; 122 | } 123 | 124 | public function addToGroup(...$groups): self 125 | { 126 | return $this->joinGroup( 127 | groups: $groups, 128 | ); 129 | } 130 | 131 | protected function getAllFeatures(array $features): Collection 132 | { 133 | foreach ($features as $feature) { 134 | $feature = strtolower($feature); 135 | } 136 | 137 | return Feature::active()->whereIn('name', $features)->get(); 138 | } 139 | 140 | protected function getAllGroups(array $groups): Collection 141 | { 142 | foreach ($groups as $group) { 143 | $group = strtolower($group); 144 | } 145 | 146 | return FeatureGroup::active()->whereIn('name', $groups)->get(); 147 | } 148 | 149 | public function groupHasFeature(string $featureName): bool 150 | { 151 | return $this->hasFeatureThroughGroup( 152 | feature: $featureName, 153 | ); 154 | } 155 | 156 | protected function featureExists(string $featureName): bool 157 | { 158 | $exists = Feature::name($featureName)->first(); 159 | 160 | return !is_null($exists); 161 | } 162 | 163 | public function features(): BelongsToMany 164 | { 165 | return $this->belongsToMany( 166 | Feature::class, 167 | 'feature_user' 168 | ); 169 | } 170 | 171 | public function groups(): BelongsToMany 172 | { 173 | return $this->belongsToMany( 174 | FeatureGroup::class, 175 | 'feature_group_user', 176 | ); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/Console/ActivateFeature.php: -------------------------------------------------------------------------------- 1 | ask('Feature name to activate'); 19 | $feature = Feature::name($featureName)->first(); 20 | 21 | while (!$feature) { 22 | $this->alert("There is no feature with the name '{$featureName}'"); 23 | $featureName = $this->ask('Feature name to activate'); 24 | $feature = Feature::name($featureName)->first(); 25 | } 26 | 27 | if ($feature->active) { 28 | $this->info("Feature '{$featureName}' is already active."); 29 | return 0; 30 | } 31 | 32 | $feature->active = true; 33 | $feature->save(); 34 | 35 | $this->info("Feature '{$featureName}' has been successfully activated"); 36 | 37 | return 0; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Console/ActivateFeatureGroup.php: -------------------------------------------------------------------------------- 1 | ask('Group name to activate'); 19 | $group = FeatureGroup::name($groupName)->first(); 20 | 21 | while (!$group) { 22 | $this->alert("There is no group with the name '{$groupName}'"); 23 | $groupName = $this->ask('Group name to activate'); 24 | $group = FeatureGroup::name($groupName)->first(); 25 | } 26 | 27 | if ($group->active) { 28 | $this->info("Group '{$groupName}' is already active."); 29 | return 0; 30 | } 31 | 32 | $group->active = true; 33 | $group->save(); 34 | 35 | $this->info("Group '{$groupName}' has been successfully activated"); 36 | 37 | return 0; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Console/AddFeature.php: -------------------------------------------------------------------------------- 1 | ask('Feature Name'); 19 | $existingFeature = Feature::name($featureName)->first(); 20 | 21 | while ($existingFeature) { 22 | $this->alert('A feature already exists with this name'); 23 | $featureName = $this->ask('Feature Name'); 24 | $existingFeature = Feature::name($featureName)->first(); 25 | } 26 | 27 | $description = $this->ask('Feature Description'); 28 | $active = $this->choice('Is the feature active', ['no', 'yes'], 'yes'); 29 | 30 | if(config('feature-flags.enable_time_bombs')) { 31 | $expires_at = $this->ask('When do you want your feature to expire? (Number of Days)', 0); 32 | } 33 | 34 | Feature::create([ 35 | 'name' => $featureName, 36 | 'description' => $description, 37 | 'active' => $active == 'yes', 38 | 'expires_at' => isset($expires_at) ? \Carbon\Carbon::now()->addDays($expires_at) : null 39 | ]); 40 | 41 | $this->info("Created '{$featureName}' feature"); 42 | 43 | return 0; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Console/AddFeatureGroup.php: -------------------------------------------------------------------------------- 1 | ask('Group Name'); 19 | $existingGroup = FeatureGroup::name($groupName)->first(); 20 | 21 | while ($existingGroup) { 22 | $this->alert('A group already exists with this name'); 23 | $groupName = $this->ask('Group Name'); 24 | $existingGroup = FeatureGroup::name($groupName)->first(); 25 | } 26 | 27 | $description = $this->ask('Group Description'); 28 | $active = $this->choice('Is the group active', ['no', 'yes'], 'yes'); 29 | 30 | FeatureGroup::create([ 31 | 'name' => $groupName, 32 | 'description' => $description, 33 | 'active' => $active == 'yes', 34 | ]); 35 | 36 | $this->info("Created '{$groupName}' group"); 37 | 38 | return 0; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Console/AddFeatureToGroup.php: -------------------------------------------------------------------------------- 1 | ask('Feature Name'); 20 | $feature = Feature::name($featureName)->first(); 21 | 22 | if (!$feature) { 23 | $this->alert("There is no feature with the name {$featureName}"); 24 | $featureName = $this->ask('Feature Name'); 25 | $feature = Feature::name($featureName)->first(); 26 | } 27 | 28 | $groupName = $this->ask('Group Name'); 29 | $group = FeatureGroup::name($groupName)->first(); 30 | 31 | if (!$group) { 32 | $this->alert("There is no group with the name {$groupName}"); 33 | $groupName = $this->ask('Group Name'); 34 | $group = FeatureGroup::name($groupName)->first(); 35 | } 36 | 37 | if ($group->hasFeature($feature->name)) { 38 | $this->alert("This feature is already assigned to this group"); 39 | return 1; 40 | } 41 | 42 | $group->addFeature($feature); 43 | $this->info("Added feature '{$featureName}' to group '{$groupName}'"); 44 | 45 | return 0; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Console/DeactivateFeature.php: -------------------------------------------------------------------------------- 1 | ask('Feature name to deactivate'); 19 | $feature = Feature::name($featureName)->first(); 20 | 21 | while (!$feature) { 22 | $this->alert("There is no feature with the name '{$featureName}'"); 23 | $featureName = $this->ask('Feature name to deactivate'); 24 | $feature = Feature::name($featureName)->first(); 25 | } 26 | 27 | if (!$feature->active) { 28 | $this->info("Feature '{$featureName}' is already inactive."); 29 | return 0; 30 | } 31 | 32 | $feature->active = false; 33 | $feature->save(); 34 | 35 | $this->info("Feature '{$featureName}' has been successfully deactivated"); 36 | 37 | return 0; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Console/DeactivateFeatureGroup.php: -------------------------------------------------------------------------------- 1 | ask('Group name to deactivate'); 19 | $group = FeatureGroup::name($groupName)->first(); 20 | 21 | while (!$group) { 22 | $this->alert("There is no group with the name '{$groupName}'"); 23 | $groupName = $this->ask('Group name to deactivate'); 24 | $group = FeatureGroup::name($groupName)->first(); 25 | } 26 | 27 | if (!$group->active) { 28 | $this->info("Group '{$groupName}' is already inactive."); 29 | return 0; 30 | } 31 | 32 | $group->active = false; 33 | $group->save(); 34 | 35 | $this->info("Group '{$groupName}' has been successfully deactivated"); 36 | 37 | return 0; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Console/ExtendFeature.php: -------------------------------------------------------------------------------- 1 | info("Time bombs are not enabled!"); 19 | 20 | $featureName = $this->ask('Feature Name to Extend'); 21 | $feature = Feature::name($featureName)->first(); 22 | 23 | $extendBy = $this->ask('When do you want your feature to expire? (Number of Days)', 0); 24 | 25 | $feature->expires_at = $feature->expires_at->addDays($extendBy); 26 | $feature->save(); 27 | 28 | $this->info("Updated '{$featureName}' feature expiry date"); 29 | 30 | return 0; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Console/ViewFeatureGroups.php: -------------------------------------------------------------------------------- 1 | toArray(); 19 | 20 | $headers = ['Name', 'Description', 'Active']; 21 | 22 | $this->table($headers, $groups); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Console/ViewFeatures.php: -------------------------------------------------------------------------------- 1 | toArray(); 20 | }); 21 | 22 | $headers = ['Name', 'Description', 'Active', 'Expires At']; 23 | 24 | $this->table($headers, $features); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Console/ViewGroupsWithFeatures.php: -------------------------------------------------------------------------------- 1 | features as $feature) { 25 | $features .= "{$feature->name}, "; 26 | } 27 | 28 | array_push($table, [$group->name, rtrim($features, ", ")]); 29 | } 30 | 31 | $this->table($headers, $table); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Exceptions/ExpiredFeatureException.php: -------------------------------------------------------------------------------- 1 | publishes([ 26 | __DIR__ . '/../config/feature-flags.php' => config_path('feature-flags.php') 27 | ], 'config'); 28 | 29 | $this->publishes([ 30 | __DIR__ . '/../database/migrations/' => database_path('migrations') 31 | ], 'migrations'); 32 | 33 | 34 | Blade::directive('feature', function ($feature) { 35 | return "check() && auth()->user()->hasFeature({$feature})): ?>"; 36 | }); 37 | Blade::directive('endfeature', function () { 38 | return ""; 39 | }); 40 | 41 | 42 | Blade::directive('featuregroup', function ($featureGroup) { 43 | return "check() && auth()->user()->inGroup({$featureGroup})): ?>"; 44 | }); 45 | Blade::directive('endfeaturegroup', function () { 46 | return ""; 47 | }); 48 | 49 | 50 | Blade::directive('groupfeature', function ($feature) { 51 | return "check() && auth()->user()->groupHasFeature({$feature})): ?>"; 52 | }); 53 | Blade::directive('endgroupfeature', function () { 54 | return ""; 55 | }); 56 | 57 | if ($this->app->runningInConsole()) { 58 | $this->commands([ 59 | AddFeature::class, 60 | ViewFeatures::class, 61 | ActivateFeature::class, 62 | AddFeatureGroup::class, 63 | ExtendFeature::class, 64 | ViewFeatureGroups::class, 65 | AddFeatureToGroup::class, 66 | DeactivateFeature::class, 67 | ActivateFeatureGroup::class, 68 | DeactivateFeatureGroup::class, 69 | ViewGroupsWithFeatures::class, 70 | ]); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Http/Middleware/API/FeatureMiddleware.php: -------------------------------------------------------------------------------- 1 | user()->hasFeature($feature)) { 18 | return abort(config('feature-flags.middleware.status_code')); 19 | } 20 | } 21 | 22 | return $next($request); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Http/Middleware/API/GroupMiddleware.php: -------------------------------------------------------------------------------- 1 | user()->inGroup($group)) { 18 | return $next($request); 19 | } 20 | } 21 | 22 | return abort(config('feature-flags.middleware.status_code')); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Http/Middleware/FeatureMiddleware.php: -------------------------------------------------------------------------------- 1 | user()->hasFeature($feature)) { 18 | if (config('feature-flags.middleware.mode') === 'abort') { 19 | return abort(config('feature-flags.middleware.status_code')); 20 | } 21 | 22 | return redirect(config('feature-flags.middleware.redirect_route')); 23 | } 24 | } 25 | 26 | return $next($request); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Http/Middleware/GroupMiddleware.php: -------------------------------------------------------------------------------- 1 | user()->inGroup($group)) { 18 | return $next($request); 19 | } 20 | } 21 | 22 | if (config('feature-flags.middleware.mode') === 'abort') { 23 | return abort(config('feature-flags.middleware.status_code')); 24 | } 25 | 26 | return redirect(config('feature-flags.middleware.redirect_route')); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Models/Builders/Concerns/HasActiveAndInactive.php: -------------------------------------------------------------------------------- 1 | where('active', true); 12 | 13 | return $this; 14 | } 15 | 16 | public function inactive(): self 17 | { 18 | $this->where('active', false); 19 | 20 | return $this; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Models/Builders/Concerns/QueryByName.php: -------------------------------------------------------------------------------- 1 | where('name', $name); 12 | 13 | return $this; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Models/Builders/FeatureBuilder.php: -------------------------------------------------------------------------------- 1 | name = strtolower($model->name); 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Models/Feature.php: -------------------------------------------------------------------------------- 1 | 'boolean', 28 | 'expires_at' => 'datetime' 29 | ]; 30 | 31 | public static function booted(): void 32 | { 33 | static::retrieved(function (Feature $feature) { 34 | $timeBombsAreEnabled = config('feature-flags.enable_time_bombs'); 35 | $environmentAllowsTimeBombs = !App::environment(config('feature-flags.time_bomb_environments')); 36 | 37 | if ($timeBombsAreEnabled && $environmentAllowsTimeBombs) { 38 | $featureHasExpired = Carbon::now()->isAfter($feature->expires_at); 39 | 40 | if ($featureHasExpired) { 41 | throw ExpiredFeatureException::create($feature->name); 42 | } 43 | return true; 44 | } 45 | return true; 46 | }); 47 | } 48 | 49 | public function groups(): BelongsToMany 50 | { 51 | return $this->belongsToMany( 52 | FeatureGroup::class, 53 | 'feature_feature_group', 54 | ); 55 | } 56 | 57 | public function inGroup(string $groupName): bool 58 | { 59 | return $this->groups->contains('name', $groupName); 60 | } 61 | 62 | public function newEloquentBuilder($query): FeatureBuilder 63 | { 64 | return new FeatureBuilder($query); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Models/FeatureGroup.php: -------------------------------------------------------------------------------- 1 | 'boolean', 25 | ]; 26 | 27 | public function addFeature(Feature $feature): void 28 | { 29 | $this->features()->attach($feature); 30 | } 31 | 32 | public function hasFeature(string $featureName): bool 33 | { 34 | return $this->features->contains('name', $featureName); 35 | } 36 | 37 | public function removeFeature(Feature $feature): bool 38 | { 39 | return (bool) $this->features()->detach($feature->id); 40 | } 41 | 42 | public function features(): BelongsToMany 43 | { 44 | return $this->belongsToMany( 45 | Feature::class, 46 | 'feature_feature_group', 47 | ); 48 | } 49 | 50 | public function users(): BelongsToMany 51 | { 52 | return $this->belongsToMany( 53 | User::class, 54 | 'feature_group_user', 55 | ); 56 | } 57 | 58 | public function newEloquentBuilder($query): FeatureGroupBuilder 59 | { 60 | return new FeatureGroupBuilder($query); 61 | } 62 | } 63 | --------------------------------------------------------------------------------