Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

File Upload Security Mistakes in PHP Apps (and the Safe Laravel Way)

About Post

A file upload form is the only place in most apps where you let a stranger put a file of their choosing onto your server. Read that sentence again, slowly. It's a strange thing to allow, and yet almost every app does it: profile photos, ID documents, signed contracts, maintenance request pictures.

Uploads are also one of the oldest and most reliable ways into a PHP application, because the mistakes are easy to make and the code that makes them looks perfectly reasonable. Here are the classic ones, why each is dangerous, and the Laravel approach that avoids all of them.

The upload handler that does everything wrong

Here's a pattern that still shows up in older PHP code and quick tutorials:

// Don't do this
$name = $_FILES['photo']['name'];

if ($_FILES['photo']['type'] === 'image/jpeg') {
    move_uploaded_file(
        $_FILES['photo']['tmp_name'],
        __DIR__ . "/uploads/{$name}"
    );
}

It checks the type. It uses a dedicated upload function. It looks careful. It contains at least four of the mistakes below.

Mistake 1: trusting the extension or the browser's MIME type

Why it's dangerous: $_FILES['photo']['type'] is sent by the client. The browser fills it in honestly, but an attacker using curl or a script can set it to anything. Upload shell.php with a type of image/jpeg and the check above passes. The file name and extension are just as easy to fake.

The fix: determine the type on the server, from the file's content. In plain PHP that's finfo. In Laravel, the mimes and mimetypes rules do it for you by inspecting the file. Combine that with an allowlist of extensions, so both the content and the name have to agree.

Even content checks aren't bulletproof: a file can be a valid image and contain hidden data. For images, re-encoding them (resizing or re-saving with GD or an image library) produces a clean file and strips metadata as a bonus.

Mistake 2: storing uploads inside the web root

Why it's dangerous: anything in your public directory can be requested by URL. If a .php file gets past your checks and lands in /uploads/, visiting /uploads/shell.php runs it. That's remote code execution: game over for that server. Some web server configurations will even execute files with double extensions like photo.php.jpg.

The fix: store uploads outside the web root, or in private object storage like S3, and serve them through a controller that checks permissions. Then even if a malicious file gets stored, there's no URL that executes it.

In Laravel, the public disk is linked into public/storage, so it's for files you genuinely want public (like a blog image). Documents, IDs and contracts belong on a private disk. In new Laravel 11 and 12 apps, the local disk points to storage/app/private for exactly this reason.

Mistake 3: keeping the user's file name

Why it's dangerous: the name is user input. Two users uploading contract.pdf overwrite each other. A name containing special characters breaks things in creative ways. And as soon as any part of a storage path comes from the request (a folder parameter, a file name in a download URL), you're open to path traversal: ../../.env is a valid string, and a careless download endpoint will happily serve it.

// Path traversal waiting to happen
return Storage::download('uploads/' . $request->query('file'));

The fix: generate the stored name yourself. Laravel's store() method gives every file a random name automatically. Keep the original name in the database for display and downloads, never as part of the path. And never build file paths from request input; look up a database record and use the path stored on it.

Mistake 4: no size limits (or limits in only one place)

Why it's dangerous: without limits, anyone can fill your disk or tie up your workers with huge uploads. Images have a sneakier version: a small file that decodes into an enormous number of pixels, using huge amounts of memory when you try to resize it.

The fix: limits exist at several layers, and they all need to agree:

  • Web server: client_max_body_size in Nginx.
  • PHP: upload_max_filesize and post_max_size in php.ini.
  • Application: Laravel's max rule (in kilobytes for files), plus the dimensions rule for images.

The application rule is the one that gives users a friendly error message. The others are the hard stop.

Mistake 5: serving uploads inline, from your own domain

Why it's dangerous: not every dangerous upload is a PHP file. An SVG or HTML file can contain JavaScript. If you serve it from your main domain with its natural content type, the browser runs that script in your users' sessions: stored cross-site scripting.

The fix: don't allow SVG or HTML unless you really need them. Serve user files as downloads (Content-Disposition: attachment), send X-Content-Type-Options: nosniff, and for apps with lots of user content, consider serving files from a separate domain or via signed S3 URLs.

The mindset: treat every uploaded file as hostile until proven otherwise. Validate the content, rename it, store it where it can't execute, and hand it back only to people who are allowed to see it.

The safe approach in Laravel

Here's what a careful upload looks like for, say, a signed contract document. Simplified, but every line addresses one of the mistakes above:

$request->validate([
    'document' => [
        'required', 'file',
        'mimes:pdf,jpg,png',      // content-based type check
        'extensions:pdf,jpg,png', // and the name must agree
        'max:10240',              // 10 MB, in kilobytes
    ],
]);

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

$contract->documents()->create([
    'path' => $file->store("contracts/{$contract->id}", 'local'), // random name, private disk
    'original_name' => $file->getClientOriginalName(),           // for display only
]);

And the download goes through a controller that checks access first:

public function download(Contract $contract, Document $document)
{
    Gate::authorize('view', $contract);
    abort_unless($document->contract_id === $contract->id, 404);

    return Storage::disk('local')
        ->download($document->path, $document->original_name);
}

That second check matters. Without it, a user who can see their own contract could swap the document ID in the URL and download someone else's file. It's a different bug (an insecure direct object reference), but it travels with uploads so often that it belongs on this list.

For files on S3, Storage::temporaryUrl() gives the user a short-lived signed link instead of streaming the file through PHP. The Laravel filesystem docs cover the disks and options.

The checklist

  • ✅ Type checked from content, with an extension allowlist.
  • ✅ Stored outside the web root or in private storage.
  • ✅ Random stored names; original name kept only in the database.
  • ✅ No file paths built from request input.
  • ✅ Size limits at the web server, PHP and app level.
  • ✅ Served as a download, after an authorization check.
  • ✅ Images re-encoded; SVG and HTML rejected unless truly needed.

Which of these do you see most often in code reviews? In my experience it's storing everything on the public disk, because it's the easiest way to make the image show up.

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close