File Uploads and Images

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against PHP 8.5 (8.5.11), checked on 2026-10-05. Every output was captured from the Bookshelf Lite cover upload running under php -S (with GD built from the 8.5.11 sources) and, for downloads, under PHP-FPM behind Nginx 1.31. In Laravel, the same steps are $request->file(), validation rules such as image and mimes, and the Storage facade (see File Storage and Images).

The upload settings

A browser uploads files in a multipart/form-data POST (<form method="post" enctype="multipart/form-data">). PHP parses the body before the script starts, writes each file to a temporary file and describes it in $_FILES. Four php.ini directives limit what it accepts:

Directive Default Meaning

file_uploads

1

Uploads are enabled at all

upload_max_filesize

2M

The largest single file; a larger one gets UPLOAD_ERR_INI_SIZE

post_max_size

8M

The largest request body, all files and fields together; must be larger than upload_max_filesize. A larger body is discarded entirely

max_file_uploads

20

The most files per request; extra files are silently dropped

The web server has its own limit, which must be at least as large (Nginx’s client_max_body_size, 1m by default, answers 413 Request Entity Too Large before PHP sees anything), and upload_tmp_dir (default: the system temporary directory) must be writable and large enough. Since all files of a request are on disk before the script runs, a script cannot reject a file early; the server limits are the only guard against huge bodies.

What arrives in $_FILES

For a field cover, $_FILES['cover'] describes one file. The output below is a real dump:

array (
  'cover' =>
  array (
    'name' => 'cover.png',            // the client's file name: untrusted
    'full_path' => 'cover.png',       // the relative path for directory uploads (8.1): untrusted
    'type' => 'image/png',            // the client's claim: untrusted
    'tmp_name' => '/tmp/phpkmoqof3cgo0l30Xbtqy',
    'error' => 0,                     // UPLOAD_ERR_OK
    'size' => 5512,
  ),
)

For a field named covers[] with several files, the structure is transposed: each key holds an array indexed by file, $_FILES['covers']['name'][0], $_FILES['covers']['tmp_name'][0] and so on:

array (
  'covers' =>
  array (
    'name' => array (0 => 'cover.png', 1 => 'evil.jpg'),
    'full_path' => array (0 => 'cover.png', 1 => 'evil.jpg'),
    'type' => array (0 => 'image/png', 1 => 'image/jpeg'),
    'tmp_name' => array (0 => '/tmp/phpbqg2pqeap095fOG2Kmv', 1 => '/tmp/php4rr7t2lic8lr2Rr8omU'),
    'error' => array (0 => 0, 1 => 0),
    'size' => array (0 => 5512, 1 => 28),
  ),
)

Note evil.jpg with type image/jpeg: curl derived the type from the extension, and the file is in fact 28 bytes of PHP code. name, full_path and type come from the client and prove nothing.

Handling the upload

The Bookshelf Lite cover upload checks the error code, confirms the file really is an upload, detects the type from the content, generates a new name, and moves the file out of the temporary directory into storage that the web server does not serve:

<?php
// src/CoverUpload.php
declare(strict_types=1);

final class CoverUpload
{
    private const array TYPES = ['image/png' => 'png', 'image/gif' => 'gif', 'image/jpeg' => 'jpg'];
    private const int MAX_BYTES = 2 * 1024 * 1024;

    public function __construct(private readonly string $directory) {}

    /** @param array{error:int,tmp_name:string,size:int,name:string} $file */
    public function store(array $file): string
    {
        if ($file['error'] !== UPLOAD_ERR_OK) {
            throw new RuntimeException(match ($file['error']) {
                UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The file is too large.',
                UPLOAD_ERR_PARTIAL => 'The upload was interrupted.',
                UPLOAD_ERR_NO_FILE => 'Choose a file.',
                default => 'The upload failed.',            // NO_TMP_DIR, CANT_WRITE, EXTENSION
            });
        }
        if (!is_uploaded_file($file['tmp_name']) || $file['size'] > self::MAX_BYTES) {
            throw new RuntimeException('Invalid upload.');
        }

        // Trust the bytes, not the client: the name and type fields are attacker-controlled
        $mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
        $extension = self::TYPES[$mime] ?? throw new RuntimeException("Not an allowed image ($mime).");

        // A generated name: no path traversal, no overwrites, no executable extensions
        $name = bin2hex(random_bytes(16)) . '.' . $extension;
        if (!move_uploaded_file($file['tmp_name'], "{$this->directory}/$name")) {
            throw new RuntimeException('Could not save the file.');
        }
        return $name;
    }
}
<?php
// public/upload.php
declare(strict_types=1);

require __DIR__ . '/../src/CoverUpload.php';

header('Content-Type: text/plain');
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > ini_parse_quantity(ini_get('post_max_size'))) {
    http_response_code(413);                       // PHP discarded the whole body: $_POST and $_FILES are empty
    echo "Request too large\n";
    return;
}
try {
    $name = (new CoverUpload(__DIR__ . '/../storage/covers'))->store($_FILES['cover'] ?? ['error' => UPLOAD_ERR_NO_FILE]);
    echo "stored as $name\n";
} catch (RuntimeException $e) {
    http_response_code(422);
    echo $e->getMessage(), "\n";
}
$ curl localhost:8000/upload.php -F 'cover=@cover.png'
stored as 4441f408dd8f550b5d71760fc6001681.png
$ curl localhost:8000/upload.php -F 'cover=@evil.jpg;type=image/jpeg'          # PHP code named .jpg
Not an allowed image (text/x-php).
$ curl localhost:8000/upload.php -F 'cover=@big.bin;filename=big.png'          # 1.5 MB, upload_max_filesize=1M
The file is too large.
$ curl -X POST localhost:8000/upload.php -d x=1
Choose a file.
$ curl -w 'status %{http_code}\n' localhost:8000/upload.php -F 'cover=@big.bin'   # with post_max_size=1M
Request too large
status 413

Each rule closes a specific attack:

  • Content detection with finfo (the fileinfo extension, which reads the file’s magic bytes) rejects a PHP script with an image name, as the second request shows. It is not a complete guarantee: a file can be a valid image and contain a payload, which re-encoding (below) removes.

  • A generated filename (bin2hex(random_bytes(16)) plus an extension chosen by the server) prevents path traversal (../../public/shell.php), overwriting another user’s file, and executable or double extensions (cover.php.jpg on a misconfigured server).

  • Storage outside the document root means that even a file that slips through can never be requested and executed as https://…/uploads/shell.php. If uploads must be public, serve them from a directory where PHP execution is disabled, or better from a separate domain or object storage.

  • is_uploaded_file() and move_uploaded_file() refuse to touch anything that is not a file PHP received in this request, so a forged tmp_name such as /etc/passwd is rejected.

When the whole body exceeds post_max_size, PHP throws it away before the script runs: $_POST and $_FILES are empty, and the form looks as if it had been submitted with nothing. Compare CONTENT_LENGTH with ini_parse_quantity(ini_get('post_max_size')) (8.2) to tell the user what happened. PHP also emits a startup warning, POST Content-Length of 1500197 bytes exceeds the limit of 1048576 bytes; with display_errors on, that warning is printed before the script runs, which sends the headers early, so http_response_code(413) fails and the client receives 200. With display_errors=0 (production), the warning goes to the log and the status is 413 as above.

Serving downloads

Files stored outside the document root are delivered by a script, which is also where access control belongs. Send a correct Content-Type, Content-Disposition: attachment for files that must be downloaded rather than displayed, and X-Content-Type-Options: nosniff so browsers do not guess a more dangerous type. readfile() streams the file through PHP; for large files, let the web server do it with X-Accel-Redirect (Nginx) or X-Sendfile (Apache with mod_xsendfile), which frees the PHP worker immediately:

<?php
// public/download.php?file=kindred.png
declare(strict_types=1);

$name = basename($_GET['file'] ?? '');                       // never a path from the client
$path = __DIR__ . "/../storage/covers/$name";
if (!preg_match('/^[a-z0-9-]+\.png$/', $name) || !is_file($path)) {
    http_response_code(404);
    return;
}
// ... check that the current user may see this file ...

header('Content-Type: image/png');
header('Content-Disposition: attachment; filename="' . $name . '"');
header('X-Content-Type-Options: nosniff');
if (($_GET['via'] ?? '') === 'php') {
    header('Content-Length: ' . filesize($path));
    readfile($path);                                          // PHP streams the file
} else {
    header('X-Accel-Redirect: /protected-covers/' . $name);   // Nginx streams it, the worker is free
}
location /protected-covers/ {
    internal;                            # only reachable through X-Accel-Redirect
    alias /app/storage/covers/;
}
$ curl -D - -o kindred.png 'http://bookshelf.test/download.php?file=kindred.png'
HTTP/1.1 200 OK
Content-Type: image/png
Content-Length: 5512
Content-Disposition: attachment; filename="kindred.png"
$ curl -o /dev/null -w '%{http_code}\n' http://bookshelf.test/protected-covers/kindred.png
404
$ curl -o /dev/null -w '%{http_code}\n' 'http://bookshelf.test/download.php?file=../../etc/passwd'
404

Nginx removed the X-Accel-Redirect header and served the file itself with its own Content-Length; requesting the internal location directly, or a path with ../, returns 404. If a file name comes from users, encode it in Content-Disposition with the RFC 6266 filename*=UTF-8''… form and strip quotes and line breaks.

Images: resizing and re-encoding with GD

PHP has two image libraries: GD (the gd extension, bundled with PHP) and Imagick (a PECL extension wrapping ImageMagick, more formats and better resampling). For covers, two operations matter: making thumbnails, and re-encoding every upload, which writes a fresh image from the decoded pixels and so drops metadata, comments and anything appended to the file.

<?php
declare(strict_types=1);

/** Re-encode an uploaded image and write a thumbnail of the given width. */
function makeThumbnail(string $source, string $target, int $width = 300): array
{
    [$w, $h, $type] = getimagesize($source) ?: throw new RuntimeException('Not an image.');
    if ($w * $h > 40_000_000) {
        throw new RuntimeException('Image too large to process.');   // decompression-bomb guard
    }
    $image = imagecreatefromstring(file_get_contents($source)) ?: throw new RuntimeException('Unreadable image.');
    $thumb = imagescale($image, $width);                              // keeps the aspect ratio
    imagepng($thumb, $target, 9);                                     // re-encoding drops anything hidden in the file
    return [$w, $h, image_type_to_mime_type($type), imagesx($thumb), imagesy($thumb)];
}

var_dump(makeThumbnail(__DIR__ . '/../storage/cover.png', __DIR__ . '/../storage/covers/thumb.png'));
// array(5) { [0]=> int(800) [1]=> int(1200) [2]=> string(9) "image/png" [3]=> int(300) [4]=> int(450) }

Check the dimensions with getimagesize() before decoding: a small file can declare an enormous canvas (a decompression bomb) that needs gigabytes of memory to decode, since GD uses about 4 bytes per pixel for a true-colour image. Since PHP 8.0, images are GdImage objects freed automatically; imagedestroy() does nothing and is deprecated since 8.5. Which formats GD supports depends on how it was built (gd_info() lists them): the build used here had PNG, GIF and BMP support, while the distribution packages usually add JPEG, WebP and AVIF.

Where older books and tutorials are outdated

  • Trusting $_FILES['…']['type'] and the original name, and saving uploads under the web root (Nixon ch. 7, Powers ch. 9, Kromann ch. 15) is the classic route to remote code execution: detect the type with finfo, generate the name, and store outside the document root. Powers' Upload class already checks MIME types and renames files; the type check must use the content, not the browser’s claim.

  • Echoing the uploaded file name unescaped (the same chapters) is an XSS hole; escape it like any other input.

  • imagedestroy() to free memory, as the GD thumbnail code written for PHP 7 does (Powers, and the image resizing in Nixon’s capstone), has done nothing since PHP 8.0 and is deprecated since 8.5; GD images are objects.

  • $_FILES without full_path: the full_path key (8.1) carries the relative path of files uploaded from a directory picker; it is as untrusted as name.