Articles Snippets Projects

Validating multipart/form-data with Laravel Validation Rules with proper JSON data types

January 1st ʼ22 7 months ago

2 min 262 words

This article co-written by me and Oğuzhan Karacabay, also available in Turkish, co-published on Medium.

When you want both upload files and send JSON payload from a Frontend Single Page Application to your Laravel Backend API, you send it with a multipart/form-data encoding.

A typical example of doing this regardless of Frontend Frameworks would be:

postMultipartFormData(form) {
	let formData = new FormData()

	formData.append('file', form.file)
	formData.append('id', form.id) // 1234
	formData.append('reason_type', form.reason_type) // 3
	formData.append('rate', form.rate) // 10.15
	formData.append('fee', form.fee * 100) // 1000 * 100
	formData.append('tax', form.tax * 100) // 190 * 100
	formData.append('description', form.description)  // some description
	formData.append('action_date', form.action_date) // 2022-01-07 17:52:06

	return apiClient
	  .post('/post/multipart-formdata', formData, {
	    headers: {'Content-Type': 'multipart/form-data'}
	  })
	  .then(response => { return response })
	  .catch(err => { throw err })
}

As a good Laravel developer, you want to validate this payload with a Laravel Request class before it comes to the Controller.

The Laravel Controller might look more or less like this:

<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Http\Requests\MultipartFormRequest;
use App\Http\Resources\MultipartFormResource;

class MultipartFormController extends Controller
{
    public function store(MultipartFormRequest $request): MultipartFormResource
    {
        $file = $request->file('file');
        $path = '/your/path';
        $filename = 'filename.xlsx';

        // Save file to a local or remote file bucket
        $file->storeAs($path, $filename, ['disk' => 's3-public']);

        // Create a model with file url
        $yourModel = YourModel::create(array_merge($request->validated(), [
            'file_url' => $path . $filename,
        ]));

        // Return a resource with your newly generated model
        return new MultipartFormResource($yourModel);
    }
}

and an example Laravel Request class would probably look something like this:

<?php

namespace App\Http\Requests;

use App\Enums\PromissoryNoteReasonType;
use BenSampo\Enum\Rules\EnumValue;
use Illuminate\Foundation\Http\FormRequest;

class MultipartFormRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'bank_id'                => ['bail', 'required', 'numeric', 'exists:banks,id'],
            'promissory_note_reason' => ['bail', 'required', new EnumValue(PromissoryNoteReasonType::class)],
            'interest_rate'          => ['bail', 'required', 'between:0,99.9999'],
            'fixed_fee'              => ['bail', 'required', 'integer'],
            'tax'                    => ['bail', 'required', 'integer'],
            'description'            => ['bail', 'required', 'string'],
            'action_date'            => ['bail', 'required', 'date'],
            'file'                   => ['bail', 'required', 'file', 'mimes:xls,xlsx',
            ],
        ];
    }
}

So far, everything went perfectly, but when you run this code, you will notice that the data you sent did not pass the validation. Dig deeper and you’ll see that the data coming into your Laravel API is very different from what we expected:

array:8 [
  "file" => "file-content"
  "id" => "123"
  "reason_type" => "3"
  "rate" => "10.15"
  "fee" => "10000"
  "tax" => "1900"
  "description" => "request description"
  "action_date" => "2020-10-07 17:52:06"
]

So you noticed that multipart/formdata, in addition to its ability to both, send binary files and JSON payload, converts all data types to strings,.

After this step, you can change your validation rules assuming that the incoming data will be all string types, you can do your parse gymnastics to fully validate string-type values.

Validation with proper JSON Data Types

If the FormData object can only send data of string types and we must use a FormData object to upload files, we will use it in this way. Of course, by first converting all the data to be sent to a JSON string.

After adding the file we want to upload to FormData (I.), we convert all the payload to a JSON string with the stringify() function. (II.)

Thus, we have a FormData object containing only the file and payload to send to the Backend API. (III.)

We used parseFloat() function because the stringify() function cannot parse float types to a JSON string with correct data type. (IV.)

postMultipartFormData(form) {
  let formData = new FormData()

  formData.append('file', form.file) // I.

  let payload = JSON.stringify({
    id: form.id,
    reason_type: form.reason_type,
    rate: parseFloat(form.rate), // IV.
    fee: form.fee * 100,
    tax: form.tax * 100,
    description: form.description,
    action_date: form.action_date
  }); // II.

  formData.append('payload', payload) // III.

  return apiClient
      .post('/post/multipart-formdata', formData, {
        headers: { 'Content-Type': 'multipart/form-data' }
      })
      .then(response => { return response })
      .catch(err => { throw err })
}

We need to convert the JSON string coming to the Backend API side into a JSON Payload just before passing it through the validation rules. The prepareForValidation() method in Laravel’s Request classes is there for just that.

  /**
   * Prepare the data for validation.
   *
   * @return void
   *             
   * @throws \JsonException
   */
  protected function prepareForValidation(): void
  {
      $this->merge(json_decode($this->payload, true, 512, JSON_THROW_ON_ERROR));
  }

After converting the JSON String to JSON Payload using the json_decode() function in the prepareForValidation() method, we combine it with other Request data with merge()which is another method of Laravel Request classes.

array:7 [
  "id" => 123
  "reason_type" => 3
  "rate" => 10.15
  "fee" => 10000
  "tax" => 1900
  "description" => "request description"
  "action_date" => "2020-10-07 17:52:06"
]

Thus, our data was cast into appropriate data types and passed all the validation rules. So there is no need to change the validation rules or do any additional validation gymnastics.