laravel-env-settings: Typed, Environment-Aware Config Instead of a Bloated .env
Open your
.envand count the lines that are genuinely secret. In most Laravel applications it's a small minority — the rest is model names, service URLs, timeouts, and mode switches that nobody reviews, nothing type-checks, and no one can compare across environments without SSHing into two servers. This is what changes when you move all of that into code instead.
- Created and maintained by: Hamed Panjeh
- Package: github.com/HPWebdeveloper/laravel-env-settings
- Live demo: laravel-env-settings.hpweb.dev
- Setup guide: getting-started
- Interactive: try-it
- Agent skill: laravel-env-settings-skills — also listed in the Laravel skills registry at skills.laravel.cloud
Table of Contents
- The Problem
- What Counts as a Secret
- Before and After
- The Onboarding Problem
- How Resolution Works
- A Settings Class from Start to Finish
- Composing a Root Settings Object
- Local Overrides Without Touching Committed Code
- The Artisan Commands
- Testing Settings
- Why Not Just Use env and config
- What This Package Is Not
- Does This Break 12-Factor
- A Real Example: AI and LLM Settings
- Where to Go Next
1. The Problem
Open the .env file of any Laravel application that has been in production for a while and count how many of those lines are actually secret.
In my experience it's a small minority. The rest looks like this:
OPENAI_TEXT_MODEL=gpt-4o
OPENAI_MAX_TOKENS=8000
PAYMENT_MODE=live
PAYMENT_CURRENCY=USD
BILLING_SERVICE_URL=https://billing.internal
INVENTORY_SERVICE_URL=https://inventory.internal
NOTIFICATION_RATE_LIMIT=200
API_TIMEOUT=10
SMS_PROVIDER=vonage
Not one of those is a credential. Knowing that production runs gpt-4o with an 8000-token ceiling grants an attacker exactly nothing. Yet because they live in .env, they inherit every property of a secret and none of the benefits of code:
- Invisible in code review. Nobody opens a pull request to change a model name or a timeout — someone SSHes in, edits a file, and the decision leaves no trace.
- Untyped. Everything read out of
.envis a string.PAYMENT_RETRY_ATTEMPTS=5is the string"5", andSANDBOX_MODE=falseis the string"false", which is truthy. - Undocumented. There is no single place that says what staging uses versus production. You find out by reading two servers.
- Fragile after caching.
env()returnsnulloncephp artisan config:cachehas run outside of the config files themselves. This bites people in production, at the worst possible time. - Drift-prone. Five environments times forty keys is two hundred values, maintained by hand, with nothing enforcing that they stay in sync.
laravel-env-settings draws a line down the middle of that file. Secrets stay in .env. Everything your application adds on top moves into typed PHP classes that resolve automatically from APP_ENV.
2. What Counts as a Secret
The whole design depends on a clean answer to this, so the package uses one test:
Does knowing this value alone grant access to anything?
If yes, it's a secret and belongs in .env. If no, it's an infrastructure decision and belongs in version control.
prod-db.internal is a hostname. Without credentials it opens nothing. gpt-4o is a product name published on a pricing page. https://app.example.com/webhooks/payments is a URL your payment provider already knows.
Stays in .env | Moves to a settings class |
|---|---|
| API keys, passwords, tokens | Service URLs and hostnames |
Laravel's stock keys (APP_KEY, DB_*, MAIL_*, QUEUE_*) | Model names, providers, modes |
Anything a vendor package reads through env() | Timeouts, retry counts, rate limits |
APP_ENV itself | Feature toggles that ship with a deploy |
That last column on the left matters. Laravel's shipped config/*.php files translate .env keys into config() entries during the LoadConfiguration bootstrap step, and the core managers — database, cache, queue, mail, session — read from config() at boot. APP_ENV and APP_KEY are read earlier still, before any service provider runs. The package deliberately does not touch any of that. It targets only the configuration your application adds on top of the framework.
3. Before and After
Before — three different lookup styles, none of them typed, none of them reviewable:
$domain = config('services.auth0.domain'); // typo? runtime surprise
$model = env('OPENAI_TEXT_MODEL'); // string? null? who knows
$mode = env('PAYMENT_MODE', 'test'); // what's production's value? check the server
After — one style, fully typed, defined per environment, in git:
envSettings(AuthSettings::class)->domain // string, IDE autocomplete
envSettings(AiSettings::class)->text_model // defined per environment
envSettings(PaymentSettings::class)->mode // visible in git, reviewable in PRs
The difference isn't cosmetic. In the "after" version, changing production's model from gpt-4o to something cheaper is a diff someone reviews. Renaming a queue is a diff. Cutting a timeout from 30s to 10s is a diff. All the things that quietly caused an incident last quarter now have an author, a date, and a reviewer.
4. The Onboarding Problem
This is the pain that pushed me to build the package, and it's the one that's hardest to see until you've lived it.
A new developer joins. To get the application running locally they need a .env. So begins the ritual:
- Copy
.env.example, which is out of date because nobody updates it. - Ask in Slack which port the inventory service runs on locally.
- Get sent a
.envfragment in a direct message — including, inevitably, a couple of real credentials that should never have been pasted into a chat window. - Discover two days later that a required key was missing entirely, because the error it produced was a
nullthree layers down in a service class.
Multiply that by every environment they eventually need to understand, and by every new hire.
With settings in code, most of that disappears. The developer clones the repository and already has every non-secret value for every environment, typed, with the differences between environments sitting side by side in one file. .env shrinks to the short list of credentials that genuinely must be handed over through a proper channel.
And when they want their own local tweaks — a different port, a longer timeout because they're debugging — they get local overrides instead of editing a shared file and accidentally committing it.
5. How Resolution Works
Each settings class defines one static factory method per environment. When the package resolves a class, it:
- Reads
APP_ENV—local,staging,production, and so on - Maps it through the
environment_mapconfig, e.g.local→development - Calls the matching static method, e.g.
::development() - Returns a fully typed instance
Resolution goes through app()->environment() and config() — never env() — so it is completely safe under php artisan config:cache. That single detail removes an entire category of production bug.
The default map is configurable:
'environment_map' => [
'local' => 'development',
'dev' => 'development',
'develop' => 'development',
'staging' => 'staging',
'stage' => 'staging',
'production' => 'production',
'prod' => 'production',
'testing' => 'testing',
'test' => 'testing',
],
If APP_ENV matches no key, fallback_environment is used, which defaults to development.
Required methods: development() and production() are abstract, so every settings class must define both. You cannot forget production.
Optional methods: staging() and testing() fall back to development(). Override them only when their values genuinely differ — which keeps the noise down for classes where staging really is just development with a different URL.
Each registered class is bound as a singleton, so it resolves once and is reused for the lifetime of the request. The try-it page demonstrates this directly: whether you reach for the helper or type-hinted injection, you get the exact same object in memory — not a copy, not a new instance.
6. A Settings Class from Start to Finish
Here's the shape, using an auth example. The env-settings:make command generates this skeleton with // TODO placeholders; you fill in the values.
<?php
declare(strict_types=1);
namespace App\Settings;
use HpWebDeveloper\LaravelEnvSettings\EnvironmentSettings;
class AuthSettings extends EnvironmentSettings
{
public function __construct(
public string $domain,
public string $redirect_url,
public int $timeout,
public bool $mfa_enabled,
) {}
public static function development(): static
{
return new static(
domain: 'dev.auth.example.com',
redirect_url: 'http://localhost:8000/callback',
timeout: 30,
mfa_enabled: false,
);
}
public static function production(): static
{
return new static(
domain: 'auth.example.com',
redirect_url: 'https://app.example.com/callback',
timeout: 10,
mfa_enabled: true,
);
}
}
Supported property types: string, int, float, bool, array.
A settings class stays inert until it's listed in config/env-settings.php:
'register' => [
\App\Settings\AuthSettings::class,
],
env-settings:make appends that line for you when the config has been published. If it can't — the config isn't published, or has no register array — it tells you exactly what to add, so a class is never left silently unregistered.
Then read it however suits the call site:
// The helper — template-typed, so your IDE autocompletes the result
$domain = envSettings(AuthSettings::class)->domain;
// Constructor injection
public function __construct(private AuthSettings $auth) {}
// The container
app(AuthSettings::class)->timeout; // 10 in production, 30 in development
Notice what's missing: no environment check, no if (app()->isProduction()), no default-value juggling. The correct environment is already resolved.
7. Composing a Root Settings Object
Once an application has several settings classes, reaching for each one separately gets noisy. A root settings class fixes that: its properties are other settings classes, so the whole configuration tree hangs off one entry point.
class AppSettings extends EnvironmentSettings
{
public function __construct(
public AiSettings $ai,
public PaymentSettings $payment,
public NotificationSettings $notification,
public ExternalApiSettings $external_api,
) {}
public static function development(): static
{
return new static(
ai: AiSettings::development(),
payment: PaymentSettings::development(),
notification: NotificationSettings::development(),
external_api: ExternalApiSettings::development(),
);
}
public static function production(): static
{
return new static(
ai: AiSettings::production(),
payment: PaymentSettings::production(),
notification: NotificationSettings::production(),
external_api: ExternalApiSettings::production(),
);
}
}
Then everything is reachable from one place:
envSettings(AppSettings::class)->ai->text_model;
envSettings(AppSettings::class)->payment->mode;
Register only the root — sub-settings are plain instances built by the factory, so they need no registration of their own. Register one individually only if you also want to inject it directly:
'register' => [
\App\Settings\AppSettings::class,
],
Nothing here is automatic, and that's deliberate. The package resolves AppSettings for the current environment; from there, each factory on the root calls the matching factory on every sub-setting — production() calls AiSettings::production(), development() calls AiSettings::development(). That wiring is ordinary code you write and control. An empty constructor will not populate itself. I chose explicitness over magic here because the alternative is a configuration layer you can't reason about by reading it.
Exporting the whole tree
toArray() expands nested settings recursively, so a composed tree serialises as-is:
return response()->json(envSettings(AppSettings::class)->toArray());
{
"ai": {
"provider": "openai",
"text_model": "gpt-4o",
"embeddings_model": "text-embedding-3-large",
"max_tokens": 8000,
"temperature": 0.2
},
"payment": {
"mode": "live",
"currency": "USD",
"retry_attempts": 5,
"webhook_url": "https://app.example.com/webhooks/payments"
}
}
Useful for a debug endpoint, a health-check payload, or handing resolved configuration to a frontend. Nesting isn't limited to one level — a sub-setting can compose children of its own the same way. The demo homepage prints exactly this tree for whichever environment it's running in.
8. Local Overrides Without Touching Committed Code
Shared config in version control raises an obvious objection: what if one developer needs different values locally? Editing the committed class means either polluting everyone's setup or living in fear of git add ..
Overrides solve it. Enable them in .env:
ENV_SETTINGS_OVERRIDE=true
Then create app/Settings/Overrides/AuthSettings.php, extending the base class and overriding only the factories you care about:
<?php
namespace App\Settings\Overrides;
use App\Settings\AuthSettings as BaseAuthSettings;
class AuthSettings extends BaseAuthSettings
{
public static function development(): static
{
return new static(
domain: 'my-custom-domain.local',
redirect_url: 'http://localhost:9000/callback',
timeout: 60,
mfa_enabled: false,
);
}
}
And ignore the directory:
app/Settings/Overrides/
When overrides are enabled and the file exists, the override class is used instead of the base. When they're disabled or the file is absent, the base class is used as normal — so production is never affected by a mechanism that only exists for local convenience.
The location is configurable:
'override' => env('ENV_SETTINGS_OVERRIDE', false),
'override_path' => null,
'override_namespace' => 'App\\Settings\\Overrides',
override_path | Resolves to |
|---|---|
null (default) | app_path('Settings/Overrides') |
'Custom/Overrides' | app_path('Custom/Overrides') |
'/mnt/shared/overrides' | used as-is |
One deployment detail worth internalising: prefer a relative path over calling app_path() in the config file. config:cache evaluates each config file once and writes the result to bootstrap/cache/config.php, freezing the absolute path as it was when the cache was built. Anywhere the app runs from a different directory than the build — Docker multi-stage builds, CI-built artifacts, per-release deploy directories — that path no longer exists, and override lookup silently falls back to the base class. A relative path carries no build-time location, so override_path is resolved at runtime instead.
9. The Artisan Commands
Three commands, and two of them are inspectors rather than generators — which is the point. Configuration you can't inspect is configuration you don't trust.
env-settings:make
# Basic
php artisan env-settings:make NotificationSettings
# With typed properties
php artisan env-settings:make NotificationSettings \
--properties="sms_provider:string,rate_limit_per_minute:int,sandbox_mode:bool"
# Custom path — namespace follows the directory
php artisan env-settings:make NotificationSettings --path=app/Settings/Infrastructure
# Explicit namespace, for directories outside the application root
php artisan env-settings:make NotificationSettings \
--path=packages/billing/src/Settings --namespace="Acme\\Billing\\Settings"
A generated class only autoloads if its namespace matches where the file was written, so the namespace is resolved in a defined order: an explicit --namespace wins; otherwise it's derived from --path when that directory sits under the application root (reading your application's own PSR-4 mapping, so a renamed app root still maps correctly); otherwise it falls back to config('env-settings.class_namespace').
| Command | Namespace |
|---|---|
env-settings:make FooSettings | App\Settings |
--path=app/Settings/Infrastructure | App\Settings\Infrastructure |
--path=app/Modules/Billing/Settings | App\Modules\Billing\Settings |
--path=packages/billing/src | App\Settings + warning |
--path=packages/billing/src --namespace="Acme\Billing" | Acme\Billing |
A path outside the application root has no PSR-4 mapping the command can read, so it falls back to the configured default and warns you. Pass --namespace in that case.
env-settings:show
What is this environment actually running?
php artisan env-settings:show # all registered classes
php artisan env-settings:show "App\Settings\AuthSettings" # one class
[ AuthSettings ] — Environment: production
+--------------+--------+----------------------------------+
| Property | Type | Value |
+--------------+--------+----------------------------------+
| domain | string | auth.example.com |
| redirect_url | string | https://app.example.com/callback |
| timeout | int | 10 |
| mfa_enabled | bool | true |
+--------------+--------+----------------------------------+
Properties whose names contain key, secret, password, or token are masked with ********. That's a safety net, not a feature — secrets belong in .env, not in a settings class.
env-settings:diff
The command I reach for most. It answers "what's different between staging and production?" in one line, which is a question that used to require two SSH sessions.
# Fully specified
php artisan env-settings:diff "App\Settings\AuthSettings" development production
# Omit any argument and you'll be prompted for it
php artisan env-settings:diff
[ AuthSettings ] — Comparing development vs production
+----------------+--------------------------------+----------------------------------+
| Property | development | production |
+----------------+--------------------------------+----------------------------------+
| domain * | dev.auth.example.com | auth.example.com |
| redirect_url * | http://localhost:8000/callback | https://app.example.com/callback |
| timeout * | 30 | 10 |
| mfa_enabled * | false | true |
+----------------+--------------------------------+----------------------------------+
* = values differ between environments
10. Testing Settings
Because settings are singletons, they're trivial to swap:
// Bind a specific instance
$this->app->singleton(AuthSettings::class, fn () => new AuthSettings(
domain: 'test.example.com',
redirect_url: 'http://test.example.com/callback',
timeout: 5,
mfa_enabled: false,
));
// Or assert a specific environment's values directly
$this->assertSame('dev.auth.example.com', AuthSettings::development()->domain);
That second form is worth pausing on: you can now write a test that asserts what production is configured to do, and it runs in CI. Try doing that with values that live only on a server.
11. Why Not Just Use env and config
Fair question, and the honest answer is that config() gets you part of the way. Here's where each approach falls short:
Why not env() directly? It stops working after config:cache, outside of config files. This is documented Laravel behaviour, and it still catches teams out, because the failure shows up as null in production rather than an exception at boot.
Why not config() with a hand-rolled config file? You get caching safety, but you keep the two things that actually hurt: string keys with no autocomplete and no type guarantees (config('services.foo.timout') fails silently), and per-environment branching you have to write yourself — usually as env() calls inside the config file, which puts you back where you started, or as match(app()->environment()) blocks scattered around.
What this package adds on top: typed constructor properties, per-environment factories as first-class structure rather than conditionals, IDE autocomplete on every access, one enforced place per concern, and inspector commands that print resolved values and cross-environment diffs.
When .env is still the right answer: any value that must change without a deployment. That's the dividing line I'd use. Use .env for what must change without a deploy; use this package for what should be reviewed before it changes. Most non-secret config changes — switching a model, renaming a queue, tightening a timeout — deserve a code review anyway.
12. What This Package Is Not
Scope discipline matters as much as features, so:
- Not a database-backed settings manager. If you need admin-panel toggles that change at runtime, use spatie/laravel-settings. It stores settings in the database; this one stores them in code. They complement each other.
- Not a feature flag system. For percentage rollouts and per-user flags, use Laravel Pennant.
- Not a secret manager. Secrets stay in
.env, or in your platform's secret store. The masking inenv-settings:showexists to catch mistakes, not to enable them. - Not a replacement for Laravel's own config. Stock keys and third-party package credentials stay exactly where the framework expects them.
It is one thing: a typed configuration layer for non-secret values that differ between environments.
13. Does This Break 12-Factor
This comes up every time, so it's worth answering head-on.
12-Factor says configuration belongs in the environment — and for secrets, that holds without qualification. The argument I'd make is that the methodology was written when "config" and "credentials" were largely the same set. They aren't anymore. A modern application's environment-varying configuration is mostly model names, service URLs, retry policies, and mode switches, none of which are sensitive.
Non-secret configuration benefits from being version-controlled, type-safe, and reviewable. The package draws that line deliberately rather than accidentally: secrets stay in .env, everything else lives in typed PHP. You still get one artifact promoted across environments, and behaviour still varies by APP_ENV — the difference is that the variation is now something you can read, review, and test.
14. A Real Example: AI and LLM Settings
This is where the package earns its keep fastest, because AI configuration varies more across environments than almost anything else — you don't want to burn production tokens in local development, and you don't want a local model's output quality in production.
From the demo application:
class AiSettings extends EnvironmentSettings
{
public function __construct(
public string $provider,
public string $text_model,
public string $embeddings_model,
public int $max_tokens,
public float $temperature,
) {}
public static function development(): static
{
return new static(
provider: 'ollama',
text_model: 'llama3.2',
embeddings_model: 'nomic-embed-text',
max_tokens: 1000,
temperature: 0.9,
);
}
public static function staging(): static
{
return new static(
provider: 'openai',
text_model: 'gpt-4o-mini',
embeddings_model: 'text-embedding-3-small',
max_tokens: 2000,
temperature: 0.5,
);
}
public static function production(): static
{
return new static(
provider: 'openai',
text_model: 'gpt-4o',
embeddings_model: 'text-embedding-3-large',
max_tokens: 8000,
temperature: 0.2,
);
}
}
Local development runs Ollama and costs nothing. Staging runs a cheap hosted model. Production runs the real one with a temperature tuned for consistency. All three are visible in a single file, in one screen.
At the call site there's no branching at all:
$ai = envSettings(AiSettings::class);
$response = Prism::text()
->using($ai->provider, $ai->text_model)
->withMaxTokens($ai->max_tokens)
->withPrompt('Summarize this document...')
->asText();
Every model change and provider swap is a reviewable diff, fully typed, with no .env juggling. And php artisan env-settings:diff "App\Settings\AiSettings" staging production answers "why is staging cheaper?" instantly.
The demo application does the same thing for payments, notifications, and external service URLs — the homepage shows all four groups resolved for whichever environment it's running in.
15. Where to Go Next
I've deliberately not walked through installation here, because the demo does it better than prose can — it shows the generated files at each step in a real application:
- Getting started guide — the full setup, step by step, with the files it produces
- Try it in the browser — switch environments and watch resolved values change, no install required
- Live demo homepage — the composed settings tree, resolved for the current environment
- README on GitHub — installation, full configuration reference, and the FAQ
A couple of practical notes for anyone adopting it:
Requirements are Laravel 12.x or 13.x on PHP 8.2–8.5 (8.3+ for Laravel 13). The only runtime dependency is illuminate/support, which every Laravel application already has, and every combination is covered by the CI test matrix.
If your team uses AI coding agents, there's a published agent skill that teaches Claude Code, Cursor, Codex, and other agents the package's conventions — how to generate and register settings classes, how to read them, how overrides and testing work, and the rule that matters most: secrets must never be placed in a settings class. You'll find it in the Laravel skills registry at skills.laravel.cloud, and it installs either way:
# Skills CLI — Claude Code, Cursor, and friends
npx skills add HPWebdeveloper/laravel-env-settings-skills
# Or through Laravel Boost
php artisan boost:add-skill HPWebdeveloper/laravel-env-settings-skills
You don't have to migrate everything at once. Pick the one group of values that has caused the most confusion — for most teams that's either external service URLs or AI/model configuration — generate a class for it, and delete those lines from every .env you maintain. The next developer who joins will notice.
Issues and pull requests are welcome on GitHub.