- 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
Someone tidies up config/billing.php and renames trial_days to trial_length_days. They update the two call sites their editor found. The suite stays green, because no test asserts how long a trial lasts. The deploy goes out. The next customer who signs up gets a free trial that ends the second it starts.
Nothing threw. The use case read config('billing.trial_days'), got null for a key that no longer exists, cast it with (int), and the PHP manual is blunt about what that does: null is always converted to zero. A zero-day trial is a perfectly valid integer.
The rename is the trigger, but the bug is older. A domain class reached out to the framework for a value it never declared.
The use case that only runs inside Laravel
This is the shape you find in a lot of Laravel codebases. The domain lives under src/Domain, mapped as its own PSR-4 namespace in Composer's composer.json, but it still calls the global helper:
<?php declare(strict_types=1); namespace Domain\Billing; use DateInterval; use DateTimeImmutable; final class StartTrial { public function __construct( private SubscriptionRepository $subscriptions, ) {} public function handle( string $accountId, DateTimeImmutable $now, ): Subscription { $days = (int) config('billing.trial_days'); $subscription = new Subscription( accountId: $accountId, currency: config('billing.currency'), trialEndsAt: $now->add( new DateInterval("P{$days}D"), ), ); $this->subscriptions->save($subscription); return $subscription; } } Three things are wrong with it, and only one of them is the rename.
It only runs inside a booted app. The config() helper is app('config')->get($key, $default), and app() asks the global container to make('config'). In a plain PHPUnit test there is no booted application, so nothing is bound under config, and the container's build() throws a BindingResolutionException with the message Target class [config] does not exist. Your unit test for a date calculation now needs Laravel's TestCase and a full boot.
The constructor lies. The signature says StartTrial needs a repository. It also needs two config keys, and the only way to learn that is to read the method body. A reviewer, a new teammate, or a coding agent wiring this class up gets no hint from the type system.
A renamed key fails quietly. The Laravel 13 configuration docs say config() returns the default when the option does not exist, and the helper's default is null. Cast it and you get the zero-day trial from the opening. Skip the cast and null travels further before something else trips over it.

The use case looks self-contained until you follow the wire it never declared.
A readonly settings object, validated once
The fix is to name the inputs. Billing needs a trial length and a currency, so give the domain a class that holds exactly those, with the rules the business actually has:
<?php declare(strict_types=1); namespace Domain\Billing; use InvalidArgumentException; final readonly class BillingSettings { public function __construct( public int $trialDays, public string $currency, ) { if ($trialDays < 1 || $trialDays > 90) { throw new InvalidArgumentException( "trialDays must be 1..90, got {$trialDays}", ); } if (preg_match('/^[A-Z]{3}$/', $currency) !== 1) { throw new InvalidArgumentException( "currency must be 3 uppercase letters, " . "got '{$currency}'", ); } } } Readonly classes arrived in PHP 8.2: every declared property becomes readonly and dynamic properties are refused. Per the properties page, a readonly property is initialized once and any later write throws an Error. Once a BillingSettings exists, it is valid, and it stays that way for the life of the request.
The range check is where the zero-day trial dies. Even if a bad cast slips through upstream, 0 never becomes a settings object.
The use case now asks for what it uses:
final class StartTrial { public function __construct( private SubscriptionRepository $subscriptions, private BillingSettings $settings, ) {} public function handle( string $accountId, DateTimeImmutable $now, ): Subscription { $days = $this->settings->trialDays; $subscription = new Subscription( accountId: $accountId, currency: $this->settings->currency, trialEndsAt: $now->add( new DateInterval("P{$days}D"), ), ); $this->subscriptions->save($subscription); return $subscription; } } For completeness, the two small types it touches:
final readonly class Subscription { public function __construct( public string $accountId, public string $currency, public DateTimeImmutable $trialEndsAt, ) {} } interface SubscriptionRepository { public function save(Subscription $subscription): void; } No use Illuminate\... anywhere in src/Domain. The time is already passed in as $now, for the same reason: the clock is a dependency too.
The service provider is the adapter
Something still has to read the config file. That job belongs at the edge, in the framework layer, and Laravel's service providers are where bindings go. Start with the config file itself, because it has a trap:
<?php // config/billing.php return [ 'trial_days' => (int) env('BILLING_TRIAL_DAYS', 14), 'currency' => env('BILLING_CURRENCY', 'EUR'), ]; The environment variable types table is clear that .env values are parsed as strings, apart from a few reserved words like true, false and null. Without the (int), BILLING_TRIAL_DAYS=30 reaches your code as the string "30". Keep env() here and nowhere else: the docs warn that once you run config:cache, the .env file is no longer loaded and env() only returns system-level variables.
Now the provider:
<?php declare(strict_types=1); namespace App\Providers; use Domain\Billing\BillingSettings; use Illuminate\Support\Facades\Config; use Illuminate\Support\ServiceProvider; final class BillingServiceProvider extends ServiceProvider { public function register(): void { $this->app->singleton( BillingSettings::class, fn () => new BillingSettings( trialDays: Config::integer('billing.trial_days'), currency: Config::string('billing.currency'), ), ); } public function boot(): void { // Fail at boot, not on the first signup. $this->app->make(BillingSettings::class); } } Config::integer() and Config::string() are the typed retrieval methods on the facade. In the config repository source, integer() checks is_int() and otherwise throws an InvalidArgumentException with the message pattern Configuration value for key [%s] must be an integer, %s given. Rename the key again and you get that exception, naming the key, instead of a quiet zero.
The boot() line is optional and cheap: it builds one small object per boot. What it buys you is timing. A bad value now fails the first request or artisan command after the deploy, with a message that names the field, instead of failing hours later inside whichever signup happens to hit it. The providers docs say boot() runs after every provider has registered, so the config repository is there when you need it. make:provider adds the class to bootstrap/providers.php for you; if you write it by hand, add it there yourself.
Because StartTrial only depends on concrete classes and an interface you bind elsewhere, the container resolves it with no extra wiring.

Settings cross the boundary once, as a typed object, through an adapter that lives outside the domain.
The test needs no framework
The domain test extends PHPUnit's own TestCase, not Laravel's, and builds everything with new:
<?php declare(strict_types=1); namespace Tests\Unit\Billing; use DateTimeImmutable; use Domain\Billing\BillingSettings; use Domain\Billing\StartTrial; use Domain\Billing\Subscription; use Domain\Billing\SubscriptionRepository; use InvalidArgumentException; use PHPUnit\Framework\TestCase; final class StartTrialTest extends TestCase { public function test_trial_ends_after_setting(): void { $repo = new class implements SubscriptionRepository { /** @var list<Subscription> */ public array $saved = []; public function save(Subscription $s): void { $this->saved[] = $s; } }; $useCase = new StartTrial( $repo, new BillingSettings(trialDays: 14, currency: 'EUR'), ); $sub = $useCase->handle( 'acc_1', new DateTimeImmutable('2026-11-02T10:00:00+00:00'), ); self::assertSame( '2026-11-16', $sub->trialEndsAt->format('Y-m-d'), ); self::assertSame('EUR', $sub->currency); self::assertCount(1, $repo->saved); } public function test_zero_day_trial_is_rejected(): void { $this->expectException(InvalidArgumentException::class); new BillingSettings(trialDays: 0, currency: 'EUR'); } } No RefreshDatabase, no Config::set(), no app boot. Want to check a 30-day trial? Pass 30. The test reads like the business rule, and the second test pins the zero-day trial from the opening so it cannot come back.
What about #[Config] and giveConfig?
Laravel has two built-in ways to inject config values, and both are better than calling the helper in a method body.
The contextual attributes let you write #[Config('billing.trial_days')] int $trialDays in a constructor. That puts the dependency in the signature, where people can see it, with almost no boilerplate. The cost is that your domain class now imports Illuminate\Container\Attributes\Config, and the docs' own re-implementation of the attribute shows it resolving through config->get($key, $default), so any validation is whatever your constructor does on its own.
giveConfig keeps the framework out of the class: $this->app->when(StartTrial::class)->needs('$trialDays')->giveConfig('billing.trial_days'). For one primitive in one class, that is a fine choice. Once three use cases need the same four settings and the same range rules, a value object reads better than four loose primitives bound in four places.
If you are on Laravel and staying on Laravel, #[Config] in an application service is a reasonable trade. Inside src/Domain, keep it a plain object.
Find every leak with one grep
Now find the others. The first line catches calls to the global config(), env() and app() helpers under the domain, and skips methods like ->config( or $config(:
grep -rnE '(^|[^[:alnum:]_>:$])(config|env|app)\(' src/Domain grep -rn 'Illuminate\\' src/Domain grep -rn 'Config::' src/Domain No output means clean. The second line catches the imported facade, the #[Config] attribute and anything else imported from the framework. The third is there because Laravel still registers default facade aliases, so \Config::get(...) works without a single Illuminate\ string in the file. To stop new ones in CI:
if grep -rnE '(^|[^[:alnum:]_>:$])(config|env|app)\(' src/Domain \ || grep -rn 'Config::' src/Domain then echo "framework settings read inside src/Domain" >&2 exit 1 fi If you already run Pest, its architecture expectations accept global function names as well as namespaces, so the same rule fits in one test:
arch('domain reads no framework settings') ->expect('Domain') ->not->toUse([ 'config', 'env', 'app', 'Config', 'Illuminate', ]); Work through the matches one bounded context at a time. Each match becomes one field on a settings object and one line in a provider, and its test stops needing the framework.
If this was useful
Settings are one of the quieter ways the framework leaks into the domain, along with the clock and the facade. Decoupled PHP is about keeping all of those at the edge, with ports, adapters and the tests that prove the boundary holds, so your use cases keep working when Laravel or Symfony moves under them.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

