DEV Community

Cover image for Laravel Pennant: Feature Flags Without Coupling Your Domain to the Framework
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel Pennant: Feature Flags Without Coupling Your Domain to the Framework


On August 1, 2012, Knight Capital deployed new code to seven of its eight servers and missed the eighth. A retired feature flag named "Power Peg" got reused for the new behavior. On the one server still running the old binary, flipping that flag woke up dead code. In 45 minutes the firm lost about $440 million and was effectively finished (SEC order, 2013).

The lesson people take from that is "test your deploys." The one they miss is quieter: a feature flag is a branch in your business logic that outlives the code that created it. If that branch is scattered across your domain as raw calls into a framework helper, you have no single place to reason about it, retire it, or test both sides.

Laravel Pennant is a good flag engine. This post is about not letting it bleed into your domain.

Where the framework call leaks in

The Pennant quickstart tells you to write this wherever you need a decision:

use Laravel\Pennant\Feature;

if (Feature::active('bundle-discount')) {
    // new pricing
}
Enter fullscreen mode Exit fullscreen mode

Drop that inside a pricing service and three things are now true. Your domain imports Laravel\Pennant\Feature. Your unit test for pricing needs a booted container and a Pennant store. And the flag's scope (which user, which team) is implicit, resolved from whoever is authenticated in the current request.

The last one is the trap. Domain logic that reads "the current user" from a static call is logic you cannot run in a queue worker, a console command, or a test without faking the world around it.

A port the domain owns

Define the contract in your domain layer, in domain terms. No mention of Pennant, no mention of the request.

<?php

namespace App\Domain\Feature;

interface FeatureGate
{
    public function enabled(
        string $feature,
        object $scope,
    ): bool;

    public function value(
        string $feature,
        object $scope,
    ): mixed;
}
Enter fullscreen mode Exit fullscreen mode

enabled() answers the yes/no question. value() returns a rich value (a variant name, a limit, a config blob) for flags that carry more than a boolean. Both take an explicit $scope, so the caller says whose flag this is instead of the gate guessing.

Now the pricing use case depends on the contract, not the framework:

<?php

namespace App\Domain\Pricing;

use App\Domain\Feature\FeatureGate;

final class PriceCart
{
    public function __construct(
        private FeatureGate $features,
    ) {}

    public function total(Cart $cart, Customer $customer): Money
    {
        if ($this->features->enabled(
            'bundle-discount',
            $customer,
        )) {
            return $this->withBundleDiscount($cart);
        }

        return $cart->subtotal();
    }
}
Enter fullscreen mode Exit fullscreen mode

PriceCart has no idea Pennant exists. It has a flag decision and a customer to scope it to. That is all the domain should know.

The Pennant adapter

The adapter lives in the infrastructure layer and is the only file that imports Pennant.

<?php

namespace App\Infrastructure\Feature;

use App\Domain\Feature\FeatureGate;
use Laravel\Pennant\Feature;

final class PennantFeatureGate implements FeatureGate
{
    public function enabled(
        string $feature,
        object $scope,
    ): bool {
        return (bool) Feature::for($scope)
            ->value($feature);
    }

    public function value(
        string $feature,
        object $scope,
    ): mixed {
        return Feature::for($scope)
            ->value($feature);
    }
}
Enter fullscreen mode Exit fullscreen mode

Feature::for($scope) is the piece that matters. It makes the scope explicit instead of pulling the authenticated user out of the container. Pass a Customer, a Team, or anything that Pennant can turn into a storage key. If the scope model implements Pennant's FeatureScopeable, you control exactly what key gets stored:

<?php

namespace App\Models;

use Laravel\Pennant\Contracts\FeatureScopeable;

class Customer extends Model implements FeatureScopeable
{
    public function toFeatureIdentifier(
        string $driver,
    ): mixed {
        return "customer:{$this->id}";
    }
}
Enter fullscreen mode Exit fullscreen mode

Defining the flags themselves

Class-based features keep the resolution logic in one file per flag, which is where you want it when the flag is a business rule rather than a coin flip.

<?php

namespace App\Features;

use App\Models\Customer;
use Illuminate\Support\Lottery;

class BundleDiscount
{
    public function resolve(
        Customer $customer,
    ): mixed {
        return match (true) {
            $customer->isInternal() => true,
            $customer->onPlan('enterprise') => true,
            default => Lottery::odds(1, 10),
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Internal accounts and enterprise plans always get it. Everyone else is a 10% rollout. Pennant stores the resolved result per scope, so a customer who rolls into the 10% stays in it on the next request instead of re-flipping the coin.

Rich values, not just on/off

Flags are often more than a boolean. A checkout redesign might ship in three variants; a rate limiter might carry a per-tier number. Pennant lets resolve() return any value.

<?php

namespace App\Features;

use App\Models\Customer;

class CheckoutLayout
{
    public function resolve(
        Customer $customer,
    ): string {
        return match ($customer->cohort()) {
            'a' => 'single-page',
            'b' => 'three-step',
            default => 'classic',
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

The domain reads the variant through the same port, still ignorant of Pennant:

$layout = $this->features->value(
    'checkout-layout',
    $customer,
);

return match ($layout) {
    'single-page' => $this->renderSinglePage($cart),
    'three-step'  => $this->renderThreeStep($cart),
    default       => $this->renderClassic($cart),
};
Enter fullscreen mode Exit fullscreen mode

Wire it once in a provider

Bind the port to the adapter so every constructor that asks for a FeatureGate gets Pennant in production.

<?php

namespace App\Providers;

use App\Domain\Feature\FeatureGate;
use App\Infrastructure\Feature\PennantFeatureGate;
use Illuminate\Support\ServiceProvider;

class FeatureServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            FeatureGate::class,
            PennantFeatureGate::class,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

One binding is the whole seam. Swap PennantFeatureGate for a LaunchDarkly adapter or a database one later and no domain code changes.

Testing both branches

This is where the port earns its keep. Domain tests get an in-memory gate with no container, no store, no HTTP.

<?php

namespace App\Infrastructure\Feature;

use App\Domain\Feature\FeatureGate;

final class InMemoryFeatureGate implements FeatureGate
{
    /** @param array<string,mixed> $flags */
    public function __construct(
        private array $flags = [],
    ) {}

    public function enabled(
        string $feature,
        object $scope,
    ): bool {
        return (bool) ($this->flags[$feature] ?? false);
    }

    public function value(
        string $feature,
        object $scope,
    ): mixed {
        return $this->flags[$feature] ?? false;
    }
}
Enter fullscreen mode Exit fullscreen mode

Both sides of the branch become two plain assertions:

it('applies the bundle discount when on', function () {
    $gate = new InMemoryFeatureGate([
        'bundle-discount' => true,
    ]);

    $price = (new PriceCart($gate))
        ->total($cart, $customer);

    expect($price)->toEqual(Money::of(90));
});

it('charges full price when off', function () {
    $gate = new InMemoryFeatureGate([
        'bundle-discount' => false,
    ]);

    $price = (new PriceCart($gate))
        ->total($cart, $customer);

    expect($price)->toEqual(Money::of(100));
});
Enter fullscreen mode Exit fullscreen mode

For the adapter itself and any feature test that goes through Pennant, use Pennant's own tools. Point Pennant at the array store in testing by adding <env name="PENNANT_STORE" value="array"/> to phpunit.xml, then set scoped state directly:

use Laravel\Pennant\Feature;

it('resolves the enterprise cohort to on', function () {
    $customer = Customer::factory()
        ->enterprise()
        ->create();

    Feature::for($customer)
        ->activate('bundle-discount');

    expect(
        Feature::for($customer)
            ->value('bundle-discount'),
    )->toBeTrue();
});
Enter fullscreen mode Exit fullscreen mode

You test the resolution rules where Pennant lives, and the business branches where it does not. Neither test drags the other's dependencies along.

Retiring a flag without a Knight Capital moment

A flag is temporary by definition. When the rollout hits 100%, the branch has to leave the code, and the stored values have to leave the store or they will resolve stale forever. Pennant has a command for the second half:

php artisan pennant:purge bundle-discount
Enter fullscreen mode Exit fullscreen mode

Because the flag name only appears in the feature class and the resolved store, retiring it is a grep for one string, a deleted class, one purge, and a deleted branch in the use case. Compare that to hunting Feature::active('bundle-discount') across forty files. The port is what makes the string appear in a small, findable set of places.

That is the whole point. A flag reused by accident is a flag nobody could see all of. Keep every flag decision behind one contract and the reuse becomes a code review comment instead of a $440 million press release.


Feature flags are runtime configuration, and runtime configuration is exactly the kind of concern that belongs at the edge of the system, behind a port the domain defines and the framework implements. Keep the decision in your use case and the mechanism (Pennant, LaunchDarkly, a database column) in an adapter, and you can change how flags are stored without opening a single domain file. That inversion, the domain owning the contract and the framework satisfying it, is what Decoupled PHP is about.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)