hpwebdeveloper / laravel-env-settings
Environment-aware, type-safe configuration classes for Laravel. Move non-secret values out of .env and into typed, IDE-friendly PHP classes.
Package info
github.com/HPWebdeveloper/laravel-env-settings
pkg:composer/hpwebdeveloper/laravel-env-settings
Requires
- php: ^8.2
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.52
- larastan/larastan: ^3.10.0
- laravel/pint: ^1.26
- orchestra/testbench: ^10.8.0 || ^11.0
- orchestra/workbench: ^10.0.7 || ^11.0
- phpstan/phpstan: ^2.2.9
- phpunit/phpunit: ^11.5.56 || ^12.5.4
- spatie/guidelines-skills: dev-main
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A typed configuration layer for non-secret values that differ between environments.
by Hamed Panjeh
🚀 See how this package works in practice — try the live demo
A real Laravel application with worked examples: settings classes, per-environment values, local overrides, and the Artisan commands in action. The fastest way to understand the package before installing it.
💡 Why this exists — the problem it solves, and whether it's for you
Fifteen reasons, each one a before and an after: what belongs in .env, types instead of strings, staying correct at runtime, working with other people, and where this fits alongside other packages. Tick the ones you recognise — it reads as a diagnosis rather than a feature list.
Contents
- AI Assistants
- Requirements
- Installation
- Quick Start
- How resolution works
- Features
- Artisan Commands
- Full documentation
- Changelog
- Contributing
- Security
- Credits
- License
Most of a typical .env holds no secrets at all — API URLs, model names, timeouts, queue names, feature modes. Those belong in version control, where they are typed, reviewable in pull requests, and visible to your whole team. This package keeps .env for secrets and the stock Laravel keys, and puts everything your application adds on top into app/Settings.
// Before: scattered, untyped, invisible to code review $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: typed, environment-aware, in version control 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
AI Assistants
A published agent skill teaches Claude Code, Cursor, Codex and others how to work with this package — and the rule that matters most: secrets never go in a settings class. It is listed at skills.laravel.cloud:
npx skills add HPWebdeveloper/laravel-env-settings-skills # Skills CLI php artisan boost:add-skill HPWebdeveloper/laravel-env-settings-skills # Laravel Boost
The skill stands alone, so your agent can learn the package before it is installed. The same guidance also ships bundled, so php artisan boost:install offers it once the package is in your project.
Requirements
| Laravel | PHP |
|---|---|
| 13.x | 8.3 – 8.5 |
| 12.x | 8.2 – 8.5 |
Every combination above is covered by the CI test matrix. The only runtime dependency is illuminate/support.
Installation
composer require hpwebdeveloper/laravel-env-settings
php artisan vendor:publish --tag="env-settings-config"
Tip
📘 Follow the step-by-step setup guide on the demo → The same installation walked through in a real application, with the generated files shown at each step.
Quick Start
1. Generate
php artisan env-settings:make AuthSettings \
--properties="domain:string,timeout:int,mfa_enabled:bool"
That writes app/Settings/AuthSettings.php with the structure in place and // TODO placeholders. Fill them in:
namespace App\Settings; use HpWebDeveloper\LaravelEnvSettings\EnvironmentSettings; class AuthSettings extends EnvironmentSettings { public function __construct( public string $domain, public int $timeout, public bool $mfa_enabled, ) {} public static function development(): static { return new static(domain: 'dev.auth.example.com', timeout: 30, mfa_enabled: false); } public static function production(): static { return new static(domain: 'auth.example.com', timeout: 10, mfa_enabled: true); } }
development() and production() are required. staging() and testing() fall back to development() — override them only when the values genuinely differ.
Property types: string, int, float, bool, array, or any enum.
A value that is the same everywhere goes in the signature, not in every factory. Give it a constructor default and write it once:
public function __construct( public string $provider, // varies by environment public float $temperature = 0.2, // the same everywhere — written once ) {} public static function development(): static { return new static(provider: 'ollama'); } public static function production(): static { return new static(provider: 'openai'); } // and the one environment that genuinely differs just passes it public static function staging(): static { return new static(provider: 'openai', temperature: 0.9); }
Generate that shape directly with --shared. PHP requires parameters with defaults to come last, so they collect at the end of the constructor.
2. Register
A settings class stays inert until it is listed in config/env-settings.php:
'register' => [ \App\Settings\AuthSettings::class, ],
env-settings:make appends this line for you when the config has been published; if it can't, it prints exactly what to add. Each registered class becomes a container singleton, resolved once per request.
3. Use it anywhere
// 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
That's it. The correct environment is resolved automatically.
Tip
▶️ Try it yourself in the browser → Switch environments and watch the resolved values change, without installing anything.
How resolution works
For the current APP_ENV, the package picks a factory in this order:
- A method marked
#[Environment]for thatAPP_ENV - The
environment_mapconfig (local→development,prod→production, …), then the rawAPP_ENVvalue as a method name fallback_environment(default:development)
It reads app()->environment() and config() — never env() — so it is fully compatible with php artisan config:cache.
Tip
🔎 See this resolution happening live on the demo →
The demo prints the current APP_ENV, the method it maps to, and the resulting instance.
Features
Each of these is covered in depth in the full guide.
Fixed-value properties (enums)
When a property only ever holds one of a few values, type it as an enum. A typo becomes a parse error, and the valid set is documented by the type:
enum PaymentMode: string { case Live = 'live'; case Sandbox = 'sandbox'; } public function __construct( public PaymentMode $mode, ) {} // development(): mode: PaymentMode::Sandbox // production(): mode: PaymentMode::Live
Reading it gives you the case itself, so calling code branches exhaustively instead of comparing strings:
$client = match (envSettings(PaymentSettings::class)->mode) { PaymentMode::Live => $gateway->live(), PaymentMode::Sandbox => $gateway->sandbox(), };
The enum defines which values are possible; the factories choose which one each environment uses. toArray() unwraps enums (backed → value, pure → case name), so JSON output keeps working. → details
Declaring environments on the class
environment_map lives in the application's config, so the same class can resolve differently in two applications. Mark the factory instead and the answer sits beside the code:
use HpWebDeveloper\LaravelEnvSettings\Attributes\Environment; #[Environment('production', 'prod')] #[Environment('demo')] // a second environment sharing production values public static function production(): static { ... } #[Environment('qa', 'uat')] // the method name need not match the environment public static function qualityAssurance(): static { ... }
APP_ENV=demo, qa or uat now resolve with no config edit. → details
Masking sensitive output
Mark a property #[Sensitive] and both env-settings:show and env-settings:diff print ******** instead of its value:
use HpWebDeveloper\LaravelEnvSettings\Attributes\Sensitive; public function __construct( #[Sensitive] public string $passphrase, ) {}
Masking affects display only — toArray() still returns real values, and diff still flags the property with * when it differs. This is a safety net, not permission to store secrets here. → details
Local developer overrides
Individual developers can override values without touching committed code: set ENV_SETTINGS_OVERRIDE=true, add app/Settings/Overrides/AuthSettings.php extending the base class, and gitignore the directory. When overrides are off or the file is missing, the base class is used as normal. → details
Composing settings into a root object
A root class whose properties are other settings classes gives the whole configuration tree one entry point:
envSettings(AppSettings::class)->auth->domain; envSettings(AppSettings::class)->payment->mode;
Register only the root. toArray() expands nested settings recursively, which makes a debug endpoint or health-check payload a one-liner. → details
Artisan Commands
env-settings:make
php artisan env-settings:make NotificationSettings \
--properties="sms_provider:string,rate_limit_per_minute:int,sandbox_mode:bool"
# Custom path — the 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"
# Mark properties #[Sensitive] as they are generated
php artisan env-settings:make VaultSettings \
--properties="endpoint:string,passphrase:string" --sensitive=passphrase
# Values that are the same in every environment become constructor defaults
php artisan env-settings:make AiSettings \
--properties="provider:string,temperature:float,currency:string" \
--shared="temperature=0.2,currency=EUR"
--shared takes name=value pairs. Each named property gets the value as a constructor default and is left out of the factories entirely, so it is written once instead of once per environment. Scalars only — an array or enum default cannot be expressed as one comma-separated argument, so those are refused rather than mangled into a class that will not parse.
A generated class only autoloads if its namespace matches where the file was written, so the namespace is resolved in this order: --namespace if given, otherwise derived from --path when that directory sits under the application root, otherwise config('env-settings.class_namespace'). A path outside the application root has no PSR-4 mapping to read, so it falls back to the default and warns — pass --namespace in that case. → full table
env-settings:check
Reports settings left at their generated placeholder, so a factory nobody finished cannot reach production:
php artisan env-settings:check --env=production # a specific environment php artisan env-settings:check # the current APP_ENV php artisan env-settings:check "App\Settings\AuthSettings"
✗ App\Settings\AuthSettings
domain empty string, but set in development()
timeout 0, but set in development()
webhook_url still contains "TODO"
1 of 3 classes incomplete for [production]: 3 values to fill in.
Run it in CI, on the branch that deploys. It exits non-zero when anything is incomplete, which makes it a gate rather than a report:
- name: Check production settings are complete run: php artisan env-settings:check --env=production
It is a static check — it resolves your classes, touches no network and needs no production credentials — so it belongs in the build, alongside your tests, not in a deploy hook where a failure is already too late.
What counts as incomplete. A value equal to its generated placeholder ('', 0, 0.0, false, [], null) when another environment supplies a real one — the shape of a class where one factory was filled in and another forgotten. A value that is empty in every environment is deliberate and never reported. Any string containing TODO is always reported.
Mark a property #[AllowEmpty] when its empty value is intentional:
use HpWebDeveloper\LaravelEnvSettings\Attributes\AllowEmpty; public function __construct( #[AllowEmpty] public string $path_prefix, ) {}
env-settings:show
php artisan env-settings:show # all registered classes php artisan env-settings:show "App\Settings\AuthSettings" # one class php artisan env-settings:show --all # every environment side by side
[ AuthSettings ] — Environment: production
+-------------+--------+------------------+
| Property | Type | Value |
+-------------+--------+------------------+
| domain | string | auth.example.com |
| timeout | int | 10 |
| mfa_enabled | bool | true |
+-------------+--------+------------------+
--all: every environment at once
A settings class states every environment in one file — but show prints only the current one and diff only ever two, so four environments took six diff runs to compare. --all puts them in one table:
[ AuthSettings ] — development, staging, production
+---------------+----------------------+--------------------------+------------------+
| Property | development | staging | production |
+---------------+----------------------+--------------------------+------------------+
| domain * | dev.auth.example.com | staging.auth.example.com | auth.example.com |
| timeout * | 30 | 20 | 10 |
| mfa_enabled * | false | true | true |
| currency | EUR | EUR | EUR |
+---------------+----------------------+--------------------------+------------------+
* = differs somewhere
1 unmarked property is the same everywhere.
An unmarked row does not vary by environment, so it may not need to be a setting at all — that trailing count is the one thing this view tells you that no other command can.
Values are compared before masking, so a #[Sensitive] property is still marked * when it differs without being revealed.
env-settings:diff
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 |
| timeout * | 30 | 10 |
| mfa_enabled * | false | true |
+---------------+----------------------+------------------+
* = values differ between environments
Full documentation
📖 GUIDE.md — the complete reference, including:
- Testing settings classes
- Composing a wider tree and exporting it
- Configuring the override location (and why
config:cachemakes relative paths matter) - The full
environment_mapand where generated classes live - A worked AI/LLM example
- FAQ — how this differs from
spatie/laravel-settings, and whether it violates 12-Factor
Changelog
See Releases for recent changes.
Contributing
Issues and pull requests are welcome on GitHub.
Security
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). See LICENSE for details.