Enums in laravel-env-settings: Typo-Proof, Not Environment-Aware
A case study in
laravel-env-settings, a Laravel package for typed, environment-aware configuration. Enums are the newest thing you can build a setting out of: they make an invalid value impossible to write. What they can't do is know which environment they're in — and the tempting shortcut of putting per-environment values inside the enum hides those values from every tool you own. Here's where that line falls, and how the two fit together.
- Enums, walked through: /enums
- Environment resolution: /environments
- Live demo: laravel-env-settings.hpweb.dev
- Working with an AI agent? The package ships an AI assistant skill that teaches Claude Code, Cursor and Codex its conventions.
Table of Contents
- Enums in Two Minutes
- What Enums Fix: The Stringly-Typed Setting
- Why an Enum Is Not Environment-Aware
- The Division of Labour
- Typing a Setting with an Enum
- One Enum, Every Surface
- The Pure Enum Trap
- Generating the Class
- When an Enum Is the Wrong Choice
- What This Buys You
- Appendix: The Legal Way to Do It Wrong
1. Enums in Two Minutes
PHP 8.1 added enums, and they come in two flavours.
A pure enum is a closed set of named cases with nothing behind them:
enum Tier
{
case Low;
case High;
}
A backed enum gives each case a scalar value — string or int:
enum PaymentMode: string
{
case Live = 'live';
case Sandbox = 'sandbox';
}
Three properties matter for what follows:
A case is a value of the enum's type. PaymentMode::Live is not a string that looks like an enum — it is an object of type PaymentMode, and there is exactly one of it per case. Identity comparison with === is exact and cheap.
The type enforces the set. Type a parameter or property as PaymentMode and the engine will not let anything else through. There is no validation to write.
match over an enum is exhaustive. Miss a case and you get an UnhandledMatchError rather than a silent fall-through, which turns "we added a third mode and forgot to handle it somewhere" from a production incident into an error.
Backed enums also give you PaymentMode::from('live') and tryFrom('live') for crossing the boundary from untrusted input — useful, but not what this article is about.
One more property is worth knowing, because it shows how much an enum can carry beyond a list of names: cases() returns them in declaration order, so the order itself can be meaningful. In laravel-pay-pocket, a multi-wallet package of mine, that is exactly how wallet priority is expressed:
enum WalletEnums: string
{
case WALLET1 = 'wallet_1';
case WALLET2 = 'wallet_2';
}
The first case is the highest-priority wallet, so paying 15 against balances of 10 and 20 drains wallet_1 first and takes the remaining 5 from wallet_2. Reordering the cases reorders the deduction. The enum is the configuration — no priority column, no sort field, no comparator to keep in sync.
That's the ceiling of what an enum does well: a closed set, typed, ordered, enforced by the engine. Now the limitation.
2. What Enums Fix: The Stringly-Typed Setting
In laravel-env-settings, a settings class declares its properties in a constructor and defines one static factory per environment. Here is the payment example with its properties written the ordinary way:
public function __construct(
public string $mode, // 'live'? 'production'? 'LIVE'? 'sandbox'?
public int $retries,
) {}
The type says string, which is true and useless. The set of values that actually work exists in a docblock, a wiki page, or somebody's memory. Every one of these compiles, deploys, and passes CI:
mode: 'sandbx', // typo
mode: 'Live', // wrong case
mode: 'test', // used to be the name, changed two releases ago
mode: 'production', // reasonable guess, not a value we accept
None of them fail where they were written. They fail later — at the payment gateway, in production, possibly on the first real transaction.
Now type it with the enum:
public function __construct(
public PaymentMode $mode,
public int $retries,
) {}
PaymentMode::Sandbx does not exist. That's not a runtime check that happens to catch it — the symbol isn't there, so the error arrives while you're writing the line, from your IDE, before anything runs. And because your editor can enumerate the cases, you're picking from a list rather than recalling a string.
The same protection extends to the consuming end. $settings->mode === 'live' is a comparison that can be wrong in four ways; $settings->mode === PaymentMode::Live can only be right or a type error.
That's the whole pitch for enums as setting types: the valid set stops being documentation and becomes the type.
3. Why an Enum Is Not Environment-Aware
Now the part worth being careful about, because the next idea is genuinely tempting.
Enums in PHP can have methods. So if the enum already knows about Live and Sandbox, why not let it hold the values that go with them?
// Tempting. Don't.
enum PaymentMode: string
{
case Live = 'live';
case Sandbox = 'sandbox';
public function retries(): int
{
return match ($this) {
self::Live => 3,
self::Sandbox => 1,
};
}
public function webhookUrl(): string
{
return match ($this) {
self::Live => 'https://app.example.com/webhooks/payments',
self::Sandbox => 'http://localhost:8000/webhooks/payments',
};
}
}
This looks tidy, and it is wrong in a specific way that's worth naming.
An enum is a type, and its cases are compile-time constants. There is exactly one set of them per process, identical in every environment. An enum has no notion of APP_ENV, no way to acquire one, and no hook that would let it resolve differently per deployment. Whatever you write inside it is the same everywhere.
So the match above isn't environment awareness. It's a proxy for it — you're using "which mode are we in" as a stand-in for "which environment are we in", and the two only coincide until they don't. The day staging needs live mode with one retry, the abstraction has nowhere to put that.
But the deeper cost isn't conceptual, it's operational. Configuration placed inside an enum becomes invisible to every tool you have:
| Tool | Why it can't see it |
|---|---|
env-settings:show | reflects over public properties; an enum method isn't one |
env-settings:diff | can't compare values it has no way to enumerate |
#[Sensitive] masking | the attribute is read from properties, not enum methods |
| Local override classes | enums are implicitly final — nothing can extend one |
That last row is a hard technical fact rather than a design preference. PHP enums cannot be extended, so the override mechanism — which works by subclassing a settings class and redeclaring a factory — structurally cannot reach inside one. Move a value into an enum and you have removed the ability to override it locally, permanently, for every developer on the team.
The rule I'd state it as: an enum answers "what values are legal." It cannot answer "which value does this environment use." Those are different questions, and answering the second one inside an enum puts the answer somewhere nothing can inspect.
4. The Division of Labour
Keep the two questions separate and everything lines up:
| Question | Answered by |
|---|---|
| Which values are legal? | the enum |
| Which value does this environment use? | the settings class factory |
| What is actually resolved right now? | env-settings:show |
| How do staging and production differ? | env-settings:diff |
| Can I change it just on my machine? | an override class extending the settings class |
The enum defines what is possible. The settings class chooses what each environment uses. Every per-environment value stays in the factories, where the resolver selects it and the commands can read it.
5. Typing a Setting with an Enum
In practice this is two files. Declare the enum once:
<?php
declare(strict_types=1);
namespace App\Enums;
enum PaymentMode: string
{
case Live = 'live';
case Sandbox = 'sandbox';
}
Then type the property with it and pick a case per environment:
<?php
declare(strict_types=1);
namespace App\Settings;
use App\Enums\PaymentMode;
use HpWebDeveloper\LaravelEnvSettings\EnvironmentSettings;
class PaymentSettings extends EnvironmentSettings
{
public function __construct(
public PaymentMode $mode,
public int $retries,
) {}
public static function development(): static
{
return new static(
mode: PaymentMode::Sandbox,
retries: 1,
);
}
public static function production(): static
{
return new static(
mode: PaymentMode::Live,
retries: 3,
);
}
}
Read it back and you get the case itself, not a string — so the call site can branch exhaustively:
envSettings(PaymentSettings::class)->mode; // PaymentMode::Live
envSettings(PaymentSettings::class)->mode->value; // 'live'
$client = match (envSettings(PaymentSettings::class)->mode) {
PaymentMode::Live => $gateway->live(),
PaymentMode::Sandbox => $gateway->sandbox(),
};
Two things happened there that are easy to miss. There is no string comparison anywhere in the chain, from declaration to usage. And that match will refuse to compile past a new case being added to the enum without being handled — so adding PaymentMode::Dispute tomorrow surfaces every place that needs updating, instead of silently taking the wrong branch.
6. One Enum, Every Surface
One declaration, and each surface does the right thing without further configuration.
env-settings:show — the Type column names the enum, so the contract is visible in the console:
| mode | App\Settings\PaymentMode | live |
env-settings:diff — backed enums render their ->value, so the comparison stays readable:
| mode * | sandbox | live |
toArray() — a JSON-safe primitive, ready to serialise:
{ "mode": "live" }
Your own code — you still hold the enum, with autocomplete and an exhaustive match:
match ($payment->mode) {
PaymentMode::Live => $gateway->live(),
PaymentMode::Sandbox => $gateway->sandbox(),
};
That last one is the point of the arrangement. The console and the JSON payload get a plain primitive, because that's what they need. Your application code gets the object, because that's what's safe to branch on. Nothing in between is doing string conversion by hand.
You can drive all four of these interactively at the bottom of the live demo, switching between a backed and a pure enum and watching each surface change.
7. The Pure Enum Trap
This is the detail I'd most want to know before typing a setting with an enum, and it has nothing to do with the package — it's a property of PHP.
Take a pure enum, with no backing value:
enum Tier
{
case Low;
case High;
}
Now serialise something holding one:
json_encode(['tier' => Tier::High]); // false
Not an exception. Not a partial payload. false. The error message, if you go looking for it with json_last_error_msg(), is "Non-backed enums have no default serialization" — but nothing throws, and nothing points at which property caused it.
Think about what that means in a real application. One pure-enum property, anywhere in a nested settings tree, turns an entire API response, health-check payload, or cache write into false. Silently. The bug report you get is "the endpoint returns nothing."
The package handles this in toArray() rather than leaving it to json_encode. Values are unwrapped before they're returned — a BackedEnum becomes its ->value, a UnitEnum becomes its ->name, and the same treatment applies recursively inside arrays and nested settings objects. So the payload survives:
envSettings(TierSettings::class)->toArray(); // { "tier": "High" }
And the commands behave sensibly too, falling back to the case name:
env-settings:show | tier | App\Settings\Tier | High |
env-settings:diff | tier * | Low | High |
Practical guidance from this: prefer backed enums for settings. A backed enum has an obvious serialised form, survives any encoder, and reads identically in the console and in JSON. Reach for a pure enum only when the value genuinely never crosses a serialisation boundary — and even then, know that toArray() protects you inside the package while a raw json_encode on the object does not.
8. Generating the Class
env-settings:make accepts the type name as written, so the property comes out correctly typed:
php artisan env-settings:make PaymentSettings --properties="mode:PaymentMode,retries:int"
There's a rough edge worth stating plainly: the generator only knows sensible defaults for the scalar types, so for an enum it seeds both factories with '' and does not add the use statement. You add the import and replace the two // TODO values. It's a few seconds of work, and I'd rather the command be honest about not guessing than have it invent an import for a class it can't verify exists.
9. When an Enum Is the Wrong Choice
Enums are satisfying enough that the temptation is to type everything with one. They're the right tool under one condition: the value is one of a small, closed set that your codebase owns. Where that isn't true:
Open-ended values. A URL, a timeout, a token limit, a hostname. "One of N" was never true for these, and an enum would just be a worse string.
Sets owned by someone else. The list of currencies your payment provider supports, or the models an AI vendor offers, changes on their schedule, not yours. An enum makes each addition a code change in your repository — sometimes that's exactly what you want for review purposes, and sometimes it's a chore you didn't sign up for. Decide deliberately.
Anything that must change without a deploy. That's .env territory, and it's the same line the package draws for its own settings: use the environment for what must change without shipping code, and typed classes for what should be reviewed before it changes.
A genuine binary. bool $sandbox_mode is honest for on/off. The moment there's a plausible third state, though — Off, On, DryRun — an enum is the better shape, and converting a bool to an enum later is more churn than starting with one.
10. What This Buys You
Three layers, each doing the one thing it's good at:
- The enum makes the invalid value unwritable. A typo is caught by your editor, not by a payment gateway.
- The settings class factory decides which legal value each environment uses, in code that ships, reviewed in a pull request.
- The commands show what's actually resolved and how environments differ, because the value is a property on an object rather than a branch buried in a method.
Put the environment choice in the enum and you keep the first layer while destroying the other two — no diff, no masking, no local override, and no way to extend it later, because enums are final. Keep them separate and the layers compose: configuration that can't be mistyped, and that you can still inspect, compare, mask, and override.
The general shape of this is the same one I keep arriving at with this package, and it's the same conclusion as the attributes article: put each fact in the place where both a human reading the code and a tool reading reflection can find it. An enum is the right home for "these are the legal values." It is the wrong home for "this is what production does."
11. Appendix: The Legal Way to Do It Wrong
For completeness — PHP will absolutely let you make an enum environment-aware. Enums can have static methods, and a static method can call anything:
enum PaymentMode: string
{
case Live = 'live';
case Sandbox = 'sandbox';
public static function forCurrentEnvironment(): self
{
return app()->isProduction() ? self::Live : self::Sandbox;
}
}
PaymentMode::forCurrentEnvironment(); // works, runs, ships
This is valid PHP and it will pass review if nobody looks twice. It is also an abuse of the construct, for one reason: a type whose value depends on which machine it runs on is not a type, it's a service wearing a type's clothes. PaymentMode::Live is a constant; PaymentMode::forCurrentEnvironment() is a runtime lookup that now requires a booted framework to evaluate — so the enum can't be reasoned about statically, can't be exercised in a unit test without the container, and can't be substituted for a fake, because enums are final.
Everything from section 3 still applies on top of that: the decision is in a method body, so show, diff, masking, and overrides can't see it.
The mechanism exists. It just isn't what enums are for.
More on the package in my earlier article on laravel-env-settings, or straight to the code on GitHub.