roave/better-reflection
Better Reflection is a PHP reflection API for static analysis: reflect on classes without loading them, parse classes from strings, inspect closures, and extract AST and type information. It prioritizes features over speed, so it is not intended for runtime use.
Install it, then confirm you actually need it — the README is blunt that this replaces native reflection for features, not speed:
composer require --dev roave/better-reflection
Keep it in require-dev unless an Artisan command or package ships to production; most Laravel uses are developer/CI tooling.
Where to look first, in this order:
docs/usage.md — explains SourceLocators and why reflectClass() may not "just work" in your app.docs/compatibility.md — the honest table of what is not supported (newInstance(), getClosure(), extension reflection). Read this before designing anything.docs/find-reflection-on-line.md — small helper that pays off immediately.UPGRADE.md — this library churns APIs across majors; don't copy blog posts from 5.x.First use case — a one-liner in Tinker that native reflection can't do. Reflect on a class without loading it into your request, e.g. a class in a package you're auditing:
$class = (new Roave\BetterReflection\BetterReflection())
->reflector()
->reflectClass(App\Models\Order::class);
$class->getMethods(); // same shape as native ReflectionClass
$class->getDocComment(); // works on classes that were never autoloaded
Then graduate to the two things that actually justify the dependency in a Laravel app: reflecting a whole directory of classes you never instantiate, and reading signatures/types without booting the framework or triggering side effects.
Don't construct BetterReflection per call — the AST parsing and locator work is the expensive part. Register a named locator set per purpose:
use Roave\BetterReflection\BetterReflection;
use Roave\BetterReflection\Reflector\DefaultReflector;
use Roave\BetterReflection\SourceLocator\Type\AggregateSourceLocator;
use Roave\BetterReflection\SourceLocator\Type\AutoloadSourceLocator;
use Roave\BetterReflection\SourceLocator\Type\DirectoriesSourceLocator;
$this->app->singleton(DefaultReflector::class, function () {
$better = new BetterReflection();
$astLocator = $better->astLocator();
return new DefaultReflector(
new AggregateSourceLocator([
new AutoloadSourceLocator($astLocator), // anything Composer can find
new DirectoriesSourceLocator([app_path()], $astLocator), // whole-tree scans
]),
$astLocator,
);
});
If you need ComposerSourceLocator, you need the ClassLoader instance — grab it from spl_autoload_functions() or re-require base_path('vendor/autoload.php'). In a Laravel app the default AutoloadSourceLocator is usually enough and needs no loader handle.
DirectoriesSourceLocator locates by name; it doesn't hand you a list. Walk the filesystem yourself and derive the FQCN from your PSR-4 map in composer.json:
foreach (File::allFiles(app_path('Jobs')) as $file) {
$class = 'App\\Jobs\\' . str_replace(
['/', '.php'],
['\\', ''],
Str::after($file->getRealPath(), app_path('Jobs') . DIRECTORY_SEPARATOR)
);
$ref = $reflector->reflectClass($class);
if (! $ref->implementsInterface(ShouldQueue::class)) {
$this->warn("{$class} is not queueable");
}
}
This is the workhorse pattern: an Artisan command that enforces conventions across app/ (Jobs implement ShouldQueue, every Policy method has a matching model parameter, every FormRequest defines rules()).
Laravel 11 leans heavily on attributes (#[Scope], #[UseFactory], #[On] in Livewire). Extraction works; instantiation doesn't:
foreach ($ref->getAttributes() as $attribute) {
$attribute->getName(); // 'App\Attributes\RequiresPermission'
$attribute->getArguments(); // resolved values, incl. ::class constants
$attribute->isRepeated();
}
Build your own tiny resolver mapping name → behaviour. Don't plan on newInstance().
Testing a rule engine against fixture files is slow and brittle. Feed source as a string instead:
$locator = new StringSourceLocator('<?php namespace Fixtures; class Foo {}', $astLocator);
$ref = (new DefaultReflector($locator, $astLocator))->reflectClass('Fixtures\Foo');
Same trick is useful at runtime for analysing generated/stub code (migration stubs, published package config classes, a "paste a class and inspect it" internal tool).
FindReflectionOnLine for debug and error tooling$finder = (new BetterReflection())->findReflectionsOnLine();
$finder('/app/Http/Controllers/OrderController.php', 42);
// → ReflectionClass | ReflectionMethod | ReflectionFunction | null
Great for a custom exception handler / Ignition-style panel that turns a stack frame into "you are inside OrderController::store(), here's its declared return type and docblock" without loading the frame's class.
Parsing thousands of files is slow; the reflector accepts a PSR-16 cache (Laravel's cache()->store() implements PSR-16), so plug it in and make sure the cache is flushed on deploy — otherwise CI sees yesterday's AST. Namespace the cache by git SHA for safety.
boot(), service container hot paths — no. If native ReflectionClass can do it at runtime on an already-loaded class, use native.newInstance() is unsupported across ReflectionClass and ReflectionAttribute. You cannot construct an attribute instance or a reflected class. getClosure(), invoke(), getStaticVariables(), getClosureThis() are also missing or partial.ReflectionExtension support, and getExtension() is not implemented. isInternal() and getExtensionName() work; don't try to introspect PHP internals.reflectFunction('my_helper') won't find your app/helpers.php. Functions are only reachable via a StringSourceLocator, SingleFileSourceLocator, or the line finder.docs/compatibility.md is still the source of truth per version. If you're on a PHP pre-release, pin BetterReflection and exercise your scanning command in CI before trusting the output.nikic/php-parser, which Pint, PHPStan, Rector, and several Pest plugins also depend on. Run composer why nikic/php-parser before upgrading, and check the installed major's PHP floor against your app's support matrix.getAst() on methods is not guaranteed on 6.x — go through the configured AstLocator). Always consult UPGRADE.md and the version's own docs when you touch AST code.getMethods() merges trait and parent methods; getDeclaringClass() may hand you the trait. If you're generating docs or enforcing signatures, decide explicitly whether inherited members count. If you enforce visibility rules (ReflectionProperty::isReadable() / isWritable()) on trait-composed properties, note 6.75.0 fixes those returning wrong results for trait scope — upgrade if you target 6.74.x in that kind of tooling.getDefaultValue() on parameters "makes some assumptions" — fine for docs, risky for codegen that must reproduce exact source.Artisan::call().$ref->getFileName() and getStartLine(). Nine times out of ten the locator resolved a different file than you assumed, not a library bug.eval'd classes, encrypted vendor code), implement SourceLocator yourself and compose it into an AggregateSourceLocator — that's the sanctioned way to teach the library where your code lives.How can I help you explore Laravel packages today?