jetbrains/phpstorm-attributes
JetBrains PhpStorm attributes provide PHP 8 attributes for richer code completion and analysis in PhpStorm, including Deprecated, ArrayShape, ObjectShape, and Immutable. Bundled with PhpStorm; add as a dev dependency for other static analysis tools.
Install it as a dev-only dependency — that is the entire setup:
composer require --dev jetbrains/phpstorm-attributes
No service provider, no publishable config, no facade, no bootstrapping, no runtime footprint. The package is a bag of attribute classes under JetBrains\PhpStorm\ (Deprecated, ArrayShape, ObjectShape, Immutable, Pure, ExpectedValues, NoReturn, Language, FileReference). vendor/jetbrains/phpstorm-attributes/src/ is roughly a dozen tiny files — read the constructors once, because the README documents only a subset of the parameters. Then verify with a smoke test that php artisan about still boots; that confirms what you already suspected: attributes are inert at runtime.
Note that PhpStorm bundles these classes already. The composer require --dev line exists for other tools (PHPStan, Psalm, IDEs that aren't PhpStorm, composer dump-autoload in CI) so they don't scream "class not found."
Start with the two attributes that give the best value-per-keystroke in a Laravel app:
#[Deprecated] on anything you've already tagged @deprecated in a docblock — legacy repository methods, custom facade methods being replaced by a service class, Str-style helper wrappers.#[ArrayShape] on methods that return structured arrays: API Resource payloads, aggregate/report arrays, config accessors, custom validator messages.First concrete use case — a typed payload contract inside a Resource:
use JetBrains\PhpStorm\ArrayShape;
use JetBrains\PhpStorm\Deprecated;
#[ArrayShape([
'id' => 'int',
'email' => 'string',
'roles' => 'string[]',
'metadata' => '?array',
])]
public function payload(): array
{
// ...
}
That single annotation gives key autocomplete and value type inference at every call site of payload(). See the "Gotchas" section for why annotating toArray() directly often does not help your controllers.
Deprecation as an inline codemod. The highest-leverage pattern in a Laravel codebase is treating #[Deprecated] as a migration tool rather than a tombstone. When moving a helper to a first-party API:
#[Deprecated(
reason: 'Use Http::withToken() instead; removed in v4.',
replacement: 'Http::withToken(%parameter0%)'
)]
public function apiClient(string $token): Client
replacement supports placeholders (%class%, %method%, %parameter0%), so the PhpStorm quick-fix rewrites the call site for you. This converts a CHANGELOG entry into something a developer actually sees, at the exact line where they need it. Pair it with a real trigger_error(..., E_USER_DEPRECATED) if you also want runtime telemetry — the attribute contributes nothing there.
Typed payload contracts on Resources and config. Laravel is full of object-like arrays. Annotate a dedicated payload()/toPayload() method on the Resource and call it from toArray(), so the shape survives through the return type (see Gotchas). For service-provider defaults and anything consumed by data_get(), put #[ArrayShape] on a small typed accessor class rather than on the config file itself — config() returns mixed, so annotating the file buys you nothing.
#[ObjectShape] for driver-level rows. Where you deliberately bypass Eloquent — DB::select() rows, json_decode($json, false), Stripe webhook payloads — #[ObjectShape(['id' => 'int', 'name' => 'string'])] makes $row->id autocomplete and type-check instead of resolving to mixed. This is the pragmatic middle ground before you commit to full DTOs.
Enums as the source of truth for #[ExpectedValues]. On Laravel 9+ with backed enums, point the attribute at the enum instead of hardcoding a list:
public function transition(#[ExpectedValues(valuesFromClass: OrderStatus::class)] string $to): void
#[NoReturn] for exit-point helpers. Mark custom redirect/abort wrappers that call exit() or throw unconditionally. PhpStorm then stops reporting unreachable code and missing returns after the call.
#[Language('RegExp')] on parameters accepting regexes (custom rule constructors, Str::-style helpers) and #[Language('SQL')] on raw-query helpers. #[FileReference] on package path constants (stub/view locations) so rename/move refactors and Ctrl+Click navigation work.
CI enforcement. Decide early whether the attribute or the PHPDoc is the source of truth. PHPStan and Psalm understand array{id: int, email: string} natively; the JetBrains attributes are only read by PhpStorm (and by a PHPStan extension, phpstan/phpstan-phpstorm, if you add it). Pick one convention and document it in CONTRIBUTING.md, or accept drift.
Scope discipline. Annotate public boundaries — controllers, Resources, service contract methods, commands — plus new DTOs. Over-annotating internal private methods is noise that reviewers will start deleting.
#[ArrayShape] type silently misleads every call site, #[Immutable] does not stop $dto->val = 'x', and #[Pure] on a function that touches the container produces confidently wrong dead-code analysis. Tests won't catch any of it. Use readonly properties (PHP 8.1+) or readonly classes (8.2+) where you want runtime immutability, and keep #[Immutable] as commentary.#[ArrayShape] on toArray() is often invisible. If your controller returns UserResource::make($user) or response()->json($resource), the IDE sees the JsonResource return type, not your override. Expose the shape via a separate method with an explicit : array return type, or add a matching @return array{...} on the class.#[ArrayShape(['id' => 'int'])] and @return array{id: int} is common (attribute for PhpStorm, docblock for PHPStan/Psalm) but they drift. If you only maintain the attribute, your CI gains nothing; if you only maintain the docblock, you're not using this package at all. Be deliberate.dev dependency vs composer install --no-dev. Attributes resolve lazily, so production is normally fine. It breaks only if some code reflects over attributes and calls newInstance() on everything, or filters with ReflectionAttribute::IS_INSTANCEOF. Audit packages doing generic attribute discovery before shipping.composer.json and run static analysis in CI.replacement placeholders are literal. %parameter0% counts from the call site, so it breaks the moment you reorder arguments. Test the quick-fix once on a throwaway call.'string[]', '?array', 'array<string, int>', and Foo::class all work; typos degrade to mixed silently. There's no validator, so a second pair of eyes on new shapes is worth the review comment.// noinspection PhpDeprecationInspection.composer update. PhpStorm caches attribute metadata; if completion looks stale, File → Invalidate Caches.WI project), not GitHub issues. Low star count and zero dependents is expected for a data-only package — don't read it as a maturity signal, but do pin a version in composer.json so a future attribute rename doesn't silently break your annotations.How can I help you explore Laravel packages today?