Validating Large Forms with Livewire

We're running validation on dynamic, large and complex forms using Livewire and Spatie's Laravel Data. This is how we do it.

We're working on an app which has very large forms, with a lot of input fields. These forms are dynamic. Segments of the form can be swapped out for a different one. And to top it off, segments are shared with different forms. Validation is tough.

Let's say we have a form that is used to handle quotes for custom made products. This form has 3 different segments, where each segment is stored in its own model. We have:

  • The meta data of the quote itself;

  • The product data like the size and colour;

  • And pricing, where we store the price for each quantity offered.

The product and pricing data segments are used in different forms too, but in its simplest form, the validation for a simple Livewire form could like this:

[
'quote.customer_id' => ['required'],
'product.size' => ['required'],
'product.colour' => ['required'],
'items.*.quantity' => ['required'],
'items.*.price' => ['required'],
];
[
'quote.customer_id' => ['required'],
'product.size' => ['required'],
'product.colour' => ['required'],
'items.*.quantity' => ['required'],
'items.*.price' => ['required'],
];

This is fine for such a simple form, but our forms are much bigger. We have a lot of data and validation rules in a single form. And we're re-using the product and pricing segment in other forms as well, and these forms have validation too. And what if we needed an API?

With the number of fields we're dealing with, together with the flexibility of our app, storing validation rules in a request or Livewire component often ends in frustration and unknowns. Especially when adding new fields or when making small changes.

Our solution

Before we can even think of a solution, we need to be clear on the requirements.

  • We would like to store validation close to the data.

  • We do not want to repeat validation rules unless absolutely necessary.

  • We need a solution that supports validation of dynamic segments.

  • We need to support Livewire, but it should also work for Laravel controllers.

First, let me explain what Livewire support means for us. A Livewire component in our app has at least 3 public properties, one for each segment. As these properties are wired to our input fields, validation errors should include the correct property name.

Storing data and validation

Data Objects, or Data Transfer Objects, are not only great for storing data. They are also great for storing validation rules.

A simple data object for the meta data of our quote looks like this:

class QuoteData extends Data
{
public function __construct(
public int $customer_id,
) {}
 
public static function rules(): array
{
return ['customer_id'] => 'required',
}
}
class QuoteData extends Data
{
public function __construct(
public int $customer_id,
) {}
 
public static function rules(): array
{
return ['customer_id'] => 'required',
}
}

We're using Spatie's Livewire Data package, but we don't use it's Livewire integration. Our app uses simple arrays and data objects are build with QuoteData::from($this->quote). The from-method has the added benefit of not triggering validation. This is great, as all validators must run at the same time to keep the app user friendly.

Unfortunately, there are some problems we need to address.

Livewire Support

In the data object, we've specified a validation rule for customer_id. In Livewire, this field has a prefix, as we separate each segment in its own public property. This means that customer_id is probably mapped to quote.customer_id instead. If we use the validation rules from the data object, the error message would not show up.

You could rename the validation rules inside the data object, but this makes our objects inflexible. You could also dynamically rename the validation rules. But this means rules like required_if need to be adjusted too.

Our solution is a new class called ValidatedData. This class allows data objects to create a standard Laravel Validator object and if required, it renames the error messages. This means our data objects can stay the same and we only need to change the class it extends.

class QuoteData extends ValidatedData
class QuoteData extends ValidatedData

Our ValidatedData class looks like this:

use Closure;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Validator as BaseValidator;
use Spatie\LaravelData\Data;
 
class ValidatedData extends Data
{
public function validator(string $prefix = null): BaseValidator
{
$validator = Validator::make(
$this->toArray(),
method_exists($this, 'rules') ? $this->rules() : [],
method_exists($this, 'messages') ? $this->messages() : [],
);
 
return ! empty($prefix)
? $validator->after($this->addPrefixToErrorKeys($prefix))
: $validator;
}
 
protected function addPrefixToErrorKeys(string $prefix): Closure
{
return function (BaseValidator $validator) use ($prefix) {
foreach ($validator->errors()->getMessages() as $item => $errors) {
$validator->errors()->forget($item);
$validator->errors()->add($prefix.'.'.$item, $errors);
}
};
}
}
use Closure;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Validator as BaseValidator;
use Spatie\LaravelData\Data;
 
class ValidatedData extends Data
{
public function validator(string $prefix = null): BaseValidator
{
$validator = Validator::make(
$this->toArray(),
method_exists($this, 'rules') ? $this->rules() : [],
method_exists($this, 'messages') ? $this->messages() : [],
);
 
return ! empty($prefix)
? $validator->after($this->addPrefixToErrorKeys($prefix))
: $validator;
}
 
protected function addPrefixToErrorKeys(string $prefix): Closure
{
return function (BaseValidator $validator) use ($prefix) {
foreach ($validator->errors()->getMessages() as $item => $errors) {
$validator->errors()->forget($item);
$validator->errors()->add($prefix.'.'.$item, $errors);
}
};
}
}

With the following example we can build a validator that supports Livewire's prefixed error messages. When the validation runs, all errors will be prefixed with quote. This allows us to keep our data objects clean and re-usable.

$validator = QuoteData::from($this->quote)->validator('quote');
$validator = QuoteData::from($this->quote)->validator('quote');

Simultaneous Validation

To make sure a user does not have to submit a form multiple times to see all errors, we're validating all data objects in one go. We've created a support class to help us with that.

class MultipleValidation
{
public static function validate(Validator ...$validators): void
{
$failure = false;
$messages = [];
 
foreach ($validators as $validator) {
if ($validator->fails()) {
$failure = true;
$messages = array_merge($messages, $validator->messages()->toArray());
}
}
 
/** @noinspection PhpUnhandledExceptionInspection */
throw_if($failure === true, ValidationException::withMessages($messages));
}
}
class MultipleValidation
{
public static function validate(Validator ...$validators): void
{
$failure = false;
$messages = [];
 
foreach ($validators as $validator) {
if ($validator->fails()) {
$failure = true;
$messages = array_merge($messages, $validator->messages()->toArray());
}
}
 
/** @noinspection PhpUnhandledExceptionInspection */
throw_if($failure === true, ValidationException::withMessages($messages));
}
}

You can pass multiple validators to it. And we're not limited to validators from data objects either. In the example below we pass two data objects and a custom validator object to it.

MultipleValidation::validate(
QuoteData::from($this->quote)->validator('quote'),
ProductData::from($this->product)->validator('product'),
Validator::make($money, ['currency' => 'required']),
);
MultipleValidation::validate(
QuoteData::from($this->quote)->validator('quote'),
ProductData::from($this->product)->validator('product'),
Validator::make($money, ['currency' => 'required']),
);

Swapping Segments

Product data can be very dynamic. A custom table or chair will have different requirements. The solution we have so far supports this. Our validation class can accept any validator. It does not matter if it's validating a chair or a table.

In our app, we re-use the product property on the Livewire component. This means no matter if we're quoting a chair or a table, its data is stored in that property. Whenever a user tries to save the data, we detect what product is currently selected and create a matching data object from it. When preparing the validator object, we pass it the product-prefix, so any errors are mapped automatically.

Repetition

The great thing about data objects is that you can use them in controllers too. When we're ready to build our API, we can use our existing data objects for validation and data mapping.

What about Livewire Forms?

With Livewire v3 you can move your form logic into a dedicated form class. This allows you to extract the data, validation and logic that comes with handling forms in Livewire. It's great for forms with a lot of data. However, it doesn't meet all of the requirements we're looking for. The biggest reason is that it's limited to Livewire.