- 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
You open the pricing use case to add a new discount rule behind a flag. The first line you write is LaunchDarkly::variation('new-pricing', ...). It works. Three months later the team moves off LaunchDarkly, and a grep for that static call returns hits in the order service, the billing job, two controllers, and one domain entity.
That last one is the problem. A domain entity now imports a SaaS SDK. To unit-test the pricing rule, you boot a client, point it at a config server, and stub HTTP. The rule that should run in microseconds against pure PHP now needs a network mock.
The fix is the same move you make for the database and the payment gateway. The flag service is just another outbound dependency. The domain states what it needs as an interface, and the SDK lives behind an adapter where it belongs.
The port
src/Application/Port/FeatureFlags.php says what the application needs, in plain terms: ask whether a flag is on, optionally for a given subject.
<?php
declare(strict_types=1);
namespace App\Application\Port;
interface FeatureFlags
{
public function isEnabled(
string $flag,
?FlagContext $context = null,
): bool;
}
FlagContext carries the keys a targeting rule might read — a user id, a plan, a country. It is a small readonly DTO, not a framework request object.
<?php
declare(strict_types=1);
namespace App\Application\Port;
final readonly class FlagContext
{
/**
* @param array<string, string|int|bool> $attributes
*/
public function __construct(
public string $subjectId,
public array $attributes = [],
) {}
}
That is the whole contract. No vendor types, no HTTP, no JSON. A use case can depend on FeatureFlags and stay readable to someone who has never heard of LaunchDarkly.
The use case reads the port, nothing else
Here is a checkout use case that gates a new pricing path behind a flag. It takes the port in its constructor and asks a question.
<?php
declare(strict_types=1);
namespace App\Application\Pricing;
use App\Application\Port\FeatureFlags;
use App\Application\Port\FlagContext;
use App\Domain\Pricing\Cart;
use App\Domain\Pricing\PriceList;
final readonly class QuoteCart
{
public function __construct(
private FeatureFlags $flags,
private PriceList $prices,
) {}
public function quote(Cart $cart, string $userId): int
{
$context = new FlagContext($userId, [
'plan' => $cart->plan(),
]);
if ($this->flags->isEnabled('tiered-pricing', $context)) {
return $this->prices->tieredTotal($cart);
}
return $this->prices->flatTotal($cart);
}
}
The use case knows there are two pricing paths and a flag that picks between them. It does not know where the flag value comes from. Swap the backing service and this file does not change.
Adapter one: environment variables
The cheapest backing store is the environment. Good for a small app, a kill switch, or a flag that flips per deploy. It implements the same interface.
<?php
declare(strict_types=1);
namespace App\Infrastructure\FeatureFlags;
use App\Application\Port\FeatureFlags;
use App\Application\Port\FlagContext;
final readonly class EnvFeatureFlags implements FeatureFlags
{
public function isEnabled(
string $flag,
?FlagContext $context = null,
): bool {
$key = 'FEATURE_' . strtoupper(
str_replace('-', '_', $flag),
);
$value = $_ENV[$key] ?? 'false';
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
}
tiered-pricing reads FEATURE_TIERED_PRICING. No targeting, no per-user logic; the context is ignored. That is honest for this adapter — env flags are global by nature.
Adapter two: a database table
When product wants to flip flags without a deploy, back them with a table. One row per flag, a boolean, maybe a rollout percentage.
<?php
declare(strict_types=1);
namespace App\Infrastructure\FeatureFlags;
use App\Application\Port\FeatureFlags;
use App\Application\Port\FlagContext;
use Doctrine\DBAL\Connection;
final readonly class DbFeatureFlags implements FeatureFlags
{
public function __construct(private Connection $db) {}
public function isEnabled(
string $flag,
?FlagContext $context = null,
): bool {
$row = $this->db->fetchAssociative(
'SELECT enabled, rollout
FROM feature_flags
WHERE name = ?',
[$flag],
);
if ($row === false || !$row['enabled']) {
return false;
}
$rollout = (int) $row['rollout'];
if ($rollout >= 100 || $context === null) {
return $rollout >= 100;
}
return $this->bucket($context->subjectId) < $rollout;
}
private function bucket(string $subjectId): int
{
$hash = crc32($subjectId);
return $hash % 100;
}
}
The rollout bucket is deterministic: the same user lands in the same bucket on every call, so a 20% rollout stays the same 20% of users instead of reshuffling on each request. The hashing is naive on purpose; a real rollout hashes the flag name with the subject so two flags at 20% target different cohorts.
Adapter three: LaunchDarkly
Now the SDK call you started with, parked where it can't leak. The vendor types stay inside this one file.
<?php
declare(strict_types=1);
namespace App\Infrastructure\FeatureFlags;
use App\Application\Port\FeatureFlags;
use App\Application\Port\FlagContext;
use LaunchDarkly\LDClient;
use LaunchDarkly\LDContext;
final readonly class LaunchDarklyFeatureFlags implements FeatureFlags
{
public function __construct(private LDClient $client) {}
public function isEnabled(
string $flag,
?FlagContext $context = null,
): bool {
$ldContext = $this->toLdContext($context);
return $this->client->variation(
$flag,
$ldContext,
false,
);
}
private function toLdContext(?FlagContext $c): LDContext
{
if ($c === null) {
return LDContext::create('anonymous');
}
$builder = LDContext::builder($c->subjectId);
foreach ($c->attributes as $key => $value) {
$builder->set($key, $value);
}
return $builder->build();
}
}
The third argument to variation is the default returned when the flag is missing or the client can't reach LaunchDarkly. Pick a safe default per flag — usually false, the old behavior. If the flag service has an outage, checkout falls back to flat pricing instead of throwing. The translation from FlagContext to LDContext is the adapter's job and lives nowhere else.
Wiring
The composition root binds the port to whichever adapter the environment wants. Switching providers is a one-line change.
<?php
$c->set(FeatureFlags::class, function ($c) {
return match ($_ENV['FLAGS_DRIVER'] ?? 'env') {
'launchdarkly' => new LaunchDarklyFeatureFlags(
$c->get(LDClient::class),
),
'db' => new DbFeatureFlags(
$c->get(Connection::class),
),
default => new EnvFeatureFlags(),
};
});
Local dev runs on env. Staging runs on db so QA can flip flags by hand. Production runs on launchdarkly. The use case is identical across all three.
Testing both states with a fake
This is where the port earns its keep. A unit test for QuoteCart should run both pricing paths without a network, a database, or an SDK. Write a fake that holds flag state in an array.
<?php
declare(strict_types=1);
namespace Tests\Fake;
use App\Application\Port\FeatureFlags;
use App\Application\Port\FlagContext;
final class FakeFeatureFlags implements FeatureFlags
{
/** @var array<string, bool> */
private array $flags = [];
public function enable(string $flag): self
{
$this->flags[$flag] = true;
return $this;
}
public function disable(string $flag): self
{
$this->flags[$flag] = false;
return $this;
}
public function isEnabled(
string $flag,
?FlagContext $context = null,
): bool {
return $this->flags[$flag] ?? false;
}
}
Now both branches of the use case get a test, and each one says in plain English which flag state it covers.
<?php
declare(strict_types=1);
namespace Tests\Unit\Pricing;
use App\Application\Pricing\QuoteCart;
use App\Domain\Pricing\Cart;
use PHPUnit\Framework\TestCase;
use Tests\Fake\FakeFeatureFlags;
use Tests\Fake\FakePriceList;
final class QuoteCartTest extends TestCase
{
public function test_uses_tiered_pricing_when_flag_on(): void
{
$flags = (new FakeFeatureFlags())
->enable('tiered-pricing');
$useCase = new QuoteCart($flags, new FakePriceList());
$total = $useCase->quote(
new Cart('pro', items: 3),
'user-1',
);
self::assertSame(2400, $total);
}
public function test_uses_flat_pricing_when_flag_off(): void
{
$flags = new FakeFeatureFlags(); // off by default
$useCase = new QuoteCart($flags, new FakePriceList());
$total = $useCase->quote(
new Cart('pro', items: 3),
'user-1',
);
self::assertSame(3000, $total);
}
}
Two tests, no mocking framework, no HTTP stub. The flag becomes a knob you turn in the test setup, and both code paths are covered. When you delete the flag after the rollout finishes, the off-branch test goes with it and the on-branch test becomes the unconditional behavior.
The cleanup nobody schedules
Flags are temporary by design and permanent by accident. The port helps here too. Because every read goes through FeatureFlags::isEnabled, a stale flag is greppable: search the codebase for the flag string and you find every branch that depends on it. With static SDK calls scattered across layers, that search is noisier and easier to get wrong.
Put a deletion date in the flag row or a ticket on the board the day you add it. When tiered pricing is the only pricing, remove the flag, delete the else, drop the off-branch test. The domain goes back to one path, and the only thing that ever knew about the flag was a single interface and the adapters behind it.
A flag is a dependency, and dependencies belong behind ports. The same discipline that keeps your database and your payment gateway out of the domain keeps your flag service out too. Decoupled PHP walks this pattern across every outbound dependency a real application grows, with the same vocabulary and the same fakes used here, so the test suite stays fast no matter how many SaaS clients the production wiring picks up.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)