Upload files from Vue to Laravel with Inertia

Send an image with the Inertia Form component, validate it, store it on the public disk, and show upload progress.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/laravel-vue-file-upload.md
Add a cover image on the existing post form.
Do not send the file with PUT, PATCH, or DELETE.
Do not run storage:link on Laravel Cloud.
Do not write the file bytes into the database.
Show the diff. Do not commit.

Next: Typed Laravel routes in Vue with Wayfinder

On this page · 9 sections
  1. The column and the link
  2. Validate and store
  3. The Vue form
  4. Replace and delete
  5. Production
  6. Recap
  7. FAQ
  8. Next steps
  9. Sources

The upload “works”, the database has a path, and the image is a broken icon. Or the file field is empty on every PUT. Uploading a cover image in Laravel + Vue is ordinary multipart, with three traps. Here is the full flow.

TL;DR

  • Run php artisan storage:link, or saved files 404.
  • Validate with image and max:2048 (kilobytes), store() on the public disk, save the path.
  • File + update: POST with _method=put; PHP does not fill files on a real PUT.
  • Delete the old file when replacing; use S3-style storage with more than one server.

What you'll learn

  • How to store an upload on the public disk
  • How to send a file from the Inertia <Form>
  • How to replace old files and plan for production

Who it's for: Laravel + Vue developers adding image uploads

Applies to: Laravel 13 · Inertia 3 · Vue 3

A file input in an Inertia form is still a normal multipart request. Laravel validates it, stores it, and redirects back. You do not need a second upload library for a cover image on the post form.

bash
php artisan make:migration add_cover_path_to_posts_table --table=posts
php artisan storage:link
database/migrations/xxxx_add_cover_path_to_posts_table.phpphp
public function up(): void
{
    Schema::table('posts', function (Blueprint $table) {
        $table->string('cover_path')->nullable();
    });
}

Gotcha: no link, no image

Add cover_path to the model’s $fillable array. storage:link points public/storage at storage/app/public, which is where the public disk writes. Without the link, the file saves and the <img> 404s.

Validate and store#

app/Http/Controllers/PostController.phpphp
public function update(Request $request, Post $post): RedirectResponse
{
    $data = $request->validate([
        'title' => ['required', 'string', 'max:255'],
        'excerpt' => ['required', 'string'],
        'cover' => ['nullable', 'image', 'max:2048'],
    ]);

    if ($request->hasFile('cover')) {
        $data['cover_path'] = $request->file('cover')->store('covers', 'public');
        unset($data['cover']);
    }

    $post->update($data);

    return to_route('posts.edit', $post);
}

Heads up: kilobytes, and paths not bytes

max:2048 is kilobytes, about 2 MB. image checks the content, not only the extension. store() returns a path like covers/abc.jpg. Save that path. Do not put the file bytes in the database.

Unset cover before update() so Eloquent does not try to write the uploaded file object into a string column.

Pass the current path to the page:

php
'post' => $post->only(['id', 'title', 'excerpt', 'cover_path']),

The Vue form#

Gotcha: files and PUT

The <Form> component from @inertiajs/vue3 sends a file field as multipart/form-data. PHP does not fill that file on a real PUT, PATCH, or DELETE. Post the form and send _method of put, which is the spoof Laravel documents. Keep the name attribute. That is the key Laravel validates.

resources/js/pages/posts/Edit.vuevue
<script setup lang="ts">
import { Form } from "@inertiajs/vue3";

defineProps<{
  post: {
    id: number;
    title: string;
    excerpt: string;
    cover_path: string | null;
  };
}>();
</script>

<template>
  <Form
    :action="`/posts/${post.id}`"
    method="post"
    #default="{ errors, processing, progress }"
    class="space-y-4"
  >
    <input type="hidden" name="_method" value="put" />
    <input name="title" :defaultValue="post.title" />
    <p v-if="errors.title">{{ errors.title }}</p>

    <textarea name="excerpt" :defaultValue="post.excerpt" />
    <p v-if="errors.excerpt">{{ errors.excerpt }}</p>

    <img
      v-if="post.cover_path"
      :src="`/storage/${post.cover_path}`"
      alt=""
      class="max-h-40"
    />

    <input type="file" name="cover" accept="image/*" />
    <p v-if="errors.cover">{{ errors.cover }}</p>
    <progress v-if="progress" :value="progress.percentage" max="100" />

    <button :disabled="processing">Save</button>
  </Form>
</template>

progress is set while the body is uploading. processing stays true until Laravel responds. Disable the button for the whole request, not only while the bar moves.

Upload something that isn't an image

Rename a text file to cover.jpg and upload it. image checks the content, so you should see “The cover field must be an image” under the input, and nothing in storage/app/public/covers.

A validation error for cover (“The cover field must be an image”) comes back on the same page, the same way errors.title does in the CRUD guide.

Replace and delete#

store() does not remove the previous file. If the post already has a cover, delete it before saving the new one:

app/Http/Controllers/PostController.phpphp
use Illuminate\Support\Facades\Storage;

if ($request->hasFile('cover')) {
    if ($post->cover_path) {
        Storage::disk('public')->delete($post->cover_path);
    }

    $data['cover_path'] = $request->file('cover')->store('covers', 'public');
}

Do this in the same request as the database update. A queue is the wrong place for “the user just picked a new avatar” unless the file is large enough that the HTTP request times out.

Production#

The public disk is fine for images you are happy to serve from the app server. For anything private, use a non-public disk and a temporary URL. For more than one server, the local disk is the wrong place: the next request may hit a machine that does not have the file. Point the disk at S3-compatible storage then. The Vue form does not change.

WhereDiskstorage:link
One server, public imagespublicOnce per machine
Private filesNon-public disk + temporary URLn/a
Several serversS3-compatiblen/a
Laravel CloudAttached bucketSkip (disk resets)

php artisan storage:link is once per machine when that machine serves the public disk. Laravel Cloud resets the app disk on each deploy, so do not rely on the symlink there. The deploy guide is the rest of that list. It will not create the covers directory for you. store() does that on first upload.

Recap#

  • Link the storage once; save paths, not bytes.
  • image + max in kilobytes on the server.
  • Spoof PUT with _method when a file is attached.
  • Clean up replaced files; move to object storage when you scale.

FAQ#

Why does my Laravel uploaded image return 404?

The public/storage symlink is missing. Run php artisan storage:link on the machine that serves the public disk.

Why is the file empty when I update with Inertia?

PHP does not parse multipart files on a real PUT or PATCH. Send the form as POST with _method set to put.

Is Laravel max:2048 in megabytes?

No, kilobytes. max:2048 is about 2 MB for files.

Does storing a new file delete the old one?

No. Delete the previous path with Storage::disk('public')->delete() in the same request.

Next steps#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.