Spatie laravel-settings vs laravel-env-settings: Two Packages, Two Different Problems
These two packages get compared constantly, because from a distance they look identical: both replace
config('some.string.key')with a typed PHP object. Look closer and they are answering completely different questions. One is about who is allowed to change a value and when. The other is about how a value differs between environments. Picking the wrong one is not a matter of taste — you end up fighting the tool for the lifetime of the project.
- laravel-settings — by Spatie
- laravel-env-settings — live demo
Table of Contents
- The Short Answer
- Three Questions That Decide It
- What Each One Actually Is
- Where the Value Lives
- Who Changes It, and When
- Type Safety: Both, But Differently
- The Cost of Change
- Environment Awareness
- Runtime Behaviour and Failure Modes
- Secrets and Encryption
- Testing
- Side by Side
- Using Both Together
1. The Short Answer
If you only read one section:
Use laravel-settings when a value must be changeable at runtime, by someone who is not deploying code — an administrator in a settings panel, a support agent toggling a feature, an operator responding to an incident at 2am.
Use laravel-env-settings when a value differs between your environments and is decided by developers — an AI model, a payment mode, an internal service URL, a retry policy — and the difference between staging and production is information your team needs to see.
They are not competitors. A serious application often wants both, and section 13 shows what that looks like.
2. Three Questions That Decide It
In order. The first one that returns an answer wins:
1. Does knowing this value alone grant access to anything?
Yes → it's a secret. .env, or your platform's secret store. Neither package.
2. Will someone who cannot deploy code need to change it? Yes → laravel-settings. It needs to be a database write, with a UI in front of it, taking effect immediately.
3. Does it differ between your environments?
Yes → laravel-env-settings. Put every environment's value in one reviewable file and let env-settings:diff answer questions about them.
If all three are no, you have a constant that's the same everywhere and read only by your code — and an ordinary config/*.php file is a perfectly good home for it. Not every value needs a package.
The rest of this article is why those three questions are the right ones, and what each package gives you once you've picked.
3. What Each One Actually Is
laravel-settings is a persistence layer with a typed façade. You declare a settings class, and its values are stored in a repository — a database table by default, Redis if you prefer — then loaded into a typed object and bound in the container:
class GeneralSettings extends Settings
{
public string $site_name;
public bool $site_active;
public static function group(): string
{
return 'general';
}
}
The class declares the shape. The values live in a row. You read them through injection, and you can write them back:
$settings = app(GeneralSettings::class);
$settings->site_name = 'New name';
$settings->save();
laravel-env-settings is a resolution layer with no persistence at all. You declare one static factory per environment, and the package calls the right one based on APP_ENV:
class AiSettings extends EnvironmentSettings
{
public function __construct(
public string $provider,
public string $text_model,
public int $max_tokens,
) {}
public static function development(): static
{
return new static('ollama', 'llama3.2', 2000);
}
public static function production(): static
{
return new static('openai', 'gpt-4o', 8000);
}
}
The class declares the shape and the values. There is nothing to save, because there is nowhere to save it to — the values are literals in a file that git tracks.
That single structural difference explains everything below.
4. Where the Value Lives
| laravel-settings | laravel-env-settings | |
|---|---|---|
| Storage | database row, or Redis | PHP source, in version control |
| Same across environments? | no — each environment has its own database | no — but every environment's values are in one file |
| Visible in a pull request | no | yes |
Answerable by git blame | no | yes |
| Requires infrastructure | yes — a database or Redis, plus cache | no |
The row that matters most is the third. With laravel-settings, changing production's site name is a database write. It happened, it worked, and there is no diff — if you want an audit trail, you build one, or you listen to the package's SettingsSaved event and write it yourself. That's not an oversight; it's inherent to storing values in a database, and it's the correct trade when a non-developer is the one making the change.
With env-settings, changing production's model name is a commit. It has an author, a timestamp, a reviewer and a message. That's also inherent — and it's the wrong trade entirely if the person who needs to make the change doesn't have commit access.
5. Who Changes It, and When
This is the question I'd actually lead with when advising a team, because it decides the answer faster than any feature comparison.
laravel-settings is built for values with a human editor who is not you:
- An admin panel where the client sets their site name, logo, support email
- A feature toggle a product manager flips without waiting for a release
- An operational limit you need to lower right now, during an incident, without a deploy pipeline standing between you and the fix
That last case is a genuine advantage and worth being honest about. When production is on fire, "change a row" beats "open a PR, get a review, wait for CI, deploy" every time.
env-settings is built for values decided by developers, shipped with the code that uses them:
- Which AI provider and model each environment talks to
- Whether payments run in sandbox or live mode
- Internal service URLs, timeouts, retry counts, rate limits
For these, requiring a deployment isn't a limitation — it's the point. Switching production to a different model should be reviewed by someone. Renaming a queue should appear in the release notes. If a change deserves scrutiny before it takes effect, putting it behind a pull request is a feature.
6. Type Safety: Both, But Differently
It's worth correcting a common misreading here: both packages are strongly typed. If someone tells you to pick env-settings "because laravel-settings isn't typed", they're wrong.
Spatie types the property and casts on the way out of storage. Because the value arrives from a database column, there's a serialisation boundary to cross, so the package ships a cast system — DateTimeInterfaceCast, enum casts, collection casts, Spatie Data objects, plus global casts and custom encoders/decoders:
public static function casts(): array
{
return ['birth_date' => DateTimeInterfaceCast::class];
}
Env-settings types the constructor and there is no boundary to cross, because the value never left PHP. A DateTimeImmutable in a factory is simply a DateTimeImmutable; an enum case is the case itself. No cast layer exists because none is needed.
The practical difference isn't strength, it's where the guarantee comes from. Spatie's guarantee is enforced when the value is hydrated: if the row is missing or the wrong shape, you find out at runtime — the package throws MissingSettings rather than handing you a broken object, which is the right behaviour but is still a runtime event. Env-settings' guarantee is enforced by the constructor at the point the factory is written, so a wrong type is a static analysis error before anything runs.
7. The Cost of Change
Adding a property is where the day-to-day difference shows up most.
With laravel-settings, the class and the storage have to stay in sync, so a new property needs a settings migration:
public function up(): void
{
$this->migrator->add('general.timezone', 'Europe/Brussels');
}
Rename one, and that's another migration (rename). Delete one, another (delete). Change a default across existing rows, another (update). This is well-designed — it's the same discipline Laravel applies to database schema, and it means every environment's stored values move forward in a controlled way.
It is also real overhead, and it's overhead you're paying for a capability you may not need. If nobody is ever going to edit that value through a UI, the migration is pure ceremony.
With env-settings, adding a property means adding a constructor parameter and filling it in for each environment. There is no migration because there is no storage. The compiler is the migration: miss an environment and the factory won't construct.
The flip side is honest too — with env-settings, changing a value in production requires a deployment. If your release process is slow or manual, that's a real cost, and it's the strongest argument for laravel-settings in cases that would otherwise look like a developer-owned setting.
8. Environment Awareness
This is the dimension where they genuinely don't overlap.
Spatie has no concept of environments, and doesn't need one. You get different values in staging and production because staging and production have different databases. That works — but the difference exists only as two rows on two servers. To answer "why does staging behave differently?" you query both, or you log into both.
Env-settings makes the difference the primary structure. Every environment's values sit in one file, next to each other, and the tooling reads them:
php artisan env-settings:diff AiSettings staging production
That command runs on your laptop. No SSH, no production access, no database connection — because all the values are in the code you already have checked out. It also ships env-settings:show for what the current environment resolves to, and env-settings:check --env=production to fail CI when a factory was left incomplete.
If your problem is "our environments drift and nobody notices", this is the axis that matters, and laravel-settings was never trying to solve it.
9. Runtime Behaviour and Failure Modes
Worth thinking about before you're debugging it at speed.
Spatie reads from a repository. That means I/O on first access — mitigated well by its cache layer, which invalidates automatically on save and can be cleared with settings:clear-cache. But the dependency is real: if the database is unreachable and the cache is cold, resolving a settings class fails. Your configuration now shares a failure domain with your data store. For most applications that's acceptable — if the database is down you have bigger problems — but if the setting controls a fallback path that's supposed to work during a database outage, think it through.
Env-settings does no I/O at all. Resolution reads APP_ENV through app()->environment() and config(), never env(), and constructs an object. There is nothing to fail and nothing to cache, and it is fully compatible with php artisan config:cache. The settings class is available as soon as the container is booted.
The trade-off is the mirror image: env-settings can't change without a deploy, and laravel-settings can't be read without its store.
10. Secrets and Encryption
Different answers, and both are deliberate.
Spatie supports encryption at rest, which is genuinely useful for a real scenario: a value that is a secret but is supplied by a user, not by your team. A customer's own API key entered through your admin panel has to be stored somewhere, and it should not be stored in plaintext.
public static function encrypted(): array
{
return ['api_key'];
}
Env-settings takes the opposite position on purpose: don't put secrets in it at all. The values are committed to your repository, so encryption would be theatre — the ciphertext and the surrounding code ship together. Its #[Sensitive] attribute exists only to mask values in console output, and the documentation is explicit that this is a safety net rather than a feature.
So the rule across all three homes for a value:
- A secret your team owns →
.envor your platform's secret store - A secret an end user supplies →
laravel-settings, encrypted - Not a secret, varies by environment → env-settings
11. Testing
Both are easy to control in tests, which is a real benefit over config() in either direction.
Spatie provides a fake:
DateSettings::fake(['birth_date' => new DateTime('16-05-1994')]);
Env-settings classes are container singletons, so you bind one — or assert a specific environment's values directly, without booting anything:
$this->app->singleton(AiSettings::class, fn () => new AiSettings(
provider: 'fake', text_model: 'test-model', max_tokens: 10,
));
$this->assertSame('gpt-4o', AiSettings::production()->text_model);
That second line is a small thing with a real consequence: you can write a CI-enforced test asserting what production is configured to do. With database-backed settings there's no equivalent, because production's values aren't in the repository to assert against.
12. Side by Side
| laravel-settings | laravel-env-settings | |
|---|---|---|
| Primary purpose | runtime-mutable application settings | environment-varying developer configuration |
| Source of truth | database or Redis | PHP source in git |
| Changed by | an end user, admin or operator | a developer, through a pull request |
| Changing it requires | a write | a deployment |
| Change is reviewable | not by default | yes, it's a diff |
| Per-environment differences | separate stores, invisible to each other | explicit, in one file, diffable by command |
| Type safety | typed properties plus a cast layer | typed constructor, no casting needed |
| Adding a property | a settings migration | a constructor parameter |
| Runtime dependency | database or Redis, plus cache | none |
| Encryption at rest | yes | no, and deliberately not |
| Test support | fake() | container binding or direct factory calls |
| Good fit for secrets | user-supplied ones, encrypted | never |
13. Using Both Together
The framing that serves teams best is that these occupy different layers, and a mature application often has both installed without any conflict.
A realistic split:
// laravel-settings — the client edits these in an admin panel
class BrandSettings extends Settings
{
public string $site_name;
public string $support_email;
public bool $maintenance_banner;
}
// env-settings — developers decide these, they differ per environment
class AiSettings extends EnvironmentSettings
{
public function __construct(
public string $provider,
public string $text_model,
public int $max_tokens,
) {}
// development() / staging() / production()
}
Nobody is confused about which is which, because the sorting rule is obvious once stated: if a non-developer might reasonably need to change it, it belongs in the database; if changing it should require review, it belongs in code.
There's a third home that neither package touches, and it's worth naming to complete the picture: Laravel's own stock keys — APP_KEY, DB_*, MAIL_*, QUEUE_* — stay in .env and their stock config files, because the framework reads them during bootstrap before the container can resolve anything. I wrote about where that line falls separately.
Both of these are good packages, built by people who understood their problem. The mistake isn't choosing one over the other — it's assuming they're solving the same problem and then being surprised when the one you picked fights you. Sort by who changes the value and when, and the choice makes itself.