Handling User Uploads: The Boring Parts That Decide Whether It Works

File uploads look like a weekend feature and fail in production for predictable reasons — validation you can trust, where files actually live, serving them safely, and cleaning up after deletion.

Handling User Uploads: The Boring Parts That Decide Whether It Works

Every app eventually needs uploads: avatars, invoices, exports, photos. It looks like a two-hour task and quietly becomes the source of a security ticket, a full disk, or a support thread about missing files. The failure modes are known and short. Here is the version we build.

Validation: trust the bytes, not the form

Client-side validation is a UI affordance, not a control. Anything the browser sends can be sent by something that isn't the browser. Three checks that actually hold:

  • Check the real MIME type server-side. finfo in PHP, or a magic-bytes library, reads the file's actual content. The extension and the form field's Accept attribute are suggestions from the client, nothing more.
  • Cap the size at the web server, not only the framework. A PHP upload_max_filesize limit stops the script, but the request has already been uploaded. client_max_body_size in nginx (or equivalent) rejects it at the door.
  • Regenerate names and strip metadata. Never store the user-supplied filename as the path. Store files under generated names (UUIDs work) and keep the original name in the database for display. For images, re-encoding through an image library also strips EXIF, which sometimes contains GPS coordinates the uploader didn't mean to publish.
// Laravel — validate content, not only the extension
$request->validate([
    'document' => ['required', 'file', 'mimes:pdf,png,jpg', 'max:10240'],
]);
$path = $request->file('document')->storeAs(
    'documents/' . auth()->id(),
    Str::uuid() . '.' . $request->file('document')->extension(),
);

Where files live

The local disk is fine on day one and the wrong answer by month three. Disks fill, deploys with rsync --delete wipe uploaded files, and horizontal scaling breaks the moment two servers can't see the same files.

The decision we make up front:

  • Small files that belong to the app (avatars, attachments): object storage — S3-compatible, self-hosted Minio, whatever the project can run. Laravel's filesystem layer makes this a config change, so start with the s3 disk in mind even if you deploy to local disk first.
  • Files only the owner should reach: private storage with expiring signed URLs, never a public bucket path you can guess.
  • Generated exports (CSV, PDF): store briefly, deliver once, delete on a schedule. They're the fastest way to fill a disk, and nobody ever comes back for the third one.

Whatever the choice, the path is namespaced by user (and by tenant, in multi-tenant apps — an unnamespaced exports/report.csv is a cross-tenant leak waiting for its first collision).

Serving them safely

An upload directory inside the web root is a classic vulnerability: someone uploads payload.php, and the server helpfully executes it. The rules:

  • Store files outside the web root, or in object storage with no web-root exposure at all.
  • Serve through a controller or signed URL that checks authorization on every request — a file link is a permission check wearing a URL.
  • Force Content-Disposition: attachment for anything that isn't an image meant to display inline, so a browser never renders an uploaded HTML or SVG file in your origin (an SVG can carry script).

That last part matters more than it sounds: a private file served without an auth check is an IDOR, and it's the same bug class we hunt in every API. The object exists, the URL is guessable, and the only thing missing is the check.

Deletion and the disk that fills anyway

Files outlive the rows that reference them unless you make cleanup deliberate:

  • Delete on model events, not on user action only. A user deletes an avatar in the UI, but the account deletion job, the admin cleanup, and the "replace file" flow also need to remove the old object. Hook the model event once.
  • Orphan sweeps. A job that lists objects in the bucket with no matching database row, and removes them after a grace period. Catches everything the event hooks missed, including uploads that never got attached to anything.
  • Database transactions don't include the filesystem. If the row insert rolls back, the uploaded file stays. Either upload after the record exists, or accept the orphan and let the sweep catch it. Designing for the sweep is simpler than pretending the two stores are transactional together.

The checklist

  • Server-side MIME check and size cap at the web server
  • Generated filenames, original names kept for display only
  • Object storage with private files behind signed, authorized URLs
  • Content-Disposition and re-encoding for anything user-supplied
  • Paths namespaced by user and tenant
  • Model-event deletion plus a scheduled orphan sweep

None of this is difficult. These are things that are cheap to do on day one and expensive to retrofit after the first incident.