Laravel 10 • 11 • 12 • 13

Your .env layer
has a coverage problem.

Static analysis for Laravel environment configuration. Catches env() calls outside config/, example file drift, and possible committed secrets. Reports an isolation score and fails CI when thresholds are breached.

$ composer require phoenix1331/laravel-env-audit --dev
Star on GitHub Packagist
php artisan env-audit:run
$ php artisan env-audit:run --fail-on=direct-usage,possible-secret   Isolation Score: 94% (17/18 env() calls live inside config/)   x Direct usage (1) app/Services/LegacyBootstrap.php:12 env('APP_NAME') called outside config/   x Possible secret in .env.example (1) STRIPE_SECRET=sk_l************************ high entropy value, may be a real secret   ! Missing from .env.example (2) FEATURE_NEW_CHECKOUT used in config/features.php:4, no matching .env.example entry MAIL_REPLY_TO used in config/mail.php:31, no matching .env.example entry   i Unused in .env.example (1) OLD_PAYMENT_PROVIDER_KEY defined in .env.example, never referenced anywhere   Failing: 2 error-level findings (direct-usage, possible-secret)
The problem

Three silent failures.
One package catches all of them.

env() outside a config file is probably the single most common Laravel footgun. It works in development, then returns null in production the moment someone runs config:cache - silently, with no error, nothing in the logs.

config:cache footgun

Any env() call outside config/ returns null after caching. The kind of bug that passes code review and staging, then breaks production hours after a deploy.

.env.example drift

New variables appear in production and never make it into the example file. Onboarding a new developer means discovering missing configuration one runtime error at a time.

Committed secrets

.env.example is copy-pasted from a real .env file more often than anyone admits. A surprisingly common place for real credentials to end up in a public repository.

How it works

AST-based. Not regex.

The scanner walks every PHP file using nikic/php-parser - real parse-tree traversal across heredocs, multiline calls, and dynamic arguments. Four independent detection categories, each configurable.

94%
Isolation Score
Percentage of env() calls that live inside config/. Reported on every run. Fails CI when it drops below your threshold. Active bypasses are tracked separately so the score cannot be inflated by suppressing rather than fixing.
Direct env() usage env() calls outside config/ - production bug waiting to happen. Error severity by default.
Possible secret High-entropy values and known key shapes (sk_live_, AKIA, ghp_, xoxb-, AIza...) in .env.example. Values are masked before storage - never appear in any output.
Missing from example Keys used in config/ but absent from .env.example. Undocumented requirements that break fresh environment setup. Warning by default.
Unused in example Keys in .env.example never referenced anywhere. Stale documentation from removed integrations. Info by default.
Commands

One command.
Multiple output formats.

php artisan env-audit:run Console output with isolation score and categorised violations
env-audit:run --json JSON output for pipe-friendly tooling integration
env-audit:run --html=storage/report.html Write a self-contained HTML report, attachable to a PR
env-audit:run --fail-on=direct-usage Exit 1 only when specified categories have findings
vendor:publish --tag=env-audit-config Publish the configuration file for customisation
Escape hatch

Bypasses require reasons.
And expiry dates.

For cases where a direct env() call is genuinely necessary, two bypass forms are supported. Both require a documented reason. Both require an expiry date - once it passes, the bypass is reported as expired rather than honoured. "Temporary" exceptions cannot quietly become permanent.

Via attribute PHP
use Phoenix1331\LaravelEnvAudit
  \Attributes\WithoutEnvAudit;

#[WithoutEnvAudit(
  'Tenant bootstrap needs TENANT_ID
   before config boots, ADR-012',
  expires: '2027-01-01'
)]
class TenantBootstrapProvider
  extends ServiceProvider
{
  public function register(): void
  {
    $id = env('TENANT_ID');
  }
}
Via inline comment PHP
// env-audit-ignore: legacy queue worker
// reads this before config boots,
// ticket INFRA-5190
$driver = env('LEGACY_CACHE_DRIVER');




// Both forms are recorded in the
// report's exclusions section.
// Active bypass count is its own
// metric - the score cannot be
// inflated by suppressing findings.
CI integration

One step in your pipeline.

.github/workflows/ci.yml YAML
- name: Run env audit
  run: php artisan env-audit:run --fail-on=direct-usage,possible-secret

# JSON output for downstream tooling
- name: Run env audit (JSON)
  run: php artisan env-audit:run --json > env-audit.json

Zero-config CI behaviour

Exits 0 when no --fail-on categories have findings, 1 otherwise. No application boot required - all analysis is static. No database connections, no service providers, no side effects.

Comparison

Why not Larastan, env-sync,
or gitleaks?

Each solves one piece of this problem. None combine all three - and none produce a score, a gate, or a report you can attach to a PR.

Capability Larastan env-sync gitleaks env-audit
Catch env() outside config/ Partial No No Yes
.env.example drift detection No Yes No Yes
Secret heuristics on example file No No Git history Yes
Isolation score across call sites No No No Yes
Configurable CI gate per category No No No Yes
Expiring bypass mechanism No No No Yes
Self-contained HTML report No No No Yes