deployhealth

How to catch a missing env var in Laravel before it reaches production

env('STRIPE_SECRET') returns null when the variable isn’t set, so a Laravel app usually boots without it and fails later, on the request that needs it, or after php artisan config:cache bakes the null in.

deployhealth compares the variables your code and config read with the ones your env files declare, on every pull request and every push, and names each gap with its file and line. It reads names and line numbers only, never values.

What deployhealth reads in a Laravel app

In .php files, Blade templates included, on one line each:

  • env('NAME') and \env('NAME'), but not ->env(), ::env() or $env()
  • Env::get('NAME')
  • getenv('NAME') (a second argument is getenv’s local-only flag, not a default)
  • $_ENV['NAME']
  • $_SERVER['NAME'] only marks a declared name as used: it holds request data too, so it’s never a read or MISSING

A config file and a service class, with a .env.example that declares three variables:

.env.example
APP_KEY=
STRIPE_KEY=
STRIPE_SECRET=
app/Services/Maps.php
<?php

namespace App\Services;

use Illuminate\Support\Env;

class Maps
{
    public string $key;

    public function __construct()
    {
        $this->key = Env::get('MAPS_API_KEY');
    }
}
artisan
#!/usr/bin/env php
<?php
config/services.php
<?php

return [
    'stripe' => [
        'key' => env('STRIPE_KEY'),
        'secret' => env('STRIPE_SECRET'),
        'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
    ],
];

What deployhealth reports

  • MAPS_API_KEY app/Services/Maps.php:13 · required
  • STRIPE_KEY config/services.php:5 · required
  • STRIPE_SECRET config/services.php:6 · required
  • STRIPE_WEBHOOK_SECRET config/services.php:7 · required

MISSING MAPS_API_KEY, STRIPE_WEBHOOK_SECRET

APP_KEY isn’t UNUSED though nothing here reads it: in a directory with an artisan file, Laravel’s own names count as used (below).

Defaults, OPTIONAL and Laravel’s own variables

A default on the same line means the read can’t be MISSING; when no env file declares it, the scan lists it as OPTIONAL. Defaults are env('NAME', 'x'), Env::get('NAME', 'x'), env('NAME') ?? 'x' or ?: 'x', getenv('NAME') ?: 'x' (not ??: getenv returns false) and $_ENV['NAME'] ?? 'x'. null isn’t a default, and neither is ?: throw ….

In a directory with an artisan file, the variables Laravel’s framework and skeleton config read (DB_URL, REDIS_HOST, MAIL_MAILER and the rest of that list) are never MISSING and never UNUSED: Laravel reads them with null meaning “not set”. APP_KEY is the exception, because Laravel won’t boot without it.

Defaults, a framework name and APP_KEY, with an empty .env.example:

.env.example
# Copy to .env and fill in.
artisan
#!/usr/bin/env php
<?php
config/app.php
<?php

return [
    'key' => env('APP_KEY'),
    'timezone' => env('APP_TIMEZONE', 'UTC'),
    'maps_key' => env('MAPS_API_KEY') ?? null,
    'region' => getenv('AWS_REGION') ?: 'eu-west-1',
    'database_url' => env('DB_URL'),
];

What deployhealth reports

  • APP_KEY config/app.php:4 · required
  • APP_TIMEZONE config/app.php:5 · optional (has a default)
  • MAPS_API_KEY config/app.php:6 · required
  • AWS_REGION config/app.php:7 · optional (has a default)
  • DB_URL config/app.php:8 · optional (has a default)

MISSING APP_KEY, MAPS_API_KEY

What it skips

  • vendor/, and beside artisan: storage/, bootstrap/cache/ and public/build/. Laravel’s cached config can hold values, so those are never opened.
  • tests and test tooling: tests/, *Test.php, phpunit.xml and .phpunit.cache (--include-tests reads the tests)
  • names the platform provides, such as CI and GitHub Actions’ own variables (--no-default-ignore checks them too)

Only the read in config/ counts here:

.env.example
STRIPE_KEY=
artisan
#!/usr/bin/env php
<?php
bootstrap/cache/config.php
<?php
return ['key' => env('CACHED_ONLY')];
config/services.php
<?php
return ['stripe' => env('STRIPE_KEY')];
tests/Feature/CheckoutTest.php
<?php
$token = env('TEST_ONLY_TOKEN');
vendor/acme/sdk/src/Client.php
<?php
$key = getenv('VENDOR_KEY');

What deployhealth reports

  • STRIPE_KEY config/services.php:2 · required

Known limits

  • A call split across lines, or with a computed name (env($name)), isn’t seen.
  • %env()% is read in Symfony’s config/**/*.yaml, but not in PHP or XML config.
  • A name only a Composer package’s unpublished config reads shows as UNUSED: deployhealth doesn’t read vendor/. Leave it out of the report with --ignore NAME.

A computed name reads nothing:

app/Support/Flags.php
<?php

function flag(string $name): ?string
{
    return env($name);
}

What deployhealth reports

No reads.

Set it up

Pull request checks

Sign in with GitHub, add a project for the repository (owner/repo) and install the deployhealth GitHub App from the project's settings. Every pull request that reads an env var no .env.example declares gets a comment and a check. No token, no secret, no workflow.

Deploy history and alerts

Add the project's ingest token as the repository secret DEPLOYHEALTH_TOKEN (a secret, not a variable) and scan every push. The project's settings page gives you a workflow that runs the CLI pinned to an exact version, npx --yes [email protected], which you can also run from any other CI.

Or use the deployhealth env check Action:

.github/workflows/deployhealth.yml
# .github/workflows/deployhealth.yml
name: deployhealth
on:
  push:
    branches: [main]
jobs:
  env-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v5
        with:
          persist-credentials: false
      - uses: Utsav-mishra-25/deployhealth-action@v1
        with:
          token: ${{ secrets.DEPLOYHEALTH_TOKEN }}

@v1 follows the latest 1.x release and can move; pin a full commit SHA if you want it fixed. An npm version never changes once published.

Try it now, without signing up

From your repository's root. It prints MISSING, UNUSED and MISMATCH with file and line in your terminal, and sends nothing:

terminal
npx -y deployhealth-scan --dry-run

Other stacks