laravel-env-audit maintained by phoenix1331
laravel-env-audit
Your
.env.exampleis a promise to the next developer. This package checks whether you kept it.
Static analysis for your Laravel environment configuration. Catches env() calls made outside config/ (which break silently after config:cache), .env.example drift, and possible secrets accidentally committed to the example file. Reports an isolation score, a categorised violation list, and a self-contained HTML report. Fails CI when configured thresholds are breached.
The second package in the Laravel Audit family, after laravel-auth-audit.
The problem this solves
env() outside a config file is probably the single most common Laravel footgun. It works perfectly in local development, then breaks silently in production the moment someone runs php artisan config:cache — because cached config no longer reads from .env at all. Any env() call outside config/ returns null from that point on, with no error, no warning, nothing in the logs. The kind of bug that passes code review, passes CI, passes staging, and only shows up hours after a production deploy.
A second, quieter version of the same problem is .env.example drift. New variables appear in production .env files and never make it into the example, so onboarding a new developer means discovering missing configuration one runtime error at a time.
A third, more serious version: .env.example is committed to git and sometimes copy-pasted from a real .env file — a surprisingly common place for a genuine secret to end up in a public repository, disguised as a placeholder.
None of Laravel's own tooling checks for any of this.
What this demonstrates
- AST-based static analysis using
nikic/php-parser— real parse-tree traversal rather than regex, so detection is trustworthy across heredocs, multiline calls, and dynamic arguments - A coverage-gate pattern (isolation score + configurable fail-on thresholds) that mirrors the approach of
laravel-auth-audit, making both packages read as a family - A hard design constraint: the tool that catches leaked secrets must never itself become a place they leak from — the HTML report only ever renders masked values, and the real
.envis never read beyond key names - Attribute-based and inline-comment bypass mechanisms with mandatory reasons and expiry dates, so "temporary" exceptions cannot quietly become permanent
Tech stack
- PHP 8.2+, Laravel 10/11/12/13
nikic/php-parser^5.0 for AST traversal- Pest for testing, Orchestra Testbench for feature tests
- Laravel Pint for code style
Getting started
Prerequisites
- PHP 8.2+
- Laravel 10, 11, 12, or 13
osv-scannerinstalled locally for the pre-commit hook (if contributing)
Installation
composer require phoenix1331/laravel-env-audit --dev
Publish the config file:
php artisan vendor:publish --tag=env-audit-config
Running the audit
# Console output with isolation score and categorised violations
php artisan env-audit:run
# JSON output (pipe-friendly, suitable for tooling integration)
php artisan env-audit:run --json
# Write a self-contained HTML report
php artisan env-audit:run --html=storage/env-audit/report.html
# CI usage: exit 1 only on the dangerous categories
php artisan env-audit:run --fail-on=direct-usage,possible-secret
Example output
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)
How detection works
1. Direct env() usage outside config/ — Error (default)
The scanner walks every PHP file under scan_paths using nikic/php-parser, finds every env() call, and flags any that do not live inside the configured config_path. This is the dangerous category: it represents a production bug waiting to happen the moment someone runs config:cache.
2. Possible secret in .env.example — Error (default)
The SecretHeuristicDetector applies two checks against .env.example values specifically — never the real .env:
- Pattern matching: known provider-issued key shapes (
sk_live_,AKIA,ghp_,xoxb-,AIza,SG., and more) - Entropy: Shannon entropy >= 3.5 bits/char on values >= 20 characters
Values that clearly look like placeholders (your-*, change-me, <...>, {...}) are skipped. The report only ever shows a masked preview — sk_l************************ — never the full value.
3. Missing from .env.example — Warning (default)
Keys passed to env() inside config files but absent from .env.example. These represent undocumented configuration requirements that will break fresh environment setup.
4. Unused in .env.example — Info (default)
Keys present in .env.example but never referenced anywhere in the scanned codebase. Stale documentation from removed integrations.
All four thresholds are independently configurable.
Configuration reference
Publish with php artisan vendor:publish --tag=env-audit-config.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Global on/off switch |
config_path |
string | config_path() |
Directory considered "inside config/" for isolation scoring |
scan_paths |
array | app/, config/, routes/, bootstrap/ |
Directories walked for env() detection |
ignore_paths |
array | vendor/ |
Directories excluded entirely |
example_file |
string | .env.example |
Path to the example file — use for .env.dist repos |
fail_on |
array | ['direct-usage', 'possible-secret'] |
Categories that cause a non-zero exit code |
secret_heuristics.enabled |
bool | true |
Toggle the secret-pattern/entropy check |
secret_heuristics.patterns |
array | [] |
Extra regex patterns beyond the built-in set |
html.output_path |
string | storage/env-audit/report.html |
HTML report destination |
html.title |
string | 'Env Audit Report' |
HTML report header text |
require_ignore_reasons |
bool | true |
Forces every bypass to carry a documented reason |
Bypass mechanism
For cases where a direct env() call is genuinely necessary, two bypass forms are supported. Both require a reason. Both support an expires date so exceptions cannot quietly become permanent — once the date passes, the tool reports the bypass as expired rather than honouring it.
Attribute (for classes and methods)
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');
// ...
}
}
Inline comment (for files where attributes are not practical)
// env-audit-ignore: legacy queue worker reads this before config boots, ticket INFRA-5190
$driver = env('LEGACY_CACHE_DRIVER');
Both bypasses are recorded in the report's exclusions section with their reasons. The count of active bypasses is reported as its own metric — to stop the isolation score being inflated by liberal use of the bypass instead of actually fixing call sites.
CI recipe
- name: Run env audit
run: php artisan env-audit:run --fail-on=direct-usage,possible-secret
The command exits 0 when no categories in fail-on have findings, 1 otherwise. JSON output is available for downstream tooling:
- name: Run env audit (JSON)
run: php artisan env-audit:run --json > env-audit.json
What this tool does not do
- Scan the real
.envfile for secret values. The tool reads.envfor key names only, never values. 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.
Why I built this
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 is no tooling that tells you where the unfixed ones are before they cost you an incident.
.env.example drift is subtler but causes real pain during onboarding and environment provisioning, and no existing tool cross-references it against what the config layer actually expects.
This package sits in a gap between Larastan (which knows nothing about env/config semantics), Enlightn (which checks a handful of production flags but does not do a full call-site audit), and Gitleaks (which is framework-unaware). It is narrow, fast, and designed to run on every CI job.
Architecture notes
The design follows nikic/php-parser's visitor pattern throughout — EnvUsageScanner, AttributeResolver, and the inline-comment extractor all use NodeTraverser with anonymous NodeVisitorAbstract subclasses rather than regex, which is why the detection is reliable across edge cases.
EnvFileParser has a deliberate split: parseKeys() returns key => null (real .env, values never stored), while parseExample() returns key => value (.env.example only, values needed by the heuristic). The type difference makes it structurally impossible to pass real secret values to the detector by mistake.
SecretHeuristicDetector::mask() is called before any value reaches a PossibleSecret DTO — the unmasked value never appears in any object, array, or output format.
Contributing
git clone https://github.com/phoenix1331/laravel-env-audit
cd laravel-env-audit
composer install
composer test # run the full test suite
composer test:unit # unit tests only
composer test:feature # feature tests only
composer lint # auto-fix code style
composer lint:check # check without fixing
Tests use Pest and Orchestra Testbench. The test-app/ directory (gitignored) contains a real Laravel 13 installation with all showcase scenarios pre-configured — run php artisan env-audit:run inside it to see every category fire.
Pre-commit hooks enforce code style (Pint) and dependency auditing (osv-scanner). Install osv-scanner before committing:
go install github.com/google/osv-scanner/cmd/osv-scanner@latest
Roadmap
- v2: Optional git-history mode layering the secret heuristic across recent commits (clearly separated from dedicated tools like Gitleaks)
- v2/v3: A shared rendering/config-publishing library extracted from both
laravel-auth-auditandlaravel-env-audit - Stretch: A GitHub Action wrapper, matching the sibling package's stretch goal
Licence
MIT. See LICENSE.