Поддержите проект, сделав пожертвование
Повторяющиеся методы моделей

Concerns: как не раздувать модели

Выносите одинаковые методы моделей в отдельные Concerns и подключайте их через трейты.

0
Проблема дублирования

Одно новое правило приходится синхронно менять сразу в нескольких моделях.

Допустим, Post, Comment и Page уже умеют отправлять записи в архив. Сначала требование звучало просто: записать текущее время в archived_at. Поэтому одинаковые методы появились прямо в моделях:

class Post extends Model
{
    public function archive(): void
    {
        $this->archived_at = now();
        $this->save();
    }

    public function isArchived(): bool
    {
        return $this->archived_at !== null;
    }
}

class Comment extends Model
{
    public function archive(): void
    {
        $this->archived_at = now();
        $this->save();
    }

    public function isArchived(): bool
    {
        return $this->archived_at !== null;
    }
}

А затем появляется новое правило: запись нужно уметь возвращать из архива. Теперь придётся добавить unarchive() во все модели и не забыть про Page. Следующее изменение — например, выборка только архивных записей — снова потребует той же работы.

Проблема не в количестве строк, а в количестве мест, где определяется одно правило. Пока реализаций несколько, они неизбежно начинают расходиться.

Общий родительский класс здесь тоже не подходит. PHP-класс может наследоваться только от одного класса, а модели уже наследуются от Eloquent Model. Кроме того, Post и Comment остаются разными моделями. Нам нужно один раз написать общие методы архивации, а не строить новую иерархию классов.

1
Что такое Concern

Concern объединяет методы одной задачи, а трейт добавляет их в модель.

В проектах на Laravel Concern обычно называют трейт, в котором собраны методы для одной задачи. Например, Archivable содержит методы для работы с архивом. Его можно подключить к Post, Comment и Page.

Трейт и Concern — не одно и то же

Трейт (trait) — конструкция языка PHP. Она позволяет добавить методы и свойства в класс без наследования. PHP знает ключевые слова trait и use, но отдельной конструкции Concern в языке нет.

Разница проста: трейт показывает, как PHP подключает методы к классу, а слово Concern объясняет, зачем эти методы собраны вместе. Если трейт содержит случайный набор вспомогательных методов, называть его Concern нет смысла. Методы Concern должны решать одну задачу, понятную из названия.

В нашем примере эта задача — архивирование. Поэтому название Archivable сразу объясняет содержимое.

Model в примере — базовый класс Eloquent, а инструкция use Archivable добавляет методы трейта в модель:

class Post extends Model
{
    use Archivable;
}

class Comment extends Model
{
    use Archivable;
}

После подключения методы Archivable становятся методами самой модели. Поэтому $this внутри трейта будет указывать на конкретный Post или Comment.

2
Создаём Archivable

Собираем методы архивации в одном трейте.

Создадим app/Models/Concerns/Archivable.php и перенесём туда методы архивации:

<?php

namespace App\Models\Concerns;

use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;

trait Archivable
{
    public function archive(): void
    {
        $this->archived_at = now();
        $this->save();
    }

    public function unarchive(): void
    {
        $this->archived_at = null;
        $this->save();
    }

    public function isArchived(): bool
    {
        return $this->archived_at !== null;
    }

    /** @param Builder<static> $query */
    #[Scope]
    protected function archived(Builder $query): void
    {
        $query->whereNotNull('archived_at');
    }
}

Теперь Archivable позволяет отправить запись в архив, вернуть её, проверить состояние и выбрать архивные записи. Эти методы больше не нужно повторять в каждой модели.

Подключим Archivable к двум моделям:

use App\Models\Concerns\Archivable;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use Archivable;
}

class Comment extends Model
{
    use Archivable;
}

Атрибут #[Scope] позволяет использовать защищённый метод archived() как Post::archived(). Вызывающий код получает понятное имя и не повторяет условие запроса.

3
Как использовать Archivable

Архивируем записи и выбираем их через методы с понятными именами.

После подключения Archivable методы можно вызывать прямо на модели:

$post->archive();

$archivedPosts = Post::archived()->get();

Проверка состояния пригодится в другом месте. Например, контроллер может запретить редактирование архивной публикации:

if ($post->isArchived()) {
    abort(409, 'Архивную публикацию нельзя редактировать.');
}

return view('posts.edit', ['post' => $post]);

Такой код следует принципу «Говори, а не спрашивай»: мы просим объект выполнить действие, а не забираем его данные, чтобы управлять состоянием снаружи. Сравните два варианта:

// Внешний код зависит от того, как модель хранит состояние.
$post->archived_at = now();
$post->save();

// Намерение выражено явно, а правило находится внутри модели.
$post->archive();

Принцип не запрещает методы проверки вроде isArchived(). Такой метод нужен, когда от ответа зависит следующий шаг, например разрешение на редактирование. Но перед вызовом archive() отдельно проверять состояние не требуется.

Для простой архивации сервис лишь оборачивает один вызов модели:

app(ArchiveService::class)->archive($post);

В данном примере модель сама умеет отправить запись в архив. Concern добавляет одинаковые методы нескольким моделям без копирования кода.

Это не означает, что сервисы всегда плохи. Если при архивации нужно проверить права, изменить несколько объектов или обратиться к внешней системе, отдельный класс действия или сервис подойдёт лучше.

4
Когда использовать Concern

Сравниваем Concern с обычным методом модели и отдельным классом.

Concern нужен не для каждого метода. Ориентируйтесь на три случая:

  • Метод модели. Оставьте обычный метод, если он нужен только одной модели. Выносить его заранее нет смысла.
  • Concern. Создайте Concern, если одни и те же методы нужны нескольким моделям. Например, Post, Comment и Page должны одинаково архивировать записи.
  • Класс действия или сервис. Выберите класс действия или сервис, если операция работает сразу с несколькими объектами, проверяет права или обращается к внешней системе.

Если из Archivable приходится вызывать несколько сервисов и менять не относящиеся к архиву данные, такой код лучше вынести в отдельный класс. В Concern должны остаться только методы архивации.

5
Правила именования и хранения

Как сделать назначение Concern понятным по его коду и расположению.

  • Одна задача. Не смешивайте архивацию, поиск и публикацию в одном Concern.
  • Понятное название. Archivable и Publishable сразу сообщают, какие методы получает модель.
  • Явные требования. Если Concern требует столбец таблицы, связь или метод модели, укажите это рядом с кодом.
  • Условия выборки рядом с методами. Вызов Post::archived()->get() позволяет не повторять whereNotNull('archived_at') по всему приложению.
  • Единое расположение. Храните такие трейты в app/Models/Concerns, чтобы их было легко найти.
  • Разные имена методов. Если два трейта объявят метод с одинаковым именем, PHP потребует явно разрешить конфликт.