PHP Composer Autoload PSR-4 Example (Step by Step)
Wire Composer PSR-4 autoload in PHP: map App\ to src/App/, require vendor/autoload.php, dump-autoload, and fix the usual class-not-found mistakes.
Secure PHP file upload validation means: reject anything that is not UPLOAD_ERR_OK, enforce your own byte limit, detect the real MIME type with finfo (never trust $_FILES['file']['type'] or the client filename), map that MIME to an allow-list extension, generate a new random name, and only then call move_uploaded_file(). Store files outside the web root when you can, or disable script execution in the upload directory. A .png extension on a PHP payload is not enough to stop an attack if you trust the browser.
I see the same shortcut in beginners’ code: check that the extension is jpg or png, then move the file into /public/uploads. That fails as soon as someone uploads shell.php.png or renames a PHP file. The examples below were run on PHP 8.4 with the fileinfo extension; the “evil†fixture is a text file pretending to be a PNG via the browser’s claimed type.
A form needs method="post" and enctype="multipart/form-data":
<form method="post" action="/upload.php" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/png,image/jpeg,image/webp" required>
<button type="submit">Upload</button>
</form>
accept is a hint for the file picker only. It is not security.
For name="avatar", PHP fills $_FILES['avatar'] with roughly:
| Key | Meaning | Trust? |
|-----|---------|--------|
| name | Original filename from the OS | No — path tricks, .php, spaces |
| type | MIME type claimed by the browser | No — fully attacker-controlled |
| tmp_name | Temporary path on the server | Yes — PHP created it |
| error | UPLOAD_ERR_* code | Yes — check first |
| size | Size in bytes (as reported) | Cross-check with filesize() |
Always start with error. If it is not UPLOAD_ERR_OK (integer 0), stop and show a clear message. Common codes: UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE, UPLOAD_ERR_PARTIAL, UPLOAD_ERR_NO_FILE.
<?php
declare(strict_types=1);
function validate_upload(array $file, int $maxBytes = 1_048_576): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
$map = [
UPLOAD_ERR_INI_SIZE => 'File exceeds server limit.',
UPLOAD_ERR_FORM_SIZE => 'File exceeds form limit.',
UPLOAD_ERR_PARTIAL => 'Upload was incomplete.',
UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
];
return [$map[$file['error']] ?? 'Upload failed.'];
}
// Live code: require is_uploaded_file($file['tmp_name'])
if (!is_file($file['tmp_name'])) {
return ['Not a valid upload.'];
}
if ($file['size'] > $maxBytes || filesize($file['tmp_name']) > $maxBytes) {
return ['File is too large (max 1 MB).'];
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
if ($mime === false || !isset($allowed[$mime])) {
return ['Only JPEG, PNG or WebP images are allowed. Detected: ' . ($mime ?: 'unknown')];
}
$safeName = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
return [
'ok' => true,
'mime' => $mime,
'name' => $safeName,
'client_name' => $file['name'],
];
}
Test results against three fixtures (browser claimed image/png for all):
=== ok.png (browser said image/png) ===
Array
(
[ok] => 1
[mime] => image/png
[name] => b466241122ffc80d5163a2d8118d73ae.png
[client_name] => ok.png
)
=== evil.php.png (browser said image/png) ===
Array
(
[0] => Only JPEG, PNG or WebP images are allowed. Detected: text/x-php
)
=== note.txt (browser said image/png) ===
Array
(
[0] => Only JPEG, PNG or WebP images are allowed. Detected: text/plain
)
Reading those results:
.png from the detected MIME, not from the client.evil.php.png is detected as text/x-php (or similar) and rejected even though the name ends in .png and the browser lied.text/plain and rejected the same way.That is the whole point of finfo: it reads file magic bytes on the server.
<?php
declare(strict_types=1);
$result = validate_upload($_FILES['avatar'] ?? []);
if (!isset($result['ok'])) {
// show $result errors; redisplay form
exit;
}
$destDir = '/var/app/storage/avatars'; // outside public/ if possible
$dest = $destDir . '/' . $result['name'];
if (!is_uploaded_file($_FILES['avatar']['tmp_name'])) {
http_response_code(400);
exit('Invalid upload.');
}
if (!move_uploaded_file($_FILES['avatar']['tmp_name'], $dest)) {
http_response_code(500);
exit('Could not save file.');
}
// Save $result['name'] in the database with the user id via PDO
is_uploaded_file / move_uploaded_file only accept paths PHP registered as uploads. That stops tricks where tmp_name is set to /etc/passwd in a forged request.
Generate names with random_bytes (or a UUID). Do not keep the user’s original filename as the on-disk name. If you need the original for download labels, store it in a separate database column after escaping it on output (htmlspecialchars).
Application checks are not enough if the request never reaches your script:
upload_max_filesize — per-file ceilingpost_max_size — whole POST body (must be larger than the file plus other fields)max_file_uploads — how many files per requestfile_uploads — must be OnWhen the body exceeds post_max_size, $_FILES and $_POST can be empty. Detect that by checking request method POST with empty $_POST and empty $_FILES and a large CONTENT_LENGTH. Surface a friendly “file too large†message.
MAX_FILE_SIZE hidden fields in HTML are advisory only; attackers ignore them. Keep your own $maxBytes check.
Best options, safest first:
/var/app/storage) and serve via a PHP script that checks the session (login sessions) and streams the file with a safe Content-Type.php_flag engine off or handing files as static assets with no PHP handler. Still use random names and MIME allow-lists.Never let uploads land in a folder where something.php would execute. Double extensions and null-byte tricks are less effective on modern PHP, but misconfigured servers still run scripts in upload dirs.
If you only want images, after the MIME allow-list you can also:
<?php
$info = @getimagesize($tmp);
if ($info === false) {
// not a decodeable image
}
// $info[0], $info[1] width/height; $info['mime']
Or re-encode with GD/Imagick (load then save as JPEG/PNG) to strip weird payloads hanging off polyglot files. That is heavier but appropriate for profile photos on a public site. For PDFs or office documents the threat model changes again — those need different parsers and usually should not be served as inline HTML.
Combine with normal field validation from PHP form validation and HTML input types:
validate_upload.$_FILES['type'] or the extension alone.../../web/shell.php style tricks, spaces, Unicode lookalikes).error === UPLOAD_ERR_OK.move_uploaded_file before MIME checks.php.ini).Because it is sent by the client. PHP does not verify it. Attackers set Content-Type: image/png on a PHP script. Always detect from the temp file with finfo.
No. Extensions are cosmetic. evil.php.png or evil.jpg with PHP content can still hurt you if the server executes it or if another bug includes the file. MIME allow-lists plus safe storage matter more.
move_uploaded_file already checks that the source is an uploaded file and returns false otherwise. An explicit is_uploaded_file before other processing (reading with finfo, getimagesize) is still good practice so you never analyse an arbitrary path.
For avatars: image/jpeg, image/png, image/webp are a sensible default. Add image/gif only if you accept animation. Avoid image/svg+xml unless you sanitise SVG. For documents, prefer storing them non-executable and serving as Content-Disposition: attachment.
Use PHP’s built-in server, a small form, and intentional bad files (rename a .php to .png, upload a huge file, omit enctype). Unit-test your validator by pointing tmp_name at fixtures and, in production code paths, still requiring is_uploaded_file.
Client checks improve UX only. Anyone can bypass them with curl or a modified browser. Server-side validation is mandatory; see also the spirit of my fetch POST JSON note — the browser is not a security boundary.
<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/db.php';
require __DIR__ . '/upload_helpers.php'; // validate_upload()
if (empty($_SESSION['user_id'])) {
header('Location: /login.php');
exit;
}
$errors = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$result = validate_upload($_FILES['avatar'] ?? []);
if (!isset($result['ok'])) {
$errors = $result;
} else {
$destDir = dirname(__DIR__) . '/storage/avatars';
if (!is_dir($destDir) && !mkdir($destDir, 0750, true)) {
$errors[] = 'Storage unavailable.';
} else {
$dest = $destDir . '/' . $result['name'];
if (!move_uploaded_file($_FILES['avatar']['tmp_name'], $dest)) {
$errors[] = 'Could not save file.';
} else {
$pdo->prepare(
'UPDATE users SET avatar_path = ? WHERE id = ?'
)->execute([$result['name'], $_SESSION['user_id']]);
header('Location: /profile.php?uploaded=1');
exit;
}
}
}
}
Serve the avatar through a small script that checks the session (or uses a non-guessable name plus long cache headers) rather than listing the storage directory. Escape any client-supplied original filename if you show it (XSS post).
When the input is name="photos[]", PHP nests arrays under each $_FILES key. Loop with an index, run validate_upload on each assembled single-file array, and stop at a maximum count (for example 5). One bad file should not leave half-moved orphans — validate all first, then move, or move to a temp staging area and commit.
Log the user id, detected MIME, size and final stored name (not the raw temp path forever). Rate-limit uploads per account to stop disk-fill attacks. Quotas belong next to your size check. Virus scanning (ClamAV etc.) is optional for trusted-size image sites and more important when you accept arbitrary documents.
Slugifying My Holiday🙂.PNG into my-holiday.png looks tidy but collides when two users upload the same name, and it still trusts that the extension matches content. Random hex names collide only with negligible probability and force the extension to come from your MIME map. Keep the pretty title in the database for UI labels.
If you store files privately and stream them:
<?php
declare(strict_types=1);
// after auth checks...
$path = $destDir . '/' . $safeBasename; // from DB, not from $_GET directly
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('X-Content-Type-Options: nosniff');
header('Content-Disposition: attachment; filename="' . e($downloadLabel) . '"');
readfile($path);
exit;
nosniff stops some browsers from MIME-sniffing a file into executable HTML. Prefer attachment for untrusted documents. For images you own and validated, inline may be acceptable. Never take the filesystem path from the query string — take an id, look up the path server-side.
On failure after move_uploaded_file, delete the partial file. On database failure after a successful move, delete the file or you leak orphans. Wrap DB work in a transaction where you can; filesystems do not roll back with InnoDB, so compensate explicitly.
Map technical failures to plain UK English:
Log the MIME and error code server-side for yourself. Do not show text/x-php to end users in a way that teaches attackers what you detect; a generic “that file type is not allowed†is enough on the public form.
Avatars are small, image-only, publicly or semi-publicly readable. Attachments to a support ticket may be PDFs and need stricter private storage and staff-only download. Reuse validate_upload with different allow-lists and $maxBytes. One function with parameters beats two copy-pasted scripts that drift apart.
enctype on the form.UPLOAD_ERR_OK.filesize.finfo allow-list.move_uploaded_file.Tick all ten and you are ahead of most tutorial code on the internet.
getimagesize is a useful extra check for images, but it is not a full substitute for finfo allow-lists, and it does nothing for PDFs. Some polyglot files try to be both image and script; re-encoding with GD (imagecreatefromjpeg → imagejpeg) is the stronger image-only hardening step when you need it. Start with finfo + size + random names; add re-encoding when avatars are shown to other users.
Check the error code, measure the size, believe finfo, rename the file, and keep executables out of the upload directory. That checklist removes most upload disasters in small PHP apps.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Wire Composer PSR-4 autoload in PHP: map App\ to src/App/, require vendor/autoload.php, dump-autoload, and fix the usual class-not-found mistakes.
Use Europe/London in PHP the safe way: date_default_timezone_set, DateTimeImmutable, GMT vs BST, store UTC, display dd/mm/YYYY, and avoid DST surprises.
A tested PHP login with sessions and PDO: password_verify, session_regenerate_id on success, a protected dashboard, and a logout that clears the session cookie.