Flysystem — Laravel's filesystem abstraction internals
Concept
PHP's file upload system routes uploaded files through a temporary holding area before your code processes them. Understanding the lifecycle, validation requirements, and secure handling is critical — file upload vulnerabilities are among the most dangerous in web applications.
Upload lifecycle: Browser sends multipart form-data → PHP stores file in sys_get_temp_dir() → populates $_FILES superglobal → your script processes/validates → move_uploaded_file() moves to permanent location → temporary file deleted at script end.
$_FILES structure: $_FILES['field']['name'] (original name), ['tmp_name'] (temp path), ['type'] (MIME claimed by browser — DO NOT TRUST), ['size'] (bytes), ['error'] (error code: 0 = success).
move_uploaded_file(string $from, string $to): The only safe way to move an uploaded file. It verifies $from was actually uploaded via HTTP (not an arbitrary temp file the attacker pointed to). Returns false if not an uploaded file — a critical security check.
Validation you MUST do:
- Check
$_FILES['file']['error'] === UPLOAD_ERR_OK. - Verify file size (don't trust the form value — check the actual uploaded file).
- Verify actual MIME type with
mime_content_type()orfinfo_file()— NOT$_FILES['type']. - Validate file extension against an allowlist.
- Generate a new filename server-side — never use the original name directly.
- Store uploads OUTSIDE the webroot, or configure the webroot to not execute scripts in the uploads directory.
Code Example
<?php
declare(strict_types=1);
function handleUpload(array $file, string $storageDir): string
{
// Check upload error
if ($file['error'] !== UPLOAD_ERR_OK) {
$errors = [
UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize',
UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE form field',
UPLOAD_ERR_PARTIAL => 'File only partially uploaded',
UPLOAD_ERR_NO_FILE => 'No file uploaded',
UPLOAD_ERR_NO_TMP_DIR => 'No temporary directory',
UPLOAD_ERR_CANT_WRITE => 'Cannot write to disk',
UPLOAD_ERR_EXTENSION => 'PHP extension blocked upload',
];
throw new \RuntimeException($errors[$file['error']] ?? 'Upload error');
}
// Size validation
$maxBytes = 5 * 1024 * 1024; // 5MB
if ($file['size'] > $maxBytes) {
throw new \RuntimeException("File too large (max 5MB)");
}
// MIME type validation — use finfo, NOT $_FILES['type']
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
$allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($mimeType, $allowedMimes, strict: true)) {
throw new \RuntimeException("Invalid file type: $mimeType");
}
// Extension allowlist (derived from MIME, not user input)
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'];
$ext = $extensions[$mimeType];
// Generate safe filename — never use original name
$newFilename = bin2hex(random_bytes(16)) . '.' . $ext;
$destination = rtrim($storageDir, '/') . '/' . $newFilename;
// move_uploaded_file — verifies file was actually uploaded via HTTP
if (!move_uploaded_file($file['tmp_name'], $destination)) {
throw new \RuntimeException("Failed to move uploaded file");
}
return $newFilename;
}
// Usage
try {
$filename = handleUpload($_FILES['avatar'], '/var/www/storage/avatars');
echo "Saved as: $filename";
} catch (\RuntimeException $e) {
http_response_code(422);
echo "Upload failed: " . $e->getMessage();
}