In this article, I'll introduce you to a recent addition to the Laravel ecosystem, which made its debut in version 10. It's called Laravel Pennant. This package enables you to manage access to new system features. This proves quite valuable when introducing fresh functionality to a project. It allows you to observe its performance in the production environment by controlling access based on certain criteria. For instance, you can restrict access by registration age, user's country, or even random selection. Let's delve into how we can put this into practice.
After installing the package, we need to create the feature in the ServiceProvider, after which we can already check if it is present in the controller or somewhere else.
In order to add a feature to the service provider, we send the name of the feature to the define method, as well as a callback, which is where the main check takes place. The most common method of checking is using match (true). In this method, we specify all the rules for enabling the new feature and also set the value for all other users, in this case false.
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Lottery;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Feature::define('canadian-form', fn (User $user) => match (true) {
$user->country === 'CAN' && Lottery::odds(5,100) => true,
default => false,
});
}
}
Of course, you can implement your own check method. In the example above, we enable the canadian-form feature for only 5 percent of Canadian users. This will give us the opportunity to gradually introduce the new functionality. We can also do the opposite - enable the new functionality for everyone except our other service, which has not yet been updated.
The second way to implement a feature is a cleaner way. We can create it using the
php artisan pennant:feature CanadianForm
console command. This will create a feature class, where we will need to describe the resolve() method, which should return the result of the checkout.
<?php
namespace App\Features;
use App\Models\User;
use Illuminate\Support\Lottery;
class CanadianForm
{
/**
* Resolve the feature's initial value.
*/
public function resolve(User $user): mixed
{
return match (true) {
$user->country === 'CAN' && Lottery::odds(5,100) => true,
default => false,
};
}
}
After installing the feature, we can start checking if the new feature needs to be used. Let's start with the simplest use case - in the controller. We can carry out a check in the controller as follows:
class FormController extends Controller
{
public function __construct(private FormService $formService)
{
}
public function show(Request $request) {
$fields = Feature::active(CanadianForm::class)
? $this->formService->getCanadianFields()
: $this->formService->getWorldwideFields();
return view('form', compact('fields'));
}
}
Here, it says that if the feature is active, then getCanadianFields(), and if not, then getWorldwideFields(). If the feature is defined in a ServiceProvider, then the name of the feature must be passed to the active method. In addition to checking whether a feature is active or not, the Feature facade has other methods: similar methods for checking whether a feature is active on multiple elements or inactive, as well as methods that run callbacks passed as arguments.
The last one might look like this:
class FormController extends Controller
{
public function __construct(private FormService $formService)
{
}
public function show(Request $request) {
$fields = Feature::when(CanadianForm::class,
fn() => $this->formService->getCanadianFields(),
fn() => $this->formService->getWorldwideFields(),
);
return view('form', compact('fields'));
}
}
These check options are convenient in that they are easy to implement and, when developed, clearly show what needs to be done to get the desired answer. However, it does not allow you to look at the list of routes and understand where such a check is applied.
In order to implement validation, before hitting the controller, there is an option to install middleware in the application. The Laravel Pennant package comes with EnsureFeaturesAreActive middleware included. After including the middleware in the list of middleware in Kernel, you can install checks directly into the root, for example like this:
Route::get('/form', [FormController::class, 'show'])
->middleware(['features:canadian-form'])
->name('form');
With this implementation, it is convenient to track whole parts of the application that are available only to some users. Such separation can be used in projects with clients from different countries or when switching to a new API version.
It is worth noting that Pennant caches the response in in-memory storage during validation. This guarantees the same response throughout the entire request. If you need to remove this cache, you can use Feature::flushCache()
If we take a closer look at the CanadianForm::release() method, we receive a user into it. This is the most common case - when we check whether a particular feature is available to a particular user. However, this is not always the case. For example, we want to show the answer depending on another user.
This can be realized by using the for() method when checking.
public function show(Request $request) {
$fields = Feature::for($request->user()->getParentUser())
->active(CanadianForm::class)
? $this->formService->getCanadianFields()
: $this->formService->getWorldwideFields();
return view('form', compact('fields'));
}
This can be used when users have groups, main users, parents, etc.
When creating a feature through the command, the argument to the release method will be of mixed type. This means that we can use objects not only of the User class but also of other classes.
<?php
namespace App\Features;
use App\Models\Subscription;
use Illuminate\Support\Lottery;
class NewDesign
{
/**
* Resolve the feature's initial value.
*/
public function resolve(Subscription $subscription): mixed
{
return match (true) {
$subscription->name === 'gold' => true,
$subscription->name === 'silver' && Lottery::odds(5,100) => true,
default => false,
};
}
}
With this setup, we either need to pass the subscription by relation from the user each time it is checked using the for() method, or we can set this as the default in the ServiceProvider.
public function boot(): void
{
Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->subscription);
}
We now have an app that will allow us to give the new design to all gold subscribers and some silver subscribers.
When dealing with features, a common query arises: what if there are more than two possible outcomes from a feature check? In such situations, it's possible to return diverse values from the check, not just limited to true/false.
Feature::define('design', fn (User $user) => match (true) {
$user->subscription->level > 3 => 'full',
$user->subscription->level > 1 => 'elements',
default => null,
});
We can then retrieve the values either via the value() method or via the @feature blade-directive.
$design = Feature::value('design');
return view('form', compact('design'));
@feature('design', 'full')
<a href="#" class="new-design">New design</a>
@elsefeature('design', 'elements')
<a href="#" class="new-elements">New elements</a>
@endfeature
You can also get values for multiple features using the values() method. The response will be an associative array with the feature names as keys. You can also use the all() method, which will return all checked features but will not return features that are not checked in this request. To change this you can put Feature::discover() in ServiceProvider, which will add unchecked features to the response.
Even when employing cached check results, performance problems can persist when dealing with multiple checks because of numerous requests. To tackle this, a method similar to Eloquent's "load()" exists. This method lets you load features for a collection all at once, like for instance, users.
public function getUsersFeatures() {
$users = User::all();
Feature::for($users)->load(['design']);
$rowColor = [];
foreach ($users as $user) {
$rowColor[$user->id] = 'color-' . (Feature::for($user)->value('design') ?? 'gray');
}
return view('admin.users.features', compact('users', 'rowColor'));
}
Due to the caching applied under the hood, there may be a problem with updating the check result. To force this change, you can use the forget() method. You may also need to force a value change using the activate() and deactivate() methods.
After a new feature is implemented, there comes a point when the feature itself becomes irrelevant. This happens in two cases - either the functionality is ready to use for all users or the functionality worked incorrectly and you need to roll back all changes. To remove values from the storage in this case, you should either use the Feature::purge('design') method or use the artisan pennant:purge design
command. The second option is convenient because it can be executed in production without the need to edit the code.
The ability to enable and disable features as needed is essentially what Canary Deployment does, but at the code level and not on the server. For small projects, this will be very convenient, as you don't need to understand the deployment settings, and you don't need to have someone on your team to configure it all. For large projects, the Laravel Pennant package will work a bit differently, as the vast majority of large projects have scaling and Canary Deployment configured, which allows you to test new versions of the application in parts without having to implement splitting in the code. In such projects, Pennant can become part of the admin panel, where you can turn features on and off as needed.
Lead image by