Skip to main content
    Courses/PHP/Environment Management

    Lesson 37 • Advanced

    Environment Management 🔐

    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.

    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.

    A .env file (lives next to your code, never committed)
    # .env  — this file lives next to your code but is NEVER committed to git.
    # Each line is KEY=value. No spaces around the =, no PHP, no quotes needed
    # unless the value has spaces or special characters.
    
    # Which environment is this? Drives debug, error display, etc.
    APP_ENV=local
    APP_DEBUG=true
    
    # Database connection. The password stays HERE, never in your code.
    DB_HOST=127.0.0.1
    DB_PORT=3306
    DB_NAME=blog
    DB_USER=root
    DB_PASSWORD="s3cr3t p@ss"   # quotes because the value has a space
    
    # A third-party API key — a textbook secret. Different per environment.
    STRIPE_KEY=sk_test_4eC39HqLyjWDarjtT1zdp7dc
    Output
    This is a .env file, not a PHP script — it has no output of its own. It's just text that PHP reads. The next example loads it.

    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.

    Loading and reading a .env
    <?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
    ?>
    Output
    From $_ENV:   127.0.0.1
    From getenv: s3cr3t p@ss
    APP_ENV:     local
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    In a real app you write only the three lines at the top (createImmutableload()) 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.)

    An env() helper with defaults and type conversion
    <?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)
    ?>
    Output
    DB host:   10.0.0.5
    Debug on?  yes
    Cache TTL: 3600
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    One codebase, behaviour driven by APP_ENV
    <?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";
    ?>
    Output
    Environment: production
    Debug:       OFF
    Caching:     ON
    Log level:   error
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    🎯 Your turn: read two env values
    <?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
    ?>
    Output
    My Blog is running on: staging
    Fill the two ___ blanks with $_ENV["APP_NAME"] and $_ENV["APP_ENV"], then run it. Your output should be one line.

    One more — the part that keeps apps from crashing. Finish env() so a missing key falls back to the default you pass in.

    🎯 Your turn: a default for missing keys
    <?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
    ?>
    Output
    Page size: 20
    Complete the return so it gives back $default when $value is false. The expected output is 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 + .env.example (the safe pattern)
    # .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=
    Output
    These are config files, not PHP — they have no output. Add the .env line to .gitignore before your first commit, and commit .env.example so the keys are documented.

    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

    ThingExampleWhat It Does
    .envDB_HOST=127.0.0.1Per-environment secrets (never commit)
    .env.exampleDB_HOST=Template of required keys (commit this)
    .gitignore.envStops 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
    phpdotenvDotenv::createImmutable()Loads a .env into $_ENV / getenv()

    Frequently Asked Questions

    Q: 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.

    Q: 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.

    Q: 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.

    Q: 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.

    Q: 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.

    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.

    🎯 Mini-Challenge: write a tiny config loader
    <?php
    // 🎯 MINI-CHALLENGE: a tiny config loader.
    // No code is filled in — work from the steps, then run it.
    //
    // 1. Set up a fake environment (pretend phpdotenv loaded it):
    //      $_ENV["APP_ENV"]     = "production";
    //      $_ENV["DB_PASSWORD"] = "live_secret";
    // 2. Write env($key, $default): return $_ENV[$key] if set, else $default.
    // 3. Read APP_ENV (it exists) and CACHE_DRIVER (it does NOT — default "file").
    // 4. echo two lines using your env() helper for both.
    //
    // 🔒 Bonus: DO NOT echo the password — print "[hidden]" instead, to practise
    //    keeping secrets out of logs and output.
    //
    // ✅ Expected output (example):
    //    Environment: production
    //    Cache driver: file
    //    DB password:  [hidden]
    
    // your code here
    ?>
    Build an env() helper with a default, read one key that exists and one that doesn't, and keep the password out of the output. Match the expected output in the comments.

    🎉 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
    • Next lesson: Payment Gateways — integrate Stripe and PayPal (with their keys safely in .env!)

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service