Filter PHP uploads by size and MIME type with open-source tools
An upload named photo.png can contain plain text, and its browser-supplied content type can say
anything. Build a PHP endpoint that checks the received bytes, accepts only your chosen types and
size range, and stores accepted files outside the webroot. We will use Respect\Validation for the
admission rules, then use HTML Purifier for the separate task of sanitizing an HTML field.
Choose the upload rules
This local example accepts nonempty JPEG, PNG, and GIF uploads up to 5 MiB, or
5 * 1024 * 1024 = 5242880 bytes. PHP’s
Fileinfo extension detects a MIME type from
the temporary file. The handler measures that file’s bytes, ignores the original filename and
multipart content type, chooses its own extension, and returns HTTP 201 only after storing the file.
These are admission checks. They do not decode an image, remove embedded content, or detect malware. A damaged image or a file with extra data can still match a MIME rule. Keep the accepted bytes private until the decoding or scanning required by your application has succeeded.
The PHP upload-size guide covers
finding the active php.ini and diagnosing request limits. Here, PHP’s limits are deliberately
higher than the application’s cap so you can observe the admission decision.
Set up the local project
Use Bash on Linux, PHP 8.5.10 with Fileinfo, DOM, and mbstring enabled, Composer 2.10.3, and cURL.
The examples below use Respect\Validation 3.1.2 and HTML Purifier 4.19.1. Respect’s
version 3 migration notes explain why
older examples using Validator, max(), or boolean validate() results need updating.
Run this block from a directory where you can create a new project. It refuses an existing
php-filter-demo directory. The subshell leaves your terminal in its original directory, including
if installation fails. The command explicitly selects the new manifest and local dependency paths,
so an enclosing Composer project or inherited
path overrides do not receive the installation.
(
set -eu
mkdir php-filter-demo
cd php-filter-demo
printf '%s\n' '{"require": {}}' > composer.json
COMPOSER=./composer.json COMPOSER_VENDOR_DIR=vendor COMPOSER_BIN_DIR=vendor/bin \
composer require --no-interaction --no-plugins --no-scripts \
respect/validation:^3.1 ezyang/htmlpurifier:^4.19
mkdir public private cache
chmod 700 private cache
)
Continue only after setup succeeds. Keep running the following commands from that same parent
directory. Save the PHP files at the paths shown below; the served directory will be
php-filter-demo/public, while dependencies, cached HTML definitions, and uploads stay outside it.
Save the upload handler
Save this complete program as php-filter-demo/public/upload.php. PHP populates $_FILES when
it receives the multipart request; a fabricated array in a command-line script would not satisfy
is_uploaded_file().
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use Respect\Validation\ValidatorBuilder as v;
const MAX_BYTES = 5 * 1024 * 1024;
header('Content-Type: application/json');
function rejectUpload(int $status, string $code, string $message): never {
http_response_code($status);
echo json_encode(['accepted' => false, 'code' => $code, 'message' => $message], JSON_THROW_ON_ERROR);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
rejectUpload(405, 'method', 'Send one file in a multipart POST request.');
}
$postLimit = ini_parse_quantity(ini_get('post_max_size'));
$contentLength = $_SERVER['CONTENT_LENGTH'] ?? null;
if ($postLimit > 0 && $contentLength !== null && (int) $contentLength > $postLimit) {
rejectUpload(413, 'request_size', 'The complete request exceeds PHP post_max_size.');
}
$file = $_FILES['upload'] ?? null;
if (array_keys($_FILES) !== ['upload'] || !is_array($file)
|| !is_int($file['error'] ?? null) || !is_string($file['tmp_name'] ?? null)) {
rejectUpload(400, 'missing_upload', 'Send one file in the upload field, without array brackets.');
}
if ($file['error'] !== UPLOAD_ERR_OK) {
[$status, $code, $message] = match ($file['error']) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => [413, 'upload_size', 'PHP rejected the file size.'],
UPLOAD_ERR_NO_FILE => [400, 'missing_upload', 'Choose a file to upload.'],
UPLOAD_ERR_PARTIAL => [400, 'partial_upload', 'The file arrived incomplete. Retry the upload.'],
default => [500, 'upload_unavailable', 'The upload service is unavailable.'],
};
rejectUpload($status, $code, $message);
}
if (!is_uploaded_file($file['tmp_name'])) {
rejectUpload(400, 'invalid_upload', 'The file is not a valid HTTP upload.');
}
$directory = null;
$destination = null;
try {
$size = filesize($file['tmp_name']);
if ($size === false) {
throw new RuntimeException('Cannot measure upload');
}
if ($size === 0) {
rejectUpload(400, 'empty_upload', 'The file is empty.');
}
if (!v::intType()->between(1, MAX_BYTES)->isValid($size)) {
rejectUpload(413, 'file_size', 'The file exceeds the 5 MiB application limit.');
}
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!v::in(array_keys($extensions))->isValid($mime)) {
rejectUpload(415, 'file_type', 'The detected type must be JPEG, PNG, or GIF.');
}
$hash = hash_file('sha256', $file['tmp_name']);
if ($hash === false) {
throw new RuntimeException('Cannot hash upload');
}
$id = bin2hex(random_bytes(16));
$candidate = dirname(__DIR__) . '/private/' . $id;
// Reserve a fresh directory so an existing upload cannot be replaced.
if (!@mkdir($candidate, 0700)) {
throw new RuntimeException('Cannot reserve storage');
}
$directory = $candidate;
$destination = $directory . '/file.' . $extensions[$mime];
if (!@move_uploaded_file($file['tmp_name'], $destination) || !@chmod($destination, 0600)) {
throw new RuntimeException('Cannot store upload');
}
http_response_code(201);
echo json_encode([
'accepted' => true,
'id' => $id,
'mime' => $mime,
'bytes' => $size,
'sha256' => $hash,
], JSON_THROW_ON_ERROR);
} catch (Throwable $error) {
if ($destination !== null) {
@unlink($destination);
}
if ($directory !== null) {
@rmdir($directory);
}
error_log('Upload storage failed: ' . get_class($error));
rejectUpload(500, 'upload_unavailable', 'The upload service is unavailable.');
}
The between() rule includes the exact 5 MiB boundary. The MIME allowlist uses Fileinfo’s result,
not the multipart header. PHP documents the
upload error codes separately
from application validation.
Every accepted request gets a new ID, even when the bytes are identical. If reserving that ID
fails, the handler returns 500 and leaves the existing directory untouched. This matters because
move_uploaded_file() overwrites an existing destination.
Successful files remain under private/<id>/file.<extension> with mode 0600, inside a directory
with mode 0700. The original filename never becomes a storage path.
Run the server and send a real file
Choose an available local port; if 8787 is busy, change it in the server command and both requests. Start PHP’s development server:
php -d file_uploads=1 -d upload_max_filesize=6M -d post_max_size=7M \
-d display_errors=0 -d log_errors=1 \
-S 127.0.0.1:8787 -t php-filter-demo/public
Leave this terminal running. In a second terminal, open the same parent directory. Save the
following as php-filter-demo/make-sample.php. It creates a tiny PNG and refuses to replace an
existing sample.png.
<?php
declare(strict_types=1);
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVQI12NoAAAAggCB3UNq9AAAAABJRU5ErkJggg==', true);
$output = @fopen(__DIR__ . '/sample.png', 'xb');
if ($output === false) {
fwrite(STDERR, "Cannot create sample.png; use the existing sample or choose a fresh project.\n");
exit(1);
}
if (fwrite($output, $png) !== strlen($png) || !fclose($output)) {
fwrite(STDERR, "Cannot finish sample.png.\n");
exit(1);
}
Create and upload it:
php php-filter-demo/make-sample.php &&
curl -sS -i -F 'upload=@php-filter-demo/sample.png' http://127.0.0.1:8787/upload.php
Expect HTTP 201 and JSON containing accepted: true, mime: "image/png", bytes: 67, a random
id, and a sha256. Copy the returned ID into this command, replacing ID:
cmp php-filter-demo/sample.png php-filter-demo/private/ID/file.png
No output and status zero mean the stored bytes match. The server serves only public/, so the
private file has no download URL. To repeat the upload, run just the cURL command; each acceptance
creates another private file.
Now send text disguised as an image. cURL’s ;type=image/png supplies the forged MIME header;
;filename=photo.png supplies the forged filename. Neither controls the detected type:
printf '%s\n' 'This is text, not an image.' > php-filter-demo/disguised.png &&
curl -sS -i -F 'upload=@php-filter-demo/disguised.png;type=image/png;filename=photo.png' \
http://127.0.0.1:8787/upload.php
Expect HTTP 415 with accepted: false and code: "file_type", with no new stored upload. This
command replaces the demo’s disguised.png on a rerun. The diagnostic requests omit cURL’s
--fail so you can read rejection bodies; a successful cURL transfer does not mean the server
accepted the file.
| Response | Meaning |
|---|---|
| 201 | The file passed the admission rules and was stored |
| 400 | Missing, malformed, partial, or empty upload |
| 413 | PHP’s file/request cap or the application’s 5 MiB cap rejected it; inspect code |
| 415 | Detected MIME type is outside the allowlist |
| 500 | Upload or private storage is unavailable; no acceptance was reported |
The request-size diagnosis uses Content-Length, which these cURL requests supply. PHP can empty
$_FILES when post_max_size is exceeded. Without a length header, this handler cannot distinguish
that situation from a missing file; enforce whole-request limits in your deployed web server.
Stop the development server with Ctrl+C when finished. Accepted files remain in the demo’s
private/ directory until you remove them.
HTML Purifier for secure HTML content
HTML sanitization transforms an HTML string. It has no role in deciding whether an uploaded PNG matches a MIME rule. HTML Purifier parses submitted markup and applies an element and attribute allowlist.
Using HTML Purifier
Save this separate example as php-filter-demo/sanitize.php. The allowlist preserves paragraphs,
bold emphasis, italic emphasis, and line breaks. It allows no attributes, links, or images.
Cache.SerializerPath keeps the library’s generated definitions in the project’s private cache.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,strong,em,br');
$config->set('Cache.SerializerPath', __DIR__ . '/cache');
$purifier = new HTMLPurifier($config);
$dirtyHtml = '<script>alert("bad")</script><p onclick="bad()" style="color:red">Hello <strong>PHP</strong><img src="x" onerror="bad()"></p>';
echo $purifier->purify($dirtyHtml), PHP_EOL;
Run it from the parent directory:
php php-filter-demo/sanitize.php
Expected output:
<p>Hello <strong>PHP</strong></p>
Add elements or attributes only when your rich-text field needs them; the
HTML.Allowed directive
controls this policy. Use the result as an HTML body fragment. It is not escaping for a JavaScript
string, CSS value, or HTML attribute. For a plain-text field, use contextual output escaping such
as htmlspecialchars() when rendering it. When storing either kind of field, use prepared SQL
statements; purification does not make SQL interpolation safe.
When to use each library
Use Respect\Validation when you want to compose admission or form-data rules as in the handler. Use HTML Purifier when a field intentionally accepts HTML. If your application already uses Symfony Validator, its File constraint provides file-size and extension/MIME checks. Keep Symfony’s constraints in your existing validation flow rather than installing a second validation framework for this example.
The local endpoint has no authentication, user quotas, or malware scanner. Its 5 MiB cap limits each accepted file, not total storage or decoded image memory. Before exposing it, decide who may upload and which decoder or scanner must approve the private bytes before they become available.
Transloadit’s file filtering
For a processing pipeline, the 🤖 /file/filter Robot selects files by metadata for subsequent Steps in an Assembly. This is separate from the local PHP handler’s storage decision. For example:
{
"steps": {
":original": {
"robot": "/upload/handle"
},
"filter_images": {
"use": ":original",
"robot": "/file/filter",
"condition_type": "and",
"accepts": [
["${file.mime}", "regex", "image"],
["${file.meta.width}", ">=", 100]
],
"declines": [["${file.size}", ">", 10485760]]
}
}
}
Both acceptance conditions must match, and files above 10 MiB are declined. This metadata rule
does not establish that an image is harmless. The Robot also accepts JavaScript conditions, such as
this alternative accepts value for landscape files below 500,000 bytes:
{
"accepts": "${file.meta.width > file.meta.height && file.size < 500000}"
}
The Robot documentation explains condition precedence and the additional charge for JavaScript evaluation. Use the PHP SDK if you need to connect a PHP application to that processing workflow.
