hpwebdeveloper/laravel-env-settings
A small Laravel package for managing environment settings, letting you read and update .env values from your application instead of editing files by hand. Useful for admin-configurable options and simpler deployments.
hpwebdeveloper/laravel-env-settings is a small, focused package that answers one question well: where do non-secret values that differ per environment live? The answer it enforces is "in a committed, typed PHP class", not "in .env, in a .env.staging file someone pasted into Slack, or in config/ with a ternary on app()->environment()". If your .env has grown past twenty keys and half of them are URLs, model names, timeouts and feature flags, this package is a genuine structural improvement — those values become greppable, diffable in PRs, and typed enough that a typo fails at parse time.
The whole onboarding is four commands and one line of reading code.
composer require hpwebdeveloper/laravel-env-settings
php artisan vendor:publish --tag=env-settings-config
php artisan env-settings:make PaymentSettings \
--properties="base_url:string,timeout:int,sandbox:bool"
The generator writes app/Settings/PaymentSettings.php with // TODO: set development value placeholders in each factory and, because the config is published, appends the class to the register array for you.
Look here first, in this order:
config/env-settings.php — four keys matter: register, environment_map, fallback_environment, override_path. If a class "isn't working", it is almost always missing from register.app/Settings/ — the classes themselves. This is the directory reviewers will actually read.php artisan env-settings:show --all — the fastest way to understand an existing install, and the fastest way to spot an unfinished one.First use case: an outbound integration whose base URL and timeout differ between local, staging and production, but whose API key does not. Build PaymentSettings with base_url, timeout, sandbox; leave the key in .env and read it via config('services.payment.key'). Fill in development() and production() immediately — an unfilled factory is the package's main failure mode, and env-settings:check exists precisely to catch it.
Then read it with the typed helper, not a factory call:
$url = envSettings(PaymentSettings::class)->base_url; // full IDE autocomplete
The payoff compounds: one class per concern, a reviewable record of how environments differ, and config:cache safety because everything resolves through app()->environment() rather than env().
Three supported reads, in descending order of preference:
envSettings(PaymentSettings::class)->domain; // template-typed helper
public function __construct(private PaymentSettings $auth) {} // injection; it's a singleton
app(PaymentSettings::class)->timeout; // container
new PaymentSettings(...) and PaymentSettings::production() in application code are anti-patterns — they bypass environment resolution and local overrides, which is the same class of bug as calling env() outside config/. Direct factory calls are legitimate in exactly one place: tests.
Three patterns do most of the work.
Invariants get constructor defaults, not a repeated argument. If a property is identical in every factory, it belongs in the signature once:
public function __construct(
public string $provider, // varies by env
public float $temperature = 0.2, // same everywhere
) {}
public static function development(): static { return new static(provider: 'ollama'); }
public static function production(): static { return new static(provider: 'openai'); }
public static function staging(): static { return new static(provider: 'openai', temperature: 0.9); }
env-settings:show --all names every property still repeated in every factory, so this isn't a style preference you have to police in review — the tool reports it. env-settings:make --shared="currency=EUR" generates the shape for you.
Closed sets become enums. Type the property as a PHP enum instead of string; a typo then fails at parse time and the valid set is documented by the type. The enum defines which values are possible; the factories decide which one each environment uses. Never put per-environment values inside the enum — they'd become invisible to show, diff, masking and local overrides. toArray() unwraps backed enums to their value and pure enums to their case name, so JSON serialisation keeps working.
Non-standard APP_ENV values are declared on the class. If APP_ENV is qa, uat or demo, don't ask every consumer to edit environment_map — mark the factory:
#[Environment('production', 'prod')]
#[Environment('demo')]
public static function production(): static { ... }
#[Environment('qa', 'uat')]
public static function qualityAssurance(): static { ... } // method name need not match
This is the right call for settings classes shipped in a package or shared across applications, and it keeps the answer next to the code rather than in an invisible config file.
The per-developer escape hatch, and the reason the classes aren't final:
ENV_SETTINGS_OVERRIDE=true in .env.app/Settings/Overrides/PaymentSettings.php extending the base class, overriding only the factories you need — never the constructor.app/Settings/Overrides/ to .gitignore.This is a better dev experience than the .env alternative it replaces: a developer pointing their local build at a staging API writes ten lines of PHP with autocomplete, instead of a stringly-typed env var whose valid values they have to guess.
With many classes, add a root whose factories call the children's factories:
public static function development(): static
{
return new static(auth: AuthSettings::development(), payment: PaymentSettings::development());
}
Then envSettings(AppSettings::class)->auth->domain. Nested classes are compared by value — show --all and diff mark the parent row * when anything inside differs.
public function __construct(
public string $webhook_url,
#[Sensitive] public string $passphrase,
#[AllowEmpty] public string $path_prefix,
) {}
#[Sensitive] masks the value in show and diff. #[AllowEmpty] tells env-settings:check that a blank value in this environment is deliberate.
env-settings:check --env=production is a static check — no network, no production credentials — so it belongs in CI next to the test suite, not in a deploy hook:
- name: Check production settings are complete
run: php artisan env-settings:check --env=production
A value counts as incomplete when it equals its placeholder ('', 0, 0.0, false, [], null) and another environment supplies a real value — the fingerprint of one factory being filled in while another was forgotten. Empty in every environment is treated as deliberate. Any string containing TODO always fails.
php artisan env-settings:show --all
php artisan env-settings:diff "App\Settings\PaymentSettings" development production
show --all is the one to reach for when the question is "how do these environments differ?" — diff answers it for exactly two, so four environments would take six runs and a spreadsheet.
Because registered classes are container singletons, rebind them for a test:
$this->app->instance(PaymentSettings::class, new PaymentSettings(
base_url: 'https://sandbox.example.test',
timeout: 30,
sandbox: true,
));
Or, for a unit test of the class itself, call PaymentSettings::production() directly and assert on the result — that's what the factories are for.
.env, config/ and a settings class| Value | Home |
|---|---|
| API keys, passwords, tokens | .env, read via config() |
Stock Laravel keys (DB_*, MAIL_*) |
.env |
| Non-secret, same in every environment | Constructor default in the settings class |
| Non-secret, differs per environment | Factory in the settings class |
Non-secret, differs per environment, needed inside config/ |
Edge case — keep it in the class and inject |
The last row is worth being deliberate about: moving a property out to config/ so some third-party package can read it splits one cohesive object across two places and breaks envSettings(...)->temperature. Do it only when a package's config() contract forces it.
Masking is cosmetic, and the fallback guesses in both directions. A #[Sensitive] property is masked whatever it holds — fine. But unmarked string properties whose names contain key, secret, password or token are masked too, which means key_rotation_days (a string) hides a perfectly public value, while passphrase or auth_token_value is caught but creds or salt is not. Mark explicitly and name properties deliberately; treat the heuristic as a safety net for legacy classes, not a design.
Never put a secret in a settings class. It is committed to git. Masking only affects show/diff output — the value is still in the repository, in every developer's clone, in CI logs if someone dumps toArray(), and in the class constructor's default. If you catch one in review, the fix is a history rewrite, not an attribute.
Rotating a non-secret value now requires a deploy. This is the real operational trade the package asks for: a URL change that used to be an .env edit and a config:cache becomes a commit, a PR and a release. That's the point — auditability and review — but it will surprise an ops team used to hotfixing via env vars. Decide up front which values genuinely need that agility.
staging() and testing() silently inherit development(). Forgetting to override them means staging quietly talks to a dev endpoint, and nothing fails loudly. show --all lists inherited factories below the table rather than as columns, so they're easy to skim past. Check them explicitly before the first staging deploy.
Attributes beat environment_map, and first declaration wins. A leftover #[Environment('production')] on the wrong method silently redirects production, with no config-level trace. Environment names are matched case-sensitively, exactly as APP_ENV is — Production will not match. When two methods claim the same environment, the first declared takes it.
A class not in register is inert. No error, no warning — it just isn't resolved. If a class "doesn't work", check register first. The auto-append only happens when the config is published, and the command leaves an already-listed class alone whether it appears in full or as a short name behind a use import.
--path outside app/ needs --namespace. Without it the class won't autoload, and the failure will look like a container error rather than a namespace one.
Never call app_path() inside config/env-settings.php. config:cache freezes the build machine's absolute path into the cached config. Set override_path to null (→ app_path('Settings/Overrides')) or a relative path, which is resolved against app_path() at runtime.
Keep @phpstan-consistent-constructor on every class, handwritten ones included. A settings class is not final because overrides extend it; without the tag PHPStan reports new.static on every factory at every level. Corollary: overrides may change factory values but never the constructor signature.
Settings objects are mutable singletons. Nothing stops envSettings(PaymentSettings::class)->timeout = 5; from changing the value for the rest of the request — and for every other consumer of that singleton. Treat them as read-only by convention and flag mutations in review.
Empty-in-every-environment is never reported. check is silent for a property that's blank everywhere, on the theory that it's deliberate. That means a whole class whose factories were never touched passes CI cleanly. check catches the half-finished case; it does not catch the entirely-forgotten one. Pair it with a review of show --all in the PR that adds a class.
Enum typo protection only applies at parse time. An enum property is validated when the factory constructs the object, which happens at resolution — so a bad enum value in a rarely-hit branch surfaces at runtime on the environment that uses it, not at boot. env-settings:show --all resolves every factory, so running it locally is the cheap way to smoke-test all environments at once.
On maturity: at ~17 stars and a single maintainer, treat this as a small dependency rather than infrastructure. The mitigating factor is that the exit cost is genuinely low — the classes are plain PHP with promoted properties and static factories, and the runtime is a thin resolver over app()->environment(). If the package is abandoned, you delete the composer requirement, keep app/Settings/ unchanged, and write roughly fifty lines of container binding plus the helper. That asymmetry — real day-to-day benefit, cheap abandonment — is what makes it a reasonable adoption at this size. Weigh it against the alternative honestly: a hand-rolled Settings class per concern plus a small service provider gets you 80% of this, minus the show/diff/check commands, which are the part you'd actually miss.
How can I help you explore Laravel packages today?