Why a Tiny Audit Tool Beats a Big Security Framework (laravel-audit)
Seventeen hand-picked static checks, zero runtime dependencies, one PHP file tree. That's the whole idea behind laravel-audit. Here's why that shape beats the comprehensive Laravel security frameworks for CI, and the specific antipatterns it's worth flagging.
Laravel is famously generous. Every piece of configuration has a safe default, an environment override, a documented rationale, and three ways to reach it. The framework will happily run with any of those knobs in an unsafe position, and most of the time it won't even warn you. I've seen production Laravel apps ship with APP_DEBUG=true committed to .env.example, 'strict' => false on the MySQL connection to silence a legacy bug, 'secure' => false on session cookies because the dev environment doesn't have TLS, and a CSRF exclude list that starts with '*' because someone couldn't figure out why their Stripe webhook was failing and "just fixed it." Every one of those is a lint-level mistake that a five-minute static check would catch, but nobody runs the five-minute static check, because the tools that exist in this space are not five-minute tools.
laravel-audit is the tool I wanted to exist in that gap. It's a single-file PHP CLI (plus a handful of small check classes), it reads your project's files without booting Laravel, it emits GitHub Actions annotations out of the box, and it has exactly 17 checks.
Seventeen. Not 170. That number is load-bearing.
GitHub: https://github.com/sen-ltd/laravel-audit
The Laravel "lots of rope" problem
Laravel's configuration surface is enormous. config/app.php, config/session.php, config/database.php, config/logging.php, config/filesystems.php, config/auth.php, config/cache.php, config/queue.php, middleware wiring in app/Http/Kernel.php (or bootstrap/app.php in Laravel 11+), the .env and .env.example files that drive all of it, and a composer dependency graph on top. Every one of those places has a documented way to accidentally ship something insecure.
A few real examples from production code I've looked at over the last couple of years:
-
.env.examplecommitted withAPP_DEBUG=trueso local dev "just works." In a rushed deploy, someone copies.env.exampleto.env, setsDB_*values, and forgets to flipAPP_DEBUG. Laravel's debug screen is now happily leaking environment variables and stack traces on every uncaught exception. -
config/database.phpwith'strict' => falseon the MySQL connection. A developer turned it off six months ago to silence a "only_full_group_by" error, then nobody turned it back on, and now the app writes empty-string dates into DATETIME columns without complaint. -
'same_site' => 'none'inconfig/session.phpbecause some third-party embed needed cross-site cookies, and nobody reverted it for the main site. -
VerifyCsrfToken::$except = ['*']because a developer debugging a 419 error on a webhook found this on Stack Overflow. -
LOG_CHANNEL=singlein production because the Laravel default isstackbut an old.envfile never got updated, and now an unrotatedlaravel.logis filling/var/logat midnight. -
DB_PASSWORD=passwordin.env.example, followed by a deploy script that never replaced it.
Every one of those is detectable by reading a text file. No runtime instrumentation, no test suite, no dependency analysis — just open the file, find the key, check the value.
The "big security framework" trap
Tools like Enlightn and Larastan are extremely capable. Enlightn has 120+ checks and covers areas laravel-audit will never touch — query N+1 detection, Blade XSS scanning, performance analysis. Larastan does PHPStan-level type inference on your Eloquent models. If you already run these in CI, don't switch. They catch things laravel-audit can't.
But they have a shape problem for one particular job: the "I just want to fail the build if the .env.example has APP_DEBUG=true" job. To run Enlightn you need a booted Laravel app. That means composer install, a valid .env, a working database connection (or a mock), and often a bunch of peer dependencies installed. Larastan wants the full Composer graph too, plus a PHPStan config. Both of them have opinions about your project that go well beyond "please check twenty things."
For a pre-merge CI hook on a pull request, that weight is a tax. Developers feel it, teams disable the slow jobs, and the audit layer quietly rots.
A tool with 17 hand-picked static checks and zero runtime dependencies can run in under a second on a fresh checkout. You can drop it into the pre-commit stage of any CI system, run it before anything else, and have every one of those checks executed before a single line of composer install happens. That's a different kind of tool, and it coexists with the big frameworks instead of replacing them.
Design
The whole tool is about four moving parts: a project detector, a check interface, a collection of check classes, and three output formatters.
The CheckInterface
namespace SenLtd\LaravelAudit;
interface CheckInterface
{
public function id(): string;
public function category(): string;
/**
* @return Finding[]
*/
public function run(string $projectPath): array;
}
Every check returns an array of Finding value objects. A Finding has a check id, a category, a severity (error/warning/info/pass), a human title, a detail message, and an optional fix hint. That's the entire vocabulary.
final class Finding
{
public const SEVERITY_ERROR = 'error';
public const SEVERITY_WARNING = 'warning';
public const SEVERITY_INFO = 'info';
public const SEVERITY_PASS = 'pass';
public function __construct(
public readonly string $checkId,
public readonly string $category,
public readonly string $severity,
public readonly string $title,
public readonly string $message,
public readonly string $fixHint = '',
) {}
}
Strict types, readonly properties, PHP 8.2. The constructor rejects unknown severities with an InvalidArgumentException so a typo in a new check fails loudly instead of silently producing pass reports.
Pass findings exist because every check has to return something — either a problem or an explicit "I looked at this and it's fine." The CI runner only fails on the severities at or above --fail-on, but the report always lists every check, so you can see at a glance which ones looked and which ones ran at all. Silent checks are a footgun in auditing tools; I've wasted hours of my life staring at a clean report that turned out to be clean because the check was skipped, not because the project was good.
A specific check: APP_DEBUG
Here's the entire implementation of the first check I wrote:
final class AppDebugCheck implements CheckInterface
{
public function id(): string { return 'env.app_debug'; }
public function category(): string { return 'environment'; }
public function run(string $projectPath): array
{
$env = EnvFile::load($projectPath . '/.env.example');
if ($env === null) {
return [new Finding(
$this->id(),
$this->category(),
Finding::SEVERITY_WARNING,
'APP_DEBUG: no .env.example',
'Could not find .env.example to inspect APP_DEBUG default.',
'Commit a .env.example with APP_DEBUG=false as the safe default.',
)];
}
$value = $env->get('APP_DEBUG');
if ($value === null) {
return [new Finding(
$this->id(),
$this->category(),
Finding::SEVERITY_WARNING,
'APP_DEBUG: not set in .env.example',
'APP_DEBUG is missing from .env.example. Developers may copy it without knowing to set it.',
'Add APP_DEBUG=false to .env.example.',
)];
}
$normalized = strtolower($value);
if ($normalized === 'true' || $normalized === '1') {
return [new Finding(
$this->id(),
$this->category(),
Finding::SEVERITY_ERROR,
'APP_DEBUG=true in .env.example',
'Committing APP_DEBUG=true as the default will leak stack traces and env values if used in prod.',
'Set APP_DEBUG=false in .env.example.',
)];
}
return [new Finding(
$this->id(),
$this->category(),
Finding::SEVERITY_PASS,
'APP_DEBUG default is safe',
".env.example sets APP_DEBUG={$value}.",
)];
}
}
That's the shape of every check in the project: load some file, inspect a value, return a Finding. No Laravel service container, no reflection, no abstract base classes. If you want a sixteenth check you copy this file, give it a new id, and point it at a new key. There's nothing to subclass and nothing to configure.
The EnvFile helper is 50 lines of stdlib PHP — it reads a .env-style file line by line, strips comments, handles quoted values, and ignores lines it can't parse. It deliberately doesn't support .env interpolation (${FOO}), multi-line values, or any of the edge cases the real vlucas/phpdotenv package handles. For audit purposes, we just want to read static assignments that a developer committed, and those edge cases don't appear in committed example files.
The Auditor runner
final class Auditor
{
public function __construct(private readonly array $checks) {}
public static function withDefaults(): self
{
return new self([
new Checks\AppDebugCheck(),
new Checks\AppEnvCheck(),
new Checks\AppKeyCheck(),
new Checks\EnvFileNotCommittedCheck(),
new Checks\SessionSecureCookieCheck(),
new Checks\SessionHttpOnlyCheck(),
new Checks\SessionSameSiteCheck(),
new Checks\DatabaseStrictCheck(),
new Checks\DatabasePasswordCheck(),
new Checks\LoggingChannelCheck(),
new Checks\LoggingLevelCheck(),
new Checks\ComposerLockCheck(),
new Checks\ComposerRequireCheck(),
new Checks\TrustProxiesCheck(),
new Checks\EncryptCookiesCheck(),
new Checks\CsrfMiddlewareCheck(),
new Checks\StoragePermissionsCheck(),
]);
}
public function run(string $projectPath, ?array $onlyCategories = null): array
{
$findings = [];
foreach ($this->checks as $check) {
if ($onlyCategories !== null && !in_array($check->category(), $onlyCategories, true)) {
continue;
}
foreach ($check->run($projectPath) as $f) {
$findings[] = $f;
}
}
return $findings;
}
}
That's the entire orchestration. The default ruleset is an explicit list of constructor calls because I want adding a new check to be a two-line diff that's easy to review. A YAML config file would have been "more flexible," but flexibility costs the "can I see the whole ruleset in fifty lines" property, which turns out to be more valuable than I expected.
The --only flag filters by category, not by id, so you can say --only session,database and scope your CI run to the parts of the config you're changing in a given PR.
Tradeoffs
This is the "what it can't do" section, because pretending a small tool doesn't have limits is how small tools become big frameworks.
No runtime config inspection. Laravel's config is .env → config/*.php → runtime merges → cache. laravel-audit reads only the files; it does not run config('session.secure') and look at the resulting value. If you have a config/session.php that does elaborate conditional logic to compute 'secure' from three environment variables, laravel-audit will see the literal PHP expression and either match its regex or miss the case entirely. Every check is a string match or a shallow regex on file content. That's the cost of not booting Laravel.
No cross-file analysis. If a route registers middleware that isn't in the middleware group map, laravel-audit won't notice. If you have a custom service provider that overrides session.php at runtime, laravel-audit won't notice. Enlightn notices these because it has Laravel's full service container loaded.
No composer advisory integration by default. I deliberately didn't wire laravel-audit to call composer audit or hit the Packagist security API. That would require either a Composer install at audit time (which breaks the "works on a broken project" property) or a network call (which breaks offline CI). The ComposerLockCheck just checks that composer.lock exists and composer.json has a require block, which is the cheapest high-value signal.
Regex fragility. A check like SessionSecureCookieCheck looks for the literal string 'secure' => false in config/session.php. If someone writes "secure" => false with double quotes, the regex misses it. There's a real class of false negatives here. I've tried to cover the common idioms, but if you're writing exotic Laravel code the tool will under-report. I'd rather ship false negatives than false positives — a noisy linter gets turned off, a quiet linter gets trusted.
Try it in 30 seconds
Docker is the easiest way:
docker build -t laravel-audit https://github.com/sen-ltd/laravel-audit.git
docker run --rm -v $(pwd):/project laravel-audit /project
The image is 52 MB — a multi-stage Alpine build that starts from php:8.2-cli-alpine in the builder stage and copies a slimmed PHP install onto a plain alpine:3.19 runtime. Composer and PHPUnit live in the builder only; the runtime image has nothing but PHP, the audit source tree, and a vendor/ directory pruned of tests, docs, and coverage tooling.
For local development:
git clone https://github.com/sen-ltd/laravel-audit.git
cd laravel-audit
composer install
vendor/bin/phpunit --no-coverage
php bin/laravel-audit /path/to/your/laravel/app
The binary works without composer install on the target — the bin/laravel-audit script registers a tiny manual PSR-4 autoloader that points at its own src/ directory. Composer is only needed for PHPUnit.
CI wiring
The GitHub Actions output format is the reason I built this tool at all:
- name: laravel-audit
run: |
git clone https://github.com/sen-ltd/laravel-audit.git /tmp/audit
php /tmp/audit/bin/laravel-audit . --format github --fail-on error
The --format github formatter emits ::error title=...::... lines that GitHub Actions picks up and attaches to the pull request as inline annotations. You don't need a custom action or a reporter plugin; it's plain stdout.
What I'd build next
If I were going to expand the check catalog, the next five checks on my list are: a CORS config sanity check for config/cors.php, a cache driver check for CACHE_DRIVER=file in multi-server setups, a queue driver check for QUEUE_CONNECTION=sync leaking into production, a storage APP_URL check for the http://localhost default, and a MAIL_MAILER=log check that warns if an alternate driver isn't configured. Each one is a small static check that catches a real production footgun. None of them are going to make laravel-audit grow into Enlightn.
The small tool stays small. That's the point.
— SEN 合同会社

Top comments (0)