To upload multiple files in Laravel 13, add the multiple attribute to a file input named files[], validate the array with files and files.* rules, then loop over $request->file('files') and call store() on each file. Save the stored paths in the database so you can find them later.
This tutorial targets Laravel 13 (PHP 8.3 to 8.5). The code was written for this article and not executed in a live project, so test it with small files first.
What do you need before you start?
- A Laravel 13 app with a database connection.
- PHP upload limits that fit your files:
upload_max_filesize,post_max_sizeandmax_file_uploadsinphp.ini. - For public downloads, run
php artisan storage:linkonce.
Step 1: How do you create the table and model?
php artisan make:model UploadedFile -mEdit the migration to store the details you need:
Schema::create('uploaded_files', function (Blueprint $table) {
$table->id();
$table->string('original_name');
$table->string('path');
$table->string('mime_type')->nullable();
$table->unsignedBigInteger('size');
$table->timestamps();
});Then allow mass assignment in the model and run php artisan migrate:
class UploadedFile extends Model
{
protected $fillable = ['original_name', 'path', 'mime_type', 'size'];
}Step 2: Which routes do you add?
use App\Http\Controllers\FileUploadController;
use Illuminate\Support\Facades\Route;
Route::get('/upload', [FileUploadController::class, 'create'])->name('upload.create');
Route::post('/upload', [FileUploadController::class, 'store'])->name('upload.store');Step 3: How do you validate and store the files?
Create php artisan make:controller FileUploadController. Validate the array itself and each item inside it.
namespace App\Http\Controllers;
use App\Models\UploadedFile;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class FileUploadController extends Controller
{
public function create()
{
return view('upload', ['files' => UploadedFile::latest()->get()]);
}
public function store(Request $request): RedirectResponse
{
$request->validate([
'files' => ['required', 'array', 'min:1', 'max:5'],
'files.*' => ['file', 'mimes:jpg,png,pdf,docx', 'max:2048'],
]);
foreach ($request->file('files') as $file) {
$path = $file->store('uploads', 'public');
UploadedFile::create([
'original_name' => $file->getClientOriginalName(),
'path' => $path,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
]);
}
return back()->with('success', 'Files uploaded.');
}
}The max rule on a file is measured in kilobytes, so 2048 means 2 MB per file. store() generates a random file name, which avoids collisions and path tricks. Keep getClientOriginalName() only as display text; never use it as the stored file name.
Step 4: How do you build the upload form?
The form needs enctype="multipart/form-data" and an input named with square brackets.
<form action="{{ route('upload.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<input type="file" name="files[]" multiple required>
@error('files') <p>{{ $message }}</p> @enderror
@error('files.*') <p>{{ $message }}</p> @enderror
@if (session('success')) <p>{{ session('success') }}</p> @endif
<button type="submit">Upload</button>
</form>
<ul>
@foreach ($files as $file)
<li><a href="{{ Storage::url($file->path) }}">{{ $file->original_name }}</a></li>
@endforeach
</ul>The @error('files.*') directive shows messages for any invalid item in the array.
How do you customise error messages for each file?
Pass a messages array as the second argument of validate():
$request->validate([
'files.*' => ['file', 'mimes:jpg,png,pdf,docx', 'max:2048'],
], [
'files.*.mimes' => 'Each file must be a JPG, PNG, PDF or DOCX.',
'files.*.max' => 'Each file may not be larger than 2 MB.',
]);How do you store files privately?
The public disk exposes files by URL. For private documents, store them on the default local disk with $file->store('private-uploads') and serve them through a controller that checks permissions, returning Storage::download($path). Never rely on an unguessable URL as the only protection.
Common mistakes
- Forgetting the brackets.
name="files"sends one file;name="files[]"sends an array. - Missing
enctype. Withoutmultipart/form-datathe request has no files at all. - Validating only
files. The array rule does not check each file. Addfiles.*. - Hitting PHP limits. If the validation passes nothing and
$request->file('files')is empty, checkpost_max_sizeandmax_file_uploads. - Trusting the client's extension. The
mimesrule checks the detected content type, but still validate on the server and scan uploads if users are untrusted. - Forgetting
storage:link. Public URLs return 404 until the symlink exists.
How do you verify it works?
- Open
/upload, select two or three small files and submit. - Check that the files exist under
storage/app/public/uploads. - Check the
uploaded_filestable has one row per file. - Try a file with the wrong type and a sixth file, and confirm validation errors appear.
For an automated check, use UploadedFile::fake() from Illuminate\Http\UploadedFile with Storage::fake('public'), post an array of fake files and assert them with Storage::disk('public')->assertExists().
FAQ
How do I upload many large files?
Raise the PHP limits, or use chunked uploads or direct-to-S3 uploads with pre-signed URLs.
Can I delete a file later?
Yes: Storage::disk('public')->delete($file->path) and then delete the database row.
How do I upload with AJAX?
Build a FormData object, append each file under files[] and send it with fetch(), including the CSRF token header.



