Skip to content

PHP Project — Build a File Manager from Scratch

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about PHP Project. We cover key concepts, practical examples, and best practices to help you master this topic.

This PHP project walks through building a complete file manager application that supports file upload with drag-and-drop, directory creation and navigation, image thumbnails and previews, file search and filtering, user permissions, secure file storage outside the web root, and sharing via expiring links. You will apply filesystem operations, MIME type validation, image manipulation with GD, and ACL-based access control.

What You'll Learn

  • Navigating the filesystem with PHP's DirectoryIterator and SplFileInfo
  • Implementing secure file upload with MIME type and size validation
  • Creating thumbnails for images using the GD library
  • Building a tree-view directory navigation with Breadcrumbs
  • Implementing full-text file search using indexed metadata
  • Setting up ACL-based user permissions for files and folders
  • Creating expiring share links with token-based access
  • Handling file operations: rename, move, copy, delete with confirmation

Why It Matters

File management is a core requirement for content management systems, cloud storage services, and collaboration platforms. PHP's built-in filesystem functions make it well-suited for this task. Understanding secure file handling, permission models, and storage architecture prepares you for building anything from a simple file uploader to a full cloud storage service.

Real-World Use

The Durga Antivirus Pro quarantine manager is built on a PHP file manager that scans uploaded files, stores them securely outside the web root, and provides an admin interface for viewing, restoring, or deleting quarantined items. The same codebase powers the file attachment system in DodaTech's support ticket system.

Learning Path

flowchart LR
  A[URL Shortener Project] --> B[File Manager\nYou are here]
  B --> C[WebSockets]
  style B fill:#f90,color:#fff

Storage Architecture

Files are stored outside the web root with a Database Index for metadata:

/var/www/storage/          -- Outside web root, not directly accessible
  files/
    ab/
      abc123def456.pdf     -- Actual file, name is SHA256 hash
    cd/
      cde789ghi012.jpg
  thumbs/                   -- Generated thumbnails
    abc123def456_thumb.jpg
  temp/                     -- Temporary upload directory

Database schema:

CREATE TABLE files (
  id INT AUTO_INCREMENT PRIMARY KEY,
  user_id INT NOT NULL,
  parent_id INT DEFAULT NULL,
  name VARCHAR(255) NOT NULL,
  path VARCHAR(500) NOT NULL,
  mime_type VARCHAR(100) DEFAULT NULL,
  size BIGINT NOT NULL DEFAULT 0,
  is_directory TINYINT(1) DEFAULT 0,
  hash VARCHAR(64) DEFAULT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  FOREIGN KEY (parent_id) REFERENCES files(id) ON DELETE CASCADE,
  FOREIGN KEY (user_id) REFERENCES users(id),
  INDEX idx_parent (parent_id),
  INDEX idx_user (user_id)
);

CREATE TABLE share_links (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_id INT NOT NULL,
  token VARCHAR(64) NOT NULL UNIQUE,
  expires_at TIMESTAMP NOT NULL,
  max_downloads INT DEFAULT NULL,
  download_count INT DEFAULT 0,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
);

CREATE TABLE file_permissions (
  file_id INT NOT NULL,
  user_id INT NOT NULL,
  permission ENUM('read', 'write', 'admin') DEFAULT 'read',
  PRIMARY KEY (file_id, user_id),
  FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

Step 1: File Upload Handler

Create src/FileUploader.php:

<?php
namespace App;

use PDO;

class FileUploader
{
  private const ALLOWED_MIME_TYPES = [
    'image/jpeg', 'image/png', 'image/gif', 'image/webp',
    'application/pdf',
    'application/zip', 'application/x-rar-compressed',
    'text/plain', 'text/csv', 'text/html',
    'application/json', 'application/xml',
    'video/mp4', 'audio/mpeg',
  ];

  private const MAX_FILE_SIZE = 100 * 1024 * 1024;  // 100 MB

  public function __construct(
    private PDO $pdo,
    private string $storagePath,
    private string $thumbPath
  ) {}

  public function upload(array $file, int $userId, ?int $parentId = null): array
  {
    $errors = [];

    if ($file['error'] !== UPLOAD_ERR_OK) {
      $errorMessages = [
        UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit',
        UPLOAD_ERR_FORM_SIZE => 'File exceeds form size limit',
        UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
        UPLOAD_ERR_NO_FILE => 'No file was uploaded',
      ];
      $errors[] = $errorMessages[$file['error']] ?? 'Unknown upload error';
      return ['success' => false, 'errors' => $errors];
    }

    if ($file['size'] > self::MAX_FILE_SIZE) {
      $errors[] = 'File size exceeds maximum of 100 MB';
      return ['success' => false, 'errors' => $errors];
    }

    $finfo = new \finfo(FILEINFO_MIME_TYPE);
    $mimeType = $finfo->file($file['tmp_name']);

    if (!in_array($mimeType, self::ALLOWED_MIME_TYPES)) {
      $errors[] = "File type $mimeType is not allowed";
      return ['success' => false, 'errors' => $errors];
    }

    $hash = hash_file('sha256', $file['tmp_name']);

    $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM files WHERE hash = :hash AND user_id = :uid');
    $stmt->execute(['hash' => $hash, 'uid' => $userId]);
    if ($stmt->fetchColumn() > 0) {
      $errors[] = 'You have already uploaded this file';
      return ['success' => false, 'errors' => $errors];
    }

    $subdir = substr($hash, 0, 2);
    $destDir = $this->storagePath . '/' . $subdir;
    if (!is_dir($destDir)) {
      mkdir($destDir, 0755, true);
    }

    $filename = $hash . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '', $file['name']);
    $destPath = $destDir . '/' . $filename;

    if (!move_uploaded_file($file['tmp_name'], $destPath)) {
      $errors[] = 'Failed to store file on server';
      return ['success' => false, 'errors' => $errors];
    }

    $stmt = $this->pdo->prepare(
      'INSERT INTO files (user_id, parent_id, name, path, mime_type, size, hash)
       VALUES (:uid, :parent, :name, :path, :mime, :size, :hash)'
    );
    $stmt->execute([
      'uid' => $userId,
      'parent' => $parentId,
      'name' => $file['name'],
      'path' => $subdir . '/' . $filename,
      'mime' => $mimeType,
      'size' => $file['size'],
      'hash' => $hash,
    ]);

    $fileId = (int)$this->pdo->lastInsertId();

    if (str_starts_with($mimeType, 'image/')) {
      $this->generateThumbnail($destPath, $fileId, $mimeType);
    }

    return [
      'success' => true,
      'file_id' => $fileId,
      'name' => $file['name'],
      'size' => $file['size'],
    ];
  }

  private function generateThumbnail(string $sourcePath, int $fileId, string $mimeType): void
  {
    if (!extension_loaded('gd')) {
      return;
    }

    $maxWidth = 300;
    $maxHeight = 300;

    $image = match ($mimeType) {
      'image/jpeg' => imagecreatefromjpeg($sourcePath),
      'image/png' => imagecreatefrompng($sourcePath),
      'image/gif' => imagecreatefromgif($sourcePath),
      'image/webp' => imagecreatefromwebp($sourcePath),
      default => null,
    };

    if ($image === null) return;

    $origWidth = imagesx($image);
    $origHeight = imagesy($image);

    $ratio = min($maxWidth / $origWidth, $maxHeight / $origHeight, 1.0);
    $newWidth = (int)round($origWidth * $ratio);
    $newHeight = (int)round($origHeight * $ratio);

    $thumb = imagecreatetruecolor($newWidth, $newHeight);
    imagecopyresampled($thumb, $image, 0, 0, 0, 0, $newWidth, $newHeight, $origWidth, $origHeight);

    $thumbDir = $this->thumbPath . '/' . $fileId;
    if (!is_dir($thumbDir)) {
      mkdir($thumbDir, 0755, true);
    }

    imagewebp($thumb, $thumbDir . '/thumb.webp', 80);
    imagedestroy($image);
    imagedestroy($thumb);
  }
}

Step 2: Directory Listing

Create browse.php:

<?php
require_once 'config.php';
require_once 'auth.php';

requireLogin();

$userId = $_SESSION['user_id'];
$parentId = isset($_GET['dir']) ? (int)$_GET['dir'] : null;

$pdo = getDb();

// Get breadcrumbs
$breadcrumbs = [];
$currentId = $parentId;
while ($currentId !== null) {
  $stmt = $pdo->prepare('SELECT id, name, parent_id FROM files WHERE id = :id AND is_directory = 1');
  $stmt->execute(['id' => $currentId]);
  $dir = $stmt->fetch();
  if ($dir) {
    $breadcrumbs[] = $dir;
    $currentId = $dir['parent_id'];
  } else {
    break;
  }
}
$breadcrumbs = array_reverse($breadcrumbs);

// Get directories and files
$stmt = $pdo->prepare('
  SELECT id, name, mime_type, size, is_directory, created_at,
         (SELECT COUNT(*) FROM files f2 WHERE f2.parent_id = f.id) as children
  FROM files f
  WHERE f.user_id = :uid AND f.parent_id IS NULL
  ' . ($parentId !== null ? 'AND f.parent_id = :parent' : 'AND f.parent_id IS NULL') . '
  ORDER BY f.is_directory DESC, f.name ASC
');

$params = ['uid' => $userId];
if ($parentId !== null) {
  $params['parent'] = $parentId;
}
$stmt->execute($params);
$entries = $stmt->fetchAll();
?>
<!DOCTYPE html>
<html>
<head>
  <title>File Manager</title>
  <link rel="stylesheet" href="/static/style.css">
</head>
<body>
  <h1>File Manager</h1>

  <nav class="breadcrumbs">
    <a href="/browse.php">Root</a>
    <?php foreach ($breadcrumbs as $crumb): ?>
      / <a href="/browse.php?dir=<?= $crumb['id'] ?>"><?= htmlspecialchars($crumb['name']) ?></a>
    <?php endforeach; ?>
  </nav>

  <div class="upload-zone" id="upload-zone">
    <p>Drag and drop files here or click to upload</p>
    <input type="file" id="file-input" multiple style="display:none">
  </div>

  <table>
    <tr>
      <th>Name</th>
      <th>Size</th>
      <th>Type</th>
      <th>Modified</th>
      <th>Actions</th>
    </tr>
    <?php foreach ($entries as $entry): ?>
    <tr>
      <td>
        <?php if ($entry['is_directory']): ?>
          <a href="/browse.php?dir=<?= $entry['id'] ?>"><?= htmlspecialchars($entry['name']) ?>/</a>
        <?php else: ?>
          <a href="/view.php?id=<?= $entry['id'] ?>"><?= htmlspecialchars($entry['name']) ?></a>
        <?php endif; ?>
      </td>
      <td><?= $entry['is_directory'] ? '-' : formatBytes($entry['size']) ?></td>
      <td><?= $entry['is_directory'] ? 'Directory' : htmlspecialchars($entry['mime_type']) ?></td>
      <td><?= $entry['created_at'] ?></td>
      <td>
        <a href="/download.php?id=<?= $entry['id'] ?>">Download</a>
        <a href="/share.php?id=<?= $entry['id'] ?>">Share</a>
        <a href="/rename.php?id=<?= $entry['id'] ?>" class="action-rename">Rename</a>
        <a href="/delete.php?id=<?= $entry['id'] ?>" onclick="return confirm('Delete this item?')">Delete</a>
      </td>
    </tr>
    <?php endforeach; ?>
  </table>

  <script>
    const zone = document.getElementById('upload-zone');
    const fileInput = document.getElementById('file-input');

    zone.addEventListener('click', () => fileInput.click());

    zone.addEventListener('dragover', (e) => {
      e.preventDefault();
      zone.classList.add('drag-over');
    });

    zone.addEventListener('dragleave', () => {
      zone.classList.remove('drag-over');
    });

    zone.addEventListener('drop', (e) => {
      e.preventDefault();
      zone.classList.remove('drag-over');
      uploadFiles(e.dataTransfer.files);
    });

    fileInput.addEventListener('change', () => {
      if (fileInput.files.length > 0) {
        uploadFiles(fileInput.files);
      }
    });

    function uploadFiles(files) {
      const formData = new FormData();
      formData.append('parent_id', '<?= $parentId ?? '' ?>');
      for (const file of files) {
        formData.append('files[]', file);
      }

      fetch('/upload.php', {
        method: 'POST',
        body: formData,
      })
      .then(response => response.json())
      .then(data => {
        if (data.success) {
          location.reload();
        } else {
          alert('Upload failed: ' + data.errors.join(', '));
        }
      });
    }
  </script>
</body>
</html>

Common Mistakes

  1. Storing files inside the web root: Files in public/uploads/ are directly accessible via URL. Store them outside the web root and serve them through a PHP script that checks permissions.
  2. Trusting the file extension or MIME type from the client: The browser can report any MIME type. Use finfo to detect the actual MIME type from the file content on the server.
  3. Not limiting file size on the server side: php.ini's upload_max_filesize and post_max_size are global limits. Add per-user or per-request limits in your application code.
  4. Allowing upload of executable files: PHP files, shell scripts, and executables can be used to compromise the server. Block .php, .phtml, .sh, .exe, .jar, and other executable extensions.
  5. Not cleaning up orphaned files: If a database record is deleted but the physical file remains, disk space leaks. Implement a garbage collector that removes files without database records.

Practice Questions

  1. Why store files outside the web root?

    • Files outside the web root are not directly accessible via URL. A PHP script must serve them, allowing access control checks, logging, and MIME type enforcement.
  2. How does the SHA256 hash prevent duplicate uploads?

    • Before storing a file, compute its SHA256 hash. If a file with the same hash already exists for the user, reject the upload. This saves storage and prevents exact duplicates.
  3. What is the purpose of the two-character subdirectory prefix?

    • Splitting files into 256 subdirectories (00-ff) prevents millions of files from accumulating in one directory, which causes filesystem performance degradation.
  4. How does the thumbnail generation work?

    • The GD library loads the original image, resizes it to fit within 300x300 pixels while preserving the aspect ratio, and saves a WebP thumbnail to a dedicated thumbs directory.
  5. Challenge: Implement a versioning system where uploading a file with the same name creates a new version instead of overwriting. Store all versions and allow the user to download or restore any previous version.

Mini Project

Add advanced file management features:

  • Full-text search that indexes file names and content (PDF, DOCX, TXT).
  • Multiple view modes: grid, list, and gallery for images.
  • File tagging: users can assign tags to files and filter by tag.
  • Trash bin: deleted files go to a trash folder and can be restored within 30 days.
  • File encryption: encrypt uploaded files using AES-256 before storing.
  • Public file sharing: generate a public link (optionally password-protected) with expiration.

FAQ

What is the best way to store uploaded files?

Store them outside the web root in a directory organized by hash prefix (e.g., storage/ab/abc123...). Serve them through a PHP script that validates permissions.

How do I handle very large file uploads?

For files over 100MB, implement chunked uploads where the client splits the file into pieces and sends them sequentially. Reassemble the pieces on the server.

What MIME types should I allow?

Allow common document, image, and media types. Block executable files, scripts, and unknown types. Use finfo for server-side MIME detection.

How do I scan for viruses on upload?

Integrate with ClamAV via the clamav-php extension or call clamscan from the command line. Quarantine infected files and notify the administrator.

What's Next

Now explore real-time communication with WebSockets for live notifications and collaboration features.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro