ADR-0016: Config Attribute Injection
Accepted Cross-ProjectDate: 2026-03-13
Context
Laravel classes frequently need configuration values — API keys, feature flags, timeouts, URLs. Three approaches exist in the ecosystem:
Configfacade —Config::get('key')— static global state, untestable without framework bootstrapconfig()helper —config('key')orconfig()->string('key')— same global state, slightly less visible#[Config]attribute —#[Config('key')] private string $value— constructor-injected by the container, explicit dependency
The #[Config] attribute (Illuminate\Container\Attributes\Config) was introduced in Laravel 11. It leverages PHP 8.1 attributes and the service container's contextual binding to inject configuration values as constructor parameters — the same way services are injected.
Current State
| Territory | #[Config] | config() helper | Config facade |
|---|---|---|---|
| the-laboratory | 23 (100%) | 0 | 0 |
| brick-inventory | 3 (100%) | 0 | 0 |
| kendo | 21 (68%) | 10 (32%) | 0 |
Two territories already use #[Config] exclusively. Kendo has 10 legacy config() calls across Auth Actions, MCP Tools, Mail, Middleware, and Console Commands — all migrable.
Why This Matters
The config() helper requires the Laravel container to be booted. Unit tests that instantiate a class directly (without the framework) cannot control config values without config()->set() in the test setup. With #[Config], the value is a regular constructor parameter — tests pass it directly:
// With config() — needs framework bootstrap
$action = app(EnableTwoFactorAction::class);
config()->set('two-factor.issuer', 'TestApp');
// With #[Config] — plain constructor call
$action = new EnableTwoFactorAction(
google2fa: $mockGoogle2fa,
twoFactorIssuer: 'TestApp',
);This aligns with the Action Class Architecture principle of explicit dependency injection and the broader "explicit over implicit" doctrine.
Decision
All configuration access in application classes MUST use the #[Config] attribute for constructor injection. The config() helper and Config facade are prohibited outside of the documented exception.
Pattern
use Illuminate\Container\Attributes\Config;
final readonly class SendFeedbackAction
{
public function __construct(
private ConnectionInterface $db,
#[Config('mattermost.webhook_url')]
private string $webhookUrl,
) {}
public function execute(/* ... */): void
{
// Use $this->webhookUrl — no runtime config lookup
}
}With Default Values
For optional configuration (sensible defaults when env var is absent):
#[Config('services.rebrickable.base_url', 'https://rebrickable.com/api/v3')]
private string $baseUrl,Type Safety
The property type declaration provides the type contract. The container injects the raw config value; PHP's type system enforces correctness:
#[Config('two-factor.window')]
private int $window, // config returns mixed, PHP coerces to int
#[Config('claude.claude_md_paths')]
private array $claudeMdPaths, // array config values
#[Config('app.url')]
private string $appUrl, // string valuesScope
The rule applies to all classes resolved from the container:
| Class Type | In Scope | Notes |
|---|---|---|
Actions (final readonly) | Yes | Already 100% on brick-inventory and the-laboratory |
Services (final readonly) | Yes | Already 100% across all territories |
| MCP Tools | Yes | Extend Laravel\Mcp\Server\Tool, container-resolved |
| Middleware | Yes | Container-resolved via route stack |
| Console Commands | Yes | Container-resolved via Artisan |
| Mailables | Yes | Container-resolved when dispatched. Queued mailables serialize the injected value — safe |
| Jobs | Yes | Container-resolved when dispatched |
| Controllers | N/A | Controllers should not access config directly — delegate to Actions/Services |
Single Exception: ServiceProvider Config Mutation
ServiceProvider::boot() and register() methods may use config()->set() for runtime config mutation (e.g., setting tenant database connection). This is the only permitted use of the config() helper.
Rationale: #[Config] is read-only injection at resolve time. Config mutation is a fundamentally different operation that only ServiceProviders should perform.
Architecture Test Enforcement
Each territory must include an architecture test that prohibits config() helper and Config facade usage outside ServiceProviders:
arch('application classes must not use config() helper')
->expect('App')
->not->toUse(['config'])
->ignoring('App\Providers');
arch('application classes must not use Config facade')
->expect('App')
->not->toUse(['Illuminate\Support\Facades\Config'])
->ignoring('App\Providers');Options Considered
| Option | Verdict | Reason |
|---|---|---|
Allow config() helper everywhere | Rejected | Global state, implicit dependency, untestable without framework bootstrap |
Allow config() in non-Action classes (Actions-only rule) | Rejected | Inconsistent. If the pattern works in Actions, there's no reason to use a different pattern in Services, Tools, or Middleware. Fewer exceptions = less cognitive load. |
Config facade with DI | Rejected | Injects the entire Config repository. Over-broad dependency when only specific keys are needed. |
#[Config] attribute for all container-resolved classes | Accepted | Explicit, testable, type-safe, already proven across 47 usages in 3 territories |
Consequences
Positive
- Every config dependency is visible in the constructor — no hidden
config()calls buried in method bodies - Unit tests construct classes with plain values — no framework bootstrap needed
- IDE autocomplete and PHPStan analysis work on typed properties
- Consistent with the Action Class Architecture's explicit DI principle
- Architecture tests prevent regression — CI catches any
config()introduction
Negative
- Constructor parameter lists grow longer when a class needs multiple config values
- Developers unfamiliar with the attribute may default to
config()— architecture tests catch this - The attribute is Laravel 11+ only — not relevant for these projects (all on Laravel 12) but limits portability
Risks
- Queued job/mailable serialization with config values — Mitigation: the injected value is a plain scalar, serializes normally. No closure or resource reference.
- Config values that change at runtime (e.g., after
config()->set()in a ServiceProvider) — Mitigation:#[Config]captures the value at resolve time, which is after ServiceProvider boot. Runtime mutation after resolve is not a supported use case.
References
- Action Class Architecture — established
#[Config]for Actions, this ADR generalizes it - Laravel documentation: Contextual Attributes
- Architecture tests:
tests/Arch/ConfigTest.php(to be created per territory)
Implementation
| Territory | State | Notes |
|---|---|---|
| the-laboratory | Complete | 23 usages, 0 config() calls. No architecture test yet — add when test infrastructure matures. |
| brick-inventory | Complete | 3 usages, 0 config() calls. Architecture test needed. |
| kendo | Complete | All 10 config() calls migrated (PR #475). Architecture test enforcing pattern (ConfigTest.php). |
| entreezuil | Complete | 4 config() calls migrated (PR #16): UpdatePassword, CMSmsUser. Architecture test enforcing (ConfigTest.php). |
| ublgenie | Partial | 2/6 config() calls migrated (PR #24): InvoiceInboundAuthenticated, AzureService. 4 remaining blocked — LogicTokenService, LogicAPIService, Invoice model are manually new'd (not container-resolved). Requires DI refactor. No architecture test yet. |