Предыстория

Все началось с того, что мне понадобилось отличать пустые строки от null в апи запросах. Напомню: стандартное поведение Laravel заключается в обрезании у строк начальных и конечных пробелов и преобразовании пустых строк в null. Это актуально для запросов, пришедших из html форм, но в современном мире, где все пуляются по ajax json'ами уже не удобно. Отключается это просто:

Если нужно отключить это поведение для всего приложения, то это делается в файле bootstrap/app.php (ссылка на документацию):

<?php
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\TrimStrings;

return Application::configure(basePath: dirname(__DIR__))
  ->withMiddleware(function (Middleware $middleware) {
      $middleware->remove([
          ConvertEmptyStringsToNull::class,
          TrimStrings::class,
      ]);
  })
  ->create();

Если это нужно сделать только для какой-то группы маршрутов, то это делается для соответствующего маршрута или группы маршрутов с помощью метода withoutMiddleware (ссылка на документацию):

<?php
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\TrimStrings;

Route::->group(function () {
...
})->withoutMiddleware([
  ConvertEmptyStringsToNull::class,
  TrimStrings::class,
]);

Проблема

После этого пустые строки перестают преобразовываться в null, и кроме правила required никакие правила валидации на них не срабатывают. Выстрел в ногу произошел с датой (которая у меня должна была быть либо null либо валидной датой). Небольшое копание интернете показало, что не только у меня такая проблема. В таких случаях рекомендуют создавать свое implicit (больше всего, наверное, подходит перевод перевод «безоговорочный») правило командой php artisan make:rule RuleName --implicit , но мне показалось, что при отключении преобразования пустых строк в null своих правил не напасешься, поэтому я решил изменить поведение валидатора.

Поиск проблемного места

Полазив по коду в папке vendor/laravel/framework/src/Illuminate/Validation в файле Validator.php я нашел процедуру, в которой выполняется эта проверка: цепочка validateAttribute — isValidatable — presentOrRuleIsImplicit. В последней как раз проверяется, является ли входное значение пустой строкой и если да — то является ли применяемое правило implicit.

Найдя проблемное место я даже написал issue, но был послан. Наверное, правильно, так как поведение достаточно сильно меняется, а проблема возникает только при отключении middleware ConvertEmptyStringsToNull.

Дорабатываем валидатор

Зная проблемное место, с этим уже можно что‑то сделать. Я решил написать свой класс‑наследник со своей процедурой проверки необходимости проверки правила. Получилось что‑то такое:

<?php

namespace App\Validator;

use Illuminate\Validation\Validator;

class MyValidator extends Validator
{
  /**
   * Determine if the field is present, or the rule implies required.
   *
   * @param  object|string  $rule
   * @param  string  $attribute
   * @param  mixed  $value
   * @return bool
   */
  protected function presentOrRuleIsImplicit($rule, $attribute, $value)
  {
    if (is_null($value) || (is_string($value) && trim($value) === '')) && !$this->hasRule($attribute, ['Nullable', 'Present', 'Sometimes'])) {
      return $this->isImplicit($rule);
    }

    return $this->validatePresent($attribute, $value) ||
      $this->isImplicit($rule);
  }
}

Т. е. если есть правила Nullable, Sometimes и Present — все равно запускать проверку, если во входящих данных есть это поле.

Осталось найти, как применить всю силу ООП чтобы везде в приложении использовать класс‑наследник.

Подменяем то, что производит фабрика

Мем, чтобы как-то разнообразить статью
Мем, чтобы как-то разнообразить статью

Для создания валидаторов в laravel используется фабрика, скрытая за фасадом 'validator' (см. vendor/laravel/framework/src/Illuminate/Support/Facades/Validator.php). Таким образом, заменив то, что создает фабрика на наш класс-наследник с помощью своего сервис-провайдера мы получим желаемое.

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

Добавляем свой сервис-провайдер: php artisan make:provider MyValidatorProvirer регистрируем его в файле bootstrap/providers.php (ссылка на документацию):

<?php

return [
    App\Providers\AppServiceProvider::class,
  // ...
    App\Providers\MyValidatorProvider::class,
  // ...
];

в функции boot() подставляем свой resolver в фабрику (см. Illuminate\Validation\factory.php, там есть функция-сеттер resolver, которая устанавливает коллбэк для получения нужного класса), т.е. это именно то, что предусмотрено создателями фреймворка:

<?php

namespace App\Providers;

use App\Validator\MyValidator;
use Illuminate\Support\ServiceProvider;

class MyValidatorProvider extends ServiceProvider
{
  /**
   * Register services.
   */
  public function register(): void
  {
    //
  }

  /**
   * Bootstrap services.
   */
  public function boot(): void
  {
    $this->app['validator'] // фабрика
      ->resolver( // эта функция устанавливает колбэк для получения нужного экземпляра
        function ($translator, $data, $rules, $messages) {
          return new MyValidator(
            $translator,
            $data,
            $rules,
            $messages
          );
        });
  }
}

Всё, готово. Теперь правила, вызванные на пустые строки будут возвращать ошибки:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;

class TestController extends Controller
{
  public function __invoke(Request $request)
  {
    $validator = Validator::make(['test' => ''], [
      'test' => 'nullable|date'
    ]);
    dump($validator->errors());
  }
}
Попробуйте выполнить код выше со стандартным валидатором и сравните.
Попробуйте выполнить код выше со стандартным валидатором и сравните.

Заключение

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

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


  1. tkovacs
    23.09.2024 02:58

    Вообще не понял в чем проблема и для чего все это)


    1. Siddthartha
      23.09.2024 02:58

      чтобы реализовать nullable|date валидацию?


    1. Fragster Автор
      23.09.2024 02:58
      +1

      По дефолту laravel преобразует пустую (или состоящую только из пробельных символов) к null. Это поведение не всегда подходит. Если же его отключить, то правила валидации не срабатывают на пустые строки (которые до этого автоматически преобразовывались к null).


      1. tkovacs
        23.09.2024 02:58

        Почему это не подходит? null это ничего, пустая строка это тоже ничего


        1. Fragster Автор
          23.09.2024 02:58
          +4

          Например по дефолту (без выключения нормализации), когда приходит json

          {"field": ""}

          И мы делаем $model.fill($validated) а field при этом не nullable - мы получим ошибку.

          Да и вообще, ситуации, когда нужно различать пустые строки, null и, например, 0 - существуют.


          1. PiramidHead
            23.09.2024 02:58

            Да и вообще, ситуации, когда нужно различать пустые строки, null и, например, 0 - существуют.

            И довольно часто встречаются. Как раз по этой причине на собеседованиях нередко можно услышать вопрос: "Что проверяет empty()".