Skip to content

ADR-0016: Config Attribute Injection

Accepted Cross-Project

Date: 2026-03-13

Context

Laravel classes frequently need configuration values — API keys, feature flags, timeouts, URLs. Three approaches exist in the ecosystem:

  1. Config facadeConfig::get('key') — static global state, untestable without framework bootstrap
  2. config() helperconfig('key') or config()->string('key') — same global state, slightly less visible
  3. #[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() helperConfig facade
the-laboratory23 (100%)00
brick-inventory3 (100%)00
kendo21 (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:

php
// 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

php
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):

php
#[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:

php
#[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 values

Scope

The rule applies to all classes resolved from the container:

Class TypeIn ScopeNotes
Actions (final readonly)YesAlready 100% on brick-inventory and the-laboratory
Services (final readonly)YesAlready 100% across all territories
MCP ToolsYesExtend Laravel\Mcp\Server\Tool, container-resolved
MiddlewareYesContainer-resolved via route stack
Console CommandsYesContainer-resolved via Artisan
MailablesYesContainer-resolved when dispatched. Queued mailables serialize the injected value — safe
JobsYesContainer-resolved when dispatched
ControllersN/AControllers 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:

php
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

OptionVerdictReason
Allow config() helper everywhereRejectedGlobal state, implicit dependency, untestable without framework bootstrap
Allow config() in non-Action classes (Actions-only rule)RejectedInconsistent. 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 DIRejectedInjects the entire Config repository. Over-broad dependency when only specific keys are needed.
#[Config] attribute for all container-resolved classesAcceptedExplicit, 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

Implementation

TerritoryStateNotes
the-laboratoryComplete23 usages, 0 config() calls. No architecture test yet — add when test infrastructure matures.
brick-inventoryComplete3 usages, 0 config() calls. Architecture test needed.
kendoCompleteAll 10 config() calls migrated (PR #475). Architecture test enforcing pattern (ConfigTest.php).
entreezuilComplete4 config() calls migrated (PR #16): UpdatePassword, CMSmsUser. Architecture test enforcing (ConfigTest.php).
ublgeniePartial2/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.

Architecture documentation for contributors and collaborators.