log in
consulting hosting industries the daily tools about contact

Laravel Pennant Is Good Until It Isn't

Pennant's database driver will quietly become a bottleneck under real traffic. Here's where it breaks and what I do instead.

Laravel Pennant shipped in Laravel 10 and I was genuinely excited about it. A first-party feature flag package that integrates with gates, works with any authenticatable model, and doesn't require a third-party service? That's exactly what most of my clients need. I've now run it in production on three separate apps, and the conclusion I've landed on is: the array and database drivers are fine for small-scale use, but the moment you have real traffic and flags checked on every request, you're going to have a bad time.

Let me save you the debugging session.

What Pennant Actually Does

Pennant gives you a clean API for defining feature flags and scoping them to users (or any model). You define a flag, optionally give it a lottery or conditional resolution, and then check it in your controllers, views, or middleware. The resolved value gets stored so it's consistent across a session - a user either has the feature or they don't, and that decision sticks.

That last part is important. Pennant isn't just config('features.new_ui'). It resolves once per scope and stores the result. That storage is the entire source of the problem.

A Typical Setup

Here's how I'd normally define a flag:

// AppServiceProvider::boot()

use Laravel\Pennant\Feature;

Feature::define('new-checkout', function (User $user) {
    // Roll out to internal users first, then 20% of everyone else
    if ($user->hasRole('internal')) {
        return true;
    }

    return Feature::lottery(0.2);
});

And check it:

if (Feature::for($user)->active('new-checkout')) {
    return response()->json(['checkout' => 'v2']);
}

return response()->json(['checkout' => 'v1']);

This is clean. I like this API. No complaints here.

You can also check multiple flags at once, which Pennant does in a single query - it's not stupid about it:

Feature::for($user)->loadMissing([
    'new-checkout',
    'express-shipping',
    'loyalty-points',
]);

That loadMissing call is something you should be doing aggressively if you're on the database driver. If you're not, you'll fire one query per flag per request.

Where the Database Driver Breaks Down

The database driver stores resolved flag values in a features table. Every time Pennant needs to know if a flag is active for a scope it hasn't seen yet in the current request lifecycle, it hits the database.

In a low-traffic app with a handful of flags, this is fine. In an app doing a few hundred requests per second with eight or ten flags checked per request, this is a lot of queries. I ran into this on an e-commerce client's checkout flow last year. We had seven flags active for the rollout of a new cart experience, and I hadn't thought hard about where they were being resolved.

The slow query log started showing SELECT * FROM features WHERE ... over and over. Not slow individually - 2-5ms each. But at 300 requests per minute with seven flags and imperfect use of loadMissing, it added up fast. We were adding 15-30ms of pure database overhead to requests that were otherwise snappy.

The in-memory array driver solves this for the current request but forgets everything between requests, which defeats the purpose of storing resolved values at all. It's really only useful for testing.

The Fix: Redis Driver

Pennant doesn't ship with a Redis driver, but writing one is about 60 lines of PHP. Here's the driver I actually use in production:

<?php

namespace App\Features;

use Illuminate\Support\Facades\Redis;
use Laravel\Pennant\Contracts\Driver;

class RedisFeatureDriver implements Driver
{
    protected string $prefix = 'pennant:';
    protected int $ttl = 3600; // 1 hour

    public function __construct(protected string $store = 'default') {}

    protected function key(string $feature, string $scope): string
    {
        return $this->prefix . $feature . ':' . $scope;
    }

    public function get(string $feature, mixed $scope): mixed
    {
        $value = Redis::connection($this->store)
            ->get($this->key($feature, $scope));

        return $value !== null ? json_decode($value, true) : null;
    }

    public function set(string $feature, mixed $scope, mixed $value): void
    {
        Redis::connection($this->store)
            ->setex(
                $this->key($feature, $scope),
                $this->ttl,
                json_encode($value)
            );
    }

    public function getAll(string $feature): array
    {
        // Scan for all keys matching this feature
        $keys = [];
        $cursor = '0';
        $pattern = $this->prefix . $feature . ':*';

        do {
            [$cursor, $found] = Redis::connection($this->store)
                ->scan($cursor, 'MATCH', $pattern, 'COUNT', 100);
            $keys = array_merge($keys, $found);
        } while ($cursor !== '0');

        if (empty($keys)) {
            return [];
        }

        $values = Redis::connection($this->store)->mget($keys);

        $result = [];
        foreach ($keys as $i => $key) {
            $scope = str_replace($this->prefix . $feature . ':', '', $key);
            $result[$scope] = json_decode($values[$i], true);
        }

        return $result;
    }

    public function delete(string $feature, mixed $scope): void
    {
        Redis::connection($this->store)
            ->del($this->key($feature, $scope));
    }

    public function purge(string|null $feature): void
    {
        $pattern = $feature
            ? $this->prefix . $feature . ':*'
            : $this->prefix . '*';

        $cursor = '0';
        do {
            [$cursor, $keys] = Redis::connection($this->store)
                ->scan($cursor, 'MATCH', $pattern, 'COUNT', 100);
            if (!empty($keys)) {
                Redis::connection($this->store)->del(...$keys);
            }
        } while ($cursor !== '0');
    }
}

Register it in a service provider:

use Laravel\Pennant\Feature;

Feature::extend('redis', function (Application $app) {
    return new RedisFeatureDriver(
        store: config('pennant.redis_connection', 'default')
    );
});

Then in config/pennant.php:

'default' => env('PENNANT_DRIVER', 'redis'),

Now resolved flag values live in Redis with a TTL. A flag checked for a given user costs one Redis GET at ~0.5ms instead of a database query at 3-5ms, and subsequent checks within the same request lifecycle are served from Pennant's in-memory cache anyway.

Gotchas I've Actually Hit

The scope serialization is on you. Pennant uses the FeatureScopeable interface and defaults to MorphMap or class+id. When you build a custom driver, make sure you're consistent about how scopes become cache keys. I serialize with get_class($scope) . ':' . $scope->getKey() after checking for the interface - don't just use $user->id or you'll collide across models.

Flag changes don't propagate immediately with a TTL cache. If you turn a flag off via Feature::deactivate('new-checkout'), that calls delete() on the driver, but only for the scope you specify. Any other users who already have a cached value will keep it until TTL expires. For flags that are genuinely on/off switches that you need to kill immediately, call purge('new-checkout') instead. That SCAN-and-delete loop is intentional.

Don't use SCAN in a Redis Cluster without testing it. The SCAN in getAll() and purge() works fine against a single Redis instance. Against a cluster, keys are distributed across nodes and a simple SCAN only hits one slot. If you're on a cluster, you'll need to either use a hash tag in your key prefix or accept that getAll is incomplete. For most of my clients, a single Redis instance with replication is enough and this isn't an issue.

The loadMissing call matters regardless of driver. Even with Redis, if you're checking flags one at a time across a request, you're making multiple round trips. Pennant doesn't have a native batch GET on the driver interface (it batches at the application layer after checking its in-memory store), so structure your code to check all flags early via loadMissing.

Testing. Set PENNANT_DRIVER=array in your .env.testing and annotate tests with Feature::define() inline. The array driver is exactly right for tests - fast, isolated, no state.

When I'd Reach for Pennant

I'd use Pennant when I need user-scoped flags with persistent resolution and I want to stay inside the Laravel ecosystem without paying for LaunchDarkly or Flagsmith. It's a good fit for rollouts by user role, lottery-based gradual rollouts, and A/B experiments where you want consistent assignment.

I wouldn't reach for Pennant if I need real-time flag evaluation with no caching (say, a kill switch that needs to fire in under a second for all active sessions), or if the flag logic is genuinely complex with targeting rules across multiple dimensions. For that, a dedicated service like LaunchDarkly is worth the money.

I also wouldn't use it if the app doesn't already have Redis. Adding Redis just for feature flags is usually overkill. In that case, the database driver with aggressive use of loadMissing and a low flag count is probably fine.

Closing

Pennant is a solid, well-designed package and I'm glad it's in core. But the database driver is a trap if you don't see it coming, and the Redis driver it's missing is not hard to write. Add 60 lines, set a TTL, purge explicitly when you need to, and you've got a feature flag system that won't embarrass you at scale.

Need help shipping something like this? Get in touch.