File uploads

File uploads allow web applications to transfer files efficiently and reliably, while preserving file content and metadata in a standardized way that works across browsers and servers.

Issues

The native PHP $_FILES superglobal has several limitations and pitfalls that developers often run into:

  • Awkward structure: When handling multiple files, $_FILES becomes a deeply nested array that is difficult to iterate over and easy to misuse.
  • Upload errors are easy to overlook: Failing to check $_FILES['error'] can result in processing incomplete or failed uploads.
  • Populated without uploads: $_FILES may contain entries even when no file was uploaded, typically with the UPLOAD_ERR_NO_FILE error code, which can be misleading if not handled explicitly.

To address these issues, a dedicated file upload component was introduced.

Prepare

To submit a file using an HTML form, ensure the form uses the POST method and the correct encoding type:

<form action="/upload" method="POST" enctype="multipart/form-data">
  <label for="document">Choose a document:</label>
  <input type="file" id="document" name="document">
  <button type="submit">Upload</button>
</form>

Alternatively, files can be sent via JavaScript using FormData:

const fileInput = document.getElementById("document");
const file = fileInput.files[0];

const formData = new FormData();
formData.append("file", file);

fetch("/upload", {
    method: "POST",
    body: formData
})
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));

Accessing uploaded file

To access uploaded files, call the getFiles() method on the request service from within your controller. This returns an instance of Krystal\Http\FileTransfer\FileEntity.

public function uploadAction()
{
    // Get file instance from input named "document"
    $file = $this->request->getFiles('document');

    if ($file) {
        $file->getName();       // Original filename
        $file->getUniqueName(); // Unique filename (preserves extension)
        $file->getType();       // Guessed MIME type
        $file->getMimeType();   // Verified MIME type (server-side check)
        $file->getTmpName();    // Temporary file path
        $file->getError();      // Native PHP upload error code
        $file->getSize();       // File size in bytes
        $file->getHumanSize();  // Formatted size (e.g., "200.00 KB")
        $file->isDangerous();   // Security check
        // ...
    }

    // ...
}

If no file was uploaded, getFiles() returns an empty array.

Regardless of input name or nesting depth, this method always returns a normalized and predictable collection of uploaded files, unlike the native $_FILES superglobal.

Check if file is dangerous

Always call $file->isDangerous() before moving or processing any uploaded file.

Why & How

$file->isDangerous() protects against common malicious uploads by checking:

File extension against a deny-list (.php, .htaccess, etc.) Real MIME type using magic bytes (prevents disguised scripts)

$file = $this->request->getFiles('avatar');

// Basic
if ($file && $file->isDangerous()) {
    // Dangerous file type detected!
}

// With extra extensions to verify
if ($file->isDangerous(['docm', 'xlsm', 'jar'])) { 
    // Dangerous file type detected!
}

// Only check extension (skip MIME verification)
if ($file->isDangerous([], false)) {
    // Dangerous file type detected!
}

Uploading a file

To persist uploaded files, use the dedicated uploader component:

<?php

namespace Site\Controller;

use Krystal\Http\FileTransfer\FileUploader;
use Krystal\Application\Controller;

final class User extends AbstractController
{
    public function upload()
    {
        $files = $this->request->getFiles('files'); // If parameter provided, only from that input data is exacracted

        if ($files) { // If there's a file
            $destination = $this->appConfig->getUploadsDir(); // e.g /data/uploads/

            $uploader = new FileUploader();
            $uploader->upload($destination, $files); // Returns boolean value

            // Other methods
            $uploader->getUploadedFiles(); // Returns array of uploaded files
            $uploader->getFailedFiles(); // Returns array of failed files
        }
    }
}

Moving a single file

Instead of using the full FileUploader service, you can also move an individual file directly from the entity. By default, it will use the secure name generated by getUniqueName().

public function uploadAction()
{
    $file = $this->request->getFiles('document');

    if ($file) {
        $path = '/path/to/your/uploads';

        // Moves the file and returns a boolean
        if ($file->move($path)) {
            echo "File saved as: " . $file->getUniqueName();
        }
    }
}

Custom naming

If you prefer to keep the original filename (not recommended for public uploads) or provide a custom one, you can pass it as the second argument:

// Saves the file with its original name
$file->move($path, $file->getName());

// Saves the file with a custom name
$file->move($path, 'profile_v1.png');