Skip to content

File Uploads

File uploads usually have three parts: validate the file, store the file, and save the path in the database.

I prefer keeping the controller thin, validating the file in a Form Request, and moving the storage logic to an Action when the upload is part of a real feature.

Basic Flow

The form sends a file to the backend. Laravel receives it as an UploadedFile, validates it, stores it in a configured disk, and saves the generated path.

vue
<script setup lang="ts">
import { Form } from '@inertiajs/vue3'
import { store } from '@/routes/posts'
</script>

<template>
    <Form :action="store()" method="post" enctype="multipart/form-data">
        <input type="text" name="title" placeholder="Title">
        <textarea name="content" placeholder="Content" />
        <input type="file" name="image_path">

        <button type="submit">
            Create post
        </button>
    </Form>
</template>
php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class CreatePostRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // your authorization logic here
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'content' => ['nullable', 'string'],
            'image_path' => ['nullable', 'image', 'mimes:png,webp,jpeg,jpg', 'max:2048'],
        ];
    }
}
php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\CreatePost;
use App\Http\Requests\CreatePostRequest;
use App\Models\Post;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;

final readonly class PostController
{
    public function index(): Response
    {
        $posts = Post::query()
            ->select('id', 'title', 'content', 'image_path')
            ->latest()
            ->get();

        return Inertia::render('posts/index', [
            'posts' => $posts,
        ]);
    }

    public function store(CreatePostRequest $request, CreatePost $createPost): RedirectResponse
    {
        $post = $createPost->handle($request->validated());

        return to_route('posts.show', $post)
            ->with('success', 'Post created successfully.');
    }
}
php
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Post;
use Illuminate\Http\UploadedFile;

final readonly class CreatePost
{
    public function handle(array $attributes): Post
    {
        $file = $attributes['image_path'] ?? null;

        if ($file instanceof UploadedFile) {
            $attributes['image_path'] = $file->store('posts', 'public');
        }

        return Post::query()->create($attributes);
    }
}
php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;

#[Appends(['image_url'])]
#[Fillable(['title', 'content', 'image_path'])]
final class Post extends Model
{
    public function casts(): array
    {
        return [
            'id' => 'integer',
            'title' => 'string',
            'content' => 'string',
            'image_path' => 'string',
        ];
    }

       public function getImageUrlAttribute(): ?string
        {
            return $this->image_path ? Storage::disk('public')->url($this->image_path) : null;
        }

}

What Is Happening

The Page.vue file is responsible for sending the form data to Laravel. Because the form includes a file input, the form needs enctype="multipart/form-data". Without that, the browser will not send the file correctly.

The important part is that the file input name matches the validation field:

md
image_path

That same name appears in the Vue form, the Form Request, the Action and the model fillable attributes.

The CreatePostRequest validates the incoming request before the controller receives clean data. The image_path field is nullable, but if it is present, it must be an image with one of the allowed extensions and a maximum size of 2048 KB.

The controller stays small. It does not store the file itself. It only passes the validated request data to the CreatePost Action:

php
$post = $createPost->handle($request->validated());

The Action checks if image_path contains an uploaded file. If it does, Laravel gives it as an instance of UploadedFile.

php
if ($file instanceof UploadedFile) {
    $attributes['image_path'] = $file->store('posts', 'public');
}

The store('posts', 'public') call saves the file in the public disk, inside the posts folder. It returns the path of the stored file, for example:

md
posts/example-image.jpg

That returned path replaces the original uploaded file in the $attributes array. After that, the Action creates the post with a normal Eloquent create:

php
return Post::query()->create($attributes);

The model stores only the path in image_path. The image_url accessor converts that path into a public URL when the frontend needs to display the image.

So the database keeps this:

md
posts/example-image.jpg

And the frontend can receive this:

md
/storage/posts/example-image.jpg

Storage

The store() method saves the file and returns the path that should be stored in the database.

php
$path = $image->store('posts', 'public');

In this example, the file is stored in the public disk, inside a posts folder. The database should store the path, not the full URL.

To make public files accessible, Laravel needs the storage link:

bash
php artisan storage:link

Database

The model usually only needs a column for the path:

php
$table->string('image_path')->nullable();

I prefer storing the file path instead of the full URL because the disk, domain or CDN can change later.

Updating Files

When replacing an existing file, delete the old file after the new one is stored successfully.

php
$path = $image->store('posts', 'public');

if ($post->image_path !== null) {
    Storage::disk('public')->delete($post->image_path);
}

$post->update([
    'image_path' => $path,
]);

Notes

For simple uploads, storing the file directly in the controller can be fine. But when the upload is part of a larger flow, I prefer using an Action so the controller does not grow too much.