Lesson 03 / 15
Configuration and Environment Variables
Separating deployment-dependent values such as the connection name and the key from the code and reading them from environment variables; the explicit error on a missing variable and the cost of a value fixed in code.
Published Updated
In this lesson
- Builds a configuration that reads deployment-dependent values from environment variables instead of writing them into the code
- Distinguishes a default value from an explicit error on a missing variable
- Measures that the same code gives a different result when the environment changes and classifies the result as environment
- Reads that a value fixed in code relies on the environment's deviation and what that costs
Contents
In the Cookies lesson the cart stayed on the client, and in the Sessions lesson on the server. In both, the request determined the value. Not every value an application reads comes from the request: which database to connect to, the signing key and the address of an external service are determined when the application is deployed. The same code runs with one value on the developer’s machine and with another in production.
The most common way to carry these values is the environment variable: name and value pairs that the operating system gives to a process. In PHP, getenv() reads and putenv() writes. The person or tool performing the deployment sets the variable before the process starts; application code normally only reads it. In the Production Go: Testing, Tooling and Performance course, the question asked was which tool a piece of information was read from. Here the question is who can change the value.
The Configuration Layer
Reading from the environment is not scattered across the code; it is gathered in one place. config.php provides three functions, and it is the basis of the measurement.
<?php // config.php — helpers that read configuration from environment variables declare(strict_types=1); function env(string $name, ?string $fallback = null): string { $value = getenv($name); if ($value !== false) { return $value; } if ($fallback !== null) { return $fallback; } throw new RuntimeException("missing environment variable: $name"); } function config(): array { return [ 'database' => env('APP_DATABASE', 'app.db'), 'key' => env('APP_KEY'), ]; } /** Sets the variables temporarily, does the task, puts the previous values back. */ function withEnvironment(array $variables, callable $task): mixed { $previousValues = []; foreach ($variables as $name => $value) { $previousValues[$name] = getenv($name); putenv("$name=$value"); } try { return $task(); } finally { foreach ($previousValues as $name => $previous) { putenv($previous === false ? $name : "$name=$previous"); } } }
env() reads the variable with getenv(). If the variable does not exist, getenv() returns false; the function first looks at the fallback value, and if that was not given either, it throws a RuntimeException. The distinction between a default and an error is deliberate: there is a reasonable default for the database name, and none for the key. If the key is missing, moving on silently with an empty value would put an unsigned application into production; the application has to stop as it starts.
config() gathers the values in an array. The rest of the code does not know getenv(); it asks for this array. withEnvironment() is the measurement helper: it sets the variables temporarily, does the task and puts the previous values back.
The party core is the same in this lesson and does not change.
<?php // party.php — who can change this result? declare(strict_types=1); // The parties outside the code, in decision order: every request, every external call, every deployment. const EXTERNAL_PARTIES = ['client', 'third party', 'environment']; /** Computes the result once without deviation, then once with each external party's deviation. */ function probe(callable $result, array $deviations): array { $baseline = $result([]); $changed = []; foreach (EXTERNAL_PARTIES as $party) { if (array_key_exists($party, $deviations)) { $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline; } } return $changed; } /** The first external party that can change the result; the server if none can. */ function party(array $changed): string { foreach (EXTERNAL_PARTIES as $p) { if ($changed[$p] ?? false) return $p; } return 'server'; } function partyReport(string $name, callable $result, array $deviations): string { $changed = probe($result, $deviations); $lines = []; foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change'); $owner = party($changed); echo "$name → $owner\n ", implode(' · ', $lines), "\n"; return $owner; }
The Same Code, a Different Environment
environment.php first deletes the two variables, so the lesson does not trust what the working environment carries. Then it calls config() with the key missing and catches the error. The class name and the message of the error are printed; since this code wrote the message, it does not depend on the environment. Then it sets the key and prints whether it was read. The key itself is not printed: a secret value does not enter the log, the output or an error message.
<?php // environment.php — the same code, a different environment, a different connection name declare(strict_types=1); require 'party.php'; require 'config.php'; putenv('APP_DATABASE'); putenv('APP_KEY'); try { config(); } catch (RuntimeException $error) { echo get_class($error), ': ', $error->getMessage(), "\n"; } putenv('APP_KEY=lesson-secret-value'); $settings = config(); echo 'key was read: ', $settings['key'] !== '' ? 'yes' : 'no', "\n"; partyReport( 'database name (from environment variable)', fn(array $d) => withEnvironment($d['environment'] ?? [], fn() => config()['database']), ['environment' => ['APP_DATABASE' => 'test.db']], );
RuntimeException: missing environment variable: APP_KEY
key was read: yes
database name (from environment variable) → environment
environment changed
The last measurement is the answer to the question. When the party performing the deployment sets the APP_DATABASE variable to test.db, code that has not changed a single line returns a different name.
SS8. The decision order is client, third party, environment, server. Here the client’s and the third party’s deviations are not defined, because neither the sender of the request nor an external service supplies the value; the only deviation is the environment’s, and it changed the result. The party is environment. The environment’s surface is the narrowest: the value of the variable is given by whoever performs the deployment; running code can change only its own process’s environment with putenv(), and the deviation in the measurement is applied that way. This narrowness determines responsibility, not security. The owner of a wrong value is now not the author of the code but the party that performs the deployment.
SS9. For this reason secret values are not written into code but read from the environment. A key in code enters the repository, reaches everyone who reads the repository, and changing it requires redeploying the code. A key in the environment is not in the repository and can be rotated by whoever performs the deployment. This does not make a leak impossible; it only moves the leak surface from the code repository to the deployment layer.
The Limit of the Default
A default value is a convenience, and it carries a risk. If APP_DATABASE is forgotten, the application opens with app.db and runs as if nothing happened. On a developer machine this is the wanted behavior. In production, writing to the wrong database is more expensive than refusing to start. For this reason a default is given only to variables for which a wrong value is harmless; every variable that could do harm leaves the second argument of env() empty and turns the absence into an error.
The message of the error is also a design decision. The message says the name of the variable and not its value. Saying the name shows the party performing the deployment what is missing; saying the value, if the value is secret, would leak it into the log. The first line of the measurement prints this message, and only the name is visible.
Who holds a variable is also part of the configuration. The environment variable is set by the party that starts the process; a running process can change only its own environment with putenv(), not the environment of another process. This is the real difference between a value from the client and a value from the environment: the client’s value arrives anew with every request and can be different on every request, while the environment’s value arrives when the process starts and stays the same throughout the process unless the code explicitly changes it.
The Bounding Measurement
The alternative to configuration is writing the value into the code. If the same database name sits in the code as a constant, can the environment change it?
<?php // constant.php — the environment cannot change a value fixed in code declare(strict_types=1); require 'party.php'; require 'config.php'; const FIXED_DATABASE = 'app.db'; function fixedDatabase(): string { return FIXED_DATABASE; } partyReport( 'database name (fixed in code)', fn(array $d) => withEnvironment($d['environment'] ?? [], fn() => fixedDatabase()), ['environment' => ['APP_DATABASE' => 'test.db']], );
database name (fixed in code) → server
environment could not change
SS10. It could not change, and the result is server. The constant ignored the environment’s deviation: even though APP_DATABASE was test.db, the code returned app.db. The same name, in the same code, stays the same in every environment.
This measurement does not say the constant is defensible. The constant is sound against the environment’s deviation, but changing the value means changing the code and redeploying it. If the same name cannot be used on the developer machine and in production, what travels between environments is the code itself, and a separate version is needed for each environment. The constant returns to the server, but its cost is portability.
Summary
- Deployment-dependent values (a connection name, a key) are not written into code; they are read from environment variables and gathered in a single configuration layer.
- A default is given when there is a reasonable value; if an indispensable value such as the key is missing, the application stops as it starts, with a
RuntimeException. - A secret value is not printed to the output, the log or an error message.
- The same code returns a different name when
APP_DATABASEchanges. The measured party of this lesson is environment: the value is determined not by the author of the code but by whoever performs the deployment. - The limit: a value fixed in code stays on the server and the environment cannot change it, but it cannot be carried between environments.
Next Step
Three lessons measured where state lives and who can change it: the client in the cookie, the server in the session, the environment in the configuration. The next topic, Data Access, moves on to the database and asks the same question: is the type of a value read decided by the code or by the environment the code runs in?
To keep your progress and take notes, Log in
My notes
Log in to take notes.