Undefined Pennant Features
Pennant is a first-party Laravel package that helps you with feature flags. Turns out that by not registering (defining) your features you can solve a common annoyance.
Documented workflow
According to docs we start by defining a feature. Let’s give the new forum to 20% of our users:
Feature::define('forum-v2', Lottery::odds(20 / 100)); And then you can add checks in your code, e.g.
if (Feature::active('forum-v2')) {
// ...
} The first such check for a particular user will roll the lottery and the user
will get it activated with 20% chance. The resolved result will be stored and
subsequent checks will always return the same true or false for that user.
Controlled release
A common use case for feature flags is when you want to release a feature for some selected group of users instead of a random portion. Personally I’ve never needed the random one. So you’d actually define the feature like this:
// deny by default
Feature::define('forum-v2', false); Any user without a stored value will get the feature denied. And for your selected users you do explicit
$user->features()->activate('new-layout'); Probably invoking that via some feature management panel or smtn like that.
What happens is that:
- If you
activate, atrueentry for that user gets stored in the database. - When you check for the feature, Pennant looks in the database and
- If there’s a value (
trueorfalse), it gets returned; - Otherwise it’s resolved (to
falseas defined above) and the value is stored in the database.
- If there’s a value (
As a result the table of features will not only contain those three or eight true entries, but also thousands or more false entries.
That’s just noise and mess. In your database and in your feature management panel. We just wanted to have eight entries for the selected users, no point to store negatives. You might start having doubts whether Pennant is fit for such feature flags at all.
Undefined features
Here’s the trick. Never define the feature. Just do this:
$user->features()->activate('forum-v2'); and check it as usual, e.g.
if ($user->features()->active('forum-v2')) {
// ...
} That’s it. How it works is:
- If you
activate, atrueentry for that user gets stored in the database. - When you check for the feature, Pennant looks in the database and
- If there’s a value (
trueorfalse), it gets returned; - Otherwise there’s nothing to resolve, a
falseis returned without storing anything.
- If there’s a value (
And that’s exactly what we need. Only the enabled ones are listed in the database and otherwise you just don’t have an entry and don’t have the feature.
Are we supposed to rely on an undocumented feature??
Yeah, I was thinking about the same. In fact I made up the question before you asked. You probably didn’t even ask it, right?
Although this behaviour is not described in the docs, there is a test that ensures it works like that. So it is an intentional behaviour and the test makes it safe to rely on this behaviour.
Btw here is the code responsible for this.
But shouldn’t I define my features???
In fact you can. The trick is just that you shouldn’t define the resolver function. It’s very doable by defining your features as classes.
class ForumV2
{
public function before(User $user)
{
if (app()->environment('local'))
return true;
if ($user->doesntDeserveNewThings())
return false;
if ($user->isBetaTester())
return true;
if (now()->isAfter(config('forum.v2_released_at')))
return true;
}
// Just don't add a `resolve()` method here.
} And now you have all the control and scheduled release capabilities of before as well as being able to activate it for selected users, but for others it
will just be inactive without trying to store anything.
Publicēts 2026-03-14