Search code examples
reactjslaravelvalidationinertiajs

Uploading images from React to the Laravel backend empties the request body


I'm developing an ecommerce website with Laravel and React using Inertia.js. I've created a component for image uploads using Dropzone. This component is used on a page within a form, which serves both for creating and updating products.

Regarding product creation, there are no issues, and everything works as expected. The problem arises when I try to update products. If I only modify the fields without adding images, the update works perfectly. However, when I add one or more images, validation errors are triggered on all fields, as if they were left empty.

I tried to do a 'dump and die' of the $request in the Request class and noticed that it's empty when images are included. I also used console.log statements in the frontend to check that the data was correctly sent and everything seems fine. I assume the issue lies in how I'm sending the data, but I can't pinpoint the exact error.

This is my code

Image upload component

export default function ImageUploader({ className, setData, data }) {
    const [files, setFiles] = useState([]);
    const [rejected, setRejected] = useState([]);

    useEffect(() => {
        setData({ ...data, images: files });
    }, [files]);
    //[JSON.stringify(...files)]
    const onDrop = useCallback((acceptedFiles, rejectedFiles) => {
        if (acceptedFiles?.length) {
            setFiles((previousFiles) => [
                ...previousFiles,
                ...acceptedFiles.map((file) =>
                    Object.assign(file, { preview: URL.createObjectURL(file) }),
                ),
            ]);
        }

        if (rejectedFiles?.length) {
            setRejected((previousFiles) => [
                ...previousFiles,
                ...rejectedFiles,
            ]);
        }
    }, []);
    const { getRootProps, getInputProps, isDragActive } = useDropzone({
        onDrop,
        accept: {
            'image/*': [],
        },
        maxSize: 1024 * 1000,
    });

    const removeFile = (name) => {
        setFiles((files) => files.filter((file) => file.name !== name));
    };

    const removeRejected = (name) => {
        setRejected((files) => files.filter(({ file }) => file.name !== name));
    };

    return (
        <>
            <InputLabel
                className={`text-xl`}
                htmlFor="images"
                value="Immagini"
            />

            <p className="pt-2">Dimensioni massime: 1 Mb</p>

            <div
                {...getRootProps({
                    className,
                })}
            >
                <input
                    {...getInputProps()}
                    id="images"
                    name="images"
                    form="productForm"
                />
                {isDragActive ? (
                    <p>Trascina i file qui ...</p>
                ) : (
                    <p>Trascina qui i file, o clicca per selezionarli</p>
                )}
            </div>

            ....
            ....
            ....
}

Data Form

export default function ProductForm({ auth, product, categories }) {
    const { data, setData, post, put, processing, errors } = useForm(
        product
            ? {
                  name: product.name,
                  description: product.description,
                  sku: product.sku,
                  price: product.price,
                  discounted_price: product.discounted_price,
                  cost: product.cost,
                  quantity: product.quantity,
                  track_quantity: product.track_quantity,
                  sell_out_of_stock: product.sell_out_of_stock,
                  status: product.status,
                  category_id: product.category_id,
                  images: [],
              }
            : {
                  name: '',
                  description: '',
                  sku: '',
                  price: '',
                  discounted_price: '',
                  cost: '',
                  quantity: '',
                  track_quantity: true,
                  sell_out_of_stock: false,
                  status: '',
                  category_id: '',
                  images: [],
              },
    );

    const submit = (event) => {
        event.preventDefault();
        if (product) {
            put(route('admin.products.update', product));
            return;
        }
        post(route('admin.products.store'));
    };

    ....
    ....
    ....
<ImageUploader
    setData={setData}
    data={data}
    className="mt-2 rounded-lg border border-gray-400 bg-gray-100 p-16 text-center shadow-lg"
/>

Request class

public function authorize(): bool
    {
        return $this->user()->can('update', $this->route('product'));
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        dd($this->request);
        return [
            'name' => 'required|string|max:255',
            'description' => 'required|string',
            'sku' =>
                'required|string|unique:products,sku,'.$this->route('product')->id,
            'track_quantity' => 'sometimes|nullable|boolean',
            'quantity' => 'required_if:track_quantity,true|nullable|int',
            'sell_out_of_stock' => 'required_if:track_quantity,true|boolean',
            'category_id' => 'required|int|exists:categories,id',
            'price' => 'required|numeric|min:0',
            'cost' => 'sometimes|nullable|numeric',
            'discounted_price' => 'sometimes|nullable|numeric',
            'status' => 'required|string|in:active,draft,review',
            'images' => 'sometimes|nullable|array',
        ];
    }

update method

public function update(UpdateProductRequest $request, Product $product)
    {
        $product->update(
            $request
                ->safe()
                ->collect()
                ->filter(fn($value) => !is_null($value))
                ->except(['images'])
                ->all()
        );

        $images = $request->file('images');

        if ($images !== null) {
            foreach ($images as $image) {
                Cloudinary::upload($image->getRealPath(), [
                    'transformation' => [
                        'width' => '700',
                        'quality' => 'auto',
                        'crop' => 'scale',
                    ]
                ])->getSecurePath();

                $product->attachMedia($image);
            }
        }

        return to_route('admin.products.index')->with('message', 'Prodotto aggiornato con successo');
    }

console.log data


Solution

  • Inertia will automatically convert your form to a FormData object.

    https://inertiajs.com/file-uploads#form-data-conversion

    When making Inertia requests that include files (even nested files), Inertia will automatically convert the request data into a FormData object. This conversion is necessary in order to submit a multipart/form-data request via XHR.

    But Laravel doesn't support the PUT method for multipart/form-data so you have to use POST and spoof the request using _method: 'put'.

    https://inertiajs.com/file-uploads#multipart-limitations

    Uploading files using a multipart/form-data request is not natively supported in some server-side frameworks when using the PUT,PATCH, or DELETE HTTP methods. The simplest workaround for this limitation is to simply upload files using a POST request instead.

    However, some frameworks, such as Laravel and Rails, support form method spoofing, which allows you to upload the files using POST, but have the framework handle the request as a PUT or PATCH request. This is done by including a _method attribute in the data of your request.

    So your code should look something like this. I believe Laravel will handle interpreting it as a PUT request automatically.

    // Post the form, but tell Laravel to treat it as a PUT request.
    post(route('admin.products.update', product), {
        _method: 'put',
    });