phoenix1331/laravel-env-audit

Coverage reporting for your .env: static analysis for env() call isolation, .env.example drift, and secret heuristics.

Maintainers

Package info

github.com/phoenix1331/laravel-env-audit

pkg:composer/phoenix1331/laravel-env-audit

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.0 2026-08-21 14:58 UTC

This package is not auto-updated.

Last update: 2026-08-21 15:04:40 UTC


README

Latest Version on Packagist PHP Version Laravel Tests Total Downloads License

Your .env.example is 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.

Screenshot 2026-08-20 163113

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, which is 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.

Why not just use Larastan, env-sync, or gitleaks?

Each of those tools solves one piece of this problem. None combine all three.

What you want Larastan env-sync packages gitleaks laravel-env-audit
Catch env() outside config/ Partial (rule not on by default) No No Yes
.env.example drift detection No Yes No Yes
Secret heuristics against .env.example No No Yes (git history) Yes (current file, Laravel-aware)
Isolation score across all 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

Larastan's noEnvCallsOutsideOfConfigRule exists but 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 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. And none of them produce a score, a gate, or a report you can attach to a PR.

Design principles

  • 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 .env is 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-scanner installed 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 by 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 by 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 by 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 by 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|null null HTML report destination; set to a path to write the report on every run, or leave null and use --html= per-invocation
html.title string 'Env Audit Report' HTML report header text
drift.check_real_env bool false Compare the real .env against .env.example by key names only; opt-in so CI environments without a .env are not broken
drift.env_file string|null null Path to the real .env file; defaults to base_path('.env') when null
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 so the isolation score cannot be 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 .env file for secret values. The tool reads .env for 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.example and 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

v1.1: CI matrix (PHP 8.2-8.4 x Laravel 10-13), bug fixes from the post-release audit (attribute line-range coverage, --fail-on validation, path normalisation for Windows, require_ignore_reasons enforcement, unparseable file surfacing), real .env drift via parseKeys(), and the competitor comparison section.

v1.2: Second-pass fixes: html.output_path defaults to null so the HTML report is opt-in (set a path in config or pass --html= per-invocation); --fail-on= with an empty value or unknown category now returns a proper error exit rather than silently passing; env-only-keys and example-only-keys wired into countFor() and KNOWN_CATEGORIES so real .env drift can gate CI; getEndLine() guard uses > 0 to handle the PHP-Parser -1 sentinel correctly; EnvAuditReport::build() requireReasons default aligned with the config default; composer config policy.advisories.block false in CI scoped to prefer-lowest runs only; vendor/bin/pint --test added to CI.

v2: Baseline file for legacy adoption (only new violations fail the build), GitHub Actions annotations and SARIF output, a phoenix1331/env-audit-action marketplace action, extended detection surface (Env::get(), getenv(), superglobals, Blade templates), secret heuristics v2 (per-key allowlist, key-name patterns, confidence levels), and sync helpers (env-audit:sync, env-audit:ignores).

Longer term: Pest/PHPUnit assertions, cross-package alignment with laravel-auth-audit under a shared audit-family brand.

Licence

MIT. See LICENSE.