Laravel Validation Options – Model, Form Request or Livewire?

L

Validation is one of those tiresome things that is archaic and should have been solved for good already, but because there are so many use-cases and scenarios – and therefore options – it can be tough to figure out which is the right solution to the problem at hand.

As often happens in Laravel, we have a multitude of possible ways to handle validation. The most straightforward way is using Laravel’s built-in validation methods, but these validation methods can be used in a variety of ways, and it’s up to you to decide which way makes the most sense for your application. Additionally, a sufficiently large enough application may use multiple methods – which is totally okay! They key is to think through the scenario in the short-term and the long-term to see what will work best.

I can walk you through this using an example of one way we screwed this up – but first, let me break down the options you have to start with:

Validation within a controller: This is sort of the default way Laravel expects you to do validation, although they don’t really say that out loud. Or maybe I’m wrong, but it’s the way it’s often done. The pre-built validation rules they provide out of the box are solid, and the validator is very easily extended and customized so that you can create your own validation rules as needed. You can see an example of that in our code here.

This validator is the building block for everything else we’ll talk about here, so take a moment to familiarize yourself with the docs here.

Model-level validation: This isn’t something supported out of the box by Laravel, but there’s a great package by Dwight Watson that handles this quite well. It might be considered a little out of date these days, especially with the introduction of Form Request validation in Laravel 5, but I still think it’s enormously useful. In fact, I find it kind of an odd choice that Laravel doesn’t support model-level validation out of the box, as other popular frameworks like Ruby on Rails do, especially since so much of Laravel is clearly modeled after RoR. Model-level validation ensures that the model cannot even be saved if all of the requirements are not met.

This can be a great option to universally guarantee that the object’s model itself always meets certain validation requirements. What’s nice about model-level validation is that you are able to protect the business object, whether that comes from the API, the web GUI, or anything else. This sounds great until it’s awful, but I’ll discuss that in just a bit.

Form request validation: This type of validation in Laravel is used strictly for, well, forms. It’s super helpful in validating that a form has the values you expect, and is more of a transactional validation than a permanent state. The validity is checked during form submission and then never heard from again unless that form is resubmitted.

Livewire Form Validation: Livewire is a newer addition to Laravel and is a very clever system for validating forms using Javascript that’s similar to Vue.js or React, but with no build time. You make the changes to Livewire blades, and there’s no need to regenerate JS assets each time you change something – which on a small project may not be a big deal, but on a project the size of Snipe-IT, those build times add up.

Determining Which Validation Method to Use

This is where it can get a little tricky, but (un)fortunately, I have a great use case that demonstrates how blindly sticking with one type of validation can hitch you up. This is probably more pertinent to open source projects, where you have to support a project throughout it’s entire lifecycle, from v0.0.1-alpha to whatever the current version is, but can also relate to longer-term projects with changing requirements.

Let’s take a look at our Settings model for Snipe-IT. This model is an odd one, as that settings table really only ever has one record in it, but we needed to make it a database table instead of an .env setting, since we wanted to provide a UI so that admins could modify some of these settings. (The settings table contains things like language, timezones, password requirements, etc.)

At first glance, this looks like a lot, but it also seems pretty straightforward. Model level validation rules should handle this all nicely, and keeps it all pretty compact, right in the model:

/**
 * Model rules.
 *
 * @var array
 */
protected $rules = [
      'brand'                               => 'required|min:1|numeric',
      'qr_text'                             => 'max:31|nullable',
      'alert_email'                         => 'email_array|nullable',
      'admin_cc_email'                      => 'email|nullable',
      'default_currency'                    => 'required',
      'locale'                              => 'required',
      'slack_endpoint'                      => 'url|required_with:slack_channel|nullable',
      'labels_per_page'                     => 'numeric',
      'slack_channel'                       => 'regex:/^[\#\@]?\w+/|required_with:slack_endpoint|nullable',
      'slack_botname'                       => 'string|nullable',
      'labels_width'                        => 'numeric',
      'labels_height'                       => 'numeric',
      'labels_pmargin_left'                 => 'numeric|nullable',
      'labels_pmargin_right'                => 'numeric|nullable',
      'labels_pmargin_top'                  => 'numeric|nullable',
      'labels_pmargin_bottom'               => 'numeric|nullable',
      'labels_display_bgutter'              => 'numeric|nullable',
      'labels_display_sgutter'              => 'numeric|nullable',
      'labels_fontsize'                     => 'numeric|min:5',
      'labels_pagewidth'                    => 'numeric|nullable',
      'labels_pageheight'                   => 'numeric|nullable',
      'login_remote_user_enabled'           => 'numeric|nullable',
      'login_common_disabled'               => 'numeric|nullable',
      'login_remote_user_custom_logout_url' => 'string|nullable',
      'login_remote_user_header_name'       => 'string|nullable',
      'thumbnail_max_h'                     => 'numeric|max:500|min:25',
      'pwd_secure_min'                      => 'numeric|required|min:8',
      'audit_warning_days'                  => 'numeric|nullable',
      'audit_interval'                      => 'numeric|nullable',
      'custom_forgot_pass_url'              => 'url|nullable',
      'privacy_policy_link'                 => 'nullable|url',
];

So, yeah, there’s a lot of fields here, but it’s all pretty straightforward, right?

This all made sense when the settings were a single page, but what ended up happening was that the settings section got a lot bigger as people asked for more customizations. As a result, we split the settings across multiple pages, which would normally be fine – except then the requirements changed. The minimum password character count was bumped from 6 to 8 in Laravel v5, which means that the Setting model would fail validation if you tried to change ANYTHING in that model without updating that pwd_secure_min value so that it passes validation.

What this really means is that you could be trying to update your SAML settings, but since the model level validation is failing, it won’t save your changes, with the added bonus of not providing you with any useful visual feedback on the form itself, since the real validation failure is related to a field that isn’t on the SAML settings page. You get a generic error at the top of the page, but no field is highlighted and no feedback as to what’s actually wrong is provided. Overall a terrible user experience. The settings won’t save, and the user has no idea why or how to fix it.

What we should have done – and are doing for Snipe-IT v6 – would be to use Form Request Validation and/or Livewire validation. As you can see in the screenshot above, the settings section has grown quite a lot, and each one of those pages should have its own Form Request validation.

We should have switched to form request validation for each of those settings screens and pruned back the model level validation rules so that each form field has relevant feedback if it fails validation. We display those messages to the user by accessing the error bag, like so:

{{ Form::text('slack_endpoint', old('slack_endpoint', $setting->slack_endpoint), array('class' => 'form-control','placeholder' => 'https://hooks.slack.com/services/XXXXXXXXXXXXXXXXXXXXX', 'id' => 'slack_endpoint')) }}
{!! $errors->first('slack_endpoint', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}

… which basically just checks to see if there is an error bag value for that field and uses a CSS class to make it show up as red so it stands out as being a failed validation field.

The creator of the model-level validation package basically explains that you shouldn’t use model level validation and form request validation at the same time, but I think it can be done as long as it’s well thought out. I’m not saying YOU should use both – that’s going to depend on your project – but for us, we like to use both.

For example, there would never be a time when an asset should belong to a location ID that doesn’t exist in the database, so at the model level, we could allow it to be nullable (since that field is optional), but IF a value is passed, it should exist in the locations table. There would also never be a time when the asset tag is empty, since that’s a required field and is important to the business logic of the system.

The rule of thumb I think we should consider using is that model level validation is GOOD, but should be used as a baseline, with more fine-tuning done by the form requests.

Where this can get a little awkward is the order of operations and preventing duplicated code. The way form requests work in Laravel, they fire before model level validation and controller-invoked validation. This can result in a weird user experience where the form request validation fails, the form is re-presented, the user corrects whatever failed there, re-submits, and then it fails at the model level with a whole other error message. Not fatal, but it’s not a great user experience, for sure, so you should think about this as you’re planning out your validators.

We’re still working our way through where Livewire validation makes sense for us. A good example would be the LDAP or Slack integration settings pages. Livewire really shines where there are dependencies in the form. Since it’s brower-based validation, you should always have model or form request validation as a backup, but it provides a super fast ajax-y validation system that you can sprinkle in on your forms where needed. In the Slack settings example, we only want to show a “Test Slack Integration” button if there are values entered in the required Slack fields (webhook endpoint, channel names, etc).

Snipe-IT v5 uses jQuery to handle that, but Livewire makes small validation dependencies even easier.

Conclusion: It Depends

I know, probably not the conclusion you were hoping for, but it’s true. Open source projects are different than closed source projects, since there is always the burden of supporting multiple databases, platforms, versions, etc at any moment in time – and the requirements shift often – sometimes because of your choices, and sometimes because of the framework.

Using a smart hierarchy of validation rules can give you a re-usable belt-and-suspenders style of backend and frontend validation providing the best experience for the user while not turning your project into an unmaintainable nightmarish hellscape.

Until next time 🙂

About the author

A. Gianotto

Alison is the founder and CEO of Grokability, Inc, the company that makes the open source product Snipe-IT.

By A. Gianotto

Snipe-IT at Laracon

Open Source in Business Interview

Snipe-IT at Longhorn PHP