How do I handle a file upload safely in PHP?

Short answer

Read the file from $_FILES, verify its real MIME type with finfo, generate your own filename, and move it with move_uploaded_file() into a directory that does not execute PHP.

The form needs the right encoding, which is the most common thing to forget:

html
<form method="post" enctype="multipart/form-data">
  <input type="file" name="avatar">
  <button>Upload</button>
</form>

Without enctype="multipart/form-data", $_FILES is empty and nothing works.

#The handler

php
$file = $_FILES['avatar'] ?? null;

if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
    throw new RuntimeException('Upload failed.');
}

if ($file['size'] > 2 * 1024 * 1024) {
    throw new RuntimeException('Maximum size is 2 MB.');
}

// Check the real type, not the reported one
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime  = $finfo->file($file['tmp_name']);

$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
if (!isset($allowed[$mime])) {
    throw new RuntimeException('Images only.');
}

// Generate the name yourself — never reuse the client's
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$dest = __DIR__ . '/../uploads/' . $name;

if (!move_uploaded_file($file['tmp_name'], $dest)) {
    throw new RuntimeException('Could not save the file.');
}

#The three rules

Never trust $_FILES['x']['type']. It is supplied by the browser and trivially forged. finfo inspects the actual bytes.

Never use the client's filename. ../../index.php is a path traversal; shell.php.jpg may still execute on a misconfigured server. Generate a random name and derive the extension from the verified type.

Store uploads outside the web root, or disable PHP execution in that directory:

apache
# uploads/.htaccess
php_flag engine off

An uploaded file that the server will execute is a remote shell.

#Size limits live in three places

upload_max_filesize and post_max_size in php.ini, plus your own check. A file larger than post_max_size arrives with an empty $_FILES and no obvious error, which is why the ?? null guard above matters.