firefly/config layers Spring-style configuration onto Laravel's config repository: active profiles, a
typed config accessor, #[ConfigProperties] DTO binding, and a config-backed ValueResolver so
#[Value('${…}')] reads real application configuration.
Active profiles come from FIREFLY_PROFILES_ACTIVE (comma-separated), falling back to APP_ENV, then
default:
use Firefly\Config\Profile\ProfileResolver;
$profiles = (new ProfileResolver())->resolve();
$profiles->isActive('prod'); // bool
$profiles->all(); // list<string>ProfileResolver consults three sources per setting, in this order, and treats a blank or non-scalar value
at any level as absent:
Illuminate\Support\Env— the reader behind Laravel'senv()helper. It sees$_ENV,$_SERVERandputenv()values, so PHPUnit<env>entries,docker --env,php-fpmenv[]and a parsed.envall resolve here. A real environment variable is the most specific signal available, so it wins.- The config repository —
firefly.profiles.active, thenapp.env. A list is accepted here, because['prod', 'eu']reads far better in a PHP config file than'prod,eu'; both spellings converge. - Raw
getenv()— last resort, for a process that calledputenv()after Env's repository was built, or that runs with no Laravel application at all.
Reading getenv() only — which is what this used to do — collapsed profiles to ['default'] in exactly
the two places they matter most. Under orchestra/testbench the environment is set on the config repository
and putenv() is never called, so a test asserting that a #[Profile('test')] bean is registered watched it
silently not be. And under php artisan config:cache, Laravel's LoadEnvironmentVariables bootstrapper
returns early, so .env is never parsed while the cached repository holds the correct app.env the whole
time — profiles switched themselves off in production the moment an app followed the deployment guide.
#[Profile('prod')] on a #[ConfigProperties] DTO means the DTO is bound only when one of the named
profiles is active. Multiple names are OR, never AND:
use Firefly\Config\Attributes\ConfigProperties;
use Firefly\Config\Profile\Profile;
#[Profile('prod', 'staging')]
#[ConfigProperties('payments')]
final readonly class PaymentsProperties
{
public function __construct(public string $gatewayUrl) {}
}The chain is: ProfileRequirement reads the attribute once, at scan time; ConfigPropertiesScanner
records the result on the descriptor; the compiled config-properties.php carries it; and ConfigRegistrar
skips the binding when the profiles are not active. Nothing reflects a user class at boot to discover the
gate, and an excluded DTO simply does not exist — injecting it fails loudly at resolution time rather
than quietly handing back configuration that was meant to be unreachable.
Until this landed the attribute was pure decoration: it was exported and documented, grep -rn 'Profile::class' packages/*/src matched zero lines of production code, and a class marked #[Profile('prod')] was registered
under every profile including the ones the annotation exists to exclude.
For a non-DTO bean — anything that is not #[ConfigProperties] — use firefly/context's
#[ConditionalOnProfile] instead. It is the same predicate, already wired into ConditionEvaluator.
Gating a general #[Component] with #[Profile] additionally needs firefly/context to record the
requirement while it scans, and firefly/config sits below Context in the layer graph, so it cannot reach
up to do it.
Config wraps the repository with fail-fast typed getters — a missing required key or a type mismatch throws
ConfigurationException instead of returning null:
$config->string('mail.host'); // required — throws if absent
$config->int('mail.port', 25); // default when absent
$config->bool('mail.tls', false);
$config->array('mail.recipients', []);Bind a config subtree onto a plain readonly DTO:
use Firefly\Config\Attributes\ConfigProperties;
#[ConfigProperties('mail')]
final readonly class MailProperties
{
public function __construct(
public string $host,
public int $port = 25,
public bool $tls = false,
) {}
}The DTO is registered as a container singleton bound from config('mail'), so it can be injected wherever
MailProperties is requested. Nested object-typed constructor parameters bind recursively from their
sub-arrays. Discovery compiles to a cached manifest (Octane-safe). The binder sits behind a ConfigBinder
seam, so a richer binder can be swapped in without touching your DTOs.
A constructor parameter is not matched by its exact name alone. Each one is looked up under four spellings, in this fixed precedence order — the same relaxed binding Spring Boot performs:
| # | Spelling | Example for $dailyTransferLimitMinor |
|---|---|---|
| 1 | exact parameter name | dailyTransferLimitMinor |
| 2 | snake_case |
daily_transfer_limit_minor |
| 3 | kebab-case |
daily-transfer-limit-minor |
| 4 | SCREAMING_SNAKE_CASE |
DAILY_TRANSFER_LIMIT_MINOR |
The order depends only on the parameter name, never on the iteration order of the config array, so binding stays deterministic even when an array carries two spellings of the same property at once. Duplicate spellings collapse — a parameter already written in snake_case yields two candidates, not four.
This exists because the two worlds otherwise never met. A config/*.php file is written by hand in whatever
casing the application's house style prefers, and its values very often arrive from environment variables,
which are SCREAMING_SNAKE by convention; a PHP constructor parameter is camelCase because PSR-12 says so.
Matching only the exact name meant 'daily_transfer_limit_minor' => 250000 bound nothing onto
public int $dailyTransferLimitMinor — and since an unmatched parameter with a default is not an error, the
DTO came out holding the default. No exception, no log line, no failing test, just a wrong limit in
production. This repo's own book shipped exactly that example, which is how the defect was caught.
Two details worth knowing:
- Acronyms survive. The camelCase → snake_case step breaks a lower-or-digit → upper boundary and an
acronym running into a following word, so
$apiURLbecomesapi_urland$HTTPProxyHostbecomeshttp_proxy_host— notapi_u_r_land_h_t_t_p_proxy_host, which nobody would ever type into a config file. - A present-but-null key does not stop the search.
'port' => env('MAIL_PORT')yieldsnullwhen the variable is unset — the ubiquitous Laravel idiom — so anullunder the exact name must not mask a real value written in snake_case. It is read as "not supplied", exactly asConfig::required()reads it.
A parameter with no matching key, no default and no nullable type throws a ConfigurationException naming
the property, the class, and every key that was tried.
With firefly/config installed, #[Value] injection resolves against config first, then the environment,
then the default:
final class Mailer
{
public function __construct(
#[Value('${mail.host:localhost}')] public readonly string $host,
) {}
}firefly/config binds its ConfigValueResolver over firefly/container's default resolver; because the
container registrar only registers its default if none is bound, config-backed resolution wins.