Fixing nested validation in Laravel

This post was first published on the Flare blog.

Since the early days, Laravel includes an excellent validator class. It allows you to check if the input provided to your application is valid according a set of rules and follows a specific format. Laravel's validator works on any arbitrary array of data but it's typically used for request validation. You can check if an email is valid, a password is confirmed, a file is a pdf, a string is not longer than a certain amount of characters, and so much more.

Validating input in Laravel is incredibly powerful and quick to set up. It's always been one of our favorite Laravel features. But, a few strange edge cases can be found where the validator isn't working as expected. When developing a Flare feature, we encountered one of these oddities and found a nice way to work around it.

Suppose that we're trying to validate a basic array with a nested team property:

[
	'name' => 'Ruben Van Assche',
	'team' => [
		'id' => 1,
		'role' => 'engineer'
	]
]

(The example above is abbreviated for better readability and clarity)

We could now write a FormRequest to validate this data automatically or manually validate the array using the Validator facade in the controller. To keep things simple, we'll stick to using the validator class directly:

$rules =  [
    'name' => ['required', 'string'],
    'team' => ['required', 'array'],
    'team.id' => ['required', Rule::exists('teams', 'id')],
    'team.role' => ['required', Rule::in(['engineer', 'project_manager', 'boss'])],
];

$validator = Validator::make($data, $rules);

We can now check if our data array (or "payload") is valid:

$validator->passes(); // Returns true, data is valid
$validator->messages(); // An empty message bag, great!

When passing an invalid payload array as our input, the validator will fail and provide us with a couple of validation messages:

$data = [
	'name' => 'Ruben Van Assche',
	'team' => [
		'id' => 2023,
		'role' => 'ceo'
	]
];

$validator = Validator::make($data, $rules);
$validator->passes(); // returns false!

The error bag contains the following validation messages:

[
    "team.id" => ["The selected team.id is invalid.]
    "team.role" => ["The selected team.role is invalid."]
]

Great, so up until now, everything is working as expected. We can validate both root level properties and nested properties in the team array.

Let's make things more complicated by allowing the team property to be nullable. Maybe your application allows for users without teams, so the team property can also be null.

Let's update our rules by adding the nullable rule:

[
	'name' => ['required', 'string'],
	'team' => ['nullable', 'array'],
	'team.id' => ['required', Rule::exists('teams', 'id')],
	'team.role' => ['required', Rule::in(['engineer', 'project_manager', 'boss'])],
]

When testing our two previous example payload again, everything still works as expected: the valid example payload passes and the second example contains errors so the validator fails. However, let's now test our new feature where we don't have a team at all:

[
	'name' => 'Ruben Van Assche',
]

The team property is nullable so this payload should not give us validation errors. Let's validate:

[
    "team.id" => ["The selected team.id is invalid.]
    "team.role" => ["The selected team.role is invalid."]
]

Wait what?! For some reason, the validator ran the team.id and team.role rules even though we wrote a rule that the entire team array is allowed be null, so what happened here?

The validator did its job correctly, albeit a bit bizarre. We wrote three rules for the team array. The first one tells the validator that team should be an array but can also be null. This rule succeeds with our last example payload. The other two rules require a value to be in a specific format. And these fail as the value is always required.

Laravel's validator is built in a way where each rule exists on its own. This means the two team.* rules have no context of the first rule indicating that the team array can be null. So they get executed as they would typically, resulting in two error messages.

How can we fix this?

Nullable all the way

We could rewrite our rules like this:

[
    'name' => ['required', 'string'],
    'team' => ['nullable', 'array'],
    'team.id' => ['nullable', Rule::exists('teams', 'id')],
    'team.role' => ['nullable', Rule::in(['engineer', 'project_manager', 'boss'])],
]

Now when validating the following:

[
	'name' => 'Ruben Van Assche',
]

Great! No validation messages, but what if we provide a payload like this:

[
	'name' => 'Ruben Van Assche',
	'team' => [
	    'id' => 1,
	]
]

Also no validation messages, but we expect one since a team always needs an id and role. There are better approaches to writing rules like this.

Better requiring

We could rewrite our require rules smarter by explicitly telling when a specific value is required:

[
	'name' => ['required', 'string'],
	'team' => ['nullable', 'array'],
	'team.id' => ['required_with:team', Rule::exists('teams', 'id')],
	'team.role' => ['required_with:team', Rule::in(['engineer', 'project_manager', 'boss'])],
]

When validating:

[
	'name' => 'Ruben Van Assche',
]

No messages. Up to the next one:

[
	'name' => 'Ruben Van Assche',
	'team' => [
	    'id' => 1,
	]
]

We now get the following message:

[
	"team.role" => ["The team.role field is required when team is present."]
]

Marvelous, we succeeded in writing the correct rules! But from now on, we must always use the required_with:team rule instead of the required rule and remember this for new properties to be added in the future.

We also need to be careful. If someone decides to change the name of the team property, then all our rules need to be updated. We only have two rules now, but as an application grows, these numbers tend to rise rapidly, and a mistake is quickly made.

Lastly, what if we also want to nest another array within the team array like the payload bellow, where we also want to be able to (optionally) disable or enable extra features per team:

[
    'name' => 'Ruben Van Assche',
    'team' => [
        'id' => 1,
        'role' => 'engineer',
        'features' => [
            'github' => true,
            'jira' => false,
        ]
    ]
]

And to make things more complicated, you should also be able to provide no features (in this case, defaults could be used):

[
    'name' => 'Ruben Van Assche',
    'team' => [
        'id' => 1,
        'role' => 'engineer',
    ]
]

These were the best rules I could come up with:

[
    'name' => ['required', 'string'],
    'team' => ['nullable', 'array'],
    'team.id' => ['required_with:team', Rule::exists('teams', 'id')],
    'team.role' => ['required_with:team', Rule::in(['engineer', 'project_manager', 'boss'])],
    'team.features' => ['nullable', 'required_with:team', 'array'],
    'team.features.github' => ['required_with:team.features', 'boolean'],
    'team.features.jira' => ['required_with:team.features', 'boolean'],
];

But these are not 100% watertight since the latest payload we'v constructed without the features array will result in the following validation message:

[
	"team.features" => [
	  "The team.features field is required when team is present."
	]
]

The required_with rules can fix our problem but have a few disadvantages. Is there another way?

Fixing the issue once and for all

In Flare, we don't write validation rules ourselves. One of our packages called laravel-data automatically generates them.

Let's revisit our previous example, but we now structure our data using data objects. Which has as an added benefit a stricter type structure we can use in the backend. We stop using untyped arrays with data and have objects with typed properties.

The laravel-data package can also generate TypeScript definitions based upon the types within our data objects, so our frontend code is now also typed exactly like our backend. Cool!

These are the data objects we've created based on the rules we decided on earlier:

class UserData extends Data
{
    public function __construct(
        public string $name,
        public ?TeamData $team,
    ) {
    }
}

class TeamData extends Data
{
    public function __construct(
        #[Exists('teams', 'id')]
        public int $id,
        #[In('engineer', 'project_manager', 'boss')]
        public string $role,
        public ?TeamFeaturesData $features,
    ) {
    }
}

class TeamFeaturesData extends Data
{
    public function __construct(
        public bool $github,
        public bool $jira,
    ) {
    }
}

Within our controller, we inject the User data object as follows:

class UpdateUserController
{
    public function __invoke(
        User $user,
        UserData $data,
        UpdateOrCreateUserAction $updateOrCreateUserAction
    )
    {
        $updateOrCreateUserAction($data, $user);

        flash('User updated!');

        return redirect()->back();
    }
}

As you can see, we also inject the user model we're updating and the action (a class with business logic) to perform the update within the database.

Due to the nature of laravel-data, when injected in a controller, the data object will validate the request input before creating itself. Let's take a look at the previous inputs we provided to the validator:

[
    'name' => 'Ruben Van Assche',
];

No validation messages 👍

[
    'name' => 'Ruben Van Assche',
    'team' => [
        'id' => 1,
        'role' => 'engineer',
    ],
]

Also no validation messages 🥳

[
    'name' => 'Ruben Van Assche',
    'team' => [
        'id' => 1,
        'role' => 'engineer',
        'features' => [
            'github' => true,
            'jira' => false,
        ],
    ],
]

No validation messages. We fixed our initial problem! 🎉

At this point in time, you start to wonder, is the validation working? Let's try this one out:

[
    'name' => 'Ruben Van Assche',
    'team' => [
        'id' => 1,
    ],
]

// Validation messages:

[
    "team.role" => ["The team.role field is required when team is present."]
]

It looks like everything is still validated as expected.

Internals

Let's go quickly over the internals of laravel-data. Until a few weeks ago, laravel-data worked precisely like our first approach by making all rules nullable in nested arrays. Though working, there were better solutions than this. So we completely rewrote the validation logic for laravel-data's third version.

Instead of generating rules before the payload is provided, laravel-data has a sort of JIT rule generator. It will generate rules based on the definition in the data class and the payload passed in.

For example, when we have a data object like UserData, all non-nested data property rules will be generated. The nested TeamData is nullable, so it will only start doing the same and thus generate rules for itself when we find a key team within the input payload that is not null.

If you're more interested in the internal workings of the validation rule generation, then take a look over here, where we generate validation rules for data objects just in time.

Closing

Laravel's validator is incredible, but sometimes it needs some help. Expect more articles like this on the Flare blog in the coming months and a new coat of paint for our website and application. Want a sneak peek? Mail us at support@flareapp.io.