Courses

Vue Laravel 12 Starter Kit: CRUD Project

File Upload with Spatie Media Library

Summary of this lesson:
- Add file uploads to tasks using Spatie Media Library
- Configure backend file handling with proper database relationships
- Implement file upload fields in create/edit forms with progress indicators
- Display uploaded files as thumbnails in the task listing table

Now, let's add a file upload field to the Tasks CRUD.


Prepare Back-End: Spatie Media Library

Let's install a well-known package spatie/laravel-medialibrary to handle the file data.

composer require "spatie/laravel-medialibrary"

Then, according to its documentation, we need to publish and run migrations:

php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"
php artisan migrate

Finally, we add the appropriate Traits to the Task Model file.

app/Models/Task.php:

use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
 
// ...
 
class Task extends Model
class Task extends Model implements HasMedia
{
use HasFactory;
use InteractsWithMedia;
 
// ...

Ok, the package installation/configuration is ready. Let's dive into the forms.


File Upload in Create Form: Front-End

This time, let's start with the front end.

For reference, here's the Inertia documentation on File Uploads.

Let's see how our Create.vue file needs to change.

  • We add media to our form data
  • We add a progress indicator from Inertia
  • We add the <Input> for the file picker

We start with the Form changes.

resources/js/pages/Tasks/Create.vue:

<script>
// ...
 
const form = useForm({
name: '',
due_date: '',
media: '',
});
 
const fileSelected = (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
 
if (!file) {
return;
}
 
form.media = file;
};
 
const submitForm = () => {
form.transform((data) => ({
...data,
due_date: data.due_date ? data.due_date.toDate(getLocalTimeZone()) : null,
})).post(route('tasks.store'), {
forceFormData: true,
preserveScroll: true,
});
};
</script>
 
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Create Task" />
<div class="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
<form class="space-y-6" @submit.prevent="submitForm">
// ...
 
<div class="grid gap-2">
<Label htmlFor="name">Media</Label>
 
<Input type="file" id="name" v-on:change="fileSelected($event)" class="mt-1 block w-full" />
 
<progress v-if="form.progress" :value="form.progress.percentage" max="100">{form.progress.percentage}%</progress>
 
<InputError :message="form.errors.media" />
</div>
 
<div class="flex items-center gap-4">
<Button :disabled="form.processing" variant="default">Create Task</Button>
</div>
</form>
</div>
</AppLayout>
</template>

Here's the visual result:

Now, what happens after we submit the form?


Submit the Create Form: Back-End

We need to modify the Controller and Form Request to accept the media file.

app/Http/Controllers/TaskController.php:

public function store(StoreTaskRequest $request)
{
Task::create($request->validated() + ['is_completed' => false]);
$task = Task::create($request->validated() + ['is_completed' => false]);
 
if ($request->hasFile('media')) {
$task->addMedia($request->file('media'))->toMediaCollection();
}
 
return redirect()->route('tasks.index');
}

Also, we need to add a validation rule to the Form Request.

app/Http/Requests/StoreTaskRequest.php:

return [
'name' => ['required', 'string', 'max:255'],
'due_date' => ['nullable', 'date'],
'media' => ['nullable', 'file', 'max:10240'],
];

Showing File in the Table

In the Controller, we need to just eager load the media() relationship:

app/Http/Controllers/TaskController.php:

public function index()
{
return Inertia::render('Tasks/Index', [
'tasks' => Task::paginate(20)
'tasks' => Task::with('media')->paginate(20)
]);
}

But we also need to change one default behavior of the package.

By default, Spatie Media Library returns multiple files per record. To overcome that and auto-load what we actually need, let's create a special method to return the first media file for each Task. This is a bit tricky advanced Eloquent operation:

  • We need to auto-load the file with $appends Eloquent property
  • However, to avoid N+1 query performance issues, we need to check for eager loading

app/Models/Task.php:

class Task extends Model implements HasMedia
{
// ...
 
protected $appends = [
'mediaFile'
];
 
public function getMediaFileAttribute()
{
if ($this->relationLoaded('media')) {
return $this->getFirstMedia();
}
 
return null;
}
}

Now, we can reference the mediaFile on the front end in TypeScript.

We need to add that mediaFile key to the Task type.

resources/js/types/index.d.ts:

export interface Task {
id: number;
name: string;
is_completed: boolean;
due_date?: string;
mediaFile?: MediaFile;
created_at: string;
updated_at: string;
}

Now, what's that MediaFile type? We need to create it in the same index.d.ts file. It's the structure returned by the Media Library package.

resources/js/types/index.d.ts:

export interface MediaFile {
id: number,
model_type: string,
model_id: number,
uuid: string,
collection_name: string,
name: string,
file_name: string,
mime_type: string,
disk: string,
conversions_disk: string,
size: number,
manipulations: string[],
custom_properties: string[],
generated_conversions: string[],
responsive_images: string[],
order_column: number,
created_at: string,
updated_at: string,
original_url: string,
preview_url: string,
}

And now, we can finally reference the task.mediaFile and show it in the table.

resources/js/pages/Tasks/Index.vue:

<template>
<Table class="mt-4">
<TableHeader>
<TableRow>
<TableHead>Task</TableHead>
<TableHead>File</TableHead>
<TableHead class="w-[200px]">Status</TableHead>
<TableHead class="w-[200px]">Due Date</TableHead>
<TableHead class="w-[200px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="task in tasks.data" :key="task.id">
<TableCell>{{ task.name }}</TableCell>
<TableCell>
<a v-if="task.mediaFile" :href="task.mediaFile.original_url" target="_blank">
<img :src="task.mediaFile.original_url" class="h-8 w-8" />
</a>
</TableCell>
<TableCell :class="{ 'text-green-600': task.is_completed, 'text-red-700': !task.is_completed }">
{{ task.is_completed ? 'Completed' : 'In Progress' }}
</TableCell>
<TableCell>{{ task.due_date ? df.format(new Date(task.due_date)) : '' }}</TableCell>
<TableCell class="flex gap-x-2 text-right">
<Link :class="buttonVariants({ variant: 'default' })" :href="`/tasks/${task.id}/edit`">Edit </Link>
<Button variant="destructive" @click="deleteTask(task.id)" class="mr-2">Delete</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</template>

As you can see, we've added a ternary operator logic:

  • If there's no task.mediaFile, we show an empty string
  • Otherwise, we show the link to the original file with the image

I've tried to upload files for a few tasks. Here's the result in the table:


Edit Form: Show the File

Finally, let's add the same input in the Edit Task form with the current file image shown.

First, we need to append some data in the Controller method. The default Route Model Binding with (Task $task) doesn't load those fields by default, so we need to do it manually.

app/Http/Controllers/TaskController.php:

public function edit(Task $task)
{
$task->load(['media']);
$task->append('mediaFile');
 
return Inertia::render('Tasks/Edit', [
'task' => $task,
]);
}

Then, we need to add that input to the form with similar changes we made to the Create.vue file.

resources/js/pages/Tasks/Edit.vue:

<script>
// ...
 
const form = useForm({
name: task.name,
is_completed: task.is_completed,
due_date: task.due_date ? fromDate(new Date(task.due_date)) : null,
media: '',
});
 
const fileSelected = (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
 
if (!file) {
return;
}
 
form.media = file;
};
 
const submitForm = () => {
form.transform((data) => ({
...data,
due_date: data.due_date ? data.due_date.toDate(getLocalTimeZone()) : null,
})).put(route('tasks.update', task.id), {
forceFormData: true,
preserveScroll: true,
});
};
</script>
 
<template>
 
<form class="space-y-6" @submit.prevent="submitForm">
// ...
 
<div class="grid gap-2">
<Label htmlFor="name">Media</Label>
 
<Input type="file" id="name" v-on:change="fileSelected($event)" class="mt-1 block w-full" />
 
<progress v-if="form.progress" :value="form.progress.percentage" max="100">{form.progress.percentage}%</progress>
 
<InputError :message="form.errors.media" />
 
<img v-if="task.mediaFile" :src="task.mediaFile.original_url" class="w-32 h-32 rounded-lg mx-auto mt-2" />
</div>
 
<div class="flex items-center gap-4">
<Button :disabled="form.processing" variant="default">Update Task</Button>
</div>
</form>
</template>

And now, if we visit the Edit page, it shows the current file thumbnail!


Submit the Edit Form

This part will be a little more tricky.

First, let's add the validation rule to the Form Request.

app/Http/Requests/UpdateTaskRequest.php:

public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'is_completed' => ['required', 'boolean'],
'due_date' => ['nullable', 'date'],
'media' => ['nullable', 'file', 'max:10240'],
];
}

Then, we update the Controller method for update.

app/Http/Controllers/TaskController.php:

public function update(UpdateTaskRequest $request, Task $task)
{
$task->update($request->validated());
 
if ($request->hasFile('media')) {
$task->getFirstMedia()?->delete();
$task->addMedia($request->file('media'))->toMediaCollection();
}
 
return redirect()->route('tasks.index');
}

Next, with our Edit, we have to be careful since the Inertia PUT method does not support file uploads:

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.

Instead, we will use the Manual visits feature with the router.post() method.

resources/js/pages/Tasks/Edit.vue

<script>
import { Head, useForm } from '@inertiajs/vue3';
import { Head, router, useForm } from '@inertiajs/vue3';
 
// ...
 
const submitForm = () => {
form.transform((data) => ({
...data,
due_date: data.due_date ? data.due_date.toDate(getLocalTimeZone()) : null,
})).put(route('tasks.update', task.id), {
forceFormData: true,
preserveScroll: true,
});
 
router.post(
route('tasks.update', task.id),
{
...form.data(),
due_date: form.data().due_date ? form.data().due_date.toDate(getLocalTimeZone()) : null,
_method: 'PUT' },
{
forceFormData: true,
preserveScroll: true,
},
);
};
</script>

And that's it. Now, we can replace the file with another file in the edit form!


Here's the GitHub commit for this lesson.

Previous: Due Date: Using Shadcn Vue Date Picker
avatar

Recordar que se tiene que linkear la carpeta storage:

php artisan storage:link

avatar
You can use Markdown
avatar
You can use Markdown