Checkboxes; hidden input and Laravel request solutions

Checkboxes are an HTML element that allows you to define a simple on/off piece of information. However, developers often stumble across an issue where if the checkbox is not checked, then no information is sent to the server. When making a POST request to PHP, this data isn't set and can often trip up people unfamiliar with this quirk.

Hidden input solution

A common solution is to insert a hidden input field before the checkbox, named the same as the checkbox, with a value you want to send if the field isn't checked.

<input type="hidden" name="opt_in" value="0" />
<input type="checkbox" name="opt_in" value="1" />

StackOverflow has an answer that was posted back in January 2010. It's a simple solution and people are still solving it this way, as found by a post in March 2022 on Tricks Panda.

Using Laravel requests

I use the PHP framework Laravel which helps handle many common development problems. On top of the standard MVC framework, there is a powerful way to handle application validation.

Although you can use validation in an ad-hoc way, I prefer to use the Form Request validation. This is a custom class that you can configure with validation rules and other helpful methods. To use the validation you type-hint the class in the controller method. Any requests to the URL method go through your defined request and return errors if validation fails.

The following request validation could be used with the hidden input approach above.

<?php
  
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CheckboxRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'opt_in' => [
                'boolean',
            ],
        ];
    }
}

In your controller, you would type-hint this request so it is validated and the data is set correctly.

Regardless of whether you include the hidden input, Laravel provides a convenient method to always return data for this type of field. Using the boolean helper, a boolean true/false is always returned. The method also includes a second parameter for when the field doesn't exist – this defaults to false.

/** @param \App\Http\Requests\CheckboxRequest $request */
public function store(CheckboxRequest $request)
{
    $optIn = $request->boolean('opt_in'); // false
}

I often pass the data from the request to a model for creating or updating. There is a validated() method that returns the data you want, without any other data that might have been set. If you include the hidden input in your HTML, then opt_in would appear in the $data array returned by the validated() method.

However, if you don't include the hidden input, then it would be missing. This is a problem if you use this method to populate a model or database which requires the field.

/** @param \App\Http\Requests\CheckboxRequest $request */
public function store(CheckboxRequest $request)
{
    // The incoming request is valid…
    // Retrieve the validated input data…
    $data = $request->validated();
    // array_key_exists('opt_in', $data); // true
}

Require the field using validation

Without including the hidden input, we can solve the missing value by using the request prepareForValidation() method. This allows you to merge data into the request before validation happens.

In the request class, you include a prepareForValidation() method. Here we can use the boolean() method to always set the opt_in value. We can also make the field required because we set a default in the prepare method.

<?php
  
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CheckboxRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'opt_in' => [
                'required',
                'boolean',
            ],
        ];
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'opt_in' => $this->boolean('opt_in'),
        ]);
    }
}

This expanded post was inspired by my tweet about this Laravel validation solution that received a lot of likes and retweets. So it seems that developers are still interested in solving this.

Setting more data

The example of setting the boolean value only scratches the surface of the usefulness of the prepareForValidation() method. I often use this method to populate other required data that might be used for models or databases. This helps keep controllers slim, moving this data checking and requirement to one place.

If you were inserting a new user, we can associate them to a client based on the current logged in user.

<?php
  
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class NewUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'opt_in' => [
                'required',
                'boolean',
            ],
            'client_id' => [
                'required',
                'integer',
            ],
        ];
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'opt_in' => $this->boolean('opt_in'),
		    'client_id' => $this->user()->client->id,
        ]);
    }
}

Another solution I have found is setting default values for filtering data. I needed to build a query that always used date and other filters. The following request allows for default values.

<?php
  
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class FilterRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'month' => [
                'required',
                'date_format:Y-m',
            ],
            'include_deleted' => [
                'required',
                'boolean',
            ],
            'include_archived' => [
                'required',
                'boolean',
            ],
        ];
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
  		    'month' => $this->month ?? now()->format('Y-m'),
  		    'include_deleted' => $this->boolean('include_deleted'),
  		    'include_archived' => $this->boolean('include_archived', true),
        ]);
    }
}

Using this request, if they didn't choose any options, then the month would be this month, it would exclude deleted items, but include archived ones.

/** @param \App\Http\Requests\FilterRequest $request */
public function store(FilterRequest $request)
{
    $result = Model::query()->filter($request->validated())->get();
}