Search by

Validates and stores a single uploaded file, with separate visitor and operator error messages

Package info

github.com/umityatarkalkmaz/phpUpload

pkg:composer/umityatarkalkmaz/upload

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v2.1.0 2026-09-02 17:03 UTC

This package is auto-updated.

Last update: 2026-09-02 17:34:51 UTC


README

Validates and stores a single uploaded file. Every rejection produces two messages: a short one for the visitor and the underlying cause for you.

Requirements

PHP 8.2 or newer, with ext-fileinfo.

Installation

composer require umityatarkalkmaz/upload

Usage

use UmitYatarkalkmaz\Upload;

$uploader = new Upload(
    targetDir: __DIR__ . '/../storage/uploads',
    maxFileSize: 2 * 1024 * 1024,
    allowedExtensions: ['jpg', 'jpeg', 'png', 'gif'],
);

$filename = $uploader->upload('photo');

if ($filename === null) {
    // Safe to show the visitor.
    foreach ($uploader->getErrors() as $message) {
        echo htmlspecialchars($message), "\n";
    }

    // Never show this; send it to your log.
    error_log(implode(' | ', $uploader->getDebugErrors()));

    return;
}

echo 'Stored as ', $filename;

upload() returns the stored filename, or null when the file was rejected.

Where to store, and how to serve

The target directory belongs outside the document root. A file the web server can reach directly is a file the web server may decide to execute or to render inline, and neither is something the uploader can prevent from here.

Serve stored files through a script that decides what the browser is allowed to do with them:

$filename = Upload::sanitizeFilename($_GET['file'] ?? '');
$path = __DIR__ . '/../storage/uploads/' . $filename;

if (!is_file($path)) {
    http_response_code(404);

    return;
}

header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('X-Content-Type-Options: nosniff');
readfile($path);

Content-Disposition: attachment keeps the response out of the page's origin, and nosniff stops the browser from guessing a type the header did not claim.

If the directory has to live inside the document root, disable PHP execution in it — php_admin_flag engine off in the Apache directory block, or simply never routing that path to PHP-FPM in nginx.

The two error channels

getErrors() returns messages written for the visitor: File is too large., File type not allowed., The file could not be stored. They say what went wrong without revealing anything about the server.

getDebugErrors() returns the matching operator-facing detail for the same failure, in the same order: which php.ini directive was exceeded, what MIME type the content actually had, which path could not be written. Log it. Putting it in a response hands an attacker a map of your configuration.

Both arrays are cleared at the start of every upload() call.

What is checked, in order

  1. The $_FILES entry exists, is well-formed, and is not a multi-file field.
  2. The PHP upload error code is UPLOAD_ERR_OK.
  3. is_uploaded_file() confirms the temporary file really came from this request.
  4. The size is within maxFileSize.
  5. The extension is in the allowlist.
  6. The content matches that extension, sniffed with finfo — a PHP script renamed to .png is rejected here.

The stored name is built from scratch: the original base name is slugified, truncated to 60 characters, and given a random 16-character suffix plus the validated extension. The name is then claimed with an exclusive create, so an upload never overwrites a file that is already there; if the random name is taken it is regenerated, and after five collisions the upload is rejected through the normal error channels. Nothing the visitor puts in the filename can escape the target directory.

Configuration is checked at construction

The constructor throws InvalidArgumentException when the target directory is missing or unwritable, when maxFileSize is not positive, when the allowlist is empty, or when it names an extension the class has no MIME mapping for:

new Upload(__DIR__ . '/uploads', allowedExtensions: ['exe']);
// InvalidArgumentException: Unsupported extension: exe

Supported extensions: jpg, jpeg, png, gif, webp, bmp, pdf, txt, csv, zip, doc, docx, xls, xlsx.

Filenames outside an upload

sanitizeFilename() names files that never went through upload(). It slugifies the base name, caps it at 60 characters unless you pass a shorter limit, and keeps the extension only when it is one of the supported types listed above. Anything else is dropped, extension and all:

Upload::sanitizeFilename('Şeker Ağacı.JPG');    // 'seker-agaci.jpg'
Upload::sanitizeFilename('Şeker Ağacı.jpg', 4); // 'seke.jpg'
Upload::sanitizeFilename('../../etc/passwd');   // 'passwd'
Upload::sanitizeFilename('avatar.php');         // 'avatar'
Upload::sanitizeFilename('report.exe');         // 'report'

It looks at the name only. Whether the bytes match the extension is a question only upload() answers, with finfo.

Development

composer install
composer check   # phpstan (level max) + phpunit

License

MIT. See LICENSE.