How to Manage PHP Configuration and Secrets Without Committing Them
Separate PHP configuration and secrets from committed code using environment variables—here is the difference between .env files and php.ini and the production caching pitfall.
Stop Committing Secrets: PHP Config That Survives Production
A .env file does not make secrets safe. It never did. The real job is keeping API keys, database credentials and runtime flags out of version control and off the request path. A .env file committed to a repository or parsed on each HTTP hit is a leak and a performance failure, not a solution. Stop treating it as one.
The core mechanism is the twelve-factor principle: store config in the process environment. But PHP does not read that natively. It reads php.ini. The confusion pair that damages live setups is treating .env as a replacement for the engine configuration file rather than a development convenience. The ini file controls the engine before your code boots: error_reporting, display_errors, opcache, memory_limit. A .env file, read by a library like vlucas/phpdotenv, populates app-level config after the engine is already running. Conflating the two is how staging leaks stack traces while live servers crawl.
The Three-Part Discipline
Separate code from config. Keep secrets out of source control. Control which values reach which context. Code lives in the repository. Config lives outside it. Secrets, a credential-level subset, live in a vault or the server environment and are never written to disk in plain text inside the project directory.
Failure to enforce this produces the .env-in-live problem. When a framework loads a .env file on every request, it adds file I/O and string parsing overhead. If the config is not cached, that overhead repeats for each HTTP hit. A single parse of a 50-line file is negligible. On a site serving 200 requests per second, it becomes a measurable cost. The fix is a configuration cache, built once at deploy time, that bakes the resolved values into a single PHP file loaded by the framework. Laravel offers php artisan config:cache for exactly this reason. Symfony has a similar compile step. Without it, a .env file is a development convenience that becomes a live liability.
The .env and php.ini Split
The confusion pair .env vs php.ini is a hiring-interview weed-out question for a reason. The ini file is the PHP runtime configuration. It is read once per process at startup by the Zend Engine. Its directives control memory limit, error reporting, extension loading, and opcache behaviour. Application code cannot alter it after the process starts except through ini_set(), and many directives are locked at that point. A .env file is an app-level convention, not a PHP mechanism. It is a plain-text file of KEY=value pairs parsed by a Composer package such as vlucas/phpdotenv and loaded via putenv() or $_ENV.
The practical split: use the ini file or PHP-FPM pool config for settings the engine must know before your first line of code runs. Use server-fed variables, or a secrets manager, for app config. Use a .env file only in development, and never commit it. The file exists to give a developer a local replica of the values that will exist in the live system, without manually exporting them into every shell session.
Twelve-Factor Config in PHP
The twelve-factor methodology states that configuration is everything likely to vary between deploys. Database URLs, API keys, and per-context debug flags are config. Your code reads these from getenv() or a framework config service, never from a hardcoded array inside a config file.
The anti-pattern is a config/database.php file that switches on a context name with a conditional. That pattern commits environment structure to the repository. A developer who adds a staging branch must now update a config file, which defeats the purpose. The correct pattern is a single config file that references server-fed variables as defaults. In Laravel, the config/database.php file uses the env() helper only in config files, and those config files are the sole place where such values are read. The rest of the codebase reads from the config service, which is cached in live operation.
A concrete failure: a team deploys to a container platform that injects the process context, but their code reads from a .env file baked into the Docker image. The file overrides the live values because the library reads the file after the real context is set. The live database password is now the development password, and the deploy passes all smoke tests because the wrong database still responds.
vlucas/phpdotenv: Convenience to Trap
The vlucas/phpdotenv package is the de facto standard for loading .env files in PHP. Usage is straightforward: require the package, create a .env file in the project root, and call $dotenv->load() early in the bootstrap process. The library reads the file, parses the key-value pairs, calls putenv() and populates $_ENV and $_SERVER. It also supports type casting, variable interpolation, and boolean parsing.
Where the Defaults Break
The problem is not the library. It is the default usage pattern that ships with framework skeletons. Laravel's bootstrap/app.php loads the .env file unconditionally in development. A live deploy that does not remove that call or that lacks a cached config will parse the file on every request. The fix is a context check: call $dotenv->load() only when APP_ENV is not set to the live context, or use the framework's optimisation command that disables the loader entirely.
A second failure mode is the .env.example anti-pattern. A team commits a .env.example file with placeholder values, but a developer copies it to .env, fills in real credentials, and accidentally commits the real file. The .gitignore entry for .env prevents the commit if it exists. It cannot prevent a developer from running git add -f .env or from renaming the file. The only reliable guard is a pre-commit hook or CI check that scans for patterns matching credential structures: AWS keys, database connection strings, and Stripe secret keys.
A Configuration Hierarchy That Survives Live Operation
A configuration hierarchy is the order in which settings are read and overridden. The durable pattern for PHP, drawn from Symfony's config component and adopted by Laravel, is a four-layer stack. At the base, a set of framework defaults shipped with the package. Above that, app config files in a config/ directory that define fallbacks. Above that, server-injected values. At the top, a configuration cache file generated at deploy time that freezes the resolved values.
How the Cache Works
This hierarchy means a setting can be changed by altering a server-fed variable without touching code, but the codebase does not parse a .env file on every request to find that variable. The value is baked into the cache. A deploy pipeline runs php artisan config:cache or the Symfony equivalent after injecting live variables. The resulting file is a single PHP array in bootstrap/cache/config.php. It loads in one opcache hit. No file reads, no string parsing, no putenv() calls per request.
The Error Visibility Triad
The live config that matters most is the error visibility triad. display_errors must be Off. This is not an app setting. It belongs in the engine configuration file or the PHP-FPM pool config because a fatal error before the framework boots will bypass any app-level error handler. error_reporting should be set to E_ALL & ~E_DEPRECATED & ~E_STRICT in live operation. Deprecation notices from a PHP minor version migration are noise in logs, not a user-facing problem. log_errors must be On, and the log file path must be outside the document root with strict file permissions.
Secrets Management Beyond the .env File
A .env file is not secrets management. It is a plain-text file on disk. Secrets management means credentials are never stored in the project directory, never committed, and rotated on a schedule. For PHP apps in a cloud context, the minimum viable approach is to inject secrets as process variables at the container or instance level and never write them to a file inside the app root.
An AWS EC2 instance can pull secrets from AWS Secrets Manager at boot time and inject them into the PHP-FPM context via the pool configuration file. The PHP process inherits them from its parent, and getenv() returns the value with no file I/O. A Docker container can receive secrets via the orchestrator's secret injection mechanism, which mounts them as a tmpfs volume that exists in memory, not on disk. The code reads the secret file once at bootstrap and holds the value in a PHP variable scoped to the request or the process lifetime.
The failure to avoid is a secrets.php file in the config directory that returns an array of credentials. That file is a commit away from a breach. Rotation becomes a code change. Multiple contexts mean multiple copies of the file, each with slight variations, and drift is inevitable. The committed code separation principle answers this: if a file contains a value that differs between contexts, it does not belong in version control. Config structure belongs in version control. Config values do not.
PHP-FPM Pool Config and Engine-Level Settings
The PHP-FPM pool configuration file is where per-app engine settings live in a live environment. It can set process variables for a specific pool, overriding the system context. This is the correct place to inject database credentials and API keys when not using a secrets manager, because the values are scoped to the app's FPM workers and never appear in a file inside the web root.
Memory and OPCache Settings
The pool config also controls the engine directives that matter at scale. memory_limit should be set to the peak legitimate usage of the app plus a 20% buffer. A 128MB limit on a container with 512MB of RAM leaves room for the FPM process overhead and the opcache shared memory segment. Setting it to -1 masks memory leaks until the OOM killer terminates the process. error_reporting and display_errors have pool-level overrides that take precedence over the global engine config, which is how a single server running multiple apps can have different error visibility per app.
opcache configuration belongs in the pool file as well. opcache.enable=1 is not enough. opcache.validate_timestamps should be 0 in live operation to eliminate the stat call on every request. opcache.revalidate_freq is irrelevant when timestamps are off. opcache.max_accelerated_files must exceed the number of unique PHP files in the project by at least 20%, or the cache will churn. A Laravel app with hundreds of vendor files, application files and framework bootstrap files needs a value sized to the codebase. The default is usually sufficient, but check yours.
The Config Cache Defines Production Readiness
A config cache is a single PHP file that contains the merged, resolved configuration for the entire codebase. Building it is a deploy step, not a runtime operation. The framework reads the process context once at cache build time, resolves all fallbacks, and writes the result as an array that can be loaded with a single require statement. No variable reads, no file parsing, no conditionals. The opcache holds it in shared memory, and every request loads it in microseconds.
The failure case is a deploy that clears the config cache but does not rebuild it, or a rebuild that runs before the context variables are available. The cached file contains empty strings for database credentials. The app boots without errors but every database query fails. The smoke test that catches this is a specific health-check endpoint that performs a lightweight database query and returns a 200 status. A generic uptime check that hits a static route will report the app is healthy while it serves 500 errors on every dynamic page.
The config cache is also the mechanism that makes the .env file irrelevant in live operation. When the cache exists, the framework loads it and never touches the .env loader. A .env file can sit in the project root with development credentials, and the live system will ignore it. This is the correct behaviour, but it requires the deploy pipeline to enforce that the cache is built after context injection and before the web server is reloaded.
What Deploy Pipelines Get Wrong
The single thing that most often goes wrong is a deploy pipeline that runs composer install before injecting context variables. The Composer post-install scripts may include an artisan optimize or cache clear command. If that command runs without the live database URL, the cached config is invalid. Fix it by ordering the deploy steps so that context variables are available before any PHP process executes. In a Docker build, this means the config cache build happens at container startup in an entrypoint script, not in the Dockerfile where build-time arguments may differ from runtime variables.
A second recurring failure is the .env file overriding server-injected variables because the app loader runs putenv() after the server sets the real context. The vlucas/phpdotenv library has an overload method that controls this behaviour. The default is to overwrite existing variables, which is the opposite of what live operation needs. Set the overload parameter to false so the library will not replace a variable that already exists. The server-injected value wins, and the .env file is a fallback only for development where the variable is absent.
Common Questions
Should I commit the .env file to version control?
No. The .env file contains context-specific values and often secrets. Commit a .env.example file with placeholder keys and no real credentials. The .env file itself must be in .gitignore. If a credential enters the repository history, revoke it immediately and rewrite the history with git filter-branch or BFG Repo-Cleaner.
What is the difference between getenv() and $_ENV in PHP?
getenv() reads from the actual process context. $_ENV is a superglobal populated only if the variables_order INI directive includes 'E'. In a PHP-FPM pool, variables set in the pool configuration are available to getenv(). $_ENV may be empty if the directive excludes it. Prefer getenv() for cross-context consistency.
How do I set context variables in PHP-FPM?
Edit the pool configuration file, typically at /etc/php/8.x/fpm/pool.d/www.conf. Add env[DB_PASSWORD] = your_value entries. Restart PHP-FPM. The variables are available to getenv() in the PHP process and are scoped to that pool. They never appear in a file inside the document root.
Why is display_errors On in my staging context even though the engine config sets it to Off?
The app or framework is likely overriding it at runtime. Check for ini_set('display_errors', 1) in the bootstrap file or a framework debug mode that sets it. In Laravel, APP_DEBUG=true sets display_errors on. Fix it by setting APP_DEBUG=false in the staging context and relying on log_errors instead.
Does the config cache eliminate the need for context variables?
No. The config cache freezes the resolved values from context variables at build time. The variables must still exist when the cache is built. The cache eliminates the runtime cost of reading them on every request, but the values originate from the context.
Can I use ini_set() to configure engine settings in live operation?
For some directives, yes. But many engine-level settings like memory_limit are locked after startup and cannot be changed with ini_set(). Settings that control the engine before your code runs, such as opcache.enable and display_errors, must be set in the engine config file, the PHP-FPM pool configuration, or a .user.ini file per directory.
What is the twelve-factor rule for PHP configuration?
Store configuration in the process context, not in code. Database URLs, API keys, and per-deploy flags come from the context, read once at bootstrap, and never hardcoded. PHP implements this by reading getenv() in config files, caching the resolved config, and never writing credentials to files inside the project.