Toidicode.com

Toidicode.com

BASIC TO ADVANCE

Bài 20: Upload file và Storage trong Laravel 13

Ở Bài 8 mình có hẹn phần upload file. Tiếp tục với series, bài này mình sẽ giới thiệu với mọi người về upload file và storage trong Laravel 13, rồi làm chức năng đính kèm file cho task của TaskFlow.

1. Filesystem và disk.

Laravel đọc ghi file qua Flysystem, code như nhau dù file nằm trên server hay S3. Mỗi nơi lưu là một disk (driver và thư mục gốc), khai báo trong config/filesystems.php:

- config/filesystems.php (trích)

'default' => env('FILESYSTEM_DISK', 'local'),

'disks' => [
    'local' => [
        'driver' => 'local',
        'root' => storage_path('app/private'),
    ],
    'public' => [
        'driver' => 'local',
        'root' => storage_path('app/public'),
        'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
    ],
],

Trong đó:

  • local: disk mặc định, trình duyệt không truy cập được.
  • public: cho file ai cũng xem được như avatar.
  • s3 (có sẵn trong file): cài thêm league/flysystem-aws-s3-v3, đặt FILESYSTEM_DISK=s3.

2. Storage facade.

Facade Storage đọc ghi file trên disk mặc định, hoặc disk khác qua disk(), đường dẫn tính từ thư mục gốc của disk. Thử trong php artisan tinker:

Storage::put('notes/hello.txt', 'Xin chào TaskFlow'); // true, lưu ở storage/app/private/notes/hello.txt
Storage::get('notes/hello.txt'); // "Xin chào TaskFlow"
Storage::delete('notes/hello.txt'); // true

Storage::disk('public')->put('demo/logo.txt', 'logo'); // true
Storage::disk('public')->url('demo/logo.txt'); // "http://localhost:8000/storage/demo/logo.txt"

Web server chỉ phục vụ thư mục public, nên URL trên cần symbolic link public/storage trỏ tới storage/app/public (chạy một lần trên mỗi máy):

php artisan storage:link
# INFO  The [public/storage] link has been connected to [storage/app/public].

3. Upload và validate file.

Form upload cần enctype="multipart/form-data", controller lấy file bằng $request->file('tên') (object UploadedFile). VD file anh.png nhưng nội dung là chữ:

$file = $request->file('file');
$file->getClientOriginalName(); // "anh.png", tên trình duyệt gửi lên
$file->extension(); // "txt", đoán từ nội dung
$file->getSize(); // 17 (byte)

Lưu file bằng store($thuMuc, $disk) (tên ngẫu nhiên 40 ký tự + đuôi đoán từ nội dung) hoặc storeAs($thuMuc, $ten, $disk), bỏ trống $disk thì dùng disk mặc định. Cả hai trả về đường dẫn file trên disk để lưu vào database.

Chú ý: Đừng dùng tên file người dùng gửi lên cho storeAs(). Kẻ xấu đặt tên shell.php, nếu file nằm trong thư mục web thì nó sẽ chạy như code PHP. Tên gốc chỉ lưu vào database để hiển thị.

Các rule validate file (Bài 10):

  • file: phải là file upload thành công. image: phải là ảnh (không gồm SVG).
  • mimes:jpg,pdf: kiểu file đoán từ nội dung, không tin MIME type trình duyệt gửi. extensions:jpg,pdf kiểm tra đuôi tên file, nên dùng kèm mimes.
  • max:1024: với file đơn vị là KB.

Lưu ý: PHP còn giới hạn upload_max_filesize (mặc định 2M) trong php.ini, file lớn hơn bị bỏ trước khi tới rule max và Laravel báo lỗi rule uploaded.

4. Bảng attachments cho TaskFlow.

Mỗi task có nhiều file, mình tạo model Attachment kèm migration và controller:

php artisan make:model Attachment -m
php artisan make:controller AttachmentController

- database/migrations/xxxx_create_attachments_table.php (phần up())

Schema::create('attachments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('task_id')->constrained()->cascadeOnDelete();
    // đường dẫn trên disk, tên gốc để hiển thị, dung lượng (byte)
    $table->string('path');
    $table->string('original_name');
    $table->unsignedBigInteger('size');
    $table->timestamps();
});

Chạy php artisan migrate, rồi khai báo quan hệ như Bài 17:

- app/Models/Attachment.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

#[Fillable(['path', 'original_name', 'size'])]
class Attachment extends Model
{
    /**
     * Task chứa file đính kèm.
     */
    public function task(): BelongsTo
    {
        return $this->belongsTo(Task::class);
    }
}

Model Task thêm method attachments(): HasMany trả về $this->hasMany(Attachment::class).

- routes/web.php (thêm use App\Http\Controllers\AttachmentController;)

Route::get('/tasks/{task}/attachments', [AttachmentController::class, 'index'])
    ->name('tasks.attachments.index');
Route::post('/tasks/{task}/attachments', [AttachmentController::class, 'store'])
    ->name('tasks.attachments.store');
Route::get('/attachments/{attachment}', [AttachmentController::class, 'show'])
    ->name('attachments.show');
Route::delete('/attachments/{attachment}', [AttachmentController::class, 'destroy'])
    ->name('attachments.destroy');

5. Upload, tải về và xóa file.

- app/Http/Controllers/AttachmentController.php

<?php

namespace App\Http\Controllers;

use App\Models\Attachment;
use App\Models\Task;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;

class AttachmentController extends Controller
{
    public function index(Task $task): View
    {
        $attachments = $task->attachments()->latest('id')->get();

        return view('attachments.index', compact('task', 'attachments'));
    }

    public function store(Request $request, Task $task): RedirectResponse
    {
        $request->validate([
            'file' => ['required', 'file', 'extensions:jpg,png,pdf,txt', 'mimes:jpg,png,pdf,txt', 'max:1024'],
        ]);

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

        // disk mặc định local (private), tên ngẫu nhiên: attachments/1/xxxx.pdf
        $path = $file->store('attachments/'.$task->id);

        $task->attachments()->create([
            'path' => $path,
            'original_name' => $file->getClientOriginalName(),
            'size' => $file->getSize(),
        ]);

        return back()->with('status', 'Đã tải lên '.$file->getClientOriginalName().'.');
    }

    public function show(Attachment $attachment): StreamedResponse
    {
        abort_unless(Storage::exists($attachment->path), 404, 'File không còn trên server.');

        return Storage::download($attachment->path, $attachment->original_name);
    }

    public function destroy(Attachment $attachment): RedirectResponse
    {
        Storage::delete($attachment->path);
        $attachment->delete();

        return back()->with('status', 'Đã xóa '.$attachment->original_name.'.');
    }
}

File nằm trên disk local nên không mở thẳng bằng URL được, phải tải qua route attachments.show. Khi xóa nhớ xóa cả file trên disk.

Chú ý: TaskFlow chưa có đăng nhập và phân quyền: ai cũng upload được vào mọi task, đổi id trong /attachments/{id} là tải về hoặc xóa được file bất kỳ. Bài 21 thêm đăng nhập, Bài 22 kiểm tra quyền ở store, show, destroy, đừng đưa code này lên server thật trước đó.

Form HTML chỉ gửi GET, POST nên nút xóa dùng @method('DELETE') để Laravel hiểu là request DELETE:

- resources/views/attachments/index.blade.php

<x-layout :title="'File của task '.$task->title">
    <h1 class="mb-4 text-2xl font-bold">File đính kèm: {{ $task->title }}</h1>

    <form method="POST" action="{{ route('tasks.attachments.store', $task) }}" enctype="multipart/form-data" class="mb-6">
        @csrf
        <input type="file" name="file">
        <button type="submit" class="rounded bg-blue-600 px-4 py-2 text-white">Tải lên</button>
        @error('file')
            <p class="mt-1 text-sm text-red-600">{{ $message }}</p>
        @enderror
    </form>

    <ul class="list-disc pl-6">
        @forelse ($attachments as $attachment)
            <li>
                <a href="{{ route('attachments.show', $attachment) }}" class="text-blue-600">{{ $attachment->original_name }}</a>
                ({{ max(1, round($attachment->size / 1024)) }} KB)
                <form method="POST" action="{{ route('attachments.destroy', $attachment) }}">
                    @csrf
                    @method('DELETE')
                    <button type="submit" class="text-sm text-red-600">Xóa</button>
                </form>
            </li>
        @empty
            <li>Chưa có file nào.</li>
        @endforelse
    </ul>
</x-layout>

Mỗi task ở trang project có thêm link <a href="{{ route('tasks.attachments.index', $task) }}">File ({{ $task->attachments_count }})</a> (query task thêm ->withCount('attachments')). Thông báo tiếng Việt cho các rule file (thiếu thì hiện câu tiếng Anh):

- lang/vi/validation.php (trích)

'file' => 'Trường :attribute phải là một file.',
'extensions' => 'Trường :attribute phải có đuôi: :values.',
'mimes' => 'Trường :attribute phải là file kiểu: :values.',
'uploaded' => 'Không tải lên được :attribute, file có thể quá lớn.',
'max' => [
    // 'string' có sẵn giữ nguyên
    'file' => 'Trường :attribute không được lớn hơn :max KB.',
],

Kết quả ở /tasks/1/attachments:

  • de-bai.pdf lưu thành attachments/1/<40 ký tự>.pdf, click thì tải về đúng tên gốc.
  • shell.png chứa code PHP: "Trường file phải là file kiểu: jpg, png, pdf, txt.".
  • File 1,5 MB: "Trường file không được lớn hơn 1024 KB.", file 3 MB: "Không tải lên được file, file có thể quá lớn.".
  • Mở thẳng /storage/attachments/1/... bị chặn (403).

Lưu ý: cascadeOnDelete() chỉ xóa bản ghi, file vẫn còn trên disk, việc này sẽ xử lý bằng model event ở Bài 26.

6. Lời kết.

Như vậy chúng ta đã tìm hiểu xong upload file và storage trong Laravel 13. Các bạn xem thêm tại tài liệu chính thức. Bài sau mình sẽ giới thiệu về xác thực người dùng trong Laravel 13 (đăng ký, đăng nhập, đăng xuất).

Đăng ký nhận tin.

Chúng tôi chỉ gửi tối đa 2 lần trên 1 tháng. Tuyên bố không spam mail!

Vũ Thanh Tài

About author
The best way to learn is to share
Xem tất cả bài đăng

0 Comments

Bài viết chưa có ai bình luận, hãy là người đầu tiên đi bạn!

Bình luận

Captcha