PHPDoc для Eloquent: типы get(), first() и аксессоров в Laravel 13

PHPDoc для Eloquent: типы get(), first() и аксессоров в Laravel 13
содержание

Введение

Знакомая ситуация: метод сервиса возвращает результат 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 вручную не стоит: после очередной миграции комментарий легко забыть обновить, и он начнёт противоречить схеме. Есть два практичных пути:

  1. Запускать php artisan ide-helper:models и поручить актуальность аннотаций генератору — подробно в статье Laravel IDE Helper: автодополнение моделей и фасадов.
  2. Ручной @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

Правила:

  1. Не аннотировать то, что анализатор выводит сам: get(), first(), find(), paginate() на месте вызова.
  2. Аннотировать границы методов: любой метод, возвращающий коллекцию, получает @return Collection<int, Model>.
  3. Не путать Eloquent\Collection и Support\Collection при импорте.
  4. Каждому Attribute-аксессору — @return Attribute<TGet, TSet>.
  5. Отношения — всегда <Related, $this>.
  6. Колонки не дублировать в @property вручную.
  7. После правок запускать PHPStan: он находит расхождения быстрее, чем ревью.

Итог

В Laravel 13 с Larastan большая часть типов Eloquent выводится без аннотаций, поэтому PHPDoc нужен на границах методов (@return Collection<int, Order>), у аксессоров нового стиля (Attribute<TGet, TSet>) и у отношений (<Related, $this>). Ручной @property пригождается редко, а колонки и аксессоры для IDE удобнее поддерживать генератором IDE Helper.

Похожие