Request-Level Validation

Feature image: Request-Level Validation

When we’re building web apps, one of our most common workflows is code that collects and validates user input. Laravel provides an rich suite of tooling for validating user input—for example, form submissions. However, some times we want to validate data that’s a part of the request but wasn’t provided by the user—for example, a part of the URL.

In this post, we’ll explore four possible validation solutions for validating data on a request that wasn’t provided by the user.

Our example

Imagine we have a shopping app with a list of products. Our products table has an is_featured column, and any product that’s featured on the home page will have that column set to true.

In our app, we want to make sure no one can ever delete a featured product. Let’s imagine this controller method can be called with a DELETE call to a URL like http://ourapp.com/products/14.

// ProductController.php
public function destroy(Product $product)
{
// Perform some validation here to make sure the product isn't featured
$product->delete();
}

What does it look like to ensure this product isn’t deleted if it’s featured? At first glance, we might assume we can’t use Laravel’s native validation tooling, since the data we’re checking against (the product, which is passed as a part of the URL) isn’t technically user input.

Let’s take a look at a few solutions below.

Option 1: Authorization (and the authorize() method)

Note: While this is the most common solution I see folks reaching for in these types of situations, I personally believe it’s not the best option. Read on to my final paragraph of this option to see why.

We can try using Laravel’s authorization tooling; there’s a method, $this->authorize(), available in controllers that checks against that object’s policy to see if this action is permitted.

In order to use $this->authorize(), we’d use Laravel’s policies to define permissions for deleting a product. If we already have a ProductPolicy in place, we can modify the delete() method to check whether the given product is featured:

// ProductPolicy.php
public function delete(User $user, Product $product)
{
return ! $product->is_featured;
}

There are a few ways to attach Laravel’s authorization tooling to a route, including the can middleware, which is my preference. However, for this example, let’s keep it in the controller and use the authorize() method:

// ProductController.php
public function destroy(Product $product)
{
$this->authorize('delete', $product);
 
$product->delete();
}

Here’s the downside of this solution, and any other solutions using authorization: a 403 Forbidden response suggests the reason the user can’t delete this product is because they’re not authorized to do so. But this isn’t an authorization issue; it’s a validation issue. You may be authorized to delete products, but you’ve made an invalid request, which I think merits a different response.

Option 2: The abort() and abort_if() Helpers

Since we’re dealing with validation, not authorization, let’s find a better response code. I’d probably use 422 Unprocessable Entity, which is the status code Laravel throws when a JSON request fails validation.

In this case, we can use the abort_if helper to check the product’s status inline return a 422 status code if it’s invalid.

// ProductController.php
public function destroy(Product $product)
{
abort_if($product->is_featured, 422);
 
$product->delete();
}

This is a good start. However, we’re missing out on a lot of what Laravel provides when using built-in validation.

When validation fails using Laravel’s native methods, Laravel throws a ValidationException with errors attached to it. The exception handler class (Illuminate\Foundation\Exceptions\Handler) catches these exceptions and then converts them to either a JSON response or a redirect, depending on the request type. We don’t get any of this with our abort_if() call.

Option 3: Throw a Validation Exception

Since we know throwing a ValidationException would allow us to generate a more robust error, we can manually throw one in the controller:

// ProductController.php
public function destroy(Product $product)
{
throw_if($product->is_featured, ValidationException::withMessages([
'product' => ['Featured products cannot be deleted.'],
]));
 
$product->delete();
}

Here we’re using Laravel’s throw_if helper function to throw the ValidationException when the first parameter (product is featured) is true. The withMessages static constructor method provides a convenient way to add our custom error message to the product key.

These throw_if and abort_if solutions are probably the cleanest options for a simple boolean check; however, what if the validation logic is complicated enough to warrant extracting this code out of the controller? Let’s explore another option for this below.

Option 4: Form Request Object

Laravel’s form requests are a perfect tool for extracting validation logic into a dedicated class. Each form request has a rules() method, which allows us to define how to validate each piece of user input.

Since the examples we’re covering here aren’t validating user input, we might assume the rules() method, which traditionally pulls only the user data (using request()->all()), would be out of the question.

However, it turns out it’s possible to override the data a form request is using for its validation, and we can add our own data in and then use rules() to validate that data.

Let’s imagine we’ve created a DeleteProductFormRequest. We want to modify this request so it uses our own array for the data it’s validating, which we can define in the validationData() method:

// DeleteProductFormRequest.php
public function validationData()
{
return [
'product' => $this->route('product'),
];
}

Note: $this->route('product') gives us the product variable defined in the route and instantiated as an Eloquent object by route model binding. However, if this request wasn’t using route model binding, we could instead look up the product in the validationData() method or its partner method prepareForValidation(), using its ID pulled from $this->request('product_id') or something similar.

Now that we have a product to validate, we can add our validation to the rules method; since this is not normal validation use case, we need to build a custom rule for it, passed as a closure:

// DeleteProductFormRequest.php
public function rules()
{
return [
'product' => [
function ($attribute, $value, $fail) {
if ($value->is_featured) {
$fail('Featured products cannot be deleted.');
}
},
],
];
}

There you have it: one not-so-great and three great solutions for the next time you need to validate a request based on something other than user input.

Please, if you remember nothing else, remember this: there’s a difference between authorization and validation. Authorization—for example, using $this->authorize()—sends a confusing message to the consumers of this route, suggesting the wrong reason it’s being rejected.

If we set our brains to understand that we’re instead validating whether this is a valid request, we see examples in Laravel’s existing tooling that guide us toward a better way to handle this issue, just making minor tweaks to the existing validation ecosystem to allow it to handle non-user-provided data.

Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.
©2024 Tighten Co.
· Privacy Policy