Environment and Secrets

Which .env file is loaded, why a system environment variable beats it, and how to rotate APP_KEY without losing old ciphertext.

Syntaxenv('SHIPPING_API_KEY', 'default') // only inside config/*.php

Every value that differs between your laptop and the server belongs in the environment, not in the code. Laravel loads it with the DotEnv library before the application is configured, which is why it is available to config/ and nothing earlier.

Which file is loaded

The .env file in the project root. It is listed in .gitignore and stays there. .env.example is committed with placeholder values, so the next person can see which keys exist. If APP_ENV is provided as a real system variable, or --env=staging is passed to Artisan, Laravel loads .env.staging and falls back to .env when that file is absent. A real system environment variable always overrides a line in the file. That is how a secret store, a container runtime or Kubernetes supplies values with no file at all.

Reading values

Everything arrives as a string, with reserved words converted: true, (true), false, (false), null, (null), empty and (empty). Quote any value containing spaces. Call env() only inside config/. Everywhere else config('services.ship.key') and Config::get('services.ship.key') are the same call, written short and long.

Rotating a key

APP_KEY encrypts cookies, sessions and anything through Crypt. php artisan key:generate --show prints a replacement without writing files. Set the new value in APP_KEY and move the old one into APP_PREVIOUS_KEYS; config/app.php passes that comma-separated list to the encrypter, so existing ciphertext and existing cookies still open.

Example

Example · php
<?php
// config/payments.php

return [

    /*
     * This file is the only place env() may be called. After
     * `php artisan config:cache` the .env file is not loaded at all, and
     * whatever env() returned at cache-build time is frozen into the
     * cached config file.
     */

    'secret' => env('PAYMENTS_SECRET'),

    'currency' => env('PAYMENTS_CURRENCY', 'eur'),

    // PAYMENTS_CAPTURE=false in .env arrives as a real boolean false,
    // not the string "false".
    'capture' => env('PAYMENTS_CAPTURE', true),

    // Numbers do not get that treatment, so cast them here.
    'retries' => (int) env('PAYMENTS_RETRIES', 3),

    'webhook' => [
        'tolerance' => (int) env('PAYMENTS_WEBHOOK_TOLERANCE', 300),
    ],

];

When to use it

  • Giving a staging server different credentials without a second branch.
  • Moving secrets out of .env and into a secret store that injects environment variables.
  • Rotating APP_KEY after someone with repository access leaves.
  • Explaining why a value changed in the secret store had no effect on the running site.

More examples

Reading the value, short and long

Config::string(), integer(), float(), boolean(), array() and collection() all throw when the value does not match. Reach for them in code that would otherwise fail much later with a confusing type error, such as a timeout that silently became the string '300'.

Example · php
<?php

use Illuminate\Support\Facades\Config;

// Identical: the helper is a wrapper around the facade.
$secret = config('payments.secret');
$secret = Config::get('payments.secret');

// With a fallback for a key that may not exist.
$currency = config('payments.currency', 'eur');

// Typed reads throw if the stored value is the wrong type, which turns a
// mistyped .env line into an exception at the point of use.
$tolerance = Config::integer('payments.webhook.tolerance');
$capture = Config::boolean('payments.capture');

// Setting at runtime, short and long. Useful in tests, not in requests.
Config::set('payments.capture', false);
config(['payments.capture' => false]);

Committing an encrypted environment file

This is the option when you have no secret store and want the values in version control anyway. The default cipher is AES-256-CBC, which needs a 32 character key. Note that you have moved the problem rather than removed it: the decryption key still has to reach the server by some other route.

Example · shell
# Encrypts .env into .env.encrypted and prints the key once. Store that
# key in a password manager, not in the repository.
php artisan env:encrypt

# --readable keeps variable names visible and encrypts only the values,
# so a pull request shows which keys changed.
php artisan env:encrypt --readable --key=3UVsEgGVK36XN82KKeyLFMhvosbZN1aF

# A second environment file gets its own encrypted copy.
php artisan env:encrypt --env=staging

# On the server. The key comes from LARAVEL_ENV_ENCRYPTION_KEY if --key
# is not given. This writes .env, so --force is needed to overwrite one.
php artisan env:decrypt --force

Rotating APP_KEY on a running system

Steps 3 and 4 are the part people miss. A cached config file holds the value env() returned when it was built, so changing the secret store alone changes nothing. Workers and Octane processes keep the old key until they are reloaded, which is why they get their own step.

Example · shell
# 1. Generate the replacement without touching any file.
php artisan key:generate --show
# base64:9lQ0s...

# 2. In the secret store, set both:
#    APP_KEY=base64:<new>
#    APP_PREVIOUS_KEYS=base64:<old>

# 3. The new value is only read when the config cache is rebuilt.
php artisan config:cache

# 4. Long-running processes still hold the old config in memory.
php artisan reload

Discussion

  • Be the first to comment on this lesson.