Введение
Знакомая ситуация: метод сервиса возвращает результат get(), а в вызывающем коде IDE вдруг перестаёт подсказывать свойства модели, и статический анализатор ругается на mixed. С аксессорами похоже: $order->total_formatted работает в рантайме, но подсвечивается как неизвестное свойство. Почти всегда причина одна — тип теряется на границе метода, у которого нет ни возвращаемого типа, ни PHPDoc.
В Laravel 13 дело обстоит лучше, чем принято думать. Фреймворк вместе с Larastan выводит большинство типов без единой аннотации: Order::get(), first(), find(), paginate(), свойства-колонки из миграций. Аннотации нужны лишь в нескольких местах — в собственных методах, возвращающих Collection, в аксессорах, отношениях и пользовательских коллекциях. Дальше разберём, где именно и что при этом показывает анализатор.
В статье используется сквозной пример: модель Order с аксессором total_formatted и отношением items. Проверка выполнена на чистом Laravel 13.34 с Larastan 3.12 (PHPStan 2.2, PHP 8.5). Поведение PhpStorm отдельно не проверялось — подробности в разделе «Проверка результата».
Что выводится без аннотаций
Допустим, есть таблицы orders и order_items, модель Order с отношением items() и аксессором totalFormatted(). В таблице — результат dumpType() из PHPStan для выражений, вокруг которых нет никаких дополнительных аннотаций:
| Выражение | Тип по Larastan 3.12 |
|---|---|
Order::get() |
Eloquent\Collection<(int|string), Order> |
Order::all() |
Eloquent\Collection<int, Order> |
Order::where(...)->get() |
Eloquent\Collection<int, Order> |
Order::first() |
Order|null |
Order::find($id) |
Order|null |
Order::find([1, 2]) |
Eloquent\Collection<(int|string), Order> |
Order::findOrFail($id) |
Order |
Order::pluck('total') |
Support\Collection<(int|string), mixed> |
Order::paginate(15) |
LengthAwarePaginator<int, Order> |
DB::table('orders')->get() |
Support\Collection<int, stdClass> |
$order->items |
Eloquent\Collection<int, OrderItem> |
$order->user |
User|null |
$order->total (колонка unsignedInteger) |
int<0, max> |
$order->created_at |
Carbon|null |
Для типовых запросов дописывать ничего не требуется. Исключения — pluck(), значения которого приходят как mixed, и DB::table(): он отдаёт stdClass, а свойства колонок анализатору неизвестны.
Есть важная оговорка: все эти типы выводятся только на месте вызова. Стоит результату покинуть метод — и всё определяется аннотацией самого метода.
Collection<int, Order> в собственных методах
Публичный метод без возвращаемого типа и без PHPDoc ничего не передаёт наружу: для анализатора его результат — mixed.
class OrderService
{
// тип потерян: результат вызова — mixed
public function paid()
{
return Order::where('status', 'paid')->get();
}
}
Larastan сообщает Method OrderService::paid() has no return type specified, а IDE не показывает свойства элементов. Лечится это generic-аннотацией:
use App\Models\Order;
use Illuminate\Database\Eloquent\Collection;
class OrderService
{
/** @return Collection<int, Order> */
public function paid(): Collection
{
return Order::where('status', 'paid')->get();
}
}
Нативный тип и PHPDoc не дублируют друг друга, а работают в паре: язык проверяет класс коллекции, а анализатор и IDE берут из @return тип элементов.
Какой Collection импортировать
Классов два, и путаница между ними — самая частая причина «потерянного» типа.
| Класс | Что возвращает |
|---|---|
Illuminate\Database\Eloquent\Collection |
get(), all(), find([...]), отношения; методы load(), modelKeys(), fresh() |
Illuminate\Support\Collection |
pluck(), map() с не-моделями, DB::table()->get() |
Eloquent-коллекция наследует базовую, поэтому вернуть её из метода с типом Support\Collection формально можно. Но вызывающий код лишится методов Eloquent-коллекции, а IDE подставит не тот класс. В сигнатуре нужно указывать именно тот класс, который метод возвращает на самом деле.
Свойства и переменные
Для свойства класса и для локальной переменной используется @var:
class ReportBuilder
{
/** @var Collection<int, Order> */
private Collection $orders;
public function load(): void
{
/** @var Collection<int, Order> $orders */
$orders = $this->repository->paidOrders(); // если у метода нет типа
foreach ($orders as $order) {
echo $order->total_formatted; // тип $order — Order
}
}
}
@var над переменной — временное решение для чужого кода без типов. Если метод принадлежит проекту, лучше описать его через @return, и тогда подсказка сработает во всех местах вызова сразу.
Массив моделей записывается как list<Order> (индексы 0..n) или array<int, Order>. Форма Order[] тоже понимается, но list<> выражает мысль точнее.
Аксессоры
Новый стиль: Attribute
Аксессор в стиле Laravel 9+ — это protected-метод, который возвращает Attribute. Имя свойства получается из camelCase-имени метода в snake_case: totalFormatted() превращается в $order->total_formatted.
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
/** @return Attribute<string, never> */
protected function totalFormatted(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->total / 100, 2, ',', ' ') . ' ₽',
);
}
}
Формат Attribute<TGet, TSet>: первый параметр описывает значение при чтении, второй — при записи. Если атрибут доступен только для чтения, вторым параметром ставится never. Стоит помнить, что в самом классе Illuminate\Database\Eloquent\Casts\Attribute generics не объявлены — шаблоны добавляет Larastan через собственные stubs.
Прогон на Larastan 3.12 дал три случая:
| Аннотация метода | Что видит анализатор |
|---|---|
@return Attribute<string, never> |
$order->total_formatted — string |
| без PHPDoc | свойство неизвестно: Access to an undefined property ... $no_doc плюс замечание про незаданные TGet, TSet |
@return Attribute<int, never> |
$model->int_attr — int |
Вывод простой: каждому Attribute-аксессору нужен @return Attribute<...>. Без него Larastan не связывает метод со свойством.
Старый стиль: getFooAttribute
Для методов вроде getStatusLabelAttribute() хватает нативного возвращаемого типа:
public function getStatusLabelAttribute(): string
{
return strtoupper($this->status);
}
В этом случае $order->status_label определяется как string. Если тип не указан (getPlainAttribute() без : string), свойство остаётся mixed.
Мутаторы и касты
Тип записи задаётся вторым параметром generic: Attribute<string, string> подходит для пары «читаем строку, пишем строку». Если поле скалярное и ему нужно лишь приведение типа, проще воспользоваться casts(): Larastan учитывает касты при выводе типа свойства (стандартные created_at и updated_at определяются как Carbon|null). Разделять их удобно так: аксессор — для вычисляемых значений, каст — для преобразования самой колонки.
@property и @property-read
Когда-то @property над классом был единственным способом сообщить IDE о колонках. Сегодня картина другая:
- Larastan сам разбирает миграции и создаёт «магические» свойства модели (по документации — автоматически, настраивается). В проверке
$order->totalи$order->statusопределились без единой аннотации. - Аксессоры описываются через
@return Attribute<...>или типизированныйgetXAttribute(). - IDE без плагина и без сгенерированных файлов таких свойств не знает. Именно ей и нужны
@propertyлибо сгенерированный helper.
Дублировать колонки в PHPDoc вручную не стоит: после очередной миграции комментарий легко забыть обновить, и он начнёт противоречить схеме. Есть два практичных пути:
- Запускать
php artisan ide-helper:modelsи поручить актуальность аннотаций генератору — подробно в статье Laravel IDE Helper: автодополнение моделей и фасадов. - Ручной
@property-readоставлять только для вычисляемых значений, у которых нет ни колонки, ни аксессора (например, добавленных черезsetAttribute()илиwithCount).
/**
* Заполняется через withCount('items').
*
* @property-read int $items_count
*/
class Order extends Model
{
}
Если анализатор уже видит свойство, а сверху вручную добавлен ещё и @property, приоритет и возможный конфликт типов лучше проверить прогоном PHPStan.
Отношения
У методов отношений generic-аннотация обязательна: в ней указывается связанная модель и $this в роли владельца.
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/** @return HasMany<OrderItem, $this> */
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
$this означает «модель, у которой вызван метод», в том числе для наследников. С такой аннотацией $order->items определяется как Collection<int, OrderItem>, а $order->items() — как HasMany<OrderItem, Order>. Без неё Larastan выдаёт замечание о неуказанных типах у generic-класса. Свойство $order->user получает тип User|null, потому что связь может отсутствовать.
Свои Collection и Builder
Когда у модели собственный класс коллекции, его подключают атрибутом #[CollectedBy] и трейтом HasCollection:
use Illuminate\Database\Eloquent\Attributes\CollectedBy;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\HasCollection;
/** @extends Collection<int, Order> */
class OrderCollection extends Collection
{
public function paid(): static
{
return $this->filter(fn (Order $order) => $order->status === 'paid');
}
}
#[CollectedBy(OrderCollection::class)]
class Order extends Model
{
/** @use HasCollection<OrderCollection> */
use HasCollection;
}
После этого Order::get(), Order::all() и Order::get()->paid() определяются как OrderCollection — проверено на Larastan 3.12. Аннотация @use должна стоять непосредственно над строкой use HasCollection; внутри класса. Если поместить её над самим классом, PHPStan сообщит, что generic-трейт использован без типов. Для кастомного Builder схема та же: @extends Builder<Order> у класса и @use HasBuilder<OrderBuilder> у модели.
Проверка результата
Для проверки хватит небольшой конфигурации PHPStan с расширением Larastan (нужны PHP 8.2+ и Laravel 11.15+):
composer require --dev larastan/larastan
# phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 6
paths:
- app
vendor/bin/phpstan analyse
До добавления аннотаций анализатор выдаёт:
Method App\Services\OrderService::paid() has no return type specified.
Access to an undefined property App\Models\Invoice::$no_doc.
После @return Collection<int, Order> и @return Attribute<string, never> обе ошибки пропадают. Какой тип PHPStan вывел для выражения, показывает функция PHPStan\dumpType($expr) — именно ею составлена таблица в начале статьи.
Отдельно о «Cannot access property on mixed». По умолчанию PHPStan не считает неявный mixed ошибкой при обращении к свойству, поэтому даже на строгом уровне потеря типа не всегда заметна; надёжный сигнал — замечание об отсутствии типа у метода. Сообщение про mixed появляется в IDE и в конфигурациях со включённой строгой проверкой.
В PhpStorm тип выражения показывает Ctrl+Q (Quick Documentation) или Ctrl+Shift+P (Type Info).
Шпаргалка
| Где | Что писать |
|---|---|
Метод возвращает get()/all() |
@return Collection<int, Order> + нативный : Collection (Eloquent) |
Метод возвращает pluck() |
@return Support\Collection<int, string> (тип значений указывается вручную) |
| Свойство или переменная | @var Collection<int, Order> |
| Аксессор только чтение | @return Attribute<string, never> |
| Аксессор чтение и запись | @return Attribute<string, string> |
Старый getFooAttribute |
нативный : string |
| Отношение | @return HasMany<Item, $this> |
| Своя коллекция | @extends Collection<int, Order> + @use HasCollection<OrderCollection> |
| Колонки таблицы | ничего (Larastan) или ide-helper:models |
Правила:
- Не аннотировать то, что анализатор выводит сам:
get(),first(),find(),paginate()на месте вызова. - Аннотировать границы методов: любой метод, возвращающий коллекцию, получает
@return Collection<int, Model>. - Не путать
Eloquent\CollectionиSupport\Collectionпри импорте. - Каждому
Attribute-аксессору —@return Attribute<TGet, TSet>. - Отношения — всегда
<Related, $this>. - Колонки не дублировать в
@propertyвручную. - После правок запускать PHPStan: он находит расхождения быстрее, чем ревью.
Итог
В Laravel 13 с Larastan большая часть типов Eloquent выводится без аннотаций, поэтому PHPDoc нужен на границах методов (@return Collection<int, Order>), у аксессоров нового стиля (Attribute<TGet, TSet>) и у отношений (<Related, $this>). Ручной @property пригождается редко, а колонки и аксессоры для IDE удобнее поддерживать генератором IDE Helper.