// tutorial

Keep secrets in .env, never in code

An API key hardcoded in a controller "just to test it quickly" has a way of outliving the test — and the commit. Every credential belongs in .env, referenced through config(), with the file itself never touching git. Here's the whole chain, including the two ways it quietly breaks anyway.

php laravel security 6 min read
  1. .env holds the value, .env.example holds the shape

    .env is where the real key lives, and it ships with Laravel already listed in .gitignore — worth confirming, not assuming, especially in an older project. .env.example is the committed template: every key name a fresh clone needs, with blank or dummy values, so setup is "copy this file" instead of "ask someone on Slack."

    bash
    # .env — real, gitignored
    STRIPE_SECRET=sk_live_51H8x...
    
    # .env.example — committed, no real value
    STRIPE_SECRET=
  2. Read it once, in a config file

    env() belongs in exactly one place: a file under config/. Everywhere else in the app — controllers, jobs, service classes — pull the value through config() instead. It's a small indirection that pays for itself in the next step.

    php
    // config/services.php
    return [
        'stripe' => [
            'secret' => env('STRIPE_SECRET'),
        ],
    ];
    
    // anywhere else — a controller, a job, a service class
    $secret = config('services.stripe.secret');
  3. Why not just call env() everywhere

    php artisan config:cache flattens every config/*.php file into a single cached file and stops reading .env entirely — that's what makes production boot faster. An env() call sitting outside a config file never gets baked into that cache, so it silently returns null the moment caching is on, even though the exact same code worked fine locally.

  4. Keep it out of tests and seeders too

    A real key hardcoded in a test or a seeder is just as committed as one in a controller. Point tests at .env.testing (Laravel loads it automatically when the environment is testing) or fake the service entirely instead of hitting a real API with a real credential.

    php
    // tests/Feature/CheckoutTest.php
    Http::fake([
        'api.stripe.com/*' => Http::response(['status' => 'succeeded'], 200),
    ]);
  5. Production secrets live in the host, not in a file

    Deploying doesn't mean shipping .env to the server. The hosting platform — Forge, Vapor, a container orchestrator's secret store, whatever's in use — sets the real environment variables directly, so the production credentials never exist as a file that could be copied, logged, or accidentally committed.

  6. If a secret already got committed

    Deleting the line in a new commit isn't enough — the value is still sitting in every earlier commit's history, readable by anyone who clones the repo. Two things, in this order:

    1. Rotate the credential at the provider immediately — the leaked key stops being useful the moment it's replaced, regardless of what happens to the git history.
    2. Then, if the repo is shared or public, strip it from history with git filter-repo (or the BFG Repo-Cleaner) and force-push the rewritten history.