Let's tweak the standard Laravel validator a bit, or first experience with facades and service providers

Background

It all started when I needed to distinguish empty strings from null in API requests. Let me remind you: Laravel's standard behavior is to trim leading and trailing spaces from strings and convert empty strings to null. This is relevant for requests coming from HTML forms, but in the modern world, where everyone is shooting AJAX JSONs, it is no longer convenient. This is easily disabled:

If you need to disable this behavior for the entire application, this is done in the file bootstrap/app.php (link to documentation https://laravel.com/docs/11.x/requests#input-trimming-and-normalization):

<?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();

If this needs to be done only for a certain group of routes, then this is done for the corresponding route or group of routes using the method withoutMiddleware (link to documentation https://laravel.com/docs/11.x/middleware#excluding-middleware):

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

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

Problem

After this, empty strings stop being converted to null, and in addition to the rule required no validation rules work on them. The shot in the foot happened with the date (which for me should have been either null or a valid date). A little digging on the Internet showed that I am not the only one with this problem. In such cases, it is recommended to create your own implicit (probably the most appropriate translation is “unconditional”) rule with the command php artisan make:rule RuleName --implicit but it seemed to me that when disabling the conversion of empty strings to null, you won’t have enough of your own rules, so I decided to change the behavior of the validator.

Finding the problem area

Having dug through the code in the vendor/laravel/framework/src/Illuminate/Validation folder in the Validator.php file, I found the procedure that performs this check: the chain validateAttribute – isValidatable – presentOrRuleIsImplicit. The latter checks whether the input value is an empty string and, if so, whether the applied rule is implicit.

Having found the problem area, I even wrote issuebut was sent. Probably correct, since the behavior changes quite a lot, and the problem only occurs when disabling the middleware ConvertEmptyStringsToNull

We are finalizing the validator

Knowing the problematic place, you can already do something about it. I decided to write my own inheritor class with its own procedure for checking the need to check the rule. It turned out something like this:

<?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);
  }
}

That is, if there are Nullable, Sometimes and Present rules, the check will still be run if the incoming data contains this field.

It remains to find how to apply the full power of OOP to use the inherited class everywhere in the application.

We replace what the factory produces

A meme to somehow diversify the article

A meme to somehow diversify the article

To create validators in laravel, a factory is used, hidden behind the 'validator' facade (see vendor/laravel/framework/src/Illuminate/Support/Facades/Validator.php). Thus, by replacing what the factory creates with our inherited class using our service provider, we get what we want.

This can be done using a service provider that does not provide any of its own services, but changes the behavior of the application. This approach is described in the documentation here: https://laravel.com/docs/11.x/providers#the-boot-method

Add your service provider: php artisan make:provider MyValidatorProvirer we register it in the file bootstrap/providers.php (link to documentation: https://laravel.com/docs/11.x/providers#registering-providers):

<?php

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

in the boot() function we substitute our resolver into the factory (see Illuminate\Validation\factory.php, there is a resolver setter function that sets a callback to get the required class), i.e. this is exactly what is provided by the creators of the framework:

<?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
          );
        });
  }
}

That's it, done. Now rules called on empty lines will return errors:

<?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());
  }
}
Try running the code above with the standard validator and compare.

Try running the code above with the standard validator and compare.

Conclusion

I wrote this article to structure what I learned while solving the problem. Maybe it will be useful for someone else and will help them understand the framework architecture a little better.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *