File Uploads
Introduction
Slenix provides a robust file upload class at Slenix\Supports\Uploads\Upload that wraps PHP's $_FILES data with a secure, fluent API. It performs multi-layer validation — size, MIME type, extension, filename, and content scanning — before allowing any file to be moved to its final destination.
The class integrates directly with the Request object:
$file = $request->file('avatar');
if ($file->isValid()) {
$path = $file->store('avatars');
}Accessing Uploaded Files
Single File
Use $request->file() to retrieve an uploaded file by its input field name:
$file = $request->file('avatar');Multiple Files
Use $request->files() to retrieve all uploaded files as an array of Upload instances:
$files = $request->files();
foreach ($files as $key => $file) {
if ($file->isValid()) {
$file->store('uploads');
}
}Checking if a File Was Uploaded
if ($request->hasFile('avatar')) {
$file = $request->file('avatar');
// ...
}Validating Files
Validation must be called explicitly. There are two approaches: exceptions or silent validation.
Throwing on Failure
validate() throws a RuntimeException on the first failing rule:
try {
$file->setMaxSize(2 * 1024 * 1024)
->setAllowedMimeTypes(['image/jpeg', 'image/png', 'image/webp'])
->setAllowedExtensions(['jpg', 'jpeg', 'png', 'webp'])
->validate();
$path = $file->store('avatars');
} catch (\RuntimeException $e) {
// e.g. "Tipo MIME 'application/octet-stream' não permitido."
$response->error($e->getMessage(), 422);
}Silent Validation
isValid() runs all rules and returns a boolean without throwing:
if ($file->isValid()) {
$path = $file->store('avatars');
} else {
$errors = $file->getErrors(); // array of error messages
}Configuring Validation Rules
All configuration methods return $this for chaining and reset the internal validation cache:
$file->setMaxSize(5 * 1024 * 1024) // 5 MB
->setAllowedMimeTypes(['application/pdf'])
->setAllowedExtensions(['pdf'])
->setStrictMimeValidation(true);| Method | Description |
|---|---|
setMaxSize(int $bytes) | Maximum file size in bytes |
setAllowedMimeTypes(array) | Whitelist of accepted real MIME types |
setAllowedExtensions(array) | Whitelist of accepted file extensions |
setStrictMimeValidation(bool) | Enforce real MIME vs. declared MIME check |
setAllowExecutableFiles(bool) | Allow executable extensions (default: false) |
Constructor Options
You can also pass options directly when constructing the class:
$upload = new Upload($_FILES['document'], [
'max_size' => 10 * 1024 * 1024,
'allowed_mime_types' => ['application/pdf'],
'allowed_extensions' => ['pdf'],
'strict_mime_validation' => true,
'allow_executable_files' => false,
]);Validation Rules Applied
Each call to validate() or isValid() runs the following checks in order:
- Size — file is not empty and does not exceed
$maxSize - MIME type — real MIME (via
finfo) is in$allowedMimeTypes - Extension — extension is not in the blocked list and is in
$allowedExtensions - Filename — no control characters, no path traversal sequences, within length limit
- File content — declared images pass
getimagesize()check - Security scan — no double extensions, no dangerous signatures in first 1 KB
Storing Files
store() — Unique Auto-Named File
store() generates a unique filename (timestamp + random hex) and moves the file. Validation runs automatically:
// stores as e.g. "avatars/2025-06-15_143022_a3f8b2c1d4e5.jpg"
$path = $file->store('avatars');
// Without timestamp prefix
$path = $file->store('avatars', false);move() — Manual Filename
move() gives you full control over the destination filename:
// Auto-generated unique name
$path = $file->move('uploads/docs');
// Custom filename
$path = $file->move('uploads/docs', 'contract-2025.pdf');
// Preserve the original sanitised filename
$path = $file->move('uploads/docs', null, true);If a file with the same name already exists at the destination, a numeric suffix is appended automatically (contract_1.pdf, contract_2.pdf, etc.).
Both methods create the destination directory recursively (0755) if it does not exist, and set the final file permissions to 0644.
File Information
After receiving or moving a file, you can inspect it using the following methods:
$file = $request->file('document');
$file->getOriginalName(); // Original sanitised filename
$file->getBasename(); // Filename without extension
$file->getExtension(); // Extension in lowercase (e.g. 'pdf')
$file->getSize(); // Size in bytes
$file->getHumanSize(); // Human-readable (e.g. '1.24 MB')
$file->getMimeType(); // Real MIME type detected by finfo
$file->getClientMimeType(); // MIME type declared by the browser
$file->getError(); // PHP upload error code
$file->getRealPath(); // Temporary path (before move)
$file->getHash(); // SHA-256 hash of file contentsImage Information
For image uploads, retrieve dimensions and metadata:
if ($file->isImage()) {
$info = $file->getImageInfo();
// [
// 'width' => 1920,
// 'height' => 1080,
// 'type' => 2, // IMAGETYPE_JPEG constant
// 'mime' => 'image/jpeg',
// 'channels' => 3,
// 'bits' => 8,
// ]
}Type Helpers
$file->isImage(); // JPEG, PNG, GIF, WebP, BMP, SVG
$file->isDocument(); // PDF, Word, Excel, TXT, CSV
$file->isVideo(); // Any video/* MIME type
$file->isAudio(); // Any audio/* MIME type
$file->isExecutable(); // Extension is in the blocked listPractical Examples
Avatar Upload
public function updateAvatar(Request $request, Response $response): void
{
if (!$request->hasFile('avatar')) {
$response->error('No file uploaded.', 422);
return;
}
$file = $request->file('avatar');
$file->setMaxSize(2 * 1024 * 1024)
->setAllowedMimeTypes(['image/jpeg', 'image/png', 'image/webp'])
->setAllowedExtensions(['jpg', 'jpeg', 'png', 'webp']);
if (!$file->isValid()) {
$response->error($file->getErrors()[0], 422);
return;
}
$path = $file->store('avatars');
auth()->user()->update(['avatar' => $path]);
$response->json([
'path' => $path,
'size' => $file->getHumanSize(),
'mime' => $file->getMimeType(),
]);
}PDF / Document Upload
public function uploadDocument(Request $request, Response $response): void
{
$file = $request->file('document');
try {
$file->setMaxSize(10 * 1024 * 1024)
->setAllowedMimeTypes(['application/pdf'])
->setAllowedExtensions(['pdf'])
->validate();
$hash = $file->getHash();
// Prevent duplicate documents
if (Document::where('hash', $hash)->exists()) {
$response->error('This document has already been uploaded.', 409);
return;
}
$path = $file->store('documents');
$doc = Document::create([
'name' => $file->getOriginalName(),
'path' => $path,
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'hash' => $hash,
'user_id' => auth()->id(),
]);
$response->status(201)->json($doc->toArray());
} catch (\RuntimeException $e) {
$response->error($e->getMessage(), 422);
}
}Gallery — Multiple Image Upload
public function uploadPhotos(Request $request, Response $response): void
{
$files = $request->files();
$saved = [];
$errors = [];
foreach ($files as $key => $file) {
$file->setMaxSize(5 * 1024 * 1024)
->setAllowedMimeTypes(['image/jpeg', 'image/png', 'image/webp'])
->setAllowedExtensions(['jpg', 'jpeg', 'png', 'webp']);
if (!$file->isValid()) {
$errors[$key] = $file->getErrors();
continue;
}
$path = $file->store('gallery');
$info = $file->getImageInfo();
$saved[] = [
'path' => $path,
'width' => $info['width'],
'height' => $info['height'],
'size' => $file->getHumanSize(),
];
}
$response->json(['saved' => $saved, 'errors' => $errors]);
}Preserve Original Filename
$file = $request->file('report');
$file->setAllowedMimeTypes(['application/pdf', 'text/csv'])
->setAllowedExtensions(['pdf', 'csv'])
->validate();
// Keeps the original sanitised name, appends suffix if conflict
$path = $file->move('reports', null, true);Security
The Upload class implements several layers of protection that run automatically:
Forbidden extensions — the following extensions are always blocked regardless of $allowedExtensions:
php, phtml, php3–php8, exe, bat, cmd, scr, vbs, js, jar, sh, py, pl, rb, asp, aspx, jspReal MIME detection — file type is verified using PHP's finfo extension, which reads the file's actual binary signature rather than trusting the browser-supplied Content-Type. A client claiming to upload image/jpeg while sending a PHP script will be caught.
Dangerous content scan — the first 1 KB of every file is scanned for known malicious signatures:
| Category | Signatures Detected |
|---|---|
| Executable | MZ header (PE/COFF — Windows .exe, .dll) |
| PHP | <?php, <?= |
| Script injection | <script, <iframe, <object, <embed |
Double extension blocking — filenames like shell.php.jpg are rejected if any intermediate extension appears in the forbidden list.
Filename sanitisation — control characters, path separators, and shell-special characters are stripped. The sanitised name is used for all operations; the raw original name from $_FILES is never used directly.
Safe file move — move_uploaded_file() is used internally, which PHP guarantees only works with files that arrived via an HTTP upload — preventing attacks that attempt to move arbitrary server files.
Permissions — files are stored with 0644 (owner read/write, group/world read-only). Directories are created with 0755.
Production Recommendations
Store uploaded files outside the web root (i.e. not inside public/) and serve them through a controller that enforces access control. If you must store inside public/, add a .htaccess to the upload directory that disables script execution:
# public/uploads/.htaccess
Options -ExecCGI
AddHandler cgi-script .php .php3 .php4 .php5 .phtml .pl .py .jsp .asp .shAlways enable strict MIME validation in production:
$file->setStrictMimeValidation(true);Never set setAllowExecutableFiles(true) in production. The option exists only for specialised internal tooling on trusted networks.
Method Reference
| Method | Returns | Description |
|---|---|---|
validate(throwOnError?) | bool | Run all validation rules |
isValid() | bool | Validate silently (no exception) |
getErrors() | array | Validation error messages |
clearErrors() | self | Reset validation state |
store(directory, timestamp?) | string | Save with auto-generated unique name |
move(directory, filename?, preserve?) | string | Save with explicit or generated name |
getOriginalName() | string | Sanitised original filename |
getBasename() | string | Filename without extension |
getExtension() | string | Lowercase file extension |
getSize() | int | File size in bytes |
getHumanSize(precision?) | string | Human-readable size (e.g. 1.24 MB) |
getMimeType() | string | Real MIME type via finfo |
getClientMimeType() | ?string | Browser-declared MIME type |
getError() | int | PHP upload error code |
getRealPath() / getTempPath() | string | Temporary file path |
getHash() | string | SHA-256 hash of file contents |
getImageInfo() | ?array | Width, height, type, MIME (images only) |
isImage() | bool | Is an image MIME type |
isDocument() | bool | Is a document MIME type |
isVideo() | bool | Is a video MIME type |
isAudio() | bool | Is an audio MIME type |
isExecutable() | bool | Extension is in the blocked list |
setMaxSize(int) | self | Set max size in bytes |
setAllowedMimeTypes(array) | self | Whitelist MIME types |
setAllowedExtensions(array) | self | Whitelist extensions |
setStrictMimeValidation(bool) | self | Toggle strict MIME checking |
setAllowExecutableFiles(bool) | self | Toggle executable file permission |