- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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
}
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;
}
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();
}
}
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);
}
}
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}";
}
}
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),
};
}
}
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',
};
}
}
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),
};
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,
);
}
}
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;
}
}
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));
});
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();
});
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
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.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)