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.
The native PHP $_FILES superglobal has several limitations and pitfalls that developers often run into:
$_FILES becomes a deeply nested array that is difficult to iterate over and easy to misuse.$_FILES['error'] can result in processing incomplete or failed 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.
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));
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 (deny-list + MIME)
$file->getRealExtension(); // Detected type by magic bytes
$file->isContentAllowed([]); // Whitelist content check
$file->isExecutable(); // Executable binary 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.
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!
}
getExtension() reads the file name and can be spoofed — an attacker can rename exploit.php to photo.jpg. getRealExtension() inspects the file's actual content (magic bytes) and returns the real type, regardless of the name.
$file = $this->request->getFiles('image');
if ($file) {
$claimed = $file->getExtension(); // e.g. "jpg" — from the name
$real = $file->getRealExtension(); // e.g. "php" — from the content
if ($claimed !== $real) {
// Extension spoofing detected — reject the upload
}
}
Returns null if the type cannot be detected (rare for well-formed files).
Supported types include:
isContentAllowed() is the whitelist counterpart to isDangerous(). It returns true only if the real content matches one of the allowed extensions. This is the recommended way to validate uploads for a specific field (e.g. only images allowed for an avatar).
$file = $this->request->getFiles('avatar');
if ($file) {
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!$file->isContentAllowed($allowed)) {
// Content does not match any of the allowed types — reject
}
}
jpeg and jpg are treated as equivalent — both refer to the same underlying format. Case is normalized, so 'JPG' and 'jpg' behave identically.
Because FileEntity reuses a single cached detector, calling isContentAllowed() after getRealExtension() or isExecutable() reads the file only once.
isExecutable() returns true if the file is a recognized executable binary: ELF (Linux/BSD), Mach-O (macOS), PE (Windows .exe), or Java class. Use it as an additional layer on top of extension checks — it catches executables even when renamed to .jpg.
$file = $this->request->getFiles('document');
if ($file && $file->isExecutable()) {
// Reject — the file is an executable binary
}
For any upload, combine the checks in this order:
$file = $this->request->getFiles('image');
if ($file) {
// 1. Deny-list: block known-bad extensions and dangerous MIME types
if ($file->isDangerous()) {
throw new RuntimeException('Dangerous file type');
}
// 2. Executable binaries: block ELF / Mach-O / PE / Java class
if ($file->isExecutable()) {
throw new RuntimeException('Executable files are not allowed');
}
// 3. Whitelist: only allow known-safe image formats
if (!$file->isContentAllowed(['jpg', 'jpeg', 'png', 'gif', 'webp'])) {
throw new RuntimeException('File content does not match allowed image types');
}
// 4. Extension vs. content: detect spoofing
$claimed = $file->getExtension();
$real = $file->getRealExtension();
if ($real !== null && $claimed !== $real && !($claimed === 'jpeg' && $real === 'jpg')) {
throw new RuntimeException('Extension spoofing detected');
}
// 5. Move the file
if (!$file->move($uploadDir)) {
throw new RuntimeException('Failed to save the file');
}
}
Each layer answers a different question:
| Method | Question |
|---|---|
isDangerous() |
Is this a known-bad extension or MIME type? |
isExecutable() |
Is this an executable binary? |
isContentAllowed() |
Does the content match what we expect? |
getRealExtension() |
Does the name lie about the content? |
No single check is enough. Combining all four gives strong defense in depth.
isContentAllowed(['svg']) does not sanitize it. If you accept SVG uploads, sanitize them with a dedicated library (e.g. enshrined/svg-sanitize) before serving, or store them outside the web root.getimagesize() — it parses the full structure and catches polyglot files (a valid image with embedded code).getRealExtension() returns zip for all of them; it cannot distinguish between them. To verify a specific document type, inspect the archive's internal structure.ftyp container. getRealExtension() returns mp4 for all of them.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
}
}
}
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');
Putting it all together — a controller action that accepts only image uploads:
public function uploadAction()
{
$file = $this->request->getFiles('image');
if (!$file) {
throw new RuntimeException('No file was uploaded');
}
// Layer 1: deny-list
if ($file->isDangerous()) {
throw new RuntimeException('Dangerous file type');
}
// Layer 2: executables
if ($file->isExecutable()) {
throw new RuntimeException('Executable files are not allowed');
}
// Layer 3: whitelist by content
if (!$file->isContentAllowed(['jpg', 'jpeg', 'png', 'gif', 'webp'])) {
throw new RuntimeException('Only JPG, PNG, GIF and WEBP images are allowed');
}
// Layer 4: extension vs. content
$claimed = $file->getExtension();
$real = $file->getRealExtension();
if ($real !== null && $claimed !== $real && !($claimed === 'jpeg' && $real === 'jpg')) {
throw new RuntimeException('Extension spoofing detected');
}
// Layer 5: move to a non-executable directory
$destination = $this->appConfig->getUploadsDir();
if (!$file->move($destination)) {
throw new RuntimeException('Failed to save the file');
}
return $this->json([
'saved' => $file->getUniqueName(),
]);
}