0

Flysystem — Laravel's filesystem abstraction internals

Intermediate5 min read·php-13-010
laravel-src

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:

  1. Check $_FILES['file']['error'] === UPLOAD_ERR_OK.
  2. Verify file size (don't trust the form value — check the actual uploaded file).
  3. Verify actual MIME type with mime_content_type() or finfo_file() — NOT $_FILES['type'].
  4. Validate file extension against an allowlist.
  5. Generate a new filename server-side — never use the original name directly.
  6. Store uploads OUTSIDE the webroot, or configure the webroot to not execute scripts in the uploads directory.

Code Example

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