File Uploads

Read $_FILES, check the error code first, confirm the real file type, and move it to a directory outside the web root.

Syntaxmove_uploaded_file(string $from, string $to): bool

A file upload needs a form with method="post" and enctype="multipart/form-data". Without that encoding the browser sends the file's name as text and nothing else.

PHP writes the uploaded bytes to a temporary file and describes it in $_FILES. For <input type="file" name="avatar"> you get $_FILES['avatar'], an array with the keys name (the filename from the client), full_path (the client's path, added in PHP 8.1), type, tmp_name, error and size.

Check the error code first

Compare $_FILES['avatar']['error'] to UPLOAD_ERR_OK before touching anything else. The other codes report a file larger than upload_max_filesize, an upload cut short, or no file chosen. After a failure tmp_name is an empty string, so code that skips the check fails somewhere far less obvious.

Trust the contents, not the labels

Both name and type come from the client. A file called cat.png can hold PHP code and type can claim any value at all. Work out the type yourself, with getimagesize() for images or the fileinfo extension for everything else, and store the file under a name you generate.

Where it goes

move_uploaded_file() moves the temporary file to a path you choose; otherwise it is deleted when the request ends. Choose a directory outside the web root, so a file that does contain code cannot be requested and executed.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Accept a profile picture and reject anything that is not really an image.
  • Report back which of the upload error codes a failed submission hit.
  • Store uploads under generated names so two people's cv.pdf cannot collide.
  • Let only a signed-in owner download a file that has no public URL.

More examples

The handler, start to finish

Every step here exists because of a way uploads are attacked or go wrong. is_uploaded_file() confirms the path really is this request's temporary file, so a path smuggled in from elsewhere cannot be moved. getimagesize() reads the file's own header and returns an array with a mime key, or false when the bytes are not an image, which is a check on contents rather than on the client's claims. The stored name is generated, so a filename like ../../index.php has nothing to overwrite, and the destination sits outside the web root. The sandbox has no request and no upload, so this cannot be run there.

Example · php
<?php

declare(strict_types=1);

const UPLOAD_DIR = '/var/app/storage/uploads'; // outside the web root
const MAX_BYTES = 2 * 1024 * 1024;

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

if ($file === null || $file['error'] !== UPLOAD_ERR_OK) {
    http_response_code(400);
    exit('The upload did not arrive.');
}

if (!is_uploaded_file($file['tmp_name']) || $file['size'] > MAX_BYTES) {
    http_response_code(400);
    exit('That file was rejected.');
}

$info = getimagesize($file['tmp_name']);

if ($info === false) {
    http_response_code(415);
    exit('That file is not an image.');
}

$extension = match ($info['mime']) {
    'image/png' => 'png',
    'image/jpeg' => 'jpg',
    default => null,
};

if ($extension === null) {
    http_response_code(415);
    exit('Only PNG and JPEG are accepted.');
}

$storedName = bin2hex(random_bytes(16)) . '.' . $extension;

if (!move_uploaded_file($file['tmp_name'], UPLOAD_DIR . '/' . $storedName)) {
    http_response_code(500);
    exit('The file could not be stored.');
}

echo 'Stored as ' . htmlspecialchars($storedName, ENT_QUOTES, 'UTF-8');

The form that makes an upload possible

The enctype attribute is what makes the browser send the file's bytes in the body; it is the single most common thing missing when $_FILES turns out to be empty. The name attribute is the key your PHP reads, exactly as with a text field. accept only filters the file picker as a convenience, so a request can still carry any content and the server-side checks are the ones that decide.

Example · html
<form method="post" action="/avatar" enctype="multipart/form-data">
    <label for="avatar">Profile picture</label>
    <input type="file" id="avatar" name="avatar" accept="image/png,image/jpeg">
    <button type="submit">Upload</button>
</form>

Serving a stored file back out

A file outside the web root has no URL of its own, so a small script hands it out instead. The filename never comes from the request: the URL carries an id, the id finds a record, and the record names the file. That removes path traversal entirely, where a value like ../../etc/passwd would otherwise be appended to your directory. Content-Type is taken from what you determined at upload time, and readfile() streams the bytes as the response body.

Example · php
<?php

declare(strict_types=1);

// In a real app this row comes from your database.
$records = [
    41 => ['stored' => 'a3f1c0de9b7f4a2d8c6e5f0b1a2d3c4e.png', 'mime' => 'image/png'],
];

$id = filter_var($_GET['id'] ?? '', FILTER_VALIDATE_INT);
$record = $id === false ? null : ($records[$id] ?? null);

if ($record === null) {
    http_response_code(404);
    exit('Not found');
}

$path = '/var/app/storage/uploads/' . $record['stored'];

header('Content-Type: ' . $record['mime']);
header('Content-Length: ' . filesize($path));
header('Content-Disposition: inline; filename="avatar.png"');
readfile($path);

Discussion

  • Be the first to comment on this lesson.