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-development
laravel-env-settings-development
hpwebdeveloper/laravel-env-settings
claude-code
env-settings
settings-classes
environment-config
secrets
enums
local-overrides

Create and use typed, environment-aware settings classes with hpwebdeveloper/laravel-env-settings — generating classes, registering them, reading values, declaring environments with

Install
php artisan boost:add-skill hpwebdeveloper/laravel-env-settings

Save this content to: .claude/skills/laravel-env-settings-development/SKILL.md

# Laravel Env Settings Development

## When to use this skill

Use this skill when:

- Adding configuration to a Laravel app that differs per environment but is **not secret** (API URLs, model names, timeouts, feature modes, queue names)
- Creating, registering, or reading a settings class from this package
- Deciding whether a value belongs in `.env` or in a settings class
- Setting up per-developer local overrides for settings
- Resolving a non-standard `APP_ENV` (`qa`, `uat`, `demo`) to a factory method
- Hiding a property's value in `env-settings:show` / `env-settings:diff` output
- Answering "how do these environments differ?" across more than two of them
- Adding a CI step that fails when a settings class was never finished for an environment

## Core Concepts

- **`.env` is for secrets and stock Laravel keys only.** Non-secret values that vary by environment live in typed PHP classes under `app/Settings`, committed to version control and reviewable in PRs.
- Each settings class extends `EnvironmentSettings` and defines one static factory per environment. The package resolves the right one automatically from `APP_ENV`.
- Registered classes are **container singletons** — resolved once per request.
- Everything resolves at runtime via `app()->environment()`, never `env()`, so the package is fully `config:cache` safe.
- Anti-pattern: putting API keys, passwords, or tokens in a settings class. They would be committed to git. Secrets stay in `.env` and are read via `config()`.

## Creating a settings class

```shell
php artisan env-settings:make AuthSettings --properties="domain:string,timeout:int,mfa_enabled:bool"
```

Supported property types: `string`, `int`, `float`, `bool`, `array`. For a property with a fixed set of valid values, type it 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 settings class factories choose which one each environment uses. Never store per-environment values inside the enum: they would be invisible to `show`, `diff`, masking and local overrides. `toArray()` unwraps enums (backed → value, pure → case name), so JSON output keeps working. The generated class contains `// TODO: set development value` placeholders — always fill them in immediately.

The command auto-appends the class to the `register` array in `config/env-settings.php` when the config is published, and leaves a class that is already listed alone — written in full or as a short name behind a `use` import. If it warns instead, follow the instruction it prints.

Options:

- `--path=app/Modules/Billing/Settings` — the namespace is derived from the path (`App\Modules\Billing\Settings`)
- `--namespace="Acme\Billing\Settings"` — required when `--path` is outside `app/`, otherwise the class will not autoload
- `--sensitive=passphrase,api_key` — marks the named properties `#[Sensitive]` in the generated class; unknown names fail the command
- `--shared="currency=EUR,timeout=30"` — the named properties become constructor defaults and are left out of the factories; scalars only, unknown names and values that do not fit the type fail the command

## Class anatomy

```php
namespace App\Settings;

use HpWebDeveloper\LaravelEnvSettings\EnvironmentSettings;

/**
 * Local overrides extend this class, so it is not final. They keep the same
 * constructor, which is what makes `new static()` safe.
 *
 * @phpstan-consistent-constructor
 */
class AuthSettings extends EnvironmentSettings
{
    public function __construct(
        public string $domain,
        public int $timeout,
        public bool $mfa_enabled,
    ) {}

    // Required: development() and production() are abstract.
    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);
    }

// Optional: staging() and testing() default to development().
    // Only override them when their values genuinely differ.
}
```

Keep the `@phpstan-consistent-constructor` docblock on every settings class — `env-settings:make` writes it, and a class written by hand needs it too. A settings class is not `final` because local overrides extend it; without the tag, PHPStan reports `new.static` on every factory, at any level.

## Values that never vary

A property holding the same value in every environment does not belong in every
factory. Give it a constructor default — written once, in the signature, where a
reader looks for the property anyway:

```php
public function __construct(
    public string $provider,             // varies
    public float $temperature = 0.2,     // the same everywhere
) {}

public static function development(): static { return new static(provider: 'ollama'); }
public static function production(): static  { return new static(provider: 'openai'); }

// an environment that genuinely differs just passes it
public static function staging(): static { return new static(provider: 'openai', temperature: 0.9); }
```

Do not reach for a package mechanism here — this is plain PHP and it works with
every command, masking, overrides and `toArray()` unchanged. PHP requires
parameters with defaults last, so they collect at the end of the constructor.
`env-settings:show --all` names every property still repeated in every factory
(one that already holds its default is not named), and
`env-settings:make --shared` generates this shape.

Keep it in the settings class rather than moving it to `config/`: a class holding
provider, model and temperature is cohesive, and splitting one property out means
two places to look and `envSettings(...)->temperature` no longer working.

## Registering

A settings class is inert until listed in `config/env-settings.php` (publish with `php artisan vendor:publish --tag=env-settings-config`):

```php
'register' => [
    \App\Settings\AuthSettings::class,
],
```

## Reading settings

```php
// Preferred: the template-typed helper (full IDE autocomplete)
envSettings(AuthSettings::class)->domain;

// Constructor injection (it's a container singleton)
public function __construct(private AuthSettings $auth) {}

// Container
app(AuthSettings::class)->timeout;
```

Anti-pattern: `new AuthSettings(...)` or calling `AuthSettings::production()` directly in application code — that bypasses environment resolution and overrides. Direct factory calls are for tests only.

## Environment resolution

Resolution order for the current `APP_ENV`:

1. A factory marked `#[Environment]` for that `APP_ENV`
2. `environment_map`, then the raw `APP_ENV` value tried as a method name
3. `fallback_environment` (default `development`), then `development()`

Default map: `local`/`dev`/`develop` → `development()`, `staging`/`stage` → `staging()`, `production`/`prod` → `production()`, `testing`/`test` → `testing()`.

### Declaring environments on the class

`environment_map` lives in the application's config, so reading a settings class does not tell you which environments reach which method — and the same class can resolve differently in two applications. Mark the factory instead and the answer sits beside the code:

```php
use HpWebDeveloper\LaravelEnvSettings\Attributes\Environment;

#[Environment('production', 'prod')]
#[Environment('demo')]        // repeatable: another environment on the same values
public static function production(): static { ... }

#[Environment('qa', 'uat')]   // the method name need not match the environment
public static function qualityAssurance(): static { ... }
```

`APP_ENV=qa`, `uat` or `demo` now resolve with no config edit. Reach for this when the class ships in a package or is shared across applications, or when `APP_ENV` uses names outside the default map — asking every consumer to edit their `environment_map` is not practical.

Environment names are matched **case-sensitively**, as `APP_ENV` is. Where two methods claim the same environment, the first declared wins. Attributes take precedence over `environment_map`, so a class that states which environments it serves is never silently redirected by a map it cannot see; classes without attributes behave exactly as before.

Attribute mappings are inherited down the class hierarchy, so a local override that redeclares a marked factory keeps its mapping — the override's own values are still the ones used.

## Local developer overrides

Per-developer values without touching committed code:

1. Set `ENV_SETTINGS_OVERRIDE=true` in `.env`
2. Create `app/Settings/Overrides/AuthSettings.php` extending the base class and overriding the factories you need — never the constructor: the base class's `@phpstan-consistent-constructor` makes PHPStan report an override that changes it
3. Add `app/Settings/Overrides/` to `.gitignore`

`override_path` in the config is resolved at runtime: `null` → `app_path('Settings/Overrides')`, a relative value is resolved against `app_path()`, an absolute value is used as-is. Prefer `null` or a relative path — never call `app_path()` inside the config file, since `config:cache` would freeze the build machine's absolute path.

## Composing settings

For many settings classes, compose a root object whose factories call the children's factories (`auth: AuthSettings::development()`), then read `envSettings(AppSettings::class)->auth->domain`.

## Inspecting settings

```shell
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
php artisan env-settings:diff "App\Settings\AuthSettings" development production
php artisan env-settings:diff                                # prompts for class and environments
php artisan env-settings:check --env=production              # fails if anything is unfinished
```

Reach for `--all` when the question is *"how do these environments differ?"* — `diff` answers it for exactly two, so four environments would take six runs. The matrix marks a property `*` when it differs anywhere, and names every property still written out with the same value in every factory — give each a constructor default (see *Values that never vary*) rather than moving it out of the class. An inherited `staging()` or `testing()` returns `development()`, so it is named below the table instead of getting a column. A nested settings class is compared by what it holds — `show --all` and `diff` mark its row `*` when anything inside differs; inspect that class to see where.

### Verifying settings before they ship

`env-settings:check` reports properties left at their generated placeholder and exits non-zero, so it works as a gate rather than a report.

**Where it belongs: in CI, on the branch that deploys — alongside the test suite, not in a deploy hook.** It is a static check that resolves the classes locally, so it needs no network and no production credentials, and a failure caught in the build is a failure that never reaches a server:

```yaml
- 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 signature of one factory being filled in while another was forgotten. A value empty in every environment is treated as deliberate and never reported. Any string containing `TODO` is always reported.

When an empty value is intentional, mark the property so the check stays quiet:

```php
use HpWebDeveloper\LaravelEnvSettings\Attributes\AllowEmpty;

public function __construct(
    #[AllowEmpty] public string $path_prefix,
) {}
```

### Masking values

Mark a property `#[Sensitive]` and both `env-settings:show` and `env-settings:diff` print `********` instead of its value:

```php
use HpWebDeveloper\LaravelEnvSettings\Attributes\Sensitive;

public function __construct(
    public string $webhook_url,
    #[Sensitive] public string $passphrase,
) {}
```

A marked property is masked whatever it holds. Unmarked **string** properties whose names contain `key`, `secret`, `password`, or `token` are masked too, so classes written before the attribute existed stay covered — but that fallback guesses in both directions (it hides `monkey_api_url`, and misses `passphrase`, `credentials`, `bearer`), so mark the property when it matters. Empty strings are never masked.

Masking affects display only: `toArray()` still returns real values, and `env-settings:diff` compares real values, so a masked property is still flagged with `*` when it differs between environments.

Anti-pattern: treating `#[Sensitive]` as permission to put a secret in a settings class. It is a safety net for console output, not storage — the value is still committed to git. Secrets stay in `.env`.

## Testing

Prefer `fake()`: it resolves the current environment, replaces only the properties you name, and binds the result as the container singleton. Restating every constructor argument makes tests break whenever a property is added.

```php
AuthSettings::fake(['timeout' => 5]);                      // other values stay as the environment resolves them
AuthSettings::fake(['timeout' => 5], from: 'production');  // start from another environment
```

An unknown property name throws `InvalidArgumentException` listing the known ones; a wrong type raises a `TypeError` at the call site. Each call resolves the base afresh, so two `fake()` calls do not stack.

Outside tests, `with()` is the same copy without the container binding and `from()` resolves another environment through the real path (attributes, `environment_map`, overrides):

```php
$slow = AuthSettings::from('production')->with(['timeout' => 60]);
```

Both return a new instance and leave the original untouched. `readonly` properties cannot be replaced this way.

```php
// Still fine when a test must pin every value, or assert one environment
$this->app->singleton(AuthSettings::class, fn () => new AuthSettings(
    domain: 'test.example.com', timeout: 5, mfa_enabled: false,
));
$this->assertSame('dev.auth.example.com', AuthSettings::development()->domain);
```

Laravel Env Settings Development

When to use this skill

Use this skill when:

  • Adding configuration to a Laravel app that differs per environment but is not secret (API URLs, model names, timeouts, feature modes, queue names)
  • Creating, registering, or reading a settings class from this package
  • Deciding whether a value belongs in .env or in a settings class
  • Setting up per-developer local overrides for settings
  • Resolving a non-standard APP_ENV (qa, uat, demo) to a factory method
  • Hiding a property's value in env-settings:show / env-settings:diff output
  • Answering "how do these environments differ?" across more than two of them
  • Adding a CI step that fails when a settings class was never finished for an environment

Core Concepts

  • .env is for secrets and stock Laravel keys only. Non-secret values that vary by environment live in typed PHP classes under app/Settings, committed to version control and reviewable in PRs.
  • Each settings class extends EnvironmentSettings and defines one static factory per environment. The package resolves the right one automatically from APP_ENV.
  • Registered classes are container singletons — resolved once per request.
  • Everything resolves at runtime via app()->environment(), never env(), so the package is fully config:cache safe.
  • Anti-pattern: putting API keys, passwords, or tokens in a settings class. They would be committed to git. Secrets stay in .env and are read via config().

Creating a settings class

php artisan env-settings:make AuthSettings --properties="domain:string,timeout:int,mfa_enabled:bool"

Supported property types: string, int, float, bool, array. For a property with a fixed set of valid values, type it 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 settings class factories choose which one each environment uses. Never store per-environment values inside the enum: they would be invisible to show, diff, masking and local overrides. toArray() unwraps enums (backed → value, pure → case name), so JSON output keeps working. The generated class contains // TODO: set development value placeholders — always fill them in immediately.

The command auto-appends the class to the register array in config/env-settings.php when the config is published, and leaves a class that is already listed alone — written in full or as a short name behind a use import. If it warns instead, follow the instruction it prints.

Options:

  • --path=app/Modules/Billing/Settings — the namespace is derived from the path (App\Modules\Billing\Settings)
  • --namespace="Acme\Billing\Settings" — required when --path is outside app/, otherwise the class will not autoload
  • --sensitive=passphrase,api_key — marks the named properties #[Sensitive] in the generated class; unknown names fail the command
  • --shared="currency=EUR,timeout=30" — the named properties become constructor defaults and are left out of the factories; scalars only, unknown names and values that do not fit the type fail the command

Class anatomy

namespace App\Settings;

use HpWebDeveloper\LaravelEnvSettings\EnvironmentSettings;

/**
 * Local overrides extend this class, so it is not final. They keep the same
 * constructor, which is what makes `new static()` safe.
 *
 * @phpstan-consistent-constructor
 */
class AuthSettings extends EnvironmentSettings
{
    public function __construct(
        public string $domain,
        public int $timeout,
        public bool $mfa_enabled,
    ) {}

    // Required: development() and production() are abstract.
    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);
    }

// Optional: staging() and testing() default to development().
    // Only override them when their values genuinely differ.
}

Keep the @phpstan-consistent-constructor docblock on every settings class — env-settings:make writes it, and a class written by hand needs it too. A settings class is not final because local overrides extend it; without the tag, PHPStan reports new.static on every factory, at any level.

Values that never vary

A property holding the same value in every environment does not belong in every factory. Give it a constructor default — written once, in the signature, where a reader looks for the property anyway:

public function __construct(
    public string $provider,             // varies
    public float $temperature = 0.2,     // the same everywhere
) {}

public static function development(): static { return new static(provider: 'ollama'); }
public static function production(): static  { return new static(provider: 'openai'); }

// an environment that genuinely differs just passes it
public static function staging(): static { return new static(provider: 'openai', temperature: 0.9); }

Do not reach for a package mechanism here — this is plain PHP and it works with every command, masking, overrides and toArray() unchanged. PHP requires parameters with defaults last, so they collect at the end of the constructor. env-settings:show --all names every property still repeated in every factory (one that already holds its default is not named), and env-settings:make --shared generates this shape.

Keep it in the settings class rather than moving it to config/: a class holding provider, model and temperature is cohesive, and splitting one property out means two places to look and envSettings(...)->temperature no longer working.

Registering

A settings class is inert until listed in config/env-settings.php (publish with php artisan vendor:publish --tag=env-settings-config):

'register' => [
    \App\Settings\AuthSettings::class,
],

Reading settings

// Preferred: the template-typed helper (full IDE autocomplete)
envSettings(AuthSettings::class)->domain;

// Constructor injection (it's a container singleton)
public function __construct(private AuthSettings $auth) {}

// Container
app(AuthSettings::class)->timeout;

Anti-pattern: new AuthSettings(...) or calling AuthSettings::production() directly in application code — that bypasses environment resolution and overrides. Direct factory calls are for tests only.

Environment resolution

Resolution order for the current APP_ENV:

  1. A factory marked #[Environment] for that APP_ENV
  2. environment_map, then the raw APP_ENV value tried as a method name
  3. fallback_environment (default development), then development()

Default map: local/dev/develop → development(), staging/stage → staging(), production/prod → production(), testing/test → testing().

Declaring environments on the class

environment_map lives in the application's config, so reading a settings class does not tell you which environments reach which method — and 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')]        // repeatable: another environment on the same values
public static function production(): static { ... }

#[Environment('qa', 'uat')]   // the method name need not match the environment
public static function qualityAssurance(): static { ... }

APP_ENV=qa, uat or demo now resolve with no config edit. Reach for this when the class ships in a package or is shared across applications, or when APP_ENV uses names outside the default map — asking every consumer to edit their environment_map is not practical.

Environment names are matched case-sensitively, as APP_ENV is. Where two methods claim the same environment, the first declared wins. Attributes take precedence over environment_map, so a class that states which environments it serves is never silently redirected by a map it cannot see; classes without attributes behave exactly as before.

Attribute mappings are inherited down the class hierarchy, so a local override that redeclares a marked factory keeps its mapping — the override's own values are still the ones used.

Local developer overrides

Per-developer values without touching committed code:

  1. Set ENV_SETTINGS_OVERRIDE=true in .env
  2. Create app/Settings/Overrides/AuthSettings.php extending the base class and overriding the factories you need — never the constructor: the base class's @phpstan-consistent-constructor makes PHPStan report an override that changes it
  3. Add app/Settings/Overrides/ to .gitignore

override_path in the config is resolved at runtime: null → app_path('Settings/Overrides'), a relative value is resolved against app_path(), an absolute value is used as-is. Prefer null or a relative path — never call app_path() inside the config file, since config:cache would freeze the build machine's absolute path.

Composing settings

For many settings classes, compose a root object whose factories call the children's factories (auth: AuthSettings::development()), then read envSettings(AppSettings::class)->auth->domain.

Inspecting settings

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
php artisan env-settings:diff "App\Settings\AuthSettings" development production
php artisan env-settings:diff                                # prompts for class and environments
php artisan env-settings:check --env=production              # fails if anything is unfinished

Reach for --all when the question is "how do these environments differ?" — diff answers it for exactly two, so four environments would take six runs. The matrix marks a property * when it differs anywhere, and names every property still written out with the same value in every factory — give each a constructor default (see Values that never vary) rather than moving it out of the class. An inherited staging() or testing() returns development(), so it is named below the table instead of getting a column. A nested settings class is compared by what it holds — show --all and diff mark its row * when anything inside differs; inspect that class to see where.

Verifying settings before they ship

env-settings:check reports properties left at their generated placeholder and exits non-zero, so it works as a gate rather than a report.

Where it belongs: in CI, on the branch that deploys — alongside the test suite, not in a deploy hook. It is a static check that resolves the classes locally, so it needs no network and no production credentials, and a failure caught in the build is a failure that never reaches a server:

- 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 signature of one factory being filled in while another was forgotten. A value empty in every environment is treated as deliberate and never reported. Any string containing TODO is always reported.

When an empty value is intentional, mark the property so the check stays quiet:

use HpWebDeveloper\LaravelEnvSettings\Attributes\AllowEmpty;

public function __construct(
    #[AllowEmpty] public string $path_prefix,
) {}

Masking values

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(
    public string $webhook_url,
    #[Sensitive] public string $passphrase,
) {}

A marked property is masked whatever it holds. Unmarked string properties whose names contain key, secret, password, or token are masked too, so classes written before the attribute existed stay covered — but that fallback guesses in both directions (it hides monkey_api_url, and misses passphrase, credentials, bearer), so mark the property when it matters. Empty strings are never masked.

Masking affects display only: toArray() still returns real values, and env-settings:diff compares real values, so a masked property is still flagged with * when it differs between environments.

Anti-pattern: treating #[Sensitive] as permission to put a secret in a settings class. It is a safety net for console output, not storage — the value is still committed to git. Secrets stay in .env.

Testing

Prefer fake(): it resolves the current environment, replaces only the properties you name, and binds the result as the container singleton. Restating every constructor argument makes tests break whenever a property is added.

AuthSettings::fake(['timeout' => 5]);                      // other values stay as the environment resolves them
AuthSettings::fake(['timeout' => 5], from: 'production');  // start from another environment

An unknown property name throws InvalidArgumentException listing the known ones; a wrong type raises a TypeError at the call site. Each call resolves the base afresh, so two fake() calls do not stack.

Outside tests, with() is the same copy without the container binding and from() resolves another environment through the real path (attributes, environment_map, overrides):

$slow = AuthSettings::from('production')->with(['timeout' => 60]);

Both return a new instance and leave the original untouched. readonly properties cannot be replaced this way.

// Still fine when a test must pin every value, or assert one environment
$this->app->singleton(AuthSettings::class, fn () => new AuthSettings(
    domain: 'test.example.com', timeout: 5, mfa_enabled: false,
));
$this->assertSame('dev.auth.example.com', AuthSettings::development()->domain);
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.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast