Skip to content
Thuan Bui's Blog
Go back

File Upload trong Laravel - [Phần 10] Upload file tự động với Livewire 3

Chào mừng bạn đến với phần cuối cùng của series “Laravel File Upload”. Phần 10 này ra mắt chậm trễ hơn dự kiến, vì mình phải dành thêm thời gian nghiên cứu về Livewire 3 trước khi áp dụng vào series này.

Trong các phần trước, chúng ta đã từng bước xây dựng tính năng upload file trong Laravel – từ cơ bản, qua xử lý ảnh, tích hợp thư viện Spatie Media Library và cải thiện giao diện bằng FilePond. Tuy nhiên, ở phần cuối cùng này, chúng ta sẽ thử một hướng tiếp cận khác: sử dụng Livewire 3 để xây dựng tính năng upload file realtime – đơn giản, hiện đại và không cần JavaScript phức tạp.

Ưu điểm khi dùng Livewire 3:

Yêu cầu phải có kiến thức căn bản về Livewire 3, nếu không sẽ rất khó để theo dõi nội dung chia sẻ trong phần này.

I. Cài đặt Livewire

Mình cài đặt Livewire bằng Composer

Terminal window
composer require livewire/livewire

Tạo file app.blade.php trong thư mục resources\views\components\layouts với nội dung sau

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Livewire Component</title>
<!-- Styles / Scripts -->
<script src="https://cdn.tailwindcss.com"></script>
@livewireStyles
</head>
<body>
{{ $slot }} @livewireScripts
</body>
</html>

II. Tạo Livewire Component

Tạo Livewire component UploadFile

Terminal window
php artisan livewire:component UploadFile

Hệ thống sẽ tạo ra 2 file mới:

COMPONENT CREATED 🤙
CLASS: app/Livewire/FileUpload.php
VIEW: resources/views/livewire/file-upload.blade.php

III. Chỉnh sửa Class FileUpload.php

Cách sử dụng Livewire 3 để Upload File được hướng dẫn đầy đủ trong documentation của Livewire 3 ở đây: https://livewire.laravel.com/docs/uploads

Class FileUpload.php tạm thời sẽ được cập nhật như sau

Terminal window
<?php
namespace App\Livewire;
use App\Models\Upload;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class FileUpload extends Component
{
use WithFileUploads; // Sử dụng trait WithFileUploads để xử upload file trong Livewire.
#[Validate(['files.*' => 'image|max:2048'])] // Thuộc tính Validate dùng để kiểm tra các file upload phải là hình ảnh và dung lượng tối đa 2MB cho mỗi file.
public $files = []; // Khai báo array cho danh sách các file được chọn upload.
public $persistedUploads = []; // Khai báo array cho danh sách các file đã được upload thành công (được lưu trongatabase).
/**
* Lưu các file đã chọn vào thư mục lưu trữ cập nhật sở dữ liệu.
*
* @return void
*/
public function save()
{
$this->validate();
$uploadedFiles = $this->files;
$numberOfFiles = count($uploadedFiles); // Đếm số lượng file đã upload
foreach ($uploadedFiles as $file) {
// Lấy tên file gốc từ client
$originalFilename = $file->getClientOriginalName();
$originalFilenames[] = $originalFilename; // Thêm tên gốc vào array
// 3. Tạo bản ghi trong database cho model Upload:
// Lưu ý: Chúng ta chỉ cần lưu 'original_filename'.
// Các thông tin về đường dẫn file gốc thumbnail sẽ do media-library quản lý.
$uploadEntry = Upload::create([
'original_filename' => $originalFilename,
]);
// 4. Đây phần quan trọng nhất - Thêm file vào Media Library:
$uploadEntry->addMedia($file) // Thêm file vào Media Library
->toMediaCollection('images'); // Thêm file vào collection 'images'
}
$this->persistedUploads = Upload::latest()->get();
$this->reset('files');
session()->flash('success', 'You have successfully uploaded ' . $numberOfFiles . ' files');
}
/**
* Xóa file đã upload khỏi thư mục lưu trữ sở dữ liệu.
*
* @param \App\Models\Upload $upload Model Upload cần xóa.
* @return void
*/
public function destroy(Upload $upload) {
$upload->delete();
$this->persistedUploads = Upload::latest()->get();
session()->flash('success', 'You have successfully deleted ' . $upload->original_filename);
}
/**
* Hàm được gọi khi component được khởi tạo.
* Lấy danh sách các file đã upload từ sở dữ liệu.
*
* @return void
*/
public function mount()
{
$this->persistedUploads = Upload::latest()->get();
}
/**
* Render view cho component.
*
* @return \Illuminate\Contracts\View\View
*/
public function render()
{
return view('livewire.file-upload', [
'uploads' => $this->persistedUploads
]);
}
}

Giải thích

IV. Chỉnh sửa layout file-upload.blade.php

Phần hiển thị giao diện của Livewire component được lưu trong file resources/views/livewire/file-upload.blade.php. Mình sử dụng layout của file layout.blade.php đã sử dụng trong các phần trước, chỉnh sửa lại cho phù hợp với yêu cầu của Livewire.

<div>
{{-- Upload File Form --}}
<div class="container mx-auto mt-10 p-6 bg-white rounded-lg shadow-md max-w-md">
<h1 class="text-2xl font-bold mb-6 text-center text-gray-700">Upload File</h1>
<form wire:submit.prevent="save">
<div class="mb-4">
<input type="file" wire:model="files" multiple>
</div>
<div class="mb-4">
@error("files.*")
<p class="text-red-500 text-xs mt-2">{{ $message }}</p>
@enderror
</div>
<button type="submit"
class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
Upload
</button>
</form>
</div>
{{-- Display Success Message --}}
@if (session("success"))
<div
class="container mx-auto mt-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg shadow-md max-w-md">
<p class="font-bold">Success!</p>
<p>{{ session("success") }}</p>
</div>
@endif
{{-- Display Uploaded File Info --}}
@if (count($uploads) > 0)
<div class="container mx-auto mt-10 p-10 bg-white rounded-lg shadow-md max-w-md">
<h2 class="text-xl font-semibold mb-4 text-gray-700">Previously Uploaded Files:</h2>
@foreach ($uploads as $upload)
<ul>
<li class="flex items-center justify-between mb-4">
<a class="flex items-center gap-4 py-2" href="{{ $upload->url }}" target="_blank">
<img src="{{ $upload->thumbnail_url }}" alt="{{ $upload->original_filename }}"
width="50" height="50">
<span
class="text-sm text-gray-700 hover:text-blue-600">{{ $upload->original_filename }}</span>
</a>
<button
type="button"
onclick="if (confirm('Are you sure you want to delete this file?')) { @this.destroy({{ $upload->id }}) }"
wire:loading.attr="disabled"
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">
Delete
</button>
</li>
</ul>
@endforeach
</div>
@endif
</div>

Phần upload form giờ đã được đơn giản hóa gọn gàng như bên dưới:

<form wire:submit.prevent="save">
<input type="file" wire:model="files" multiple />
@error('files.*') <span class="error">{{ $message }}</span> @enderror
<button type="submit">Upload</button>
</form>

Khi người dùng bấm nút Upload, form sẽ thực hiện function save được khai báo trong Class FileUpload.php.

Phần nút nhấn để xóa file không còn cần dùng tag <form>, mà có thể thao tác trực tiếp trên tag <button>

<button
type="button"
onclick="if (confirm('Are you sure you want to delete this file?')) { @this.destroy({{ $upload->id }}) }"
wire:loading.attr="disabled"
class="rounded bg-red-500 px-4 py-2 font-bold text-white hover:bg-red-700"
>
Delete
</button>

Khi người dùng bấm nút Delete, một hộp thoại yêu cầu xác nhận sẽ hiện ra. Nếu bấm đồng ý, hàm destroy trong Class FileUpload.php sẽ được thực thi để xóa thông tin file khỏi database và xóa file trên disk.

Kiểm tra thực tế, mọi thứ hoạt động đúng như mong muốn. Các thao tác upload file, hay xóa file đều diễn ra ngay lập tức trong trang hiện tại, không phải tải lại trang như thao tác ở các phần trước.

V. Cải thiện UX

Giao diện hiện tại vẫn chưa thật sự thân thiện: khi bấm upload hay delete, phải đợi 1-2 giây mới nhận lại được phản hồi từ trình duyệt, dễ khiến người dùng bối rối không biết hệ thống có đang xử lý đúng không.

Để khắc phục, chúng ta sử dụng tính năng Loading Indicator của Livewire 3. Cần bổ sung 2 phần khai báo wire:loading vào vị trí tương ứng của form upload và button delete

<div wire:loading wire:target="save">Uploading...</div>
<div
wire:loading
wire:target="destroy({{ $upload->id }})"
class="text-sm text-gray-500"
>
Deleting...
</div>

Ngoài ra, cần bổ sung thêm attribute wire:loading.attr="disabled" vào tag inputbutton để ngăn người dùng chọn file / xóa file trong khi hệ thống đang xử lý yêu cầu trước đó.

Tương tự như FilePond, Livewire 3 cũng hỗ trợ tính năng hiển thị ảnh thumbnail sau khi chọn file bằng cách sử dụng Temporary URL.

@if ($files)
@foreach ($files as $file)
<img src="{{ $file->temporaryUrl() }}">
@endforeach
@endif

Toàn bộ file file-upload.blade.php giờ sẽ như sau

<div>
{{-- Upload File Form --}}
<div class="container mx-auto mt-10 p-6 bg-white rounded-lg shadow-md max-w-md">
<h1 class="text-2xl font-bold mb-6 text-center text-gray-700">Upload File</h1>
<form wire:submit.prevent="save">
<div class="mb-4">
@if ($files)
@foreach ($files as $file)
<img src="{{ $file->temporaryUrl() }}">
@endforeach
@endif
</div>
<div class="mb-4">
<input type="file" wire:model="files" wire:loading.attr="disabled" multiple>
</div>
<div wire:loading wire:target="save">Uploading...</div>
<div class="mb-4">
@error("files.*")
<p class="text-red-500 text-xs mt-2">{{ $message }}</p>
@enderror
</div>
<button type="submit"
class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
Upload
</button>
</form>
</div>
{{-- Display Success Message --}}
@if (session("success"))
<div
class="container mx-auto mt-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg shadow-md max-w-md">
<p class="font-bold">Success!</p>
<p>{{ session("success") }}</p>
</div>
@endif
{{-- Display Uploaded File Info --}}
@if (count($uploads) > 0)
<div class="container mx-auto mt-10 p-10 bg-white rounded-lg shadow-md max-w-md">
<h2 class="text-xl font-semibold mb-4 text-gray-700">Previously Uploaded Files:</h2>
@foreach ($uploads as $upload)
<ul>
<li class="flex items-center justify-between mb-4">
<a class="flex items-center gap-4 py-2" href="{{ $upload->url }}" target="_blank">
<img src="{{ $upload->thumbnail_url }}" alt="{{ $upload->original_filename }}"
width="50" height="50">
<span
class="text-sm text-gray-700 hover:text-blue-600">{{ $upload->original_filename }}</span>
</a>
<button type="button"
onclick="if (confirm('Are you sure you want to delete this file?')) { @this.destroy({{ $upload->id }}) }"
wire:loading.attr="disabled"
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">
Delete
</button>
<div wire:loading wire:target="destroy({{ $upload->id }})" class="text-gray-500 text-sm">
Deleting...
</div>
</li>
</ul>
@endforeach
</div>
@endif
</div>

Form upload bằng Livewire 3 giờ đã trực quan và thân thiện hơn hẳn so với trước đó.

VI. Upload ngay sau khi chọn file

Chúng ta có thể upload file ngay sau khi chọn file mà không cần phải bấm nút Upload bằng cách sửa lại file file-upload.blade.php

{{-- Upload File Form --}}
<div class="container mx-auto mt-10 max-w-md rounded-lg bg-white p-6 shadow-md">
<h1 class="mb-6 text-center text-2xl font-bold text-gray-700">Upload File</h1>
<div class="mb-4">
<input
type="file"
wire:model="files"
wire:loading.attr="disabled"
multiple
/>
</div>
<div wire:loading wire:target="files">Uploading...</div>
<div class="mb-4">
@error("files.*")
<p class="mt-2 text-xs text-red-500">{{ $message }}</p>
@enderror
</div>
</div>

Bên file class FileUpload.php đổi tên hàm save thành updatedFiles, nội dung giữ như cũ

public function updatedFiles()
{
$this->validate();
$uploadedFiles = $this->files;
$numberOfFiles = count($uploadedFiles); // Đếm số lượng file đã upload
foreach ($uploadedFiles as $file) {
// Lấy tên file gốc từ client
$originalFilename = $file->getClientOriginalName();
$originalFilenames[] = $originalFilename; // Thêm tên gốc vào array
// 3. Tạo bản ghi trong database cho model Upload:
// Lưu ý: Chúng ta chỉ cần lưu 'original_filename'.
// Các thông tin về đường dẫn file gốc và thumbnail sẽ do media-library quản lý.
$uploadEntry = Upload::create([
'original_filename' => $originalFilename,
]);
// 4. Đây là phần quan trọng nhất - Thêm file vào Media Library:
$uploadEntry->addMedia($file) // Thêm file vào Media Library
->toMediaCollection('images'); // Thêm file vào collection 'images'
}
$this->updatePersistedUploads();
$this->reset('files');
session()->flash('success', 'You have successfully uploaded ' . $numberOfFiles . ' files');
}

Hàm updatedFiles sẽ tự động được thực thi mỗi khi thuộc tính files được cập nhật - updated (khi người dùng chọn file).

VI. Lời kết

Sau gần 3 tuần chinh chiến, mình đã hoàn thành 10 phần khám phá cách xử lý file upload trong Laravel – bắt đầu từ các khái niệm cơ bản cho đến các kỹ thuật nâng cao như sử dụng Amazon S3, tạo thumbnail, tích hợp Spatie Media Library, FilePond và Livewire 3.

Trong phần cuối này, nhờ sự góp mặt của Livewire 3, tính năng upload đã được nâng cấp thân thiện hơn:

🔗 Mã nguồn

Tham khảo mã nguồn sử dụng trong [Phần 10] ở đây: https://github.com/10h30/laravel-file-upload-series/tree/part-10-livewire-upload


Mục đích của series này là để mình ôn lại kiến thức Laravel và học thêm những công cụ mới như Spatie Media Library, Livewire,.. Việc viết blog hướng dẫn vừa giúp mình hiểu sâu hơn, vừa có thể chia sẻ lại cho những bạn mới đang tìm hiểu về Laravel giống mình.

Trong tương lai, mình dự định sẽ thực hiện thêm nhiều series khác về Laravel và các công nghệ liên quan như Livewire, Alpine.js, Inertia, … Nếu bạn thấy hứng thú, hãy theo dõi blog thuanbui.me để không bỏ lỡ các bài viết tiếp theo nhé!

Cảm ơn bạn đã đồng hành đến cuối series!


Share this post on:

Previous Post
Truy cập dịch vụ mạng nội bộ qua internet với Tailscale và Caddy (bypass CG-NAT / MAP-E)
Next Post
File Upload trong Laravel - [Phần 9] Nâng cấp giao diện upload file với FilePond