graham-campbell/analyzer
Analyzer is a PHP package by Graham Campbell that checks whether referenced classes actually exist. It helps catch missing or mistyped class references, supports PHP 8.1-8.5, and integrates with PHPUnit 10-13 for test-time analysis.
Analyzer is a dev-only PHPUnit trait — no service provider, no config file, no Artisan command. You install it, write one test case, and it walks your source tree with nikic/php-parser asserting that every referenced class name actually resolves through the autoloader.
1. Install as a dev dependency, matched to your PHPUnit major. The version matrix is strict: Analyzer ^5.1 = PHP 8.1–8.5 + PHPUnit 10–13. If you're on PHPUnit 9, you need ^4.2; on PHPUnit 7–9, ^3.1.
composer require --dev "graham-campbell/analyzer:^5.1"
2. Where to look first. Two files: the README's version table (to confirm your PHPUnit major lines up) and src/AnalysisTrait.php in vendor/. That trait is the entire public API — getPaths(), getIgnored(), shouldAnalyzeFile(), and provideFilesToCheck(), all static since v4.0. Read it before writing anything; it's ~100 lines and tells you exactly which files get scanned and which method names the data provider uses.
3. First use case — a single analysis test case. Put it under tests/ so it runs with your normal suite:
<?php
namespace Tests\Analysis;
use GrahamCampbell\Analyzer\AnalysisTrait;
use PHPUnit\Framework\TestCase;
final class AnalysisTest extends TestCase
{
use AnalysisTrait;
public static function getPaths(): array
{
return [
__DIR__.'/../../app',
];
}
public static function getIgnored(): array
{
return [];
}
}
4. Run it narrow and read the output before you widen.
vendor/bin/phpunit tests/Analysis
Start with app/ only. Adding config/, database/, routes/, and tests/ on the first run against a mature Laravel codebase produces a wall of findings you won't be able to triage in one sitting.
5. Give it a fast lane. Add a dedicated testsuite in phpunit.xml so developers can run the gate in isolation in a couple of seconds:
<testsuite name="Analysis">
<directory>tests/Analysis</directory>
</testsuite>
vendor/bin/phpunit --testsuite Analysis
getPaths() takes absolute directories. The useful Laravel split is by what breaks in production, not by directory convention:
public static function getPaths(): array
{
return [
__DIR__.'/../../app/Providers',
__DIR__.'/../../app/Http/Middleware',
__DIR__.'/../../app/Policies',
__DIR__.'/../../app/Jobs',
__DIR__.'/../../app/Console/Commands',
__DIR__.'/../../app/Mail',
];
}
Those are the directories where a renamed class becomes a production outage rather than a caught test failure. Start there; expand to app/ wholesale once the ignore list is stable.
shouldAnalyzeFile() as the first filter, getIgnored() as the lastThese are different tools and conflating them is the classic mistake. shouldAnalyzeFile() decides whether a file is parsed at all — cheap, structural, no per-class bookkeeping:
public static function shouldAnalyzeFile(string $file): bool
{
if (str_contains($file, '/vendor/')) {
return false;
}
if (str_ends_with($file, '.blade.php')) {
return false;
}
return parent::shouldAnalyzeFile($file);
}
getIgnored() is a per-class suppression list — expensive, needs ownership, and each entry is permanent debt. Keep it small, comment every entry, and put it under code review.
public static function getIgnored(): array
{
return [
// Resolved at runtime by optional package; not in the dev autoloader.
'Laravel\Horizon\Horizon',
// Generated at build time by `php artisan ide-helper:generate`.
'App\Support\Generated\*',
// Registered via class_alias() in AppServiceProvider::register().
'App\Legacy\OldUserRepository',
];
}
Wildcards are supported. Every suppressed entry should name the mechanism that makes it legitimate.
Order matters for feedback speed. Analyzer is seconds; PHPStan and Pint are minutes.
- run: composer install --prefer-dist --no-interaction
- run: vendor/bin/phpunit --testsuite Analysis
- run: vendor/bin/phpstan analyse
- run: vendor/bin/phpunit
For the first two to four weeks, mark the analysis job continue-on-error: true, collect findings, triage, then flip to blocking. Don't ship a red pipeline on day one — people will disable it rather than fix it.
Pest runs on PHPUnit, so a plain PHPUnit TestCase using the trait works unchanged. Put it in tests/Analysis/ — do not create it as a .pest.php file, since the trait's test method expects the PHPUnit lifecycle.
provideFilesToCheck() yields the files to scan. It's already static and PHPUnit 10+ compatible. Override it only if you need to inject files from outside the paths — otherwise leave it alone and control scope via getPaths() and shouldAnalyzeFile().
The tool's blind spot is string class references, which includes the providers and aliases arrays in config/app.php. Cover that gap with a small hand-written test in the same file:
public function testConfigReferencesResolve(): void
{
$config = require __DIR__.'/../../config/app.php';
foreach ([...$config['providers'], ...array_values($config['aliases'])] as $class) {
self::assertTrue(
class_exists($class),
"Configured class [{$class}] does not exist."
);
}
}
That's ten lines and it catches the breakage class Analyzer structurally cannot see.
String references are invisible. $app->make('App\Services\Foo'), Route::middleware('auth.custom'), Relation::morphMap([...]), queue payloads, event/listener auto-discovery, factory $model properties, and config/app.php arrays are all strings. A green Analyzer run says nothing about them. Read the pass as "statically visible references resolve" — not "class references are correct."
The dev autoloader is the ground truth. class_exists() triggers autoloading against vendor/composer/autoload_*.php. Three consequences:
composer install --no-dev in CI will fail every reference to a dev-only class. Install with dev deps in the job that runs the analysis.composer dump-autoload, will read as missing. Regenerate the autoloader before the run.False positives will dominate your first week. Common sources: class_alias() registrations, conditionally-defined classes behind if (class_exists(...)) guards, eval/generated classes, and PHPDoc referencing optional upstream packages (@method tags on facades, @see links to Symfony internals). Resist the instinct to ignore the file — target the specific class name so the rest of the file still gets scanned.
PHPDoc is scanned (since v2.0), which is both the feature and a trap. Docblocks feeding IDE completion and generated API docs get checked, which is genuinely useful. But @mixin, @method, and generic annotations in copied upstream docblocks are frequent false positives. Invalid tags are skipped (v3.0.4), so malformed docblocks fail silently rather than loudly — don't assume a docblock was actually parsed.
Version coupling is intentional and bidirectional. Analyzer majors track PHPUnit majors. Upgrading PHPUnit 12 → 13 means bumping Analyzer; downgrading PHPUnit to unblock something means downgrading Analyzer. Pin it and let Renovate/Dependabot propose the paired bump rather than upgrading them independently.
Data providers must be static. Since v4.0 all four trait methods are static for PHPUnit 10+ compatibility. If you copy an old blog post's non-static getPaths(), you'll get a fatal — not a helpful deprecation.
Scanned test files reference test-only classes. If you include tests/ in getPaths(), every use Tests\Fixtures\... inside a docblock must resolve. Usually fine, but it means fixture classes cannot be excluded from the autoloader in any environment where the gate runs.
Debugging a mystery finding. The error names the resolving class, but not always the file. If you can't locate it, grep by the short name:
grep -rn "OldUserRepository" app/ config/ database/ routes/
The reference is usually in a docblock, not in executable code.
The escape hatch is the extension point. If you need behaviour beyond paths and ignores — skipping interfaces, excluding a namespace, treating @internal classes differently — subclass the trait or the test case and override shouldAnalyzeFile(). That's the sanctioned seam; there's no event system or config file to hook into.
Don't let it become your static analysis story. It reasons only about existence. PHPStan/Psalm reason about types, signatures, and control flow. A green Analyzer badge plus no PHPStan is worse than either alone, because the badge creates false confidence. Position Analyzer as the cheapest gate that fails in seconds, and keep the heavier tools behind it.
Adoption signals are weak — pilot it. Stars are unlisted and dependents report zero. The surface is tiny and MIT-licensed, so replacement risk is low, but decide on a two-week pilot with a measured triage backlog rather than on a badge.
How can I help you explore Laravel packages today?