laravel/surveyor
Laravel Surveyor is a mostly static analysis tool that parses PHP files to extract detailed metadata about classes, methods, properties, and return types, exposing it in a structured format other tools can consume. Currently in beta.
Surveyor is a library, not a CLI. There is no php artisan survey:analyze to run — you install it, resolve Analyzer from the container, and build your own command, linter, doc generator, or IDE helper on top of the structured results. Install it as a dev dependency unless you are shipping a runtime tool:
composer require --dev laravel/surveyor
Where to look first, in this order:
Laravel\Surveyor\Analyzer\Analyzer — the only entry point you need on day one (analyze($path) / analyzeClass(FQCN)).ClassLikeResult — name(), namespace(), extends(), implements(), hasMethod(), getMethod(), publicMethods(), publicProperties(), getConstant().Laravel\Surveyor\Types\Type — the factory plus Type::is() for the type objects coming back from returnType() and $property->type.src/Markers section of the README — you will hit "why is this method missing?" within an hour otherwise.First use case — a throwaway artisan command that dumps a class contract. It exercises the whole happy path (analyzer, result, methods, types) and gives you something to iterate on:
use Laravel\Surveyor\Analyzer\Analyzer;
$result = app(Analyzer::class)->analyzeClass(App\Models\User::class)->result();
foreach ($result->publicMethods() as $method) {
$this->line(sprintf(
'%s(%s): %s',
$method->name(),
collect($method->parameters())->pluck('name')->implode(', '),
$method->returnType()?->toString() ?? 'mixed',
));
}
If that prints sensible return types for your models (including casts()-driven attribute types), you have a working baseline. If it prints mixed everywhere, jump to the Gotchas section before writing more code.
Analyze once, consume many times. The natural shape is a service that analyzes a set of classes and hands typed results to N consumers (route doc generation, OpenAPI stubs, a make:xyz generator, an architecture test). Do not call analyzeClass() inside a loop of format-specific code — wrap it:
final class ClassFacts
{
public function __construct(private Analyzer $analyzer) {}
public function for(string $class): ClassLikeResult
{
return $this->analyzer->analyzeClass($class)->result();
}
}
Then inject ClassFacts and let the cache do its job across consumers.
Branch on type objects, never on strings. returnType() returns a Type, and a nullable return is a UnionType, not a StringType with a flag you can compare. Flatten before you branch:
use Laravel\Surveyor\Types\{Type, UnionType, StringType, ClassType};
$type = $method->returnType();
$members = $type instanceof UnionType ? $type->types() : [$type];
$isStringy = collect($members)->contains(
fn ($t) => Type::is($t, StringType::class)
);
$type->isNullable() is the safe null check; toString() is for display only, not for control flow.
Register your marker vocabulary in a service provider. This is the extension point most day-to-day tools need — "hide this from generated output" is a product decision, and Surveyor deliberately makes you own the attribute. Implement Ignored on your own attribute, and if you need config-driven hiding, implement ConditionallyIgnored and register a resolver in boot():
use Laravel\Surveyor\Support\Markers;
Markers::registerConditionResolver(fn (string|array $condition) => is_string($condition)
? (bool) config($condition, false)
: (bool) app()->call($condition));
Markers::registerTags('ignore'); // for array keys / config-shaped output
The resolver must be registered before you read results — registration is lazy relative to analysis but eager relative to getMethod(). Put it in a provider that is loaded in the same process that consumes results.
Batch through the cache, not through memory. For a whole-app scan, iterate class names, call analyzeClass(), and immediately serialize what you need out of each result before moving on. AnalyzedCache::clearMemory() between batches keeps peak memory down while the disk cache preserves the expensive parse:
AnalyzedCache::enableDiskCache(storage_path('surveyor-cache'));
foreach ($classes as $class) {
$this->facts[$class] = summarize(app(Analyzer::class)->analyzeClass($class)->result());
AnalyzedCache::clearMemory();
}
Treat "missing" as a three-state answer. hasMethod() returning false can mean does not exist or exists but is marker-hidden. If your tool needs to distinguish, use method($name) (raw, past the filter) alongside getMethod($name) (filtered), and isIgnored() on the class. This matters for anything that reports coverage or diffs contracts.
Model analysis is opt-in by value. You get database attribute types, isModelRelation() flags on relationship methods, accessors/mutators, and casts for free — but only for classes that are actually Eloquent models and reachable through a booted app. Use it for form-request/scaffold generators where column types matter; skip it for tooling that only cares about method signatures, and you avoid the DB entirely.
composer.json and keep every Surveyor touchpoint behind your own small wrapper class so a breaking rename is a one-file fix, not a grep across the codebase.DB_CONNECTION=sqlite + :memory:), and it must not run in contexts where booting the app has side effects. Never call the analyzer from inside a request handler.git checkout/rebuild that restores timestamps, or a stale disk cache on a shared CI volume, can serve you yesterday's answer. AnalyzedCache::clear() in CI before a full run, or scope SURVEYOR_CACHE_DIR per pipeline.SURVEYOR_CACHE_ENABLED / SURVEYOR_CACHE_DIR are convenient but invisible in code review; if your tool depends on caching being on, call AnalyzedCache::enableDiskCache(...) explicitly rather than trusting an env var to be set in prod.ConditionallyIgnored readers look for constructor parameters literally named unless and when. Name them anything else and the file-based reader treats the marker as unconditional while instantiation honors the condition — a discrepancy that will only show up in one code path.// @ignore does nothing until Markers::registerTags('ignore'). Keys are matched positionally, so duplicated keys or heavily formatted arrays can shift what gets marked.Markers::fromReflection($reflection) to get an IgnoreMarker or null for those.Type::isSame() for exact comparison (strict) vs Type::is() for class checks (loose), $type->required() / optional() when generating parameter lists, and $classResult->method() / property() when you need to read a member your own marker just hid — which is easy to do accidentally while developing the marker itself.How can I help you explore Laravel packages today?