Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Env Settings Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. 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.
  2. app/Settings/ — the classes themselves. This is the directory reviewers will actually read.
  3. 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().

Implementation Patterns

The read path, and only the read path

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.

Shape of a class

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.

Local developer overrides

The per-developer escape hatch, and the reason the classes aren't final:

  1. ENV_SETTINGS_OVERRIDE=true in .env.
  2. Create app/Settings/Overrides/PaymentSettings.php extending the base class, overriding only the factories you need — never the constructor.
  3. Add 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.

Composition for large apps

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.

Masking and intentional empties

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.

The CI gate

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.

Inspection as a review habit

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.

Testing

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.

Choosing between .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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle