Eloquent - это мощный и умный инструмент, нравящийся многим своими возможностями. Он позволяет с лёгкостью выполнять операции с базами данных, сохраняя при этом простоту использования. Реализуя паттерн Active Record (AR), описанный Фаулером в книге "PoEAA", является одним из лучших реализаций на сегодняшний день.

В этой статье я хотел бы рассказать о нескольких советах и рекомендациях, о которых узнал экспериментальным путём. Например, Вы когда-нибудь задумывались о том, чтобы тем или иным образом разделить жадные загрузки? Нет? Тогда, я уверен, Вы узнаете как минимум что-то новое, так что обязательно дочитайте до конца!

Как и все существующие инструменты, у Eloquent имеются свои нюансы. Как ответственные разработчики, мы всегда должны помнить о том, на что соглашаемся. Если Вы хотите узнать больше об AR и философии её разработки, очень рекомендую статью Шона МакКула.

Переиспользуемые скоупы

Традиционно, переиспользуемые скоупы запросов всегда определялись в самой модели с помощью магического метода scopeXXX, макросов или специального класса Builder. В первых двух случаях проблема заключается в том, что они оба опираются на неявную магию, что делает (почти) невозможным получение подсказок со стороны IDE без применения специальных инструментов. Ещё хуже то, что в случае регистрации макросов, может возникнуть конфликт имён. Однако, существует и четвёртый, на мой взгляд, более эффективный подход: использование переиспользуемых скоупов (tappable scopes). Переиспользуемые скоупы - это одна из тех сокрытых жемчужин, которые чрезвычайно ценны, но в то же время практически неизвестны широким массам, поскольку нигде не документированы.

Объяснение на примере

В качестве примера используем следующий код контроллера:

public function index(): Collection
{
    return Company::query()
        ->whereNull('country_id')
        ->whereNull('verified_at')
        ->oldest()
        ->get();
}

Мы видим, что он применяет некоторые условия к объектуBuilder и возвращает результат без каких-либо преобразований. Хотя такой способ написания запроса вполне допустим, он приводит к утечке внутренних данных, а содержимое where ничего нам не говорит об условиях (в данном конкретном примере оба условия where вполне чётко говорят нам что происходит, но автор имеет ввиду те случаи, когда фильтр по колонкам является неочевидным даже при наличии осознанных наименований переменных, - прим. переводчика). Возможно, требование было таким: "Получить список непроверенных компаний, не принадлежащих пользователям с сортировкой от старых к новым". "Непроверенная" в данном случае означает, что компания не прошла проверку на стадии регистрации и именно этим понятием руководствуются наши менеджеры. Таким образом, мы можем улучшить запрос:

public function index(): Collection
{
    return Company::query()
        ->tap(new Orphan())
        ->tap(new Unverified())
        ->oldest()
        ->get();
}

Теперь условия стали более читаемы, не так ли? Давайте быстро взглянем на один из этих скоупов:

final readonly class Orphan
{
    public function __invoke(Builder $builder): void
    {
        $builder->whereNull('user_id');
    }
}

Вот и всё! Такая простота позволяет нам составлять запросы в любом виде и форме, не ограничиваясь использованием какого-либо конкретного условия и не перегружать модели.

Теперь представьте, что появилось новое требование, которое должно в новом запросе возвращать не связанные с пользователями записи, принадлежащие определённой компании. Казалось бы реализация может быть сложной, но нет! Давайте просто повторно используем скоуп:

public function index(Request $request): Collection
{
    return Member::query()
        ->whereBelongsTo($request->company)
        ->tap(new Orphan())
        ->get();
}

В этом и заключается мощь переиспользуемых скоупов. Следует также отметить, что в данном примере обе модели должны обладать концепцией "осиротелости", представляющей собой отказ от завершения операций после начатого процесса регистрации, о чём сигнализирует колонка user_id, имеющая значение null. Регистрация считается завершённой когда пользователь связан со всеми моделями. Разумеется, что нельзя просто взять и использовать любой скоуп с любой моделью. Он должен поддерживать структуру таблицы.

Примечание к паттерну Спецификация

Вы когда-нибудь слышали о паттерне "Спецификация" и пытались ли догматически его применить? Как известно, догма - корень всех зол. Этот способ применения ограничений запроса предлагает лучшее из лучших.

Вы - разработчик пакетов?

Переиспользуемые скоупы также полезны для авторов пакетов, которые хотели бы использовать их в своих программных решениях. В качестве примера возьмём laravel-adjacency-list . Скоуп scopeIsRoot может быть изменён следующим образом:

final readonly class IsRoot
{
    public function __invoke(Builder $builder): void
    {
        $builder->whereNull('parent_id');
    }
}

Такой подход также решает проблему несовпадения имён методов и скоупов, благодаря простому отказу от тёмной магии, сохранившейся во фреймворке с первых дней его существования. В целом, использование отключаемых скоупов даёт положительный результат в 90% случаев.

Не совсем глобальные скоупы

Я знаю, что название не имеет большого смысла, если читать его вне контекста, но, пожалуйста, потерпите. Время от времени в моей X-ленте появляются посты о глобальных скоупах. Общий смысл всегда сводится к тому, что "глобальные скоупы - это плохо, а локальные - хорошо". Причина в том, что документация Laravel по глобальным скоупам создаёт впечатление, что документированный способ применения является единственным, но это не так.

Некоторое время назад я размышлял над частой проблемой и меня осенило: а что будет, если пренебречь этим соглашением? Я взглянул на API глобальных скоупов и быстро понял, что на самом деле объявлять их в методе booted жизненного цикла модели совсем не обязательно. Более того, ограничений вообще нет! Они могут быть применены и в сервис-провайдере, и в мидлваре, и в джобе, и т.д.. Возможности безграничны. Однако, наилучшее применение, на мой взгляд, в сочетании с мидлварями. Итак. рассмотрим пример.

Пример: ограничение по стране

Представьте себе, что Вы работаете над приложением, подобным IMDb, имеющее публичную часть и внутреннюю панель администратора. Одним из требований может быть то, что определённые фильмы должны показываться пользователям только в том случае, если страна пользователя есть в определённом "белом списке", в противном случае фильм должен как бы не существовать. Проще говоря, необходимо разделить данные на основании страны клиента. Однако, это ограничение должно распространяться только на публичную страницу, а не на внутреннюю панель администратора. Лёгкий способ реализовать это требование - использовать не совсем глобальные скоупы.

Вначале создайте обычный глобальный скоуп:

final readonly class CountryRestrictionScope implements Scope
{
    public function __construct(private Country $country) {}

    public function apply(Builder $builder, Model $model): void
    {
        // pseudocode: do the actual country-based filtering here
        $builder->isAvailableIn($this->country);
    }
}

Далее создадим HTTP мидлварю, в обязанности которой будет входить применение скоупа к соответстующим моделям:

final readonly class RestrictByCountry
{
    public const NAME = 'country.restrict';

    public function __construct(private Repository $geo) {}

    public function handle(Request $request, Closure $next): mixed
    {
        $scope = new CountryRestrictionScope($this->geo->get());

        Movie::addGlobalScope($scope);
        Rating::addGlobalScope($scope);
        Review::addGlobalScope($scope);

        return $next($request);
    }
}

Примечание: Repository в данном примере является любым хранилищем, возвращающим страну пользователя, например laravel-geo.

Далее, откройте файл routes/web.php и примените мидлварю для группы:

$router->group([
    'middleware' => ['web', RestrictByCountry::NAME],
], static function ($router) {
    $router->resource('movies', Site\MovieController::class);
    $router->resource('ratings', Site\RatingController::class);
    $router->resource('reviews', Site\ReviewController::class);
	
    // Front-facing public website routes...
});

$router->group([
    'middleware' => ['web', 'auth'],
    'prefix' => 'admin',
], static function ($router) {
    $router->resource('movies', Admin\MovieController::class);
    $router->resource('ratings', Admin\RatingController::class);
    $router->resource('reviews', Admin\ReviewController::class);
	
    // Admin routes...
});

Обратите внимание на то, что мидлваря применяется только к публичным роутам и это имеет следующие последствия:

  • При посещении пользователем любого маршрута из числа публичных, его содержимое будет автоматически фильтроваться в зависимости от страны, что может привести к 404 ошибкам в случае недоступности запрашиваемой записи для страны пользователя;

  • При необходимости добавления новых маршрутов разработчиком не нужно помнить о том, что результаты должны фильтроваться по стране. С этим уже разобрались и обойти это ограничение невозможно, если только не сделать это специально;

  • При использовании разработчиками REPL типа tinker, не нужно беспокоиться о фильтрации данных с использованием глобальных скоупов. Помните, что они не такие уж и глобальные;

  • Когда администратор заходит в админ панель, он всегда видит всё содержимое независимо от страны привязки. Это именно то, что нам нужно.

Проще говоря, если принять природу глобальных скоупов думая о точечном расположении, то можно получить удовольствие от разработки устранив возможные разочарования в будущем. Это необязательно должно вызывать раздражение!

Бонус: комбинирование с переиспользуемыми скоупами

Ничто не мешает нам делать подобное:

final readonly class FileScope implements Scope
{
    public function __invoke(Builder $builder): void
    {
        $this->apply($builder, File::make());
    }

    /** @param File $model */
    public function apply(Builder $builder, Model $model): void
    {
        $builder
            ->where($model->qualifyColumn('model_type'), 'directory')
            ->where($model->qualifyColumn('collection_name'), 'file');
    }
}

Метод __invoke предназначен для того, чтобы сделать скоупы доступными, а apply - для соблюдения контракта Scope, что является для (не совсем глобальных) скоупов.

  • Вы хотите использовать скоуп в качестве глобальной? Есть!

  • Вы хотите применять скоуп к определённым запросам внутри них? Есть!

Фантомные свойства

В одном из недавних проектов, над которыми я работал, требовалось отобразить количество меток на интерактивной карте типа Google Maps, Leaflet или Mapbox. Эти карты принимают список геометрических типов в соответствии со спецификацией GeoJSON. Тип Point, который мне и был нужен, должен предоставлять свойство coordinates со значением широты и долготы. Проблема заключается в том, что координаты представляют собой составное значение в то время как в базе данных они представлены как addresses:id,latitude,longitude. Таблица была спроектирована таким образом из-за выбранной панели администрирования: Laravel Nova. В Nova гораздо проще работать с созданием записей, если структура данных максимально плоская. Я мог бы просто решить эту проблему в Eloquent Resource (он же трансформер), но любопытство подсказало мне, что должен быть способ лучше. Внутреннее "я", безусловно, было право: лучший способ существует благодаря тому, что я называю "фантомными свойствами".

Пример: координаты

Для решения поставленной задачи вначале нужно создать объект ValueObject, предоставляющий координаты адреса:

final readonly class Coordinates implements JsonSerializable
{
    public function __construct(
        public float $latitude,
        public float $longitude
    ) {}

    public function jsonSerialize(): array
    {
        return [$this->latitude, $this->longitude];
    }

    public function __toString(): string
    {
        return "({$this->latitude},{$this->longitude})";
    }
}

Далее следует определить каст нашего объекта:

final readonly class AsCoordinates implements CastsAttributes
{
    public function get($model,  string $key, $value, array $attributes): Coordinates
    {
        return new Coordinates(
            (float) $attributes['latitude'], 
            (float) $attributes['longitude'],
        );
    }

    public function set($model,  string $key, $value, array $attributes): array
    {
        return $value instanceof Coordinates ? [
            'latitude'  => $value->latitude,
            'longitude' => $value->longitude,
        ] : throw new InvalidArgumentException('Invalid value.');
    }
}

Наконец, мы должны назначить его в качестве каста атрибута координат модели адреса:

final class Address extends Model
{
    protected $casts = [
        'coordinates' => AsCoordinates::class,
    ];
}

Теперь мы можем просто использовать его в нашем ресурсе:

/** @mixin \App\Models\Address */
final class FeatureResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'geometry' => [
                'type' => 'Point',
                'coordinates' => $this->coordinates,
            ],
            'properties' => $this->only('name', 'phone'),
            'type' => 'Feature',
        ];
    }
}
  • Теперь Coordinates полностью отвечает за концепцию координат (представление двухмерной точки на Земле);

  • Благодаря реализованному интерфейсу, нам не придётся самостоятельно вызывать метод jsonSerialize(). Об этом позаботится Laravel, вызывав json_encode под капотом;

  • Если что-то изменится в системе координат, то найти где используется данный класс не составит труда.

Вот что мы получили в итоге, как и ожидалось:

{
    "geometry": {
        "type": "Point",
        "coordinates": [4.5, 51.5]
    },
    "properties": {
        "name": "Acme Ltd.",
        "phone": "123 456 789 0"
    },
    "type": "Feature"
}

Пример: отрисовка адреса

Ещё одним удобным способом использования фантомных свойств является помощь в рендеринге шаблонов. Обычно, если требуется вывести адрес в виде HTML, нужно сделать примерно следующее:

<address>
  <span>{{ $address->line_one }}</span>
  @if($two = $address->line_two)<span>{{ $two }}</span>@endif
  @if($three = $address->line_three)<span>{{ $three }}</span>@endif
</address>

Как видим, это может легко сломаться. Хотя я понимаю, что это довольно надуманный пример, поскольку адреса обычно отображаются по-разному в зависимости от страны, он помогает представить, что такой вариант может быстро привести к беспорядку. А что если сделать нечто подобное:

<address>
  @foreach($address->lines as $line)
    <span>{{ $line }}</span>
  @endforeach
</address>

Гораздо приятнее, правда? Наш шаблон больше не заботится о том, насколько сложным он может внезапно стать из-за различных правил в разных странах. Он занимается тем, что умеет делать лучше всего - рендерингом. Фантомное свойство, отвечающее за это, может выглядеть следующим образом:

final readonly class AsLines implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes): array
    {
        return array_filter([
            $attributes['line_one'],
            $attributes['line_two'],
            $attributes['line_three'],
        ]);
    }

    public function set($model, string $key, $value, array $attributes): never
    {
        throw new RuntimeException('Set the lines explicitly.');
    }
}

Если, например, для Антарктиды нам нужно поменять местами вторую и третью строки, мы можем сделать это в AsLines, и нам не придётся корректировать blade шаблон. Нестандартное мышление может значительно упростить визуализацию пользовательских интерфейсов и предотвратить создание слишком умных интерфейсов, которые, как правило, не приветствуется и считаются антипаттерном.

Примечание о том, что доступно в документации

Эти свойства не связаны напрямую с колонками БД и сравнимы с комбинацией акессоров и мутаторов. В документации это называется Value Object Casting, но, на мой взгляд, это вводит в заблуждение, поскольку при таком подходе приведение к объекту ValueObject не является обязательным. Причина в том, что, помимо приведённых мною выше примеров, другим вариантом использования может быть генерация номеров товаров, состоящих из сегментов. Вы хотите генерировать и сохранять значения типа CA-01-00001, но на самом деле хранить его в трёх разных столбцах (страна - отдел - последовательность), что значительно облегчает выполнение запросов:

final readonly class AsProductNumber implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes): string
    {
        return implode('-', [
            $attributes['number_country'],
            Str::padLeft($attributes['number_department'], 2, '0'),
            Str::padLeft($attributes['number_sequence'], 5, '0'),
        ])
    }

    public function set($model, string $key, $value, array $attributes): array
    {
        [$country, $dept, $sequence] = explode('-', $value, 2);

        return [
            'number_country'    => $country,
            'number_department' => (int) $dept,
            'number_sequence'   => (int) $sequence,
        ];
    }
}

Само собой, что необходимо также создать уникальный составной индекс, охватывающий эти три столбца. Фантомное свойство, отвечающее за это, создаст составленное строковое значение CA-01-00001, а не объект ValueObject, поэтому оно и вводит в заблуждение.

Флюент-вызов объектов

Я уже упоминал о пользовательских Builder-классах в первом разделе переиспользуемых скоупов. Хотя они и являются первым хорошим шагом на пути к более читаемым и удобным в обслуживании запросам, я считаю, что они быстро разваливаются, когда в пользовательские Builder-классы необходимо добавить большое количество ограничений. Она просто превращаются в ещё один тип God object. Кроме того, очень сложно довести IDE до такого состояния, чтобы она начала помогать вам с подсказками. Можно также создать специальный репозиторий для своих моделей, но мне этот вариант сильно не нравится. На мой взгляд, Repository и Eloquent - это взаимоисключающие понятия. Прежде чем Вы возьмёте вилы, я скажу, что это не совсем так. Однако, если Вы знаете почему существует ActiveRecord и почему существует Repository, то Вы поймёте к чему я веду. Подробнее об этом можно прочитать здесь.

Альтернативой является использование так называемого QueryObject. Это объект, отвечающий за составление и выполнение одного вида запроса. Хотя это не совсем соответствует определению Мартина Фаулера в PoEAA, оно достаточно близко, и, я считаю, что заимствование этого понятия для данной конкретной цели вполне допустимо. Если у Вас есть подписка на Laracasts, то Вы, возможно, уже видели урок на эту тему. Несмотря на идентичность философии и образа мышления, я хотел бы представить альтернативный API, который намного, намного приятнее в использовании: флюент-вызовы объектов.

Пример: центр уведомлений

Представьте себе, что у нас есть SPA, работающий с HTTP JSON API, в верхней части которого находится колокольчик уведомлений. На бэке имеется роут, который мы можем использовать для получения непрочитанных уведомлений вошедшего в систему пользователя. Метод контроллера, отвечающий за получение непрочитанных уведомлений, может выглядеть следующим образом:

public function index(Request $request): AnonymousResourceCollection
{
    $notifications = Notification::query()
        ->whereBelongsTo($request->user())
        ->whereNull('read_at')
        ->latest()
        ->get();

    return NotificationResource::collection($notifications);
}

Всё было просто до тех пор, пока не поступил запрос на новую функцию, требующую создания специальной страницы для управления всеми уведомлениями: прочитанными, непрочитанными, типом уведомления и т.д.. Чтобы облегчить жизнь нашим фронтендерам, мы решили создать специальный роут для каждого типа запроса. Один из них, отвечающий за получение уведомлений о прочтении, может выглядеть следующим образом:

public function index(Request $request): AnonymousResourceCollection
{
    $notifications = Notification::query()
        ->with('notifiable')
        ->whereBelongsTo($request->user())
        ->whereNotNull('read_at')
        ->latest()
        ->get();

    return NotificationResource::collection($notifications);
}

Зоркий глаз читателя, наверное, уже заметил, что в этом фрагменте всё то же самое, что и в предыдущем, за исключением условия whereNotNull и жадной загрузки релейшена notifiable. Теперь нам нужно повторить этот процесс и для других типов:

public function index(Request $request): AnonymousResourceCollection
{
    $notifications = Notification::query()
        ->whereBelongsTo($request->user())
        ->where('type', $request->type)
        ->latest()
        ->get();

    return NotificationResource::collection($notifications);
}

Думаю, Вы поняли суть. Это слишком много повторений и с этим нужно что-то делать. На помощь приходят объекты с флюент-вызовами. Сначала мы создадим класс запроса, отвечающий за "получение моих уведомлений":

final readonly class GetMyNotifications {}

Далее, мы перенесём базовый запрос (условия, которые должны постоянно применяться) в конструктор нашего нового, блестящего объекта:

final readonly class GetMyNotifications
{
    private Builder $builder;

    private function __construct(User $user)
    {
        $this->builder = Notification::query()
            ->whereBelongsTo($user)
            ->latest();
    }

    public static function query(User $user): self
    {
        return new self($user);
    }
}

Теперь нам необходимо использовать возможности с помощью трейта ForwardsCalls:

/** @mixin \Illuminate\Database\Eloquent\Builder */
final readonly class GetMyNotifications
{
    use ForwardsCalls;

    // omitted for brevity

    public function __call(string $name, array $arguments): mixed
    {
        return $this->forwardDecoratedCallTo(
            $this->builder, 
            $name, 
            $arguments,
        );
    }
}

Наблюдения:

  • ForwardsCalls позволяет нам обращаться с классом так, как будто он он является частью базового класса \Illuminate\Database\Eloquent\Builder, даже если наследование не имеет смысла. Я это люблю.

  • Аннотация @mixin поможет IDE предоставлять нам полезные предложения по автозаполнению.

  • Можно также добавить Conditionable, чтобы получить ещё более гибкий API, но, в данном случае, в этом нет необходимости из-за нашего выбора дизайна (отдельный роут для каждого типа запроса).

Остались лишь пользовательские ограничения запроса, добавим их:

/** @mixin \Illuminate\Database\Eloquent\Builder */
final readonly class GetMyNotifications
{
    // omitted for brevity
	
    public function ofType(NotificationType ...$types): self
    {
        return $this->whereIn('type', $types);
    }

    public function read(): self
    {
        return $this->whereNotNull('read_at');
    }

    public function unread(): self
    {
        return $this->whereNull('read_at');
    }
	
    // omitted for brevity
}

Замечательно. Теперь у нас есть всё что нужно для правильной реализации функции "мои уведомления". Итак, соберём воедино:

/** @mixin \Illuminate\Database\Eloquent\Builder */
final readonly class GetMyNotifications
{
    use ForwardsCalls;

    private Builder $builder;

    private function __construct(User $user)
    {
        $this->builder = Notification::query()
            ->whereBelongsTo($user)
            ->latest();
    }

    public static function query(User $user): self
    {
        return new self($user);
    }

    public function ofType(NotificationType ...$types): self
    {
        return $this->whereIn('type', $types);
    }

    public function read(): self
    {
        return $this->whereNotNull('read_at');
    }

    public function unread(): self
    {
        return $this->whereNull('read_at');
    }

    public function __call(string $name, array $arguments): mixed
    {
        return $this->forwardDecoratedCallTo(
            $this->builder, 
            $name, 
            $arguments,
        );
    }
}

Давайте отрефакторим один из предыдущих контроллеров и посмотрим как он выглядит:

public function index(Request $request): AnonymousResourceCollection
{
    $notifications = GetMyNotifications::query($request->user())
        ->with('notifiable')
        ->read()
        ->get();

    return NotificationResource::collection($notifications);
}

Не знаю как Вам, но на этот элегантный код приятно смотреть. Вы можете продолжать использование стандартных методов Illuminate\Database\Eloquent\Builder и в то же время иметь возможность вызова специфичных методов, предназначенных для определённых запросов.

Выводы:

  • Ключевые понятия инкапсулируются за значимыми интерфейсами;

  • Придерживаемся SRP;

  • Фреймворк использует свои инструменты, а не борется с ними как при использовании репозитория;

  • Легко переиспользуется в различных местах;

  • Легко поддаётся тестированию.

Бонус: комбинирование с переиспользуемыми скоупами

Ничто не мешает нам делать подобные вещи:

final readonly class GetMyNotifications
{
    // omitted for brevity
	
    public function ofType(NotificationType ...$types): self
    {
        return $this->tap(new InType(...$types));
    }

    public function read(): self
    {
        return $this->tap(new Read());
    }

    public function unread(): self
    {
        return $this->tap(new Unread());
    }
	
    // omitted for brevity
}

Возможно, нам нужно было создать эти диапазоны для не совсем глобального использования. Тогда имеет смысл повторно использовать их здесь, чтобы сохранить согласованность. Единственным ограничивающим здесь фактором является Ваше воображение.

Примечание по использованию Pipeline

На ютубе можно найти множество уроков, показывающих как можно использовать Pipeline для логического разделения цепочки операций или выполнения сложных фильтраций. Некоторые читатели могут подумать, что работа с собственным QueryObject - пустая трата времени. Однако, я не считаю что Pipeline и QueryObject взаимоисключающие понятия. Они могут дополнять и помогать друг другу выполнять задачу более эффективно. Вместо того чтобы указывать тип Builder в пайплайне, мы можем указывать тип наших пользовательских объектов QueryObject. По сути, мы создаём собственный laravel-query-builder, но с более специфическим API.

Пайплайн может выглядеть следующим образом:

$orders = Pipeline::send(
    GetMyOrders::query($request->user())
)->through([
    Filter\Cancelled::class,
    Filter\Delayed::class,
    Filter\Shipped::class,
])->thenReturn()->get();

Pipe может выглядеть следующим образом:

final readonly class Cancelled
{
    public function __construct(private Request $request) {}

    public function handle(GetMyOrders $query, Closure $next): mixed
    {
        if ($this->request->boolean('cancelled')) {
            $query->cancelled();
        }

        return $next($query);
    }
}

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

Продолжение читайте во второй части.

Комментарии (4)


  1. KoIIIeY
    22.09.2023 10:10
    -1

    Если, например, для Антарктиды нам нужно поменять местами вторую и третью строки, мы можем сделать это в AsLines, и нам не придётся корректировать blade шаблон. Нестандартное мышление может значительно упростить визуализацию пользовательских интерфейсов и предотвратить создание слишком умных интерфейсов, которые, как правило, не приветствуется и считаются антипаттерном.

    Мы уже давно живем в мире, в котором фронт отдельно, бэк отдельно.

    Вообще надоела эта любовь к абстракционизму, где каждый чих создается новый класс. Ты используешь этот код один раз в жизни, конкретно здесь, ему нахрен не нужна обертка.

    А хочешь обертку? Сделай getAttributeName, не создавай для этого еще один класс, вот когда появится миллион различных видов вывода, тогда и сделаешь.


  1. indigoram89
    22.09.2023 10:10
    +1

    Спасибо


  1. koreychenko
    22.09.2023 10:10
    +1

    Вот честное слово в Doctrine Speсifications это реализовано как-то проще, без необходимости пилить под каждое "человекочитаемое" условие отдельный класс. Часто делают просто один класс с кучей статических методов, возвращающих условия.


    1. Helldar Автор
      22.09.2023 10:10

      В Ларе также. Кроме того, автор оригинала взял для примера поля user_id и verified_at. Не знаю как у него, а все разрабы, с кем я когда-либо работал, сразу поймут что за условия whereNull и whereNotNull в связке с этими именами и пилить отдельный класс это оверхед.

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

      Для примера, в недавно написанном моём коде есть примерно такие строки:

      return Product::query()
          ->where(...)
          ->when(true, fn (Builder $builder) => $this->sorter($sortType)
              ->category($category)
              ->priceLevel($priceLevel)
              // ...
              ->toBuilder()
          )    
      )
      
      protected function sorter(SortEnum $type): Builder
      {
          return match($type) {
              SortEnum::Popular => new Popular(),
              SortEnum::LowPrice => new LowPrice(),
              // ...
          }
      }

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