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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ return [

'status_code' => 404,
],

'enable_time_bombs' => false,

'time_bomb_environments' => ['production']
];
```

Expand Down Expand Up @@ -152,6 +156,36 @@ if (auth()->user()->hasFeature('user level feature')) {
}
```

## Timebombs for Features

A common use case for Feature Flags is to allow developers to add new functionality without breaking existing code.

This process is great when paired with a solid CI/CD pipeline. But the biggest drawback to this is residual technical debt that can
occur when developers forget about removing implemented flags across a code base.

To handle this, users of this package can utilise Timebombs! Timebombs are used to cause Feature Flags to throw an exception
when a flag should have been removed from the code base.

To use Timebombs, you will need to explicitly enable them within the config ('enable_time_bombs' => true).
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).

### Defining when a timebomb should throw an exception

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).
When the current time surpasses that expiration date, then your feature flag will throw an exception.

To extend a flag, you can use the handy command

```php
php artisan feature-flags:extend-feature
```

Where you will be prompted to define how many more days are required before the flag should throw an exception again.

### Further reading

To learn more on Feature flags and Timebombs, there is a great article by Martin Fowler [Here](https://martinfowler.com/articles/feature-toggles.html).

## Template Usage

There are some Blade Directives to help control access to features in your UI:
Expand Down
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"illuminate/support": "^9.0.1"
},
"require-dev": {
"doctrine/dbal": "^3.3",
"nunomaduro/collision": "^6.0",
"orchestra/testbench": "^7.0.0",
"pestphp/pest": "^1.21.1",
Expand All @@ -44,7 +45,10 @@
},
"config": {
"sort-packages": true,
"optimize-autoloader": true
"optimize-autoloader": true,
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"extra": {
"laravel": {
Expand Down
24 changes: 24 additions & 0 deletions config/feature-flags.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,28 @@
*/
'status_code' => 404,
],

/*
|--------------------------------------------------------------------------
| Enabling Time bombs for Features
|--------------------------------------------------------------------------
|
| This option controls whether an exception will be thrown if a feature
| has expired. See Martin Fowler's blog post on this:
| https://martinfowler.com/articles/feature-toggles.html#WorkingWithFeature-flaggedSystems
|
*/
'enable_time_bombs' => false,

/*
|--------------------------------------------------------------------------
| Environments that will NOT trigger Time Bombs
|--------------------------------------------------------------------------
|
| This option controls which environment settings will prevent time bomb
| exceptions from being thrown. To trigger in all environments, leave
| the array as empty.
|
*/
'time_bomb_environments' => ['production']

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the name time_bomb_environments a little counter-intuitive. Without reading the comment, I would have assumed that these are the environments in which the time bombs are active, not the other way around.

Another thing is that my first instinct have would be to have the time bombs be deactivated in every environment and to explicitly activate them for certain environments. I think this would reduce the chance of accidentally activating it in production because of a typo or something similar.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to agree here, time bomb feature should be enabled per environment and off by default - not something you would typically have in staging/qa

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ksassnowski @JustSteveKing - Thank you for the feedback. Do you think if the 'enable_time_bombs' had an environment variable this would resolve this? As that value governs whether the feature is on/off at all.

We're using trunk based development which is why I considered having an exclude rather than include list, as we'd want it to be on all of the time except for production rather than having to specify on for dev, testing, qa, staging.

];
26 changes: 26 additions & 0 deletions database/migrations/2022_04_27_090000_add_feature_flag_expiry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up()
{
if (Schema::hasTable('features')) {
Schema::table('features', function (Blueprint $table) {
$table->datetime('expires_at')->nullable();
});
}
}

public function down()
{
if (Schema::hasTable('features')) {
Schema::table('features', function (Blueprint $table) {
$table->dropColumn('expires_at');
});
}
}
};
5 changes: 1 addition & 4 deletions src/Concerns/HasFeatures.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,6 @@ public function joinGroup(...$groups): self
groups: Arr::flatten($groups)
);

// TODO? Fix groups? Maybe?
// dd($groups);

if (is_null($groups)) {
return $this;
}
Expand Down Expand Up @@ -160,7 +157,7 @@ protected function featureExists(string $featureName): bool
{
$exists = Feature::name($featureName)->first();

return (is_null($exists)) ? false : true;
return !is_null($exists);
}

public function features(): BelongsToMany
Expand Down
2 changes: 1 addition & 1 deletion src/Console/ActivateFeature.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class ActivateFeature extends Command

protected $description = 'Activates a feature';

public function handle()
public function handle(): int
{
$featureName = $this->ask('Feature name to activate');
$feature = Feature::name($featureName)->first();
Expand Down
2 changes: 1 addition & 1 deletion src/Console/ActivateFeatureGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class ActivateFeatureGroup extends Command

protected $description = 'Activates a feature group';

public function handle()
public function handle(): int
{
$groupName = $this->ask('Group name to activate');
$group = FeatureGroup::name($groupName)->first();
Expand Down
7 changes: 6 additions & 1 deletion src/Console/AddFeature.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class AddFeature extends Command

protected $description = 'Add a new feature';

public function handle()
public function handle(): int
{
$featureName = $this->ask('Feature Name');
$existingFeature = Feature::name($featureName)->first();
Expand All @@ -27,10 +27,15 @@ public function handle()
$description = $this->ask('Feature Description');
$active = $this->choice('Is the feature active', ['no', 'yes'], 'yes');

if(config('feature-flags.enable_time_bombs')) {
$expires_at = $this->ask('When do you want your feature to expire? (Number of Days)', 0);
}

Feature::create([
'name' => $featureName,
'description' => $description,
'active' => $active == 'yes',
'expires_at' => isset($expires_at) ? \Carbon\Carbon::now()->addDays($expires_at) : null
]);

$this->info("Created '{$featureName}' feature");
Expand Down
2 changes: 1 addition & 1 deletion src/Console/AddFeatureGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class AddFeatureGroup extends Command

protected $description = 'Add a new feature group';

public function handle()
public function handle(): int
{
$groupName = $this->ask('Group Name');
$existingGroup = FeatureGroup::name($groupName)->first();
Expand Down
2 changes: 1 addition & 1 deletion src/Console/AddFeatureToGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class AddFeatureToGroup extends Command

protected $description = 'Add a feature to a group';

public function handle()
public function handle(): int
{
$featureName = $this->ask('Feature Name');
$feature = Feature::name($featureName)->first();
Expand Down
2 changes: 1 addition & 1 deletion src/Console/DeactivateFeature.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class DeactivateFeature extends Command

protected $description = 'Deactivates a feature';

public function handle()
public function handle(): int
{
$featureName = $this->ask('Feature name to deactivate');
$feature = Feature::name($featureName)->first();
Expand Down
2 changes: 1 addition & 1 deletion src/Console/DeactivateFeatureGroup.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class DeactivateFeatureGroup extends Command

protected $description = 'Deactivates a feature group';

public function handle()
public function handle(): int
{
$groupName = $this->ask('Group name to deactivate');
$group = FeatureGroup::name($groupName)->first();
Expand Down
32 changes: 32 additions & 0 deletions src/Console/ExtendFeature.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace JustSteveKing\Laravel\FeatureFlags\Console;

use Illuminate\Console\Command;
use JustSteveKing\Laravel\FeatureFlags\Models\Feature;

class ExtendFeature extends Command
{
protected $signature = 'feature-flags:extend-feature';

protected $description = 'Extend a features expiry date';

public function handle(): int
{
if(! config('feature-flags.enable_time_bombs')) $this->info("Time bombs are not enabled!");

$featureName = $this->ask('Feature Name to Extend');
$feature = Feature::name($featureName)->first();

$extendBy = $this->ask('When do you want your feature to expire? (Number of Days)', 0);

$feature->expires_at = $feature->expires_at->addDays($extendBy);
$feature->save();

$this->info("Updated '{$featureName}' feature expiry date");

return 0;
}
}
2 changes: 1 addition & 1 deletion src/Console/ViewFeatureGroups.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class ViewFeatureGroups extends Command

protected $description = 'View feature groups';

public function handle()
public function handle(): void
{
$groups = FeatureGroup::all(['name', 'description', 'active'])->toArray();

Expand Down
8 changes: 5 additions & 3 deletions src/Console/ViewFeatures.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ class ViewFeatures extends Command

protected $description = 'View features';

public function handle()
public function handle(): void
{
$features = Feature::all(['name', 'description', 'active'])->toArray();
$features = Feature::withoutEvents(function() {
return Feature::all(['name', 'description', 'active', 'expires_at'])->toArray();
});

$headers = ['Name', 'Description', 'Active'];
$headers = ['Name', 'Description', 'Active', 'Expires At'];

$this->table($headers, $features);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Console/ViewGroupsWithFeatures.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class ViewGroupsWithFeatures extends Command

protected $description = 'View groups with features';

public function handle()
public function handle(): void
{
$groups = FeatureGroup::all();
$headers = ['Group', 'Features'];
Expand Down
2 changes: 2 additions & 0 deletions src/FeatureFlagsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use JustSteveKing\Laravel\FeatureFlags\Console\AddFeature;
use JustSteveKing\Laravel\FeatureFlags\Console\ExtendFeature;
use JustSteveKing\Laravel\FeatureFlags\Console\ViewFeatures;
use JustSteveKing\Laravel\FeatureFlags\Console\ActivateFeature;
use JustSteveKing\Laravel\FeatureFlags\Console\AddFeatureGroup;
Expand Down Expand Up @@ -59,6 +60,7 @@ public function boot()
ViewFeatures::class,
ActivateFeature::class,
AddFeatureGroup::class,
ExtendFeature::class,
ViewFeatureGroups::class,
AddFeatureToGroup::class,
DeactivateFeature::class,
Expand Down
25 changes: 24 additions & 1 deletion src/Models/Feature.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,48 @@

namespace JustSteveKing\Laravel\FeatureFlags\Models;

use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Facades\App;
use JustSteveKing\Laravel\FeatureFlags\Models\Concerns\NormaliseName;
use JustSteveKing\Laravel\FeatureFlags\Models\Builders\FeatureBuilder;

class Feature extends Model
{
use NormaliseName;

protected $fillable = [
'name',
'description',
'active',
'expires_at'
];

protected $casts = [
'active' => 'boolean',
'expires_at' => 'datetime'
];

public static function booted(): void
{
static::retrieved(function(Feature $feature) {
$timeBombsAreEnabled = config('feature-flags.enable_time_bombs');
$environmentAllowsTimeBombs = ! App::environment(config('feature-flags.time_bomb_environments'));

if($timeBombsAreEnabled && $environmentAllowsTimeBombs) {
$featureHasExpired = Carbon::now()->isAfter($feature->expires_at);

if ($featureHasExpired) {
throw new Exception(sprintf('The Feature has expired - %s', $feature->name));
}
return true;
}
return true;
});
}

public function groups(): BelongsToMany
{
return $this->belongsToMany(
Expand Down
2 changes: 1 addition & 1 deletion tests/Stubs/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
class User extends AuthUser
{
use HasFeatures;

public $guarded = [];

public $table = 'users';
Expand Down
Loading