At some point, almost every Laravel developer has shipped a bug that only appeared after running php artisan config:cache in production. The app worked perfectly in local development. It worked in staging. Then config caching kicked in, and every env() call outside of config/ started silently returning null. No exception. No log entry. Just wrong behaviour.
I built laravel-env-audit to catch this class of bug statically, before it reaches production. It also catches two quieter problems: .env.example drift and possible secrets committed to the example file. This post explains how it works and why the design choices matter.
The config:cache footgun
Laravel's configuration caching is a significant performance win. Running php artisan config:cache serialises all config files into a single PHP file that is loaded once at boot. After that, the application no longer reads from .env at all.
The problem is that env() calls scattered outside config/ continue to work perfectly in development, where config caching is never enabled. The contract they implicitly rely on breaks the moment you enable caching in production. A service class calling env('STRIPE_SECRET') directly will get null in production, because the cached config process never consulted the environment file for that call.
The fix is always the same: move all env() calls into config/ files, then reference those values via config('services.stripe.secret') elsewhere. But there has been no tooling to tell you where the unfixed calls are. Larastan does not model env/config semantics. Enlightn checks some production flags but does not do a full call-site audit. That gap is what this package fills.
How the scanner works
The core of the package is EnvUsageScanner, which uses nikic/php-parser to walk PHP files as an abstract syntax tree rather than running regex across raw text. This distinction matters.
Regex-based detection can miss calls that span multiple lines, appear inside heredocs, or are written with unusual spacing. AST traversal sees the same structure the PHP interpreter sees. Every env() call, regardless of formatting, is detected correctly.
// All of these are detected reliably by the AST walker:
$key = env('APP_KEY');
$secret = env(
'STRIPE_SECRET',
null
);
$val = env( /* comment */ 'SOME_KEY' );
The scanner records the file, line number, key argument (if it is a static string), and whether the file lives inside the configured config/ path. From that data, the package derives an isolation score: the percentage of env() calls that are correctly placed inside config/.
Four violation categories
Direct usage
Any env() call outside config/ that is not covered by a bypass is a direct usage violation. This is the error-level category: it represents a production bug under config caching.
Possible secret in .env.example
The SecretHeuristicDetector applies two checks against .env.example values specifically, never the real .env. First, it matches against 15 built-in patterns for known credential shapes: Stripe live keys (sk_live_), AWS access key IDs (AKIA...), GitHub tokens (ghp_), Slack tokens (xox...), SendGrid keys (SG.), and others. Second, for values that do not match a pattern but are long enough, it calculates Shannon entropy. Values above 3.5 bits per character are flagged as high entropy.
Values that clearly look like placeholders are excluded before either check runs: anything matching your-*, change-me, <...>, {...}, xxx*, or the literals null, true, false.
A key design constraint: the report only ever shows a masked preview. The first four characters are shown and the rest are replaced with asterisks. The unmasked value never appears in any DTO, array, or output format. This is enforced structurally: EnvFileParser::parseKeys() returns key => null for real .env files (values are never stored), while parseExample() returns key => value for the example file only. It is impossible at the call site to accidentally pass real secret values to the detector.
Missing from .env.example
Keys passed to env() inside config files but absent from .env.example. These represent undocumented configuration requirements. A developer cloning the repo and copying the example file will not know these keys exist until they hit a runtime error.
Unused in .env.example
Keys present in .env.example but never referenced anywhere in the scanned codebase. Stale documentation from integrations that were removed months or years ago. Not dangerous, but noise that makes onboarding harder.
Running it
Install it as a dev dependency:
composer require phoenix1331/laravel-env-audit --dev
Publish the config file if you want to customise scan paths, ignore paths, or thresholds:
php artisan vendor:publish --tag=env-audit-config
Then run the audit:
# Console output with isolation score and categorised violations
php artisan env-audit:run
# JSON output for piping to other tools
php artisan env-audit:run --json
# HTML report written to storage/env-audit/report.html
php artisan env-audit:run --html=storage/env-audit/report.html
# CI usage: exit 1 only when the dangerous categories fire
php artisan env-audit:run --fail-on=direct-usage,possible-secret
A clean run on a well-structured project looks like this:
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
! Missing from .env.example (2)
FEATURE_NEW_CHECKOUT used in config/features.php:4, no .env.example entry
MAIL_REPLY_TO used in config/mail.php:31, no .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 bypass mechanism
There are legitimate cases where an env() call outside config is necessary: bootstrapping multi-tenant applications before config is hydrated, or reading values in a custom service provider that runs before the config layer is ready. The package supports two bypass forms, both of which require a documented reason.
For classes and methods, a PHP 8 attribute:
use Phoenix1331\LaravelEnvAudit\Attributes\WithoutEnvAudit;
#[WithoutEnvAudit(
'Multi-tenant bootstrap requires TENANT_ID before config is cached. See ADR-012.',
expires: '2027-01-01'
)]
class TenantBootstrapProvider extends ServiceProvider
{
public function register(): void
{
$tenantId = env('TENANT_ID');
// ...
}
}
For files where attributes are not practical, an inline comment:
// env-audit-ignore: legacy queue worker reads this before config boots, ticket INFRA-5190
$driver = env('LEGACY_CACHE_DRIVER');
Both forms support an expires date. When that date passes, the tool surfaces the bypass as an expired entry rather than silently honouring it. The count of active bypasses is reported as its own metric so the isolation score cannot be inflated by liberal use of the escape hatch instead of actually fixing call sites.
Adding it to CI
The command exits 0 when no categories in fail_on have findings, and 1 otherwise. That makes it straightforward to add to any CI pipeline:
- name: Run env audit
run: php artisan env-audit:run --fail-on=direct-usage,possible-secret
If you want to capture the output as an artefact:
- name: Run env audit (JSON)
run: php artisan env-audit:run --json > env-audit.json
- name: Upload audit report
uses: actions/upload-artifact@v4
with:
name: env-audit
path: env-audit.json
Because all analysis is static, there is no database connection, no service provider boot, and no application state needed. The command completes in well under a second on a typical Laravel codebase.
Why not just use Larastan, env-sync, or gitleaks?
Each of those tools solves one piece of this problem. Larastan has a noEnvCallsOutsideOfConfigRule, but it is not enabled by default and has no concept of .env.example state. The env-sync family compares your env files but does not know what your config layer actually expects. Gitleaks scans git history for secrets; this package scans the current .env.example as a Laravel developer would, understanding which values are placeholder-shaped and which are not.
None of them produce a score, a configurable CI gate per category, an expiring bypass mechanism, or a self-contained HTML report. The combination is what makes this different: it is an audit, not a lint rule.
What changed in v1.1
After shipping v1.0, a thorough code review surfaced several issues. v1.1 addresses all of them.
- CI matrix added. GitHub Actions now runs against PHP 8.2/8.3/8.4 and Laravel 10 through 13 with both
--prefer-lowestand--prefer-stableruns. The v1.0 badge was broken because the workflow file did not exist. - Testbench extended to cover Laravel 13.
orchestra/testbench ^11added so the L13 compatibility claim is actually testable in CI. --json+--html=stdout pollution fixed. The$this->info()call inwriteHtmlReport()is now guarded by a JSON check, keeping output pipe-safe.html.output_pathconfig now honoured. The report is written whenever the config key is set, not only when the--htmlflag is passed.require_ignore_reasonsnow enforced. Bypasses without a reason string surface as a violation rather than being silently accepted.- Attribute coverage tightened to line range. A
#[WithoutEnvAudit]attribute now covers only the lines of the attributed node, not everything after it in the file. EnvFileParser::parseKeys()wired in. Real.envvs.env.exampledrift is now detected in both directions via keys only, opt-in viadrift.check_real_envconfig.- Path separators normalised. Comparisons in
isInsideConfig()andisIgnored()now work correctly on Windows. - Unparseable files surfaced. Parse failures are counted and shown in all formatters rather than silently dropped from the audit.
--fail-onvalidates inputs. Unknown category names now exit with a descriptive error instead of silently treating the gate as passing.bootstrap/cacheadded to defaultignore_paths. Framework-generated files are now excluded alongsidevendor.
What this does not do
- Scan the real .env file for secret values. The tool reads
.envfor key names only. No real secret ever appears in any output. - Scan git history. That is what Gitleaks and TruffleHog are for. This package is specifically about the current
.env.exampleand current codebase, framework-aware in a way generic secret scanners are not. - Boot the application. All analysis is static. No service providers are registered, no database connections made.
- Replace Larastan. The two tools are complementary. Larastan catches type errors and logic issues. This package catches env/config semantics that Larastan is not designed to model.
Summary
The config:cache footgun is a bug practically every Laravel developer who has shipped to production will recognise. The fix is always the same: move env() calls into config/. But there has been no tooling to tell you where the unfixed ones are before they cost you an incident. laravel-env-audit fills that gap, adds drift detection and secret heuristics, and fits into a CI job with a single command.
v1.1 ships a full CI matrix, all post-release bug fixes, real .env drift detection, and a cleaner competitor story. v2 will add a baseline file for legacy adoption, GitHub Actions annotations, SARIF output, and an extended detection surface covering Env::get(), getenv(), superglobals, and Blade templates. The package is on Packagist as phoenix1331/laravel-env-audit. If you run it on your codebase and find an edge case, reach out via the contact section.