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:
APP_KEY=
STRIPE_KEY=
STRIPE_SECRET=
<?php
namespace App\Services;
use Illuminate\Support\Env;
class Maps
{
public string $key;
public function __construct()
{
$this->key = Env::get('MAPS_API_KEY');
}
}
#!/usr/bin/env php
<?php
<?php
return [
'stripe' => [
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
];
What deployhealth reports
MAPS_API_KEYapp/Services/Maps.php:13 · requiredSTRIPE_KEYconfig/services.php:5 · requiredSTRIPE_SECRETconfig/services.php:6 · requiredSTRIPE_WEBHOOK_SECRETconfig/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:
# Copy to .env and fill in.
#!/usr/bin/env php
<?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_KEYconfig/app.php:4 · requiredAPP_TIMEZONEconfig/app.php:5 · optional (has a default)MAPS_API_KEYconfig/app.php:6 · requiredAWS_REGIONconfig/app.php:7 · optional (has a default)DB_URLconfig/app.php:8 · optional (has a default)
MISSING APP_KEY, MAPS_API_KEY
What it skips
vendor/, and besideartisan:storage/,bootstrap/cache/andpublic/build/. Laravel’s cached config can hold values, so those are never opened.- tests and test tooling:
tests/,*Test.php,phpunit.xmland.phpunit.cache(--include-testsreads the tests) - names the platform provides, such as
CIand GitHub Actions’ own variables (--no-default-ignorechecks them too)
Only the read in config/ counts here:
STRIPE_KEY=
#!/usr/bin/env php
<?php
<?php
return ['key' => env('CACHED_ONLY')];
<?php
return ['stripe' => env('STRIPE_KEY')];
<?php
$token = env('TEST_ONLY_TOKEN');
<?php
$key = getenv('VENDOR_KEY');
What deployhealth reports
STRIPE_KEYconfig/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’sconfig/**/*.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:
<?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
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:
npx -y deployhealth-scan --dry-run