Handling an Upload

Validate the file, then let store() write it under a generated name, because the client's filename is input.

Syntax$request->file('avatar')->store(string $path = '', array|string $options = []): string|false

An upload arrives as an Illuminate\Http\UploadedFile: Symfony's uploaded-file class with Laravel's storage methods added. Fetch it with $request->file('avatar'), and test $request->hasFile('avatar') first, because a form posted without a file gives you null.

Validate before you store. file asserts that an upload succeeded. mimes:pdf,jpg compares the extension PHP guesses from the bytes. extensions:pdf compares the extension the client typed. They check different things, so they are usually written together. max:2048 is kilobytes.

Two ways to write the same rules

['file', 'mimes:pdf', 'max:2048'] and [File::types(['pdf'])->max('2mb')] produce the same checks. The rule object's max() accepts kb, mb, gb and tb suffixes and converts at 1000 each, so '2mb' is 2000 kilobytes, not 2048.

The filename is input

getClientOriginalName() is a string the browser sent. It can contain ../, a null byte, or .php. store() never uses it. It calls hashName(), which is Str::random(40) plus an extension guessed from the file's own bytes. Keep the original name in a database column for the download header and nowhere else.

store('avatars', 'public') returns the stored path relative to the disk, or false. Save that return value. It is the only handle you have on the file.

Example

Example · php
<?php
// app/Http/Requests/StoreAvatarRequest.php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;

final class StoreAvatarRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;
    }

    /**
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            // Longhand, as a rule string:
            //   'avatar' => ['required', 'image', 'max:2048'],
            //
            // The rule object below is the same three checks. Note that
            // max('2mb') is 2000 kilobytes, so it is NOT identical to
            // max:2048 -- the suffixes convert at 1000, not 1024.
            //
            // Since Laravel 12 the `image` rule rejects SVG unless you pass
            // File::image(allowSvg: true), because an SVG is a document that
            // can carry script.
            'avatar' => [
                'required',
                File::image()
                    ->max('2mb')
                    ->dimensions(Rule::dimensions()->maxWidth(4000)->maxHeight(4000)),
            ],
        ];
    }
}

// app/Http/Controllers/AvatarController.php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Http\Requests\StoreAvatarRequest;
use Illuminate\Http\RedirectResponse;

final class AvatarController extends Controller
{
    public function store(StoreAvatarRequest $request): RedirectResponse
    {
        $user = $request->user();

        // store() returns the path relative to the disk root, or false. The
        // filename is Str::random(40) plus an extension guessed from the
        // bytes, so nothing the browser sent reaches the filesystem.
        $path = $request->file('avatar')->store("avatars/{$user->id}", 'public');

        // Longhand for the identical write, through the disk instead:
        //   $path = Storage::disk('public')->putFile(
        //       "avatars/{$user->id}", $request->file('avatar')
        //   );

        $user->update([
            'avatar_path' => $path,
            // Kept only so a download can offer a friendly filename later.
            'avatar_name' => $request->file('avatar')->getClientOriginalName(),
        ]);

        return back()->with('status', 'Avatar updated.');
    }
}

When to use it

  • Accepting a profile photo and storing it under a name the user cannot choose.
  • Limiting a ticket form to five attachments of ten megabytes each.
  • Recording the original filename so a later download can offer it in the Content-Disposition header.
  • Importing a CSV from a mounted directory with the same hashed-name rules as a web upload.

More examples

Why the generated name is the safe one

Run this when someone proposes storing uploads under the client's filename. It shows that basename() leaves the dangerous extension in place and that the framework's generated name shares nothing with the input.

Example · php
<?php

declare(strict_types=1);

use Illuminate\Support\Str;

// What a browser can send as the filename. It is text, not a path.
$clientName = '../../public/avatar.php';

// hashName() builds Str::random(40) plus an extension guessed from the file's
// bytes. Forty characters of randomness, none of them chosen by the client.
echo Str::length(Str::random(40)), PHP_EOL;

// If you want to keep the original name, keep it as a label.
echo Str::slug(pathinfo($clientName, PATHINFO_FILENAME)), PHP_EOL;

// basename() strips the directory traversal but not the extension, which is
// why basename() alone is not a sanitiser.
echo basename($clientName), PHP_EOL;

// max:2048 is kilobytes, so this is the real limit it expresses in bytes.
echo (int) round(2_097_152 / 1024), PHP_EOL;

// Output:
// 40
// avatar
// avatar.php
// 2048

Several files from one field

Use this shape for a ticket or message form that accepts a handful of files. The nested attachments.* key is how a rule applies to each member, and it is also the key the error bag uses, so the message lands on the right input.

Example · php
<?php
// app/Http/Requests/StoreAttachmentsRequest.php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\File;

final class StoreAttachmentsRequest extends FormRequest
{
    /**
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            // The input is <input type="file" name="attachments[]" multiple>
            // so the field itself is an array and each member is a file.
            'attachments' => ['required', 'array', 'max:5'],
            'attachments.*' => [
                File::types(['pdf', 'png', 'jpg'])->max('10mb'),
            ],
        ];
    }
}

// In the controller: $request->file('attachments') is an array of
// UploadedFile, so storing them is an ordinary loop. Collect the returned
// paths, because each store() call is the only place the name is decided.
//
// $paths = collect($request->file('attachments'))
//     ->map(fn (UploadedFile $file): string => $file->store('attachments', 'local'))
//     ->all();
//
// Note that 'max:5' on the array means five items, while max('10mb') on each
// member means size. The same rule name measures different things depending
// on what it is given.

Storing a file that did not come from a form

Reach for Illuminate\Http\File when the bytes come from a command, a cron job or another service rather than a request. It gives you the same hashed-name behaviour as an upload without having to fake an UploadedFile.

Example · php
<?php
// app/Console/Commands/ImportReport.php

declare(strict_types=1);

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Http\File;
use Illuminate\Support\Facades\Storage;

final class ImportReport extends Command
{
    protected $signature = 'reports:import {path}';

    public function handle(): int
    {
        $local = $this->argument('path');

        // Illuminate\Http\File wraps a file already on disk. putFile() then
        // treats it exactly like an upload, including the generated name.
        $stored = Storage::disk('invoices')->putFile('reports', new File($local));

        // putFileAs() is the same call with the name supplied by you:
        //   Storage::disk('invoices')->putFileAs('reports', new File($local), 'q1.csv');
        //
        // In fact putFile($path, $file) IS putFileAs($path, $file, $file->hashName()).

        $this->info($stored === false ? 'Write failed.' : "Stored at {$stored}.");

        return $stored === false ? self::FAILURE : self::SUCCESS;
    }
}

Discussion

  • Be the first to comment on this lesson.