Неочевидное поведение

Laravel - фреймворк который предлагает большое количество вспомогательных инструментов и активно использует магию, что приводит иногда к неожиданным результатам.

Разработчики, избалованные гибкостью и магией фреймворка, перестают следить за тем как выполняется код под капотом и создают многочисленные обертки и неявные вызовы, которые работают, но имеют сайд-эффекты.

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

# Passed Validation

Документация предлагает методы в FormRequest для обработки значений в запросе:

prepareForValidation предназначен для подготовки полей ДО валидации. Это может быть полезно для приведения полей к определенному типу, форматирования slug, добавления составных полей и т.п.

passedValidation предназначен для нормализации полей ПОСЛЕ валидации. Может использоваться для восстановления значений, добавления новых полей, приведения к типу. Применяется не часто.

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

Неочевидность заключается в том, что методы validated и safe могут вернуть иные значения от методов get, input и only.

Это связанно с тем, что методы validated и safe возвращают значения которые прошли валидацию, включая изменения в prepareForValidation и не включая изменения в passedValidation, потому что эти значения не проходили валидацию.

1// FormRequest
2public function rules(): array
3{
4 return [
5 'field' => ['required', 'string'],
6 ];
7}
8 
9public function prepareForValidation(): void
10{
11 $this->merge([
12 'field' => 'foo',
13 ]);
14}
15 
16public function passedValidation(): void
17{
18 $this->merge([
19 'field' => 'bar',
20 ]);
21}
1// Controller
2public function index(FormRequest $request)
3{
4 $request->get('field'); // 'bar'
5 $request->validated('field'); // 'foo'
6}
highlight by torchlight.dev

Если подумать, то поведение вполне логичное, но при смешивании методов validated и get легко можно упустить, что значения разные. Например, результат validated передается в Model::create() массивом, а одиночное значение из get передается отдельно в сервис, при этом разработчик может ожидать, что значения будут одинаковыми.

# API Response

Многие разработчики уже привыкли, что в laravel из контроллера можно возвращать что угодно: ApiResource и Response, шаблоны и модели, строки и массивы, stdClass и любые реализации PSR-7.

Рекомендую ознакомиться с реализацией метода toResponse в роутере, чтобы лучше понимать почему и как это работает.

Пример ниже встречается достаточно редко, но всё же имеет неочевидное поведение:

1public function show(User $user)
2{
3 // { data: { id: 1, name: 'JohnDoe' } }
4 return new UserResource($user);
5}
1public function show(User $user)
2{
3 // { id: 1, name: 'JohnDoe' }
4 return response()->json(new UserResource($user));
5}
highlight by torchlight.dev

По умолчанию UserResource имеет wrapper data и во втором примере ресурс теряет wrapper из-за response()->json(). Это не значит, что ключ data всегда удаляется. Это происходит из-за дополнительного оборачивания в JsonResponse, чем уже и является UserResource.

Поведение не критичное, но может привести к "сборной солянке" ответов, что доставит неудобства для клиента.

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

Если же это делалось для изменения статуса, то у JsonResponse, наследника Response, уже есть необходимые методы:

1public function show(User $user)
2{
3 return new UserResource($user)
4 ->response()
5 ->setStatusCode(200);
6}
highlight by torchlight.dev

# DI в контроллере

На данную тему есть блок в «bad-практиках», где в одном из пунктов говорится о жизненном цикле фреймворка.

Внедрение зависимостей в конструкторе контроллера вполне стандартная практика, которая не влияет на сами зависимости, но не в laravel, где контроллер создается ДО выполнения middleware, а значит сервисы будут инстанцированы намного раньше, чем будут использованы.

Это оказывает неочевидное влияние, если сервис зависит от каких-либо состояний, которые вносят middleware. Самый популярный случай это работа с сессией, аутентификацией и авторизацией, но не ограничивается ими.

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

Рекомендую внедрять зависимости непосредственно в методы контроллера, таким образом они будут ближе к логике, не будут влиять на соседние методы, будут созданы в момент вызова экшена перед использованием.

# Eloquent collection

Коллекции достаточно популярны в приложениях на laravel и так или иначе с ними приходится работать. Когда говорят Collection, то чаще всего имеют в виду базовый класс \Illuminate\Support\Collection и ссылаются на документацию коллекций.

Есть еще одна популярная коллекция, с которой тоже все работают, но не все знают. Это \Illuminate\Database\Eloquent\Collection (далее просто EloquentCollection) с отдельной страницей документации .

EloquentCollection наследует Collection, поэтому очевидной разницы нет, методы написаны таким образом, что не меняется их поведение, но не значит, что так будет всегда.

Немаловажный момент, что методы countBy, collapse, flatten, flip, keys, pluck, zip, pad вернут базовую Collection, а не EloquentCollection, смотри документацию .

Также EloquentCollection добавляет новые методы , например, find, load и т.п., которых нет в базовой коллекции.

В данном блоке нет явных "проблем" или особенностей, но важно понимать с какой коллекцией работаешь.

Также нет 100% гарантии, что завтра какие-то методы базовой коллекции не изменят своё поведение, а методы EloquentCollection останутся прежними или наоборот. Конечно, эта ответственность ложится на плечи разработчиков фреймворка, но всё же.

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

# Сериализация моделей

Для асинхронного выполнения задач используют Job и часто в качестве значений передают модели Eloquent («laravel way»).

Для того чтобы правильно сериализировать модель в Job используется трейт \Illuminate\Queue\SerializesModels .

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

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

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

# Model binding

При использовании «Route Model Binding» начинающие разработчики иногда сталкиваются с тем, что в контроллер приходит "пустая" модель.

Это происходит когда не срабатывает binding и класс инжектится, как любой другой, с помощью «Service Container» , который просто создаст "пустую" модель new Model().

Самая популярная причина - это разные имена сегмента в маршруте и аргумента в контроллере. Соответствие имен - основное условие работы binding, об этом прямо сказано в документации .

Также binding может приводить к неочевидным 404-ым ошибкам, например, из-за «Global Scopes» , таких как «Soft Delete» или любых других.

Если на используемой модели есть «Soft Delete» и его надо игнорировать, то используй метод withTrashed, согласно документации .

# Cookie queue

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

1public function index()
2{
3 Cookie::queue('name', 'new'); // set new value
4 
5 return Cookie::get('name'); // old
6}
highlight by torchlight.dev

Здесь всё логично, текущее значение было old, установили new, но установятся cookie только в ответе, о чем говорит метод queue. Следовательно метод get вернет новое значение только при следующем запросе, когда клиент отправит cookie, а в текущем запросе будет читаться "предыдущее" значение.

Обрати внимание, что использование dd() или dump() помешает установке cookie, должен произойти корректный ответ клиенту.

# Request params

Если в теле запроса и в query параметрах присутствуют одинаковые имена, то при чтении параметров из Request может возникнуть неочевидная ситуация.

Метод query всегда читает только параметры из QUERY_STRING, с ним проблем нет. Если в "GET" параметрах ничего не передано, то он вернет null.

Но, остальные методы в Request возвращают разные значения. Пример:

1// POST /index?from=query
2// Body: from=body
3public function index(Request $request)
4{
5 $request->query('from'); // query
6 $request->get('from'); // query
7 $request->input('from'); // body
8 $request->from; // body
9}
1// POST /index?from=query
2// Body:
3public function index(Request $request)
4{
5 $request->query('from'); // query
6 $request->get('from'); // query
7 $request->input('from'); // query
8 $request->from; // query
9}
1// POST /index
2// Body: from=body
3public function index(Request $request)
4{
5 $request->query('from'); // null
6 $request->get('from'); // body
7 $request->input('from'); // body
8 $request->from; // body
9}
highlight by torchlight.dev

Методы get, input и магическое свойство возвращают разные значения, когда параметр передан в query и в body одновременно. Это происходит из-за внутреннего устройства методов.

У метода get приоритетным является значение из query, если его нет, то читается из body.

Метод input и магическое свойство, из-за способа слияния, сначала получают параметр из body, а затем из query.

Пустые значения также учитываются, потому что существует ключ. Методы вернут одинаковые значения, только если в запросе отсутствуют одинаковые имена.

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

# Blade components

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

Кроме перечисленных в документации, ещё app, errors, component, componentName и attributes.

Использование зарезервированных слов может неявно повлиять на выполнение шаблонов или привести к ошибке. Например, в примере ниже, если не была явно определена переменная $component, то в ней будет объект предыдущего компонента.

1<x-card />
2@dump($component) // App\View\Components\Card
highlight by torchlight.dev

Это достаточно редкий случай, но если где-то ниже в шаблоне будет проверка переменной на empty, то результат будет ложным.

Тоже самое касается и остальных имен, список объявленных переменных можно получить с помощью функции get_defined_vars().