indexzer0/laravel-validation-provider

Simple reusable composable validation providers


README

Latest Version on Packagist GitHub Tests Action Status codecov Total Downloads

  • Write all your validation rules in clean reusable composable providers.
  • Standardise the way you define and use validation in Form Requests and elsewhere.
  • Easily compose validation rules using multiple validation providers.
  • Conveniently create and validate data straight from the ValidationProvider.

Simple example

  • Nesting domain model rules within arrays.
use IndexZer0\LaravelValidationProvider\Facades\ValidationProvider;

class AuthorValidationProvider extends AbstractValidationProvider
{
    protected array $rules = ['name' => ['required']];
}

class BookValidationProvider extends AbstractValidationProvider
{
    protected array $rules = ['title' => ['required']];
}

$validationProvider = ValidationProvider::make([
    'author' => [
        AuthorValidationProvider::class,
        new ArrayValidationProvider('books', new BookValidationProvider()),
    ],
]);
$validationProvider->rules();
// [
//     'author.name'          => ['required'],
//     'author.books.*.title' => ['required'],
// ]

Requirements

Installation

You can install the package via composer:

composer require indexzer0/laravel-validation-provider

Usage

Defining Validation Providers

  • Create granular representations of domain concepts in validation provider classes.
    • Should extend AbstractValidationProvider.

Via Properties

$rules, $messages, $attributes

class AddressValidationProvider extends AbstractValidationProvider
{
    protected array $rules = [
        'post_code' => ['required', 'string', 'between:1,20'],
    ];

    protected array $messages = [];
   
    protected array $attributes = [];
}

Via Methods

  • You can also define methods rules(), messages(), attributes().
    • Sometimes you need to dynamically define rules, messages and attributes.
    • You are using a dependent rule.
class AddressValidationProvider extends AbstractValidationProvider
{
    public function rules(): array
    {
        return [
            'post_code' => ['required', 'string', "regex:" . RegexHelper::getPostCodeRegex()],
            'zip_code' => ["same:{$this->dependentField('post_code')}"]
        ];
    }
    
    public function messages(): array { return []; }
    
    public function attributes(): array { return []; }
}

Creating Validation Providers

There are 3 ways to create validation providers. Facade, Manual Creation, and Fluent API.

In all 3 examples, were going to use the following two defined validation providers along-side this packages core validation providers to achieve validation rules of:

class AuthorValidationProvider extends AbstractValidationProvider
{
    protected array $rules = ['name' => ['required'],];
}

class BookValidationProvider extends AbstractValidationProvider
{
    protected array $rules = ['title' => ['required',],];
}

// Desired validation rules:
// [
//     'author.name'          => ['required'],
//     'author.books'         => ['required', 'array', 'min:1', 'max:2'],
//     'author.books.*.title' => ['required'],
// ]

Facade

use IndexZer0\LaravelValidationProvider\Facades\ValidationProvider;

$validationProvider = ValidationProvider::make([
    'author' => [
        AuthorValidationProvider::class,
        new CustomValidationProvider(['books' => ['required', 'array', 'min:1', 'max:2']]),
        new ArrayValidationProvider('books', new BookValidationProvider()),
    ],
]);
$validationProvider->rules();
// [
//     'author.name'          => ['required'],
//     'author.books'         => ['required', 'array', 'min:1', 'max:2'],
//     'author.books.*.title' => ['required'],
// ]

Manual Creation

$validationProvider = new NestedValidationProvider(
    'author',
    new AggregateValidationProvider(
        new AuthorValidationProvider(),
        new CustomValidationProvider(['books' => ['required', 'array', 'min:1', 'max:2']]),
        new ArrayValidationProvider('books', new BookValidationProvider())
    )
);
$validationProvider->rules();
// [
//     'author.name'          => ['required'],
//     'author.books'         => ['required', 'array', 'min:1', 'max:2'],
//     'author.books.*.title' => ['required'],
// ]

Fluent API

  • For the fluent API, you compose your validation providers from bottom up.
$validationProvider = (new BookValidationProvider())
    ->nestedArray('books')
    ->with(new CustomValidationProvider(['books' => ['required', 'array', 'min:1', 'max:2']]))
    ->with(AuthorValidationProvider::class)
    ->nested('author');
$validationProvider->rules();
// [
//     'author.name'          => ['required'],
//     'author.books'         => ['required', 'array', 'min:1', 'max:2'],
//     'author.books.*.title' => ['required'],
// ]

Service/Action Class Usage

In your services and actions ->createValidator() and ->validate() methods are provided for convenience.

$addressValidationProvider = new AddressValidationProvider();

/** @var Illuminate\Validation\Validator $validator */
$validator = $addressValidationProvider->createValidator($data);

/** @var array $validated */
$validated = $addressValidationProvider->validate($data);

Form Requests Usage

You can use validation providers in your form requests via two methods.

Extending Abstract

ValidationProviderFormRequest is provided to extend your form requests from.

Using prepareForValidation hook to instantiate validation provider.

class StoreAddressRequest extends ValidationProviderFormRequest
{
    public function prepareForValidation()
    {
        $this->validationProvider = new AddressValidationProvider();
    }
}

Or using dependency injection.

// In a service provider.
$this->app->when(StoreAddressRequest::class)
    ->needs(ValidationProvider::class)
    ->give(AddressValidationProvider::class);
  
class StoreAddressRequest extends ValidationProviderFormRequest
{
    public function __construct(ValidationProvider $validationProvider)
    {
        $this->validationProvider = $validationProvider;
    }
}

Decorate With Trait

HasValidationProvider is provided to decorate your existing form requests.

Sometimes you don't have the ability to extend ValidationProviderFormRequest. You can instead use the HasValidationProvider trait in your existing form request.

class StoreAddressRequest extends YourOwnExistingFormRequest
{
    use HasValidationProvider;
    
    public function prepareForValidation()
    {
        $this->validationProvider = new AddressValidationProvider();
    }
}

Available Validation Providers

This package provides core classes that give you the ability to compose your validation providers.

Aggregate Validation Provider

  • Used when composing validation providers next to each other.
class AggregateValidationProvider extends AbstractValidationProvider {}
$validationProvider = new AggregateValidationProvider(
    new AuthorValidationProvider(),
    new BookValidationProvider(),
);
$validationProvider->rules();
// [
//     'name'  => ['required'], // From AuthorValidationProvider.
//     'title' => ['required'], // From BookValidationProvider.
// ]

Nested Validation Provider

  • Used when wanting to nest a validation provider inside an array.
class NestedValidationProvider extends AbstractValidationProvider {}
$validationProvider = new NestedValidationProvider(
    'author',
    new AuthorValidationProvider(),
);
$validationProvider->rules();
// [
//     'author.name'  => ['required'], // From AuthorValidationProvider.
// ]

Array Validation Provider

class ArrayValidationProvider extends NestedValidationProvider {}
$validationProvider = new ArrayValidationProvider('books', new BookValidationProvider());
$validationProvider->rules();
// [
//     'books.*.title'  => ['required'], // From BookValidationProvider.
// ]

Custom Validation Provider

  • Used when wanting to validate data without creating a dedicated ValidationProvider class.
class CustomValidationProvider extends AbstractValidationProvider {}
$customRules = [
    'books' => ['required', 'array', 'min:1', 'max:2'],
];
$customMessages = [
    'books.required' => 'Provide :attribute'
];
$customAttributes = [
    'books' => 'BOOKS'
];
$validationProvider = new CustomValidationProvider($customRules, $customMessages, $customAttributes);
$validationProvider->rules();
// [
//     'books' => ['required', 'array', 'min:1', 'max:2'],
// ]

Exclude Attributes Validation Provider

  • Sometimes you may want to remove certain attributes from a validation provider.
class ExcludeAttributesValidationProvider extends AbstractValidationProvider {}
$validationProvider = new ExcludeAttributesValidationProvider(
    ['one'],
    new CustomValidationProvider([
        'one' => ['required'],
        'two' => ['required']
    ])
);
$validationProvider->rules();
// [
//     'two' => ['required'],
// ]

Map Attributes Validation Provider

  • Sometimes you may want to rename an attribute.
class MapAttributesValidationProvider extends AbstractValidationProvider {}
$validationProvider = new MapAttributesValidationProvider(
    ['one' => 'two'],
    new CustomValidationProvider([
        'one' => ['required'],
    ])
);
$validationProvider->rules();
// [
//     'two' => ['required'],
// ]

Digging Deeper

Using Fluent API

Using Facade

ValidationProvider::make(ValidationProviderInterface|string|array $config): ValidationProviderInterface

  • Can use fully qualified class name strings.
// Returns AuthorValidationProvider
$validationProvider = ValidationProvider::make(AuthorValidationProvider::class);
  • Invalid class string throws exception.
// throws ValidationProviderExceptionInterface
try {
    $validationProvider = ValidationProvider::make('This is an invalid fqcn string');
} catch (\IndexZer0\LaravelValidationProvider\Contracts\ValidationProviderExceptionInterface $exception) {
    $exception->getMessage(); // Class must be a ValidationProvider
}
  • Can use validation provider objects. Essentially does nothing.
// Returns AuthorValidationProvider (same object)
$validationProvider = ValidationProvider::make(new AuthorValidationProvider());
  • Can use arrays (fully qualified class name strings and objects).
// Returns AuthorValidationProvider
$validationProvider = ValidationProvider::make([
    AuthorValidationProvider::class,
]); 

// Returns AggregateValidationProvider
$validationProvider = ValidationProvider::make([
    AuthorValidationProvider::class,
    new BookValidationProvider()
]);
  • Array string keys create NestedValidationProvider.
// Returns NestedValidationProvider
$validationProvider = ValidationProvider::make([
    'author' => [
        AuthorValidationProvider::class,
    ],
]);
  • Empty array is invalid.
// throws ValidationProviderExceptionInterface
try {
    $validationProvider = ValidationProvider::make([]);
} catch (\IndexZer0\LaravelValidationProvider\Contracts\ValidationProviderExceptionInterface $exception) {
    $exception->getMessage(); // Empty array provided
}

Composing Validation Providers

Use case:

  • You may have parts of your application that need to validate data for multiple domain concepts.
  • You may want to validate data in nested arrays without introducing duplication in your rule definitions.

Example:

Let's look at the example of 3 routes and how you could reuse your Validation Providers.

  • Route: address
    • Stores address information
    • Uses AddressValidationProvider
/*
 * ------------------
 * Address
 * ------------------
 */
Route::post('address', StoreAddress::class);
class StoreAddress extends Controller
{
    public function __invoke(StoreAddressRequest $request) {}
}
class StoreAddressRequest extends ValidationProviderFormRequest
{
    public function prepareForValidation()
    {
        $this->validationProvider = new AddressValidationProvider();
    }
}
  • Route: contact-details
    • Stores contact information
    • Uses ContactValidationProvider
/*
 * ------------------
 * Contact
 * ------------------
 */
Route::post('contact-details', StoreContactDetails::class);
class StoreContactDetails extends Controller
{
    public function __invoke(StoreContactDetailsRequest $request) {}
}
class StoreContactDetailsRequest extends ValidationProviderFormRequest
{
    public function prepareForValidation()
    {
        $this->validationProvider = new ContactValidationProvider();
    }
}
  • Route: profile
    • Stores address and contact information.
    • Uses
      • AddressValidationProvider
      • ContactValidationProvider
      • NestedValidationProvider (under the hood from Facade)
      • AggregateValidationProvider (under the hood from Facade)
/*
 * ------------------
 * Profile
 * ------------------
 */
Route::post('profile', StoreProfile::class);
class StoreProfile extends Controller
{
    public function __invoke(StoreProfileRequest $request) {}
}
class StoreProfileRequest extends ValidationProviderFormRequest
{
    public function prepareForValidation()
    {
        $this->validationProvider = ValidationProvider::make([
            'profile' => [
                'address' => AddressValidationProvider::class
                'contact' => ContactValidationProvider::class
            ]
        ]);
    }
}

Dependent Rules

  • When using any of the dependent rules, you should use the $this->dependentField() helper.
    • This ensures that when using the NestedValidationProvider and ArrayValidationProvider, the dependent field will have the correct nesting.
class PriceRangeValidationProvider extends AbstractValidationProvider
{
    public function rules(): array
    {
        return [
            'min_price' => ["lt:{$this->dependentField('max_price')}"],
            'max_price' => ["gt:{$this->dependentField('min_price')}"],
        ];
    }
}

$validationProvider = new NestedValidationProvider(
    'product',
    new PriceRangeValidationProvider()
);

$validationProvider->rules();

//  [
//      "product.min_price" => [
//          "lt:product.max_price"
//      ]
//      "product.max_price" => [
//          "gt:product.min_price"
//      ]
//  ]

Error Handling

All exceptions thrown by the package implement \IndexZer0\LaravelValidationProvider\Contracts\ValidationProviderExceptionInterface.

How-ever it doesn't harm to also catch \Throwable.

try {
    $validationProvider = ValidationProvider::make('This is an invalid fqcn string');
} catch (\IndexZer0\LaravelValidationProvider\Contracts\ValidationProviderExceptionInterface $exception) {
    $exception->getMessage(); // Class must be a ValidationProvider
} catch (\Throwable $t) {
    // Shouldn't happen - but failsafe.
}

Package Offering

// Interface
interface ValidationProvider {}

// Validation Providers
abstract class AbstractValidationProvider implements ValidationProvider {}
class AggregateValidationProvider extends AbstractValidationProvider {}
class NestedValidationProvider extends AbstractValidationProvider {}
class ArrayValidationProvider extends NestedValidationProvider {}
class CustomValidationProvider extends AbstractValidationProvider {}
class ExcludeAttributesValidationProvider extends AbstractValidationProvider {}
class MapAttributesValidationProvider extends AbstractValidationProvider {}

// Form Request
class ValidationProviderFormRequest extends \Illuminate\Foundation\Http\FormRequest {}
trait HasValidationProvider {}

// Facade
class ValidationProvider extends \Illuminate\Support\Facades\Facade {}

Testing

composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Credits

License

The MIT License (MIT). Please see License File for more information.