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.
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.phppublic 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.
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.phppublic 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.phppublic 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.
abort()
and abort_if()
HelpersSince 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.phppublic 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.
Since we know throwing a ValidationException
would allow us to generate a more robust error, we can manually throw one in the controller:
// ProductController.phppublic 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.
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.phppublic function validationData(){ return [ 'product' => $this->route('product'), ];}
Note:
$this->route('product')
gives us theproduct
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 thevalidationData()
method or its partner methodprepareForValidation()
, 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.phppublic 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.
We appreciate your interest.
We will get right back to you.