Validating Files and Images in Laravel

Every file upload endpoint is a potential attack surface. Users will send oversized files, wrong formats, executable scripts disguised as images, and corrupted data. Laravel ships with a fluent File rule builder that handles most of these scenarios without writing custom validation logic.

What follows: size limits, MIME types, image dimensions, PDF and CSV handling, multi-file uploads, and the common mistakes that catch most developers at least once.

The File Rule Builder

String-based rules like 'file|mimes:jpg,png|max:2048' work, but the File rule builder offers better readability and IDE autocompletion:

use Illuminate\Validation\Rules\File;

public function rules(): array
{
    return [
        'document' => [
            'required',
            File::types(['pdf', 'docx'])
                ->min('10kb')
                ->max('25mb'),
        ],
    ];
}

The builder accepts size values as strings with units (kb, mb, gb, tb) or as plain integers representing kilobytes. The suffixes are decimal: '1mb' equals 1000 kilobytes, not 1024. So ->min(1024) and ->min('1mb') are not the same minimum (1024 KB vs 1000 KB). Pick one form and stick with it; mixing them inside the same project is the easiest way to ship inconsistent limits.

Internally, File::types() validates MIME types by reading file contents, not by trusting the client-provided extension. A .txt file containing valid PNG binary data would pass File::types(['png']), because the check looks at the actual bytes, not the filename.

The builder methods can be chained in any order. These two are identical:

File::types(['pdf'])->min('10kb')->max('5mb')

File::types(['pdf'])->max('5mb')->min('10kb')

For image uploads, use File::image() instead of File::types(). It constrains the file to jpg, jpeg, png, bmp, gif, and webp, and exposes extra methods like dimensions():

File::image()
    ->max('5mb')
    ->dimensions(Rule::dimensions()->maxWidth(2000)->maxHeight(2000))

File Size Limits

For files, the size, min, and max rules operate in kilobytes. This trips up many developers who expect bytes or megabytes:

// Wrong: this allows only 5 KB, not 5 MB
'attachment' => 'file|max:5'

// 5000 KB (matches the File builder's decimal '5mb' = 5000 KB)
'attachment' => 'file|max:5000'

// 5120 KB if you treat MB as 1024 KB (binary)
'attachment' => 'file|max:5120'

The plain max: rule takes a raw kilobyte count - no unit suffix, no conversion - so the “MB” number you write here is whatever you choose to call MB. The mismatch only bites if you mix string rules (max:5120) with the File builder (->max('5mb')) in the same project and expect them to agree.

The File builder accepts human-readable strings, which eliminates this confusion:

use Illuminate\Validation\Rules\File;

// 5 MB max upload
File::types(['pdf', 'doc', 'docx'])->max('5mb')

// Between 100 KB and 10 MB
File::types(['zip'])->min('100kb')->max('10mb')

The php.ini Ceiling

Keep in mind that php.ini has its own limits via upload_max_filesize and post_max_size. If upload_max_filesize is 2M and your validation allows 50mb, PHP rejects the file before Laravel even sees it. The upload arrives as null, and validation fails with a generic “required” error rather than a size-related message.

Catch this scenario by customizing the error message in your Form Request:

use Illuminate\Validation\Rules\File;

public function rules(): array
{
    return [
        'report' => [
            'required',
            File::types(['pdf'])->max('20mb'),
        ],
    ];
}

public function messages(): array
{
    return [
        'report.required' => 'Upload failed. Check that the file is under '
            . ini_get('upload_max_filesize') . '.',
    ];
}

If you control the server, align these values. For a 20 MB upload limit, set upload_max_filesize = 25M and post_max_size = 30M in php.ini (slightly higher to account for multipart form overhead). On Nginx, also set client_max_body_size 30M; in the server block.

Exact File Size Validation

The size rule checks for an exact file size in kilobytes. Rarely useful for general uploads, but it makes sense for firmware updates, fixed-format binary files, or test fixtures where the file size is a known constant:

// File must be exactly 512 KB
'firmware' => 'file|size:512'

File Size Validation for Different Contexts

Different parts of your application may need different limits. Product images might allow 8 MB while user avatars cap at 2 MB. Rather than scattering magic numbers, define constants or configuration values:

// config/uploads.php
return [
    'avatar_max_kb' => 2048,
    'product_image_max_kb' => 8192,
    'document_max_kb' => 20480,
];
use Illuminate\Validation\Rules\File;

'avatar' => [
    'required',
    File::image()->max(config('uploads.avatar_max_kb')),
],

This keeps validation rules in sync with any client-side upload hints you show in the UI.

File Types and Extensions

Laravel provides three separate mechanisms for checking what kind of file was uploaded. Each solves a different problem, and mixing them up is one of the most common sources of file validation bugs.

mimes rule reads the file’s binary content and guesses its MIME type, then matches against extensions you list:

// Validates actual content, ignores the filename extension
'photo' => 'file|mimes:jpg,png,webp'

A file named virus.php with valid JPEG binary data would pass mimes:jpg. A PHP script renamed to photo.jpg would fail. The match is based on what the file contains, not what it claims to be.

mimetypes rule matches raw MIME type strings directly and supports wildcards:

// Accept any video format
'clip' => 'file|mimetypes:video/*'

// Accept specific spreadsheet types
'spreadsheet' => 'file|mimetypes:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv'

Use mimetypes when you need wildcard matching or when working with MIME types that don’t map cleanly to file extensions. Office documents are a common pain point here: .xlsx files carry the MIME type application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, and .docx uses application/vnd.openxmlformats-officedocument.wordprocessingml.document. Writing mimes:xlsx works because Laravel maps the extension to the full MIME string internally, but if you use mimetypes, you need the full string.

extensions rule checks only the user-assigned file extension:

'invoice' => ['file', 'extensions:pdf,docx']

Never rely on extensions alone. A malicious user can rename any file. Pair it with mimes or mimetypes for content verification:

'contract' => [
    'required',
    'extensions:pdf',
    File::types(['pdf'])->max('15mb'),
],

When mimes and extensions Disagree

The mimes rule does not verify that the file extension matches its content. A PNG file named photo.txt passes mimes:png because the content is valid PNG data. If you need both the extension and the content to agree, add both rules:

'avatar' => [
    'required',
    'extensions:jpg,png',
    'mimes:jpg,png',
    File::image()->max('2mb'),
],

This rejects a valid JPEG named avatar.gif (wrong extension) and a GIF renamed to avatar.jpg (content mismatch).

In practice, extension/content mismatches happen more often than you’d expect. Image editing tools sometimes save files with the wrong extension, and users rename files before uploading. Decide whether your application should be strict (reject mismatches) or lenient (trust content, ignore extension). For public-facing forms, trusting the content via mimes is usually the right call. For internal tools where file naming conventions matter, add extensions too.

PDF Uploads

PDF validation requires care because PDFs can embed JavaScript, forms, and even executables. Laravel validates the MIME type but does not scan file content for threats.

A basic Form Request for PDF uploads:

use Illuminate\Validation\Rules\File;

public function rules(): array
{
    return [
        'invoice' => [
            'required',
            File::types(['pdf'])->max('10mb'),
        ],
    ];
}

Enforce that the uploaded file is genuinely a PDF by combining content validation with extension checking:

'document' => [
    'required',
    'extensions:pdf',
    File::types(['pdf'])->max('10mb'),
],

For systems handling untrusted PDFs (user-submitted contracts, scanned documents, public uploads), consider a post-validation pipeline. Strip the embedded JavaScript with a tool like Ghostscript, or re-render to a flat PDF. This belongs in a queued job after the upload, not in the validation layer itself.

A custom rule that limits the number of pages in an uploaded PDF using the smalot/pdfparser library:

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Smalot\PdfParser\Parser;

class MaxPdfPages implements ValidationRule
{
    public function __construct(
        private int $maxPages
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (null === $value || false === $value->isValid()) {
            return;
        }

        try {
            $parser = new Parser();
            $pdf = $parser->parseFile($value->getRealPath());
            $pageCount = count($pdf->getPages());

            if ($pageCount > $this->maxPages) {
                $fail("The :attribute must not exceed {$this->maxPages} pages (found {$pageCount}).");
            }
        } catch (\Exception) {
            $fail('The :attribute could not be parsed as a valid PDF.');
        }
    }
}

Usage:

use App\Rules\MaxPdfPages;
use Illuminate\Validation\Rules\File;

'contract' => [
    'required',
    File::types(['pdf'])->max('25mb'),
    new MaxPdfPages(50),
],

For more on building rule classes, see Custom Validation Rules.

CSV Files

CSV files present a unique challenge: their MIME type varies across browsers and operating systems. Some clients send text/csv, others text/plain, and Windows might produce application/vnd.ms-excel for .csv files.

use Illuminate\Validation\Rules\File;

public function rules(): array
{
    return [
        'import_file' => [
            'required',
            File::types(['csv', 'txt'])
                ->max('5mb'),
        ],
    ];
}

Accepting txt alongside csv handles the text/plain MIME type that some browsers send. To restrict strictly to CSV, validate the extension:

'import_file' => [
    'required',
    'extensions:csv',
    File::types(['csv', 'txt'])->max('5mb'),
],

Validating CSV Structure

Checking the MIME type tells you the file looks like a CSV, but not whether it has the columns your import expects. Validate the structure after the file passes basic validation:

public function store(Request $request): RedirectResponse
{
    $request->validate([
        'import_file' => [
            'required',
            'extensions:csv',
            File::types(['csv', 'txt'])->max('5mb'),
        ],
    ]);

    $path = $request->file('import_file')->getRealPath();
    $handle = fopen($path, 'r');
    $header = fgetcsv($handle);
    fclose($handle);

    $expected = ['email', 'name', 'role'];

    if (array_diff($expected, $header)) {
        return back()->withErrors([
            'import_file' => 'CSV must contain columns: ' . implode(', ', $expected),
        ]);
    }

    ImportUsersJob::dispatch($request->file('import_file')->store('imports'));

    return redirect()->route('users.index')
        ->with('status', 'Import started.');
}

Character Encoding for CSV Files

Laravel 13 supports the encoding validation rule, which uses PHP’s mb_check_encoding to verify file contents. This catches Windows-1252 encoded files before they corrupt your UTF-8 database:

'data_file' => [
    'required',
    'extensions:csv',
    File::types(['csv', 'txt'])->max('10mb'),
    'encoding:utf-8',
],

Images and SVG

The image rule accepts jpg, jpeg, png, bmp, gif, and webp. SVG files are rejected by default because they can contain embedded JavaScript and XSS vectors.

use Illuminate\Validation\Rules\File;

// Using the File builder
'avatar' => [
    'required',
    File::image()->max('2mb'),
]

// Using string rules (2048 KB = 2 MB)
'avatar' => 'required|image|max:2048'

To allow SVG uploads for icons, logos, or illustrations where the source is trusted:

'logo' => [
    'required',
    File::image(allowSvg: true)->max('1mb'),
]

// String rule equivalent
'logo' => 'required|image:allow_svg|max:1024'

When accepting SVGs, sanitize them before serving to users. Libraries like enshrined/svg-sanitize strip <script> tags, event handlers, and other dangerous content from SVG markup.

Validating Image MIME Types Precisely

The image rule is broad: it accepts six formats. If your application needs only specific image types, use mimes instead:

// Accept only JPEG and PNG, reject GIF, BMP, WebP
'photo' => 'required|file|mimes:jpg,png|max:5120'

// With the File builder
'photo' => [
    'required',
    File::types(['jpg', 'png'])->max('5mb'),
],

For user-facing photo uploads where WebP support matters, list it explicitly:

'product_image' => [
    'required',
    File::types(['jpg', 'png', 'webp'])->max('8mb'),
],

Image Dimensions: Width and Height Constraints

The dimensions rule validates pixel dimensions. Combine it with Rule::dimensions() for a fluent API. This matters for avatars, banners, product images, and any visual element with layout constraints. The rule relies on PHP’s getimagesize() to read pixel data – if the file isn’t a readable image format, the call returns false and validation fails.

use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;

public function rules(): array
{
    return [
        // Avatar: square, between 200x200 and 1000x1000
        'avatar' => [
            'required',
            File::image()->max('5mb')->dimensions(
                Rule::dimensions()
                    ->minWidth(200)
                    ->minHeight(200)
                    ->maxWidth(1000)
                    ->maxHeight(1000)
            ),
        ],

        // Banner: at least 1200px wide, max 600px tall
        'banner' => [
            'required',
            File::image()->max('10mb')->dimensions(
                Rule::dimensions()
                    ->minWidth(1200)
                    ->maxHeight(600)
            ),
        ],
    ];
}

The string rule syntax also works, though it gets verbose with multiple constraints:

'avatar' => 'required|image|dimensions:min_width=200,min_height=200,max_width=1000,max_height=1000'

Enforcing Aspect Ratio

The ratio constraint accepts either a fraction string or a computed float:

use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;

// 16:9 banner image
'cover' => [
    'required',
    File::image()->dimensions(
        Rule::dimensions()->ratio(16 / 9)
    ),
],

// 1:1 square profile picture
'profile_pic' => [
    'required',
    File::image()->max('3mb')->dimensions(
        Rule::dimensions()->ratio(1 / 1)
    ),
],

The string syntax supports fraction notation:

'cover' => 'required|image|dimensions:ratio=16/9'

The ratio check works with floating-point comparison. An image at 1920x1080 passes ratio(16/9) because both 1920/1080 and 16/9 produce 1.777....

Exact Dimensions

For cases like favicons or social media cards where the image must match precise pixel sizes:

// Open Graph image: exactly 1200x630
'og_image' => [
    'required',
    File::image()->dimensions(
        Rule::dimensions()->width(1200)->height(630)
    ),
],

// Favicon: exactly 32x32
'favicon' => [
    'required',
    File::types(['png', 'ico'])->max('100kb'),
    'dimensions:width=32,height=32',
],

Multiple File Uploads

When a form sends an array of files, validate both the array container and each individual file:

use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;

public function rules(): array
{
    return [
        'photos' => 'required|array|min:1|max:10',
        'photos.*' => [
            File::image()
                ->max('5mb')
                ->dimensions(
                    Rule::dimensions()->maxWidth(4000)->maxHeight(4000)
                ),
        ],
    ];
}

The photos rule validates the array itself: between 1 and 10 items. The photos.* rule applies to each uploaded file individually. Forgetting the array rule on the parent field is a common mistake that causes Laravel to treat the field as a single file input.

Mixed File Types in a Single Field

Some forms accept multiple document types in a single field. Validate each file against the union of allowed types:

public function rules(): array
{
    return [
        'attachments' => 'required|array|max:5',
        'attachments.*' => [
            File::types(['pdf', 'doc', 'docx', 'jpg', 'png'])
                ->max('10mb'),
        ],
    ];
}

If you need to enforce different size limits for images vs documents, move to a custom rule or use after() validation in your Form Request:

public function after(): array
{
    return [
        function ($validator) {
            $files = $this->file('attachments', []);

            foreach ($files as $index => $file) {
                if (null === $file) {
                    continue;
                }

                $isImage = str_starts_with($file->getMimeType(), 'image/');
                $maxKb = $isImage ? 5120 : 15360;

                if ($file->getSize() / 1024 > $maxKb) {
                    $label = $isImage ? '5 MB' : '15 MB';
                    $validator->errors()->add(
                        "attachments.{$index}",
                        "This file must not exceed {$label}."
                    );
                }
            }
        },
    ];
}

Video and Audio File Validation

Laravel does not have a dedicated video or audio rule, but mimetypes with wildcards covers these formats:

// Accept any video format up to 100 MB
'video_clip' => [
    'required',
    'file',
    'mimetypes:video/*',
    'max:102400',
],

// Specific video formats only
'trailer' => [
    'required',
    'file',
    'mimetypes:video/mp4,video/webm,video/quicktime',
    'max:51200',
],

// Audio files
'podcast_episode' => [
    'required',
    File::types(['mp3', 'wav', 'ogg', 'flac'])
        ->min('10kb')
        ->max('200mb'),
],

Video files are typically large, so the php.ini ceiling matters even more here. Set upload_max_filesize and post_max_size high enough, or use chunked upload libraries (like Filepond or Resumable.js) that bypass PHP’s limit entirely by splitting the file into smaller pieces sent as separate requests.

Note that mimetypes:video/* relies on PHP’s finfo extension to detect MIME types from file content. Some obscure video containers may not be recognized correctly. For critical video processing pipelines, validate the MIME type at the Laravel level and run a secondary check with ffprobe after upload to confirm the container format and codecs.

File Uploads in API Endpoints

JSON APIs receive files through multipart/form-data, not JSON. The validation rules stay the same, but error responses differ. When using $request->validate() inside a route that expects JSON (either via Accept: application/json header or by using postJson in tests), Laravel returns validation errors as JSON automatically.

A complete API controller for document uploads:

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Document;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\File;

class DocumentController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'file' => [
                'required',
                File::types(['pdf', 'docx', 'xlsx'])
                    ->max('20mb'),
            ],
        ]);

        $path = $request->file('file')->store('documents', 's3');

        $document = Document::create([
            'title' => $validated['title'],
            'path' => $path,
            'mime_type' => $request->file('file')->getMimeType(),
            'size' => $request->file('file')->getSize(),
            'user_id' => $request->user()->id,
        ]);

        return response()->json($document, 201);
    }
}

Conditional File Validation

Not every form requires a file on every submission. Profile edit forms often make the avatar optional, while creation forms require it. Use conditional validation rules to handle this:

use Illuminate\Validation\Rules\File;

// File required only on creation
public function rules(): array
{
    $fileRule = File::image()->max('2mb');

    return [
        'avatar' => [
            'PUT' === $this->method() ? 'sometimes' : 'required',
            $fileRule,
        ],
    ];
}

A cleaner approach using Rule::when():

use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\File;

public function rules(): array
{
    return [
        'avatar' => [
            Rule::when(
                'PUT' === $this->method(),
                'sometimes',
                'required'
            ),
            File::image()->max('5mb'),
        ],
        'remove_avatar' => 'sometimes|boolean',
    ];
}

When a form allows deleting the current file, you might receive neither a new upload nor a delete flag. Handle the three states (new file, delete, keep existing) in the controller:

public function update(ProfileRequest $request): RedirectResponse
{
    $user = $request->user();

    if ($request->hasFile('avatar')) {
        Storage::delete($user->avatar_path);
        $user->avatar_path = $request->file('avatar')->store('avatars', 'public');
    } elseif ($request->boolean('remove_avatar')) {
        Storage::delete($user->avatar_path);
        $user->avatar_path = null;
    }

    $user->save();

    return redirect()->route('profile.edit');
}

Custom Error Messages for File Rules

Default validation messages for file rules are generic. Override them in your Form Request or lang/en/validation.php to give users actionable feedback:

public function messages(): array
{
    return [
        'avatar.image' => 'Upload a JPG, PNG, or WebP image.',
        'avatar.max' => 'The image must be smaller than 2 MB.',
        'avatar.dimensions' => 'The image must be at least 200x200 pixels.',
        'document.mimes' => 'Only PDF and Word documents are accepted.',
        'document.max' => 'Documents must be under 10 MB.',
        'photos.*.image' => 'Each photo must be a valid image file.',
        'photos.*.max' => 'Each photo must be under 5 MB.',
        'photos.max' => 'You can upload up to :max photos at once.',
    ];
}

For the dimensions rule, the default message is “The :attribute has invalid image dimensions.” That tells the user nothing useful. Spell out what you actually need:

'banner.dimensions' => 'Banner images must be at least 1200px wide and no taller than 600px.',

For multi-file uploads, the wildcard photos.* messages apply to each file individually. Laravel provides :index (zero-based), :position (one-based), and :ordinal-position placeholders for array validation messages, so you can tell the user exactly which file failed:

'photos.*.max' => 'Photo #:position must be under 5 MB.',

Recipe: Profile Picture Upload

A complete Form Request showing file validation, image processing hints, and error handling patterns that come up in real projects:

namespace App\Http\Requests;

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

class UpdateAvatarRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'avatar' => [
                'required',
                File::image()
                    ->max('5mb')
                    ->dimensions(
                        Rule::dimensions()
                            ->minWidth(200)
                            ->minHeight(200)
                            ->maxWidth(2000)
                            ->maxHeight(2000)
                            ->ratio(1 / 1)
                    ),
            ],
        ];
    }

    public function messages(): array
    {
        return [
            'avatar.required' => 'Select an image to upload.',
            'avatar.max' => 'The image must be under 5 MB.',
            'avatar.dimensions' => 'Upload a square image between 200x200 and 2000x2000 pixels.',
        ];
    }
}

The controller that handles this request:

use App\Http\Requests\UpdateAvatarRequest;
use Illuminate\Support\Facades\Storage;

public function update(UpdateAvatarRequest $request): RedirectResponse
{
    $user = $request->user();

    if ($user->avatar_path) {
        Storage::disk('public')->delete($user->avatar_path);
    }

    $user->avatar_path = $request->file('avatar')
        ->store('avatars/' . $user->id, 'public');
    $user->save();

    return redirect()->route('profile.edit')
        ->with('status', 'Avatar updated.');
}

Storing uploads in a user-specific subdirectory (avatars/{id}) prevents filename collisions and makes cleanup easier when deleting user accounts. The store() method generates a random filename, so two users uploading photo.jpg won’t overwrite each other.

If you need to preserve the original filename (for document downloads, for instance), use storeAs() with a sanitized name, or store the original name in the database and use a random name on disk. Never trust the client-provided filename for storage paths without sanitization.

Testing File Validation

Laravel provides UploadedFile::fake() for generating test files without touching the filesystem:

use Illuminate\Http\UploadedFile;

// Fake image with specific dimensions (width, height)
$file = UploadedFile::fake()->image('avatar.jpg', 500, 500);

// Fake image with custom size in KB
$file = UploadedFile::fake()->image('photo.png')->size(3000);

// Fake non-image file with MIME type
$file = UploadedFile::fake()->create('report.pdf', 1500, 'application/pdf');

// Fake CSV
$file = UploadedFile::fake()->create('users.csv', 200, 'text/csv');

The second argument to create() is size in kilobytes. For image(), the second and third arguments are width and height in pixels.

Pest Tests for File Uploads

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;

test('rejects files exceeding the size limit', function () {
    Storage::fake('local');

    $oversized = UploadedFile::fake()->create('huge.pdf', 30000, 'application/pdf');

    $this->postJson('/api/documents', [
        'title' => 'Test',
        'file' => $oversized,
    ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['file']);
});

test('rejects files with wrong MIME type', function () {
    Storage::fake('local');

    $textFile = UploadedFile::fake()->create('notes.txt', 100, 'text/plain');

    $this->postJson('/api/documents', [
        'title' => 'Test',
        'file' => $textFile,
    ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['file']);
});

test('accepts valid image within dimension constraints', function () {
    Storage::fake('local');

    $image = UploadedFile::fake()->image('banner.jpg', 1200, 600);

    $this->postJson('/api/banners', ['banner' => $image])
        ->assertSuccessful();
});

test('rejects image with wrong aspect ratio', function () {
    Storage::fake('local');

    // 800x800 square image for a 16:9 field
    $square = UploadedFile::fake()->image('cover.jpg', 800, 800);

    $this->postJson('/api/covers', ['cover' => $square])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['cover']);
});

test('validates each file in a multi-upload field', function () {
    Storage::fake('local');

    $valid = UploadedFile::fake()->image('ok.jpg', 400, 400);
    $tooLarge = UploadedFile::fake()->image('big.jpg', 400, 400)->size(20000);

    $this->postJson('/api/gallery', [
        'photos' => [$valid, $tooLarge],
    ])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['photos.1']);
});

For testing custom rules like MaxPdfPages, commit a small fixture PDF to your test suite or mock the PDF parser. Generating real PDFs at test time adds fragile dependencies and slows down the suite.

When testing file validation in API endpoints, remember that postJson() does not actually send JSON – for file uploads, Laravel’s test helpers automatically switch to multipart form data when the request contains UploadedFile instances. You don’t need to set the Content-Type header manually.

Common Mistakes and Gotchas

max value unit confusion. For files, max:5 means 5 kilobytes, not 5 megabytes. Use the File builder with string units to make intent clear:

// Ambiguous: is this bytes, KB, or MB?
'file' => 'file|max:5120'

// Self-documenting
'file' => [File::types(['pdf'])->max('5mb')]

mimes vs mimetypes vs extensions. The mimes rule checks actual binary content and maps extensions to MIME types. The mimetypes rule matches raw MIME type strings (with wildcard support). The extensions rule checks only the user-assigned filename extension. For secure validation, use mimes or mimetypes for content verification and add extensions as a supplementary check.

SVG rejection by default. File::image() and the image rule reject SVGs. If your form accepts SVGs but you don’t pass allowSvg: true, uploads fail without an obvious reason. Test SVG uploads explicitly in your test suite.

Missing enctype on HTML forms. When <form> lacks enctype="multipart/form-data", the file arrives as a string filename rather than an uploaded file object. Validation fails with “must be a file” even though the user selected one:

<!-- File uploads silently break without enctype -->
<form method="POST" action="/upload">

<!-- Required for file uploads -->
<form method="POST" action="/upload" enctype="multipart/form-data">

Multiple file upload without array rule. When uploading multiple files via photos[], the parent field photos needs the array rule. Without it, Laravel treats the input as a single value and validation behaves unpredictably.

dimensions on non-images. Applying dimensions to a non-image file (PDF, text) causes validation to fail because getimagesize() cannot read pixel data and returns false. Always pair dimensions with image or File::image() so users get a clear “must be an image” error before hitting the less obvious dimensions failure.

Forgetting nullable for optional file fields. If a file field is optional, use nullable instead of just omitting required. Without nullable, sending an empty field (as some JavaScript form libraries do) triggers a “must be a file” error:

// Optional file upload
'attachment' => ['nullable', File::types(['pdf'])->max('10mb')]

Old input is lost for file fields. After a validation error, old('avatar') returns nothing because PHP does not include uploaded files in the POST data that Laravel flashes to the session. Your form cannot re-populate the file input. Handle this in the UI: show a preview of the previously uploaded file from the server, or tell the user they need to select the file again. This catches many developers off guard when building multi-field forms where only one field fails validation.

Pipe-delimited rules with File builder. You cannot mix the File builder with pipe-delimited string rules. This fails:

// Wrong: object cannot be converted to string
'photo' => 'required|' . File::image()->max('5mb')

Use array syntax instead:

// Correct: array of rules
'photo' => ['required', File::image()->max('5mb')]

Quick Reference

TaskRule
Must be an uploaded filefile
Image (jpg, jpeg, png, bmp, gif, webp)image or File::image()
Image + allow SVGimage:allow_svg or File::image(allowSvg: true)
Max file size 5 MB (decimal)max:5000 or File::types([...])->max('5mb')
Min file size 100 KBmin:100 or File::types([...])->min('100kb')
Specific MIME typesmimes:pdf,docx or File::types(['pdf', 'docx'])
Raw MIME type matchmimetypes:application/pdf
File extension checkextensions:pdf,docx
Image min/max width and heightdimensions:min_width=100,max_height=500
Image aspect ratiodimensions:ratio=16/9
Exact dimensionsdimensions:width=1200,height=630
Character encodingencoding:utf-8
Multiple files (array)'field' => ['array', 'max:10'], 'field.*' => [File::image()]

For the full list of validation rules, see the Validation Rules Reference. For Form Request setup and error handling, see Validation Basics.