php-static-analysis/psalm-plugin
A Psalm plugin for PHP static analysis that extends Psalm with additional checks and integrations, helping teams catch issues earlier and enforce consistent code quality in their projects.
This package collects PHP attributes off your classes, methods, and properties and makes their constructor arguments readable directly — no ReflectionAttribute::newInstance() round-trip at the call site.
composer require <vendor>/<package> # verify name/constraint against the repo
First places to look:
src/ — the collector/registry class (the thing that walks your codebase and stores attribute instances) and the attribute value object.tests/Fixtures — the fixture attributes show the exact expected shape of stored parameters faster than the README does.php artisan vendor:publish), which typically holds the scanned paths.First use case. Declare a domain attribute with promoted constructor parameters and read it back without reflection:
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class RateLimit
{
public function __construct(
public int $attempts = 60,
public int $decaySeconds = 60,
) {}
}
#[RateLimit(attempts: 30, decaySeconds: 60)]
final class WebhookController
{
#[RateLimit(attempts: 5)]
public function store() {}
}
Before 0.5.0 you had to instantiate the attribute yourself to get at attempts:
$reflection = new ReflectionMethod($controller, 'store');
$limit = $reflection->getAttributes(RateLimit::class)[0]->newInstance()->attempts;
From 0.5.0 the parameters are stored on the collected attribute, so the reflection step disappears and you read the parameters off the collected object directly (check the collector's accessor name — arguments(), parameters(), or a public property — in src/; don't assume).
Read parameters, don't re-instantiate. This is the whole point of 0.5.0. Audit for ->newInstance() and getArguments() calls in your codebase and replace them with the stored-parameter accessor. If you have a helper that wraps newInstance(), delete it — it is now the slow path.
Middleware reading controller attributes. Resolve the route action, pull the method-level attribute off the collector, fall back to the class-level one:
public function handle(Request $request, Closure $next)
{
$action = $request->route()->getActionName(); // "Controller@method"
$limit = $this->attributes->forMethod($action, RateLimit::class)
?? $this->attributes->forClass($action, RateLimit::class);
if ($limit && $this->limiter->tooManyAttempts($key, $limit->attempts)) {
abort(429);
}
}
Note the class-level fallback — it's the pattern that makes #[RateLimit(attempts: 30)] on the class act as a default for every method.
Collect once, in a service provider. Scanning is the expensive part; reading parameters is now nearly free. Warm the collection in boot() (or behind a cache) rather than calling the collector inside a request-scoped loop:
$this->app->singleton(AttributeCollection::class, fn () => AttributeCollection::fromPaths([
app_path('Http/Controllers'),
app_path('Console/Commands'),
]));
Treat attributes as declarative config. Once parameters are readable, they compete with config/*.php for the same job. Use attributes for per-class/per-method behaviour (rate limits, feature flags, audit logging, queue settings); keep cross-cutting environment values in config and let the attribute reference them by name.
Repeatable attributes. If your attribute is Attribute::IS_REPEATABLE, design your collector for a list and decide the ordering contract explicitly — don't rely on declaration order surviving a cache round-trip.
Testing. Assert on the attribute's parameters, not on the reflection call. A unit test against the attribute class plus one integration test asserting the collector returned the right stored parameters covers most regressions.
Integration tips. Attributes survive into queued jobs, console commands, and Eloquent models equally well — anywhere you can reach a ReflectionClass, you can reach the collector. Prefer resolving the collector from the container so tests can swap it with a hand-built instance instead of scanning the fixture directory.
->newInstance() should be re-verified, not assumed equivalent.newInstance() to execute constructor-side validation, normalisation, or side effects, that no longer happens implicitly. Validate where you consume the attribute, or add an explicit newInstance() only on the paths that need it.config() calls, no function calls. If you need runtime values, store a config key string and resolve it at the consumer.$attr->attempts), never by position. Optional parameters left out at the declaration site may be absent or defaulted depending on the collector — confirm which, because isset() vs ?? matters.#[Attribute] flags bite. A TARGET_METHOD-only attribute on a class silently doesn't appear. If an attribute "vanishes", check TARGET_* first, then whether the path is inside the configured scan roots.dd($collection->forClass(MyController::class)) shows exactly what was stored, which distinguishes "not collected" from "collected with wrong args" immediately.How can I help you explore Laravel packages today?