Your .env Files Aren't DRY. One Copy Will Drift.

There is a category of configuration value that is the same in every environment — a currency code, a model temperature, a retry ceiling. It is not a secret and it does not vary, yet it has been copied into local, staging and production, and it is maintained by hand in all three. Nothing enforces that those copies agree. Sooner or later one of them won't, and you will spend an afternoon working out why staging behaves differently. Here is how one PHP constructor default removes that whole category of value from every .env you maintain, so each environment states only what makes it different.


Table of Contents

  1. The Duplication You Stopped Noticing
  2. Why Duplication Becomes Drift
  3. Seeing It First
  4. Declaring the Invariant Once
  5. The Diff Becomes Signal
  6. Adding an Environment Gets Cheap
  7. The Factory Starts Documenting Intent
  8. What This Does to Your Pipeline
  9. When Not to Share a Value
  10. The Rule

1. The Duplication You Stopped Noticing

Take a payment integration. Five environments — local, dev, staging, production, and a demo box for sales. The configuration looks something like this:

# .env on every single one of them
PAYMENT_CURRENCY=EUR
PAYMENT_RETRY_CEILING=5
PAYMENT_ROUNDING_MODE=half_up
PAYMENT_MODE=sandbox        # ← the only line that actually differs

Three of those four lines are identical everywhere and always will be. The currency isn't going to be different in staging. The rounding mode is a property of how your business does arithmetic, not of which server is running.

And yet you are maintaining three literals across five machines. Fifteen copies of three decisions.

The same thing happens after you move configuration into typed classes, if you're not paying attention. Every factory restates every value:

public static function development(): static
{
    return new static(mode: 'sandbox', currency: 'EUR', retry_ceiling: 5, rounding: 'half_up');
}

public static function staging(): static
{
    return new static(mode: 'sandbox', currency: 'EUR', retry_ceiling: 5, rounding: 'half_up');
}

public static function production(): static
{
    return new static(mode: 'live', currency: 'EUR', retry_ceiling: 5, rounding: 'half_up');
}

Read those three methods and try to answer, quickly: what does production do differently? You can work it out, but you have to compare twelve values character by character to find the one that changed. That's the reader's problem. The next section is the maintainer's.


2. Why Duplication Becomes Drift

Here is the part that makes this more than a tidiness argument.

Duplication is the precondition for drift. A value that exists in one place cannot disagree with itself. A value that exists in five places is five things that are currently equal, held that way by nothing but everyone remembering to update all five.

So the failure isn't hypothetical, it's scheduled:

  • Finance asks to raise the retry ceiling. You update production and local. Staging keeps the old number.
  • Six weeks later, staging behaves differently in a way nobody can explain, because everyone's mental model says staging and production are the same except for the mode.
  • The debugging session that follows is expensive, because you are looking for a bug in the code. There is no bug in the code. There is a number that two files disagree about.

Notice what makes this class of incident so annoying: it isn't an error. No exception, no failing test, no alert. Both values are individually valid. The system is behaving exactly as configured — it is just configured in a way nobody chose.

And in .env form, it's unfindable by ordinary means. You cannot diff two gitignored files that live on different machines without going and getting them.


3. Seeing It First

You can't DRY what you can't see, so the first step is a full picture. env-settings:show --all prints every environment side by side:

[ PaymentSettings ] — all environments

+----------------+------------+------------+------------+
| Property       | development| staging    | production |
+----------------+------------+------------+------------+
| mode *         | sandbox    | sandbox    | live       |
| currency       | EUR        | EUR        | EUR        |
| retry_ceiling  | 5          | 5          | 5          |
| rounding       | half_up    | half_up    | half_up    |
+----------------+------------+------------+------------+
* = differs between environments

That output is the whole diagnosis in one glance. One property earns its place in the factories. Three are being copied for no reason.

This matters more on real settings classes than on a four-property example. Run it against a class with fifteen properties across four environments and you will typically find that two or three actually vary — and that you have been maintaining forty-odd literals to express three decisions.


4. Declaring the Invariant Once

The fix is ordinary PHP, and it is worth noting that the language gave us this for free: a constructor default.

class PaymentSettings extends EnvironmentSettings
{
    public function __construct(
        public string $mode,
        public string $currency = 'EUR',
        public int $retry_ceiling = 5,
        public string $rounding = 'half_up',
    ) {}

    public static function development(): static
    {
        return new static(mode: 'sandbox');
    }

    public static function staging(): static
    {
        return new static(mode: 'sandbox');
    }

    public static function production(): static
    {
        return new static(mode: 'live');
    }
}

Now read those three factories again and answer the question from section 1. It takes no effort at all, because the only thing written in a factory is the thing that differs.

The shared values are declared exactly once, in the constructor signature — which is also the place a reader already has to look to know what the class holds. Nothing was hidden; it moved to where it belongs.

For new classes, v1.7.0 generates this shape directly:

php artisan env-settings:make PaymentSettings \
    --properties="mode:string,currency:string,retry_ceiling:int,rounding:string" \
    --shared="currency=EUR,retry_ceiling=5,rounding=half_up"

Anything listed in --shared becomes a constructor default, and the generator moves those parameters to the end of the signature for you — because PHP requires defaulted parameters to come last, and getting that ordering wrong is a parse error rather than a warning. Scalars only; an array or enum default is rejected with a message rather than generating something that won't compile.

And the drift problem is gone. Not reduced — gone, structurally. There is one EUR in the codebase. It cannot disagree with itself.


5. The Diff Becomes Signal

The second-order effect is the one I didn't anticipate when I built this, and it turned out to be the one I appreciate most day to day.

Before, comparing two environments produced mostly noise:

| mode *         | sandbox | live |
| currency       | EUR     | EUR  |
| retry_ceiling  | 5       | 5    |
| rounding       | half_up | half_up |

Three of four rows are there to tell you nothing happened. Your eye has to find the asterisk.

Afterwards, the shared values aren't in the factories, so the comparison is about the decisions:

php artisan env-settings:diff PaymentSettings staging production
| mode * | sandbox | live |

A one-row answer to a one-row question. When a tool's output is 100% signal, people actually read it — and a diff people read is a diff that catches mistakes.

The same applies to code review. A pull request that switches production to a different provider now shows one changed line instead of a changed line buried in a block of unchanged context. Reviewers approve what they can see.


6. Adding an Environment Gets Cheap

This is where the duplicated version quietly taxes you the most.

Adding a qualityAssurance() factory to the duplicated class means writing every property again, correctly, from memory or by copy-paste — and copy-paste is exactly how a stale value gets carried into a new environment on day one.

With the invariants in the constructor, a new environment is a single decision:

#[Environment('qa', 'uat')]
public static function qualityAssurance(): static
{
    return new static(mode: 'sandbox');
}

Currency, retry ceiling and rounding are inherited from the signature, guaranteed identical to every other environment, with no opportunity to typo them. The #[Environment] attribute maps qa and uat onto this factory without touching any config file — more on that here.

Scaling from three environments to six used to multiply your literals. Now it doesn't.


7. The Factory Starts Documenting Intent

There is a subtle expressiveness gain here that's easy to miss.

In the duplicated version, every value in production() looks like a deliberate production choice — because it's written there, explicitly, by someone. You cannot tell the difference between "production uses EUR because it was decided" and "production uses EUR because everything does."

After the refactor, those two statements look different in the code:

  • A value in the constructor signature says: this is a property of the application.
  • A value in a factory says: this environment deliberately differs.

That distinction didn't exist before, and it's genuinely useful. When you open production() and see one line, you have learned something you previously had to derive by comparing files. The code now carries the intent, not just the data.


8. What This Does to Your Pipeline

The DRY refactor is what makes the CI story work, so it's worth putting them together.

Fewer literals means fewer places to leave a mistake. Every value in a factory is a value someone can leave as a placeholder. The generator seeds new properties with // TODO markers precisely because incomplete configuration is a real deployment risk — and the fastest way to reduce that risk is to have fewer values that need filling in per environment.

A build gate that actually verifies it. env-settings:check fails on incomplete configuration, so it belongs in your pipeline:

php artisan env-settings:check --env=production

It catches the class of bug where a factory shipped with domain: '' and a // TODO beside it — something that currently deploys green and gets discovered days later by a customer. Where a value is intentionally empty, #[AllowEmpty] says so explicitly, so the check stays strict everywhere else instead of being weakened for one exception.

No per-environment provisioning for shared values. Every value that moves from .env into a constructor default is one fewer entry in your deployment templates, your secret manager, your Docker compose files, your onboarding document, and your .env.example. That's a real reduction in deployment surface, not a stylistic preference — those are all places a value can be missing or wrong.

CI can assert what production is configured to do. Because the values are in the repository, this is an ordinary test:

$this->assertSame('live', PaymentSettings::production()->mode);
$this->assertSame('EUR', PaymentSettings::production()->currency);

That test runs on every commit, without a database, without production access, without a .env. There is no equivalent when the values live on a server.

Nothing to cache, nothing to break. Resolution reads APP_ENV through app()->environment() and config(), never env(), so php artisan config:cache is safe — the trap where env() returns null in production simply cannot occur here.

Test doubles stay small. Since v1.5.0, fake(), from() and with() let a test state only the properties it cares about instead of restating the whole constructor — the same DRY principle applied to the test suite:

PaymentSettings::fake(['mode' => 'live']);

9. When Not to Share a Value

The refactor is not universal, and applying it too eagerly creates a different problem.

Don't share a value that only happens to match right now. If staging and production both use retry_ceiling: 5 but for unrelated reasons, and either could legitimately change alone, leave it in the factories. A constructor default is a statement that the value is a property of the application — making that statement falsely is worse than a bit of duplication, because the next person will believe it.

The test: would changing this for one environment only be a normal thing to do? If yes, it belongs in the factories, even if the values currently agree.

Don't share something that should be in .env. A constructor default is compiled into the class, so changing it needs a deployment. If a value must change without shipping code, it was never a candidate.

Don't share a secret. Same as always — these classes are committed to your repository. Secrets stay in .env or your platform's secret store.

Watch the parameter ordering. PHP requires defaulted parameters last. That constrains the order of your constructor, so if you have a strong grouping preference for how properties read, the two goals can conflict. --shared handles the ordering when generating; retrofitting by hand means moving parameters, and named arguments in the factories mean you can do so safely.


10. The Rule

Two questions, in order:

1. Does this value differ between any two environments? No → it is a property of your application. Declare it once, as a constructor default. Yes → it belongs in the factories, where the difference is visible.

2. Could it legitimately differ in future, for one environment alone? Yes → keep it in the factories even if every environment currently agrees. You are documenting that it is allowed to vary.

Run env-settings:show --all against your largest settings class and see how many properties are being copied for nothing. On most classes I've refactored it's the majority — and the payoff isn't the deleted lines. It's that the remaining ones all mean something, your diffs are readable, adding an environment stops being a copy-paste exercise, and a whole category of "why is staging different?" incident stops being possible.


The DRY refactor above is v1.7.0 of laravel-env-settings. If you're new to the package, start here, or try it in the browser on the live demo.