The One Thing Laravel's config() Cannot Tell You

Open any config/*.php file and ask it a simple question: what does staging use? It cannot answer — not because you wrote it badly, but because a config file describes one resolved state, the machine it happens to be running on. So the differences themselves — which AI model production runs, how long staging waits before it times out — end up in .env, a file git ignores. Nobody can review a change to it, compare two environments side by side, or find out who set a value and why. This article is about which values truly need to live in .env, which ones never did, and the one question that sorts every line into the right place.


Table of Contents

  1. What config() Is Actually For
  2. The Rules Worth Following
  3. The Question config() Cannot Answer
  4. What You Lose in the Translation
  5. The env() Trap That Only Fires in Production
  6. Where the Boundary Falls
  7. Why Laravel's Stock Keys Never Move
  8. The Decision Table
  9. What It Looks Like in Practice
  10. The Short Version

1. What config() Is Actually For

Before criticising anything, it's worth being precise about what config() is good at, because it is very good at it and none of what follows is an argument to stop using it.

It is the framework's read path. DatabaseManager reads config('database.connections.*') when it resolves a connection. CacheManager reads config('cache.stores'). QueueManager, MailManager, FilesystemManager — all the same. This is not incidental; it is the contract by which the framework is configured at all.

It is how a package exposes its settings. When you run vendor:publish and get config/scout.php, that file is the package's public API for configuration. There is no better mechanism, and inventing one per package would be a disaster.

It is fast and cacheable. php artisan config:cache compiles every config file into a single array in bootstrap/cache/config.php. One opcache-warm include instead of a dozen file reads and a .env parse on every request. In production this is a genuine, measurable win.

It is available before almost anything else. Config is loaded as the second of six bootstrap steps, long before the container is usable. That is what makes it viable for wiring up the database — and, as we'll see, it is also the exact reason for its one limitation.


2. The Rules Worth Following

The professional baseline, stated plainly. If your team follows only these, most config-related incidents disappear:

Never call env() outside config/. This is the one that isn't negotiable. Section 5 explains exactly why, and the failure mode is worse than most developers expect.

Always run config:cache in production — and understand what it means: your config files are evaluated exactly once, at deploy time, and the resulting array is frozen.

Keep config files free of closures and objects. config:cache serialises with var_export(). A closure gives you "Your configuration files are not serializable" — at deploy time if you're lucky, or an untested code path if that file only sometimes contains one.

Be careful with path helpers in config files. app_path() inside a config file is evaluated once and frozen into the cache. If the cache is built somewhere other than where the app runs — a Docker build stage, a CI artifact, a per-release deploy directory — that absolute path no longer exists.

Treat config as data, not logic. A match (app()->environment()) block inside a config file is a smell: it is environment-dependent logic in a file that gets evaluated once and cached. It usually works, right up until the cache is built in the wrong environment.

Namespace your own keys. Don't bolt application settings onto config/app.php. Create config/billing.php, config/ai.php. Stock files are the framework's; keep yours separate.


3. The Question config() Cannot Answer

Here's a config file that follows every rule above:

// config/ai.php
return [
    'provider'   => env('AI_PROVIDER', 'openai'),
    'text_model' => env('AI_TEXT_MODEL', 'gpt-4o'),
    'max_tokens' => env('AI_MAX_TOKENS', 8000),
];

Nothing is wrong with it. Now ask it the question:

What does staging use?

It cannot tell you. It tells you a key named AI_TEXT_MODEL exists, and that gpt-4o is used when nothing is set. It does not tell you what production runs, what staging runs, or whether the difference between them is deliberate.

That isn't a flaw in how the file was written. It's structural. A config file describes one resolved state — the state of the machine it is running on. The variation between environments lives in .env, which is per-machine and gitignored, so the variation is exactly the part that never appears in your repository.

And there's a trap sitting inside that snippet. The second argument to env() looks like documentation:

'text_model' => env('AI_TEXT_MODEL', 'gpt-4o'),

It isn't. It's the fallback for when the key is missing. Reading it tells you what happens if someone forgets to set the value — which is a completely different question from what production actually runs. On a properly configured production box, that default is dead code that never executes, and it will happily sit there being wrong for years.

So the shape of the limitation:

  • config() can express "this value varies by environment."
  • It cannot express "here is how it varies, and why."

For a huge amount of configuration that gap doesn't matter. For the configuration your team argues about in Slack, it matters a great deal.


4. What You Lose in the Translation

Pushing per-environment values through .env costs three things, and each one shows up as a bug eventually.

Types. Everything in .env is text. Laravel's env() helper casts a few special strings — true, false, null, empty — and strips surrounding quotes. It does not cast numbers. So:

env('AI_MAX_TOKENS', 8000)
// nothing set in .env  → int 8000
// AI_MAX_TOKENS=8000   → string '8000'

The type of that value depends on whether somebody filled in the field. Most of the time PHP's juggling hides it; occasionally it surfaces as a strict comparison failing, a JSON payload with a quoted number in it, or a declare(strict_types=1) boundary throwing.

Review. git log -p config/ai.php shows the shape of your configuration changing over time. It never shows a value changing, because the values live in a file git was told to ignore. Nobody raised the token limit in a pull request. Someone raised it on a server.

A correct key. config('ai.txet_model') returns null. No exception, no warning, no editor squiggle — a string key that doesn't exist is simply absent. The failure surfaces later and somewhere else, usually as a vendor SDK complaining that a required parameter is empty.


5. The env() Trap That Only Fires in Production

This one deserves its own section, because it is the most expensive lesson on the list and the mechanism is worth understanding exactly.

When you run php artisan config:cache, Laravel writes every resolved config value into bootstrap/cache/config.php. On the next request, the very first bootstrapper does this:

// Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables
public function bootstrap(Application $app)
{
    if ($app->configurationIsCached()) {
        return;
    }
    // ...
}

It returns before parsing anything. .env is never read. Every env() call outside a config file now returns its default — which, in the overwhelming majority of cases, is null.

Look at the cruelty of the timing:

  • Local development: config isn't cached, .env is parsed, everything works.
  • CI: same, everything passes.
  • Production: you run the optimisation command every deployment guide tells you to run, and your value becomes null.

Silently. Not an exception — a null that travels until something downstream can't cope with it.

That's why the rule is never call env() outside config/. The rule is correct, and I'd enforce it in any codebase. But notice what it actually forces you to do: every value that varies by environment must be funnelled through a config file — which is precisely the file that, from section 3, cannot describe the variation.

That's the squeeze. Do the right thing, and your environment-specific decisions end up in the one place where they are invisible.


6. Where the Boundary Falls

The way out isn't to fight config(). It's to notice that it's being asked to do two unrelated jobs.

Job A — the framework's read path. Values that Laravel or a vendor package will read through config() during boot. Connection maps, disks, drivers, queue definitions, package options. These must live in config files, because that's where the code that consumes them looks.

Job B — your application's own decisions. Values that only your code reads, that differ between environments, and where the difference itself is information your team needs. An AI provider and model. A payment mode. Internal service URLs. Retry counts and rate limits.

For Job B, a key-value lookup isn't really what you want. What you want is a typed object per environment, in version control:

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(
            provider: 'ollama',
            text_model: 'llama3.2',
            max_tokens: 2000,
        );
    }

    public static function production(): static
    {
        return new static(
            provider: 'openai',
            text_model: 'gpt-4o',
            max_tokens: 8000,
        );
    }
}

Read it and the question from section 3 answers itself. Staging's values are right there, next to production's, in a file with a git history and an author on every line.

Two properties matter beyond readability. The values are typedmax_tokens is an int because the constructor says so, not because of what someone typed into a text file. And resolution happens through the container, after providers boot, so there is no env() call anywhere in the path and the caching trap from section 5 cannot occur.


7. Why Laravel's Stock Keys Never Move

Here is the objection I'd expect from any experienced Laravel developer reading this, and it's a good one:

"DB_HOST isn't a secret. Neither is QUEUE_CONNECTION, CACHE_STORE, SESSION_LIFETIME, or AWS_BUCKET. By your own argument those should move too — so why don't they?"

They shouldn't, and the reason has nothing to do with secrecy. There are three, and the first is a hard technical wall.

Timing

Laravel boots through six bootstrappers, in this exact order:

1. LoadEnvironmentVariables
2. LoadConfiguration      ← every config/*.php is evaluated here
3. HandleExceptions
4. RegisterFacades
5. RegisterProviders      ← a settings class is bound here
6. BootProviders

A typed settings class is resolved out of the service container, and it only exists in the container once its service provider has registered — step 5. Config files are evaluated at step 2.

So you cannot write this:

// config/database.php — this cannot work
'host' => envSettings(DatabaseSettings::class)->host,

Not because it's poor style. Because at the moment that line executes, the binding does not exist and the provider that would create it has not run. The object is not late; it is not yet a thing.

APP_ENV is a stronger case still. It is the input to the entire resolution mechanism — a settings class has to know the environment before it can decide which factory to call. Moving APP_ENV into a settings class is circular by construction.

Ownership of the read path

DatabaseManager reads config('database.connections.mysql.host'). CacheManager reads config('cache.stores'). Every third-party package reads config('their-package.*').

None of that code knows your settings classes exist, and you don't control it. Move the value somewhere nicer and the framework simply doesn't find it — you haven't relocated a value, you've deleted it from the only place anything looks.

They are the one part of .env that is documented

This one is about people rather than mechanics, and I think it's underrated.

DB_HOST means the same thing in every Laravel application on earth. Forge knows it. Envoyer, Vapor, Sail, every Docker image, every CI template, every tutorial and every developer you will ever hire knows it. That shared vocabulary is worth real money in onboarding and tooling, and you get it for free.

The problem this article is about is undocumented, application-specific configuration — the keys that only exist in your project, that nobody outside your team has ever seen. Laravel's own keys are exactly the subset that doesn't suffer from that.

The better formulation

Which gives a sharper rule than "secret versus non-secret":

The boundary is not secret vs non-secret. It is read by the framework vs read only by you.

Framework and vendor read path → config files and .env, always, regardless of how unsecret the value is. Your application's own environment-varying decisions → typed classes, where they can be reviewed and compared.


8. The Decision Table

The value is…Where it belongs
A credential, key, token or password.env
A Laravel stock key (APP_*, DB_*, MAIL_*, QUEUE_*, CACHE_*).env and its stock config file
Read by a third-party package through config()that package's published config file
Framework wiring — disks, connections, driversa config file
Identical in every environment, read only by your codea config file is perfectly fine
Something that must change without a deployment.env
Different per environment, read only by your codea typed settings class
One of a fixed set of optionsa typed settings class, typed with an enum

The rows above the bold one are the majority of most applications, and config() handles them well. The bold row is the one that has been quietly costing you review history and type safety.


9. What It Looks Like in Practice

The practical difference isn't the declaration — it's what becomes possible once the values are objects in your repository rather than strings on a server.

You can see what an environment resolves to, without logging into it:

php artisan env-settings:show

You can answer section 3's question directly:

php artisan env-settings:diff AiSettings staging production

That is the question config() could never answer, answered from your laptop, without SSH access, without production being reachable, and without secrets landing on your screen.

You can fail the build on incomplete configuration:

php artisan env-settings:check --env=production

Which catches the class of bug where a factory ships with a // TODO and an empty string in it — something that currently deploys green and is discovered days later.

None of these are possible when the values live in a gitignored text file, and all of them fall out naturally once the values are code.


10. The Short Version

  • config() is the framework's read path, and it is excellent at that. Keep using it for everything Laravel and your vendor packages read.
  • "Never call env() outside config files" is the correct rule — and it funnels every environment-varying value into a file that structurally cannot describe the variation.
  • A config file describes one resolved state. It cannot tell you what the other environments use, and env()'s second argument is a fallback, not documentation.
  • Laravel's stock keys never move, because config files are evaluated at bootstrap step 2 and container bindings don't exist until step 5 — plus the framework reads them directly, and they're the one well-documented part of your .env.
  • The real boundary is "read by the framework" vs "read only by you." Everything on the second side is a candidate for types, review, and a diff.

If most of your .env is on the second side of that line — and in a modern application it usually is — the tooling exists: laravel-env-settings is the package I developed for it, and the side-by-side breakdown will tell you in about a minute whether it applies to your codebase or not.