Environment Management
Reviewed & published by Brayan K
By the end of this lesson you'll keep passwords and API keys out of your code entirely — storing them in a .env file, reading them at runtime, and running the same code safely on dev, staging, and production.
Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Explain why hardcoding secrets in code is dangerous
- Store settings in a .env file (KEY=value) per environment
- Load a .env with vlucas/phpdotenv into $_ENV and getenv()
- Read config safely with defaults so a missing key never crashes
- Switch behaviour for dev / staging / production from one codebase
- Keep secrets out of git with .gitignore and a committed .env.example
1️⃣ The Problem: Secrets in Code
A secret is any value you'd be unhappy to see on a billboard — a database password, an API key, a token. The cardinal sin is writing one straight into your source: $dbPass = "super_secret_123";. The moment you commit that, it's in your git history forever — deleting the line later does nothing, because the old commit still has it. The fix is environment variables: values that live outside your code, on the machine, and that your program reads when it runs. In PHP the everyday way to manage them is a .env file — plain text, one KEY=value per line.
2️⃣ Loading a .env with phpdotenv
The standard tool is the vlucas/phpdotenv library (install it with composer require vlucas/phpdotenv). You point it at the folder holding your .env, call load(), and every line becomes available through getenv(), $_ENV, and $_SERVER. The worked example below parses the same text by hand so you can watch what the library does for you — then reads the values back the real way.
<?php
// HOW phpdotenv WORKS (the standard PHP library: composer require vlucas/phpdotenv)
//
// use Dotenv\Dotenv;
// $dotenv = Dotenv::createImmutable(__DIR__); // looks for a .env in this folder
// $dotenv->load(); // reads it into $_ENV + getenv()
//
// After load(), every KEY=value in the .env is available three ways:
// getenv("DB_HOST") $_ENV["DB_HOST"] $_SERVER["DB_HOST"]
//
// To run this example without Composer, the snippet below parses the same .env
// text by hand so you can SEE what phpdotenv does for you.
$dotenv = <<<ENV
APP_ENV=local
APP_DEBUG=true
DB_HOST=127.0.0.1
DB_PASSWORD="s3cr3t p@ss"
ENV;
// Tiny stand-in for phpdotenv: split lines, split on the first "=", store it.
foreach (explode("\n", $dotenv) as $line) {
$line = trim($line);
if ($line === "" || str_starts_with($line, "#")) {
continue; // skip blanks and # comments
}
[$key, $value] = explode("=", $line, 2); // split on the FIRST = only
$value = trim($value, " \"'"); // strip spaces and quotes
$_ENV[$key] = $value; // phpdotenv fills $_ENV like this
putenv("$key=$value"); // ...and getenv() like this
}
// Now read them back — exactly how you would after phpdotenv's load().
echo "From \$_ENV: " . $_ENV["DB_HOST"] . "\n"; // 127.0.0.1
echo "From getenv: " . getenv("DB_PASSWORD") . "\n"; // s3cr3t p@ss
echo "APP_ENV: " . $_ENV["APP_ENV"] . "\n"; // local
?>In a real app you write only the three lines at the top (createImmutable → load()) and the library does the parsing. Immutable means a real server-level variable always wins over the .env file — exactly what you want in production.
3️⃣ Defaults and Types (the env() helper)
Two traps catch everyone. First, getenv("MISSING") returns the boolean false when a key isn't set — not an error, just a silent false that breaks things downstream. Second, every value from a .env is a string, so "false" is the text f-a-l-s-e, which is truthy in PHP. A tiny env() helper solves both: it supplies a default for missing keys and converts "true"/"false" into real booleans. (Frameworks like Laravel ship exactly this helper.)
<?php
// PROBLEM: getenv("FOO") returns the literal boolean false when FOO is not set,
// and an env value is ALWAYS a string ("false" is the text f-a-l-s-e, not false!).
// A small env() helper fixes both: it supplies a default and converts types.
function env(string $key, mixed $default = null): mixed
{
$value = $_ENV[$key] ?? getenv($key);
if ($value === false || $value === null) {
return $default; // not set → fall back to the default
}
// Turn the common string values into real PHP types.
return match (strtolower($value)) {
"true" => true,
"false" => false,
"null" => null,
default => $value, // anything else stays a string
};
}
// Pretend phpdotenv already loaded these two:
$_ENV["APP_DEBUG"] = "true";
$_ENV["DB_HOST"] = "10.0.0.5";
// Required value that exists → you get it.
echo "DB host: " . env("DB_HOST") . "\n"; // 10.0.0.5
// "true" the string becomes the boolean true.
$debug = env("APP_DEBUG");
echo "Debug on? " . ($debug === true ? "yes" : "no") . "\n"; // yes
// Missing key → you get YOUR default, not a crash and not false.
echo "Cache TTL: " . env("CACHE_TTL", 3600) . "\n"; // 3600 (the default)
?>4️⃣ Config Per Environment (dev / staging / prod)
The 12-Factor App methodology has one golden rule here: separate config from code. You ship one identical codebase to every machine, and only the environment differs. So your laptop, the staging server, and production all run the same files — but production reads a .env where APP_ENV=production, turning debug off and caching on. The litmus test: could you make this repo public right now without leaking a single password? If yes, your config is in the right place.
<?php
// 12-FACTOR RULE: the SAME code runs everywhere. Only the config changes.
// You don't ship a "dev build" and a "prod build" — you ship one app and feed
// it a different .env on each machine (dev / staging / production).
function env(string $key, mixed $default = null): mixed
{
$value = $_ENV[$key] ?? false;
return $value === false ? $default : $value;
}
// On production this comes from the server's .env. Here we set it by hand.
$_ENV["APP_ENV"] = "production";
$appEnv = env("APP_ENV", "local"); // default to "local" if nothing is set
// One switch drives behaviour that should differ between environments.
$config = match ($appEnv) {
"production" => [
"debug" => false, // never leak stack traces to users
"cache" => true,
"log_level" => "error", // only the important stuff
],
"staging" => [
"debug" => false,
"cache" => true,
"log_level" => "warning",
],
default => [ // "local" / dev
"debug" => true, // show full errors while building
"cache" => false, // always see fresh code
"log_level" => "debug", // log everything
],
};
echo "Environment: $appEnv\n";
echo "Debug: " . ($config["debug"] ? "ON" : "OFF") . "\n";
echo "Caching: " . ($config["cache"] ? "ON" : "OFF") . "\n";
echo "Log level: " . $config["log_level"] . "\n";
?>Now you try. The script below has already "loaded" the environment for you — fill in each ___ using the 👉 hint, then run it and check it against the Output panel.
<?php
// 🎯 YOUR TURN — read two values out of the loaded environment.
// phpdotenv has already filled $_ENV for you (we set it by hand below).
$_ENV["APP_NAME"] = "My Blog";
$_ENV["APP_ENV"] = "staging";
// 1) Read APP_NAME out of $_ENV
$name = ___; // 👉 use $_ENV["APP_NAME"]
// 2) Read APP_ENV using the getenv-style superglobal too
$where = ___; // 👉 use $_ENV["APP_ENV"]
echo "$name is running on: $where\n";
// ✅ Expected output:
// My Blog is running on: staging
?>One more — the part that keeps apps from crashing. Finish env() so a missing key falls back to the default you pass in.
<?php
// 🎯 YOUR TURN — never let a MISSING setting crash your app.
// Finish env() so a missing key falls back to the default you pass in.
function env(string $key, mixed $default = null): mixed
{
$value = $_ENV[$key] ?? false;
// 👉 if $value is false (the key wasn't set), return $default; otherwise $value
return ___; // 👉 e.g. $value === false ? $default : $value
}
// PAGE_SIZE is NOT set, so env() must hand back the default of 20.
echo "Page size: " . env("PAGE_SIZE", 20) . "\n";
// ✅ Expected output:
// Page size: 20
?>5️⃣ Keeping Secrets Out of Git
The .env file holds real secrets, so it must never be committed. You enforce that with one line in your .gitignore. But your teammates still need to know which variables the app expects — so you commit a .env.example instead: the same keys, with blank or fake values. It's living documentation that leaks nothing. A new developer just copies it: cp .env.example .env, then fills in their own values.
# .gitignore — tell git to NEVER track the real secrets file
.env
.env.local
.env.*.local
# .env.example — COMMIT this. Same keys, no real values.
APP_ENV=local
APP_DEBUG=true
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=
DB_USER=
DB_PASSWORD=
STRIPE_KEY=Common Errors (and the fix)
- You committed .env and pushed it — the secret is now in git history, so removing the file in a new commit is not enough. Treat the credential as compromised: rotate it (generate a new password/key) and add .env to .gitignore so it never happens again. Scrubbing history (e.g. git filter-repo) helps but rotating is what actually protects you.
- Hardcoded config — "works on my machine" — you wrote $host = "127.0.0.1"; directly in the code, so it breaks the moment production needs a different host. Move it to .env and read it with env("DB_HOST"); the same code then runs everywhere.
- "Undefined array key" / a value is silently empty — you read $_ENV["FOO"] for a key that isn't in the .env. Never read raw with no fallback; use an env("FOO", $default) helper so a missing key returns a sensible default instead of a warning.
- APP_DEBUG=false still shows errors — the value is the string "false", which is truthy. Don't test it raw; compare (env("APP_DEBUG") === "true") or use a helper that maps the text to a real boolean.
Pro Tips
- 💡 Validate required vars at boot. phpdotenv's $dotenv->required(["DB_PASSWORD"])->notEmpty(); makes the app fail loudly on startup instead of mysteriously later.
- 💡 Never echo or log a secret. Print [hidden] in dumps and keep keys out of error pages — logs get shared and screenshotted.
- 💡 Use real environment variables in production. A .env file is convenient for dev; on a server, OS-level variables or a secrets manager (AWS Secrets Manager, Vault) are even safer.
📋 Quick Reference — Environment & Config
| Thing | Example | What It Does |
|---|---|---|
| .env | DB_HOST=127.0.0.1 | Per-environment secrets (never commit) |
| .env.example | DB_HOST= | Template of required keys (commit this) |
| .gitignore | .env | Stops git from tracking the secrets file |
| getenv() | getenv("DB_HOST") | Read a var (returns false if unset) |
| $_ENV | $_ENV["DB_HOST"] | Superglobal phpdotenv fills on load() |
| env(k, d) | env("PORT", 80) | Read with a default + type conversion |
| phpdotenv | Dotenv::createImmutable() | Loads a .env into $_ENV / getenv() |
Mini-Challenge: A Config Loader
No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is exactly the read-config-safely loop every real app uses on startup.
🎉 Lesson Complete!
- ✅ Secrets never go in code — once committed they're in git history forever
- ✅ A .env file holds KEY=value settings that differ per environment
- ✅ vlucas/phpdotenv loads it into $_ENV and getenv()
- ✅ An env() helper adds defaults and converts "true"/"false" to real booleans
- ✅ One codebase + a different .env per machine = the 12-factor way (dev/staging/prod)
- ✅ .gitignore the real .env; commit a .env.example instead
Practice quiz
Why is it dangerous to hardcode a database password directly in your code?
- PHP refuses to run files that contain passwords
- Hardcoded strings are automatically logged to the screen
- Once committed, the password lives in git history forever, and the same code can't run with different credentials per environment
- It makes the file too large to deploy
Answer: Once committed, the password lives in git history forever, and the same code can't run with different credentials per environment. Committed secrets stay in history forever, and baking them in means you'd need a different copy of the code per environment.
What is the format of each line in a .env file?
- KEY=value, one per line
- <?php define(KEY, value); ?>
- JSON objects
- KEY: value with YAML indentation
Answer: KEY=value, one per line. A .env file is plain text — one KEY=value pair per line, no spaces around the =.
Which standard PHP library loads a .env file into $_ENV and getenv()?
- symfony/dotenv-loader
- php-env/reader
- composer/dotenv
- vlucas/phpdotenv
Answer: vlucas/phpdotenv. The lesson uses vlucas/phpdotenv: createImmutable() then load() fills $_ENV, getenv(), and $_SERVER.
What does getenv("MISSING") return when the key is not set?
- null
- The boolean false
- An empty string
- It throws an exception
Answer: The boolean false. getenv() returns the boolean false for an unset key — a silent false that breaks things downstream if not handled.
Why does APP_DEBUG=false in a .env still behave as if debug is on, unless you convert it?
- Because every .env value is a string, and the non-empty string "false" is truthy in PHP
- Because .env files are case-insensitive
- Because false is a reserved word PHP ignores
- Because phpdotenv inverts booleans
Answer: Because every .env value is a string, and the non-empty string "false" is truthy in PHP. All .env values are strings; the text "false" is truthy, so you must compare === "true" or map it with an env() helper.
What is the main purpose of a small env() helper function in this lesson?
- To encrypt the .env file
- To upload secrets to a server
- To supply a default for missing keys and convert "true"/"false" strings into real booleans
- To delete unused environment variables
Answer: To supply a default for missing keys and convert "true"/"false" strings into real booleans. The env() helper returns a default when a key is missing and converts common string values into real PHP types.
According to the 12-Factor App rule taught here, what should differ between dev, staging, and production?
- The codebase — ship a separate build per environment
- Only the environment/config — you ship one identical codebase everywhere
- The PHP version on each machine
- Nothing — all three must be identical including secrets
Answer: Only the environment/config — you ship one identical codebase everywhere. Separate config from code: ship one codebase and feed it a different .env on each machine.
What should you do with the real .env file regarding git?
- Commit it so teammates have the secrets
- Encrypt it and commit the encrypted version
- Rename it to config.php and commit that
- List it in .gitignore so it is never committed, and commit a .env.example instead
Answer: List it in .gitignore so it is never committed, and commit a .env.example instead. The real .env holds secrets and must be gitignored; commit a .env.example with the same keys but blank/fake values.
If you accidentally committed and pushed a .env with a live password, what is the most important fix?
- Just delete the file in a new commit
- Rotate the credential (generate a new password/key), because it is now in git history
- Make the repository private
- Add a comment saying it's a fake password
Answer: Rotate the credential (generate a new password/key), because it is now in git history. Removing the file later doesn't help — the secret is in history. Treat it as compromised and rotate it.
What is the purpose of committing a .env.example file?
- It is the file the app actually reads at runtime
- It stores the production password safely
- It documents which keys the app needs, with blank or fake values, without leaking any real secret
- It is required by Composer to install dependencies
Answer: It documents which keys the app needs, with blank or fake values, without leaking any real secret. .env.example is living documentation: same keys, no real values — a new developer copies it to .env and fills in their own.
Continue this course
- Previous: Cron Jobs, Background Tasks & Scheduling Workers
- Next: Integrating Payment Gateways (Stripe, PayPal, Mollie) — Accept payments in PHP with Stripe Checkout, webhooks, and refund handling
- Quick reference: PHP cheat sheet
Frequently asked questions
Why can't I just hardcode the database password in my code?
Two reasons. First, the moment you commit it, the password lives in your git history forever — anyone who ever clones the repo (or sees it leak publicly) has it, and deleting the line later does not remove it from history. Second, the same code has to run on your laptop, on staging, and on production, each with different credentials; if the password is baked into the code you would need a different copy of the code per environment, which defeats the point. Keep secrets in a .env file (or real server environment variables) that the code reads at runtime.
What is the difference between getenv(), $_ENV, and $_SERVER?
All three can hold environment variables, but they are populated differently. getenv("KEY") reads the process environment (set by the OS, a Docker container, or putenv()). $_ENV and $_SERVER are PHP superglobal arrays; whether real OS variables appear in them depends on the variables_order setting in php.ini. The phpdotenv library deliberately fills all of $_ENV, getenv(), and $_SERVER when you call load(), so in a typical PHP app you can read a .env value from any of them. Pick one style and be consistent — most teams standardise on a small env() helper.
Should I commit my .env file to git?
No — the real .env contains live secrets and must be listed in .gitignore so it is never committed. Instead, commit a .env.example: the same keys with blank or fake values (DB_PASSWORD= or DB_PASSWORD=changeme). That file is documentation — it tells the next developer exactly which variables the app needs without revealing any real secret. New team members copy .env.example to .env and fill in their own values.
Why does APP_DEBUG=false still behave like it is on?
Because everything in a .env file is a string. The value of APP_DEBUG is the four-character text "false", and in PHP a non-empty string is truthy — so if (env("APP_DEBUG")) is true even when you wrote false. Convert it explicitly: compare the string (env("APP_DEBUG") === "true") or use a helper that maps the text "true"/"false" to the real booleans, as the env() helper in this lesson does.
What does "separate config from code" (the 12-factor app) actually mean?
It means anything that differs between deploys — credentials, hostnames, API keys, feature flags — lives in the environment, not in your source files, so you ship one identical codebase everywhere and only the environment changes. The litmus test from the Twelve-Factor methodology: could you open-source your repository right now without leaking a single credential? If yes, your config is properly externalised. If a password would be exposed, it belongs in a .env / environment variable instead.