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:

php
$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:

php
$file = $request->file('avatar');

Multiple Files

Use $request->files() to retrieve all uploaded files as an array of Upload instances:

php
$files = $request->files();

foreach ($files as $key => $file) {
    if ($file->isValid()) {
        $file->store('uploads');
    }
}

Checking if a File Was Uploaded

php
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:

php
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:

php
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:

php
$file->setMaxSize(5 * 1024 * 1024)           // 5 MB
     ->setAllowedMimeTypes(['application/pdf'])
     ->setAllowedExtensions(['pdf'])
     ->setStrictMimeValidation(true);
MethodDescription
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:

php
$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:

  1. Size — file is not empty and does not exceed $maxSize
  2. MIME type — real MIME (via finfo) is in $allowedMimeTypes
  3. Extension — extension is not in the blocked list and is in $allowedExtensions
  4. Filename — no control characters, no path traversal sequences, within length limit
  5. File content — declared images pass getimagesize() check
  6. 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:

php
// 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:

php
// 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:

php
$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 contents

Image Information

For image uploads, retrieve dimensions and metadata:

php
if ($file->isImage()) {
    $info = $file->getImageInfo();
    // [
    //   'width'    => 1920,
    //   'height'   => 1080,
    //   'type'     => 2,        // IMAGETYPE_JPEG constant
    //   'mime'     => 'image/jpeg',
    //   'channels' => 3,
    //   'bits'     => 8,
    // ]
}

Type Helpers

php
$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 list

Practical Examples

Avatar Upload

php
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

php
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);
    }
}
php
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

php
$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:

plaintext
php, phtml, php3–php8, exe, bat, cmd, scr, vbs, js, jar, sh, py, pl, rb, asp, aspx, jsp

Real 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:

CategorySignatures Detected
ExecutableMZ 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 movemove_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:

apache
# public/uploads/.htaccess
Options -ExecCGI
AddHandler cgi-script .php .php3 .php4 .php5 .phtml .pl .py .jsp .asp .sh

Always enable strict MIME validation in production:

php
$file->setStrictMimeValidation(true);

Never set setAllowExecutableFiles(true) in production. The option exists only for specialised internal tooling on trusted networks.


Method Reference

MethodReturnsDescription
validate(throwOnError?)boolRun all validation rules
isValid()boolValidate silently (no exception)
getErrors()arrayValidation error messages
clearErrors()selfReset validation state
store(directory, timestamp?)stringSave with auto-generated unique name
move(directory, filename?, preserve?)stringSave with explicit or generated name
getOriginalName()stringSanitised original filename
getBasename()stringFilename without extension
getExtension()stringLowercase file extension
getSize()intFile size in bytes
getHumanSize(precision?)stringHuman-readable size (e.g. 1.24 MB)
getMimeType()stringReal MIME type via finfo
getClientMimeType()?stringBrowser-declared MIME type
getError()intPHP upload error code
getRealPath() / getTempPath()stringTemporary file path
getHash()stringSHA-256 hash of file contents
getImageInfo()?arrayWidth, height, type, MIME (images only)
isImage()boolIs an image MIME type
isDocument()boolIs a document MIME type
isVideo()boolIs a video MIME type
isAudio()boolIs an audio MIME type
isExecutable()boolExtension is in the blocked list
setMaxSize(int)selfSet max size in bytes
setAllowedMimeTypes(array)selfWhitelist MIME types
setAllowedExtensions(array)selfWhitelist extensions
setStrictMimeValidation(bool)selfToggle strict MIME checking
setAllowExecutableFiles(bool)selfToggle executable file permission