Why Build a File Manager in CodeIgniter 4?
A custom file manager gives you full control over how files are stored, validated, and served — without relying on third-party services or heavy CMS plugins. CodeIgniter 4 provides an excellent foundation with its built-in file upload library, validation system, and clean MVC architecture. In this tutorial, you will build:
- Drag-and-drop file uploads with Dropzone.js (images, documents, and more)
- Server-side validation for MIME types, file size, and extensions
- File listing with search, sort, and pagination using DataTables
- Image thumbnail generation for visual previews
- Document preview (PDF inline viewer, image lightbox)
- File download with proper headers and streaming
- Soft delete and permanent delete operations
- Folder organization with breadcrumb navigation
- Storage quota tracking per user

Table Of Content
1 Prerequisites
- PHP 8.1+ with GD or Imagick extension enabled
- Composer installed globally
- MySQL 5.7+ or MariaDB 10.3+
- CodeIgniter 4.4+ (installed via Composer)
- Basic knowledge of CodeIgniter 4 MVC pattern
- A code editor (VS Code recommended)
2 Project Setup & Installation
composer create-project codeigniter4/appstarter ci4-file-manager
cd ci4-file-manager
Copy env to .env and configure your environment:
cp env .env
Edit .env and set your database connection and environment:
CI_ENVIRONMENT = development
database.default.hostname = localhost
database.default.database = ci4_file_manager
database.default.username = root
database.default.password =
database.default.DBDriver = MySQLi
Create the database in MySQL:
CREATE DATABASE ci4_file_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
Create the uploads directories:
mkdir -p public/uploads/files
mkdir -p public/uploads/thumbnails
3 Database & Migration
3.1 Create Migration
php spark make:migration CreateFilesTable
Edit app/Database/Migrations/xxxx_CreateFilesTable.php:
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateFilesTable extends Migration
{
public function up()
{
$this->forge->addField([
"id" => [
"type" => "INT",
"constraint" => 11,
"unsigned" => true,
"auto_increment" => true,
],
"original_name" => [
"type" => "VARCHAR",
"constraint" => 255,
],
"file_name" => [
"type" => "VARCHAR",
"constraint" => 255,
],
"file_path" => [
"type" => "VARCHAR",
"constraint" => 500,
],
"file_ext" => [
"type" => "VARCHAR",
"constraint" => 20,
],
"file_size" => [
"type" => "BIGINT",
"constraint" => 20,
"unsigned" => true,
],
"mime_type" => [
"type" => "VARCHAR",
"constraint" => 100,
],
"folder" => [
"type" => "VARCHAR",
"constraint" => 255,
"default" => "general",
],
"has_thumbnail" => [
"type" => "TINYINT",
"constraint" => 1,
"default" => 0,
],
"is_deleted" => [
"type" => "TINYINT",
"constraint" => 1,
"default" => 0,
],
"created_at" => [
"type" => "DATETIME",
"null" => true,
],
"updated_at" => [
"type" => "DATETIME",
"null" => true,
],
]);
$this->forge->addKey("id", true);
$this->forge->addKey("folder");
$this->forge->addKey("is_deleted");
$this->forge->createTable("files");
}
public function down()
{
$this->forge->dropTable("files");
}
}
Run the migration:
php spark migrate
3.2 File Model
<?php
namespace App\Models;
use CodeIgniter\Model;
class FileModel extends Model
{
protected $table = "files";
protected $primaryKey = "id";
protected $useTimestamps = true;
protected $allowedFields = [
"original_name", "file_name", "file_path", "file_ext",
"file_size", "mime_type", "folder", "has_thumbnail", "is_deleted"
];
/**
* Get active files with optional folder filter
*/
public function getActiveFiles(?string $folder = null): array
{
$builder = $this->where("is_deleted", 0);
if ($folder) {
$builder->where("folder", $folder);
}
return $builder->orderBy("created_at", "DESC")->findAll();
}
/**
* Get files for DataTables server-side processing
*/
public function getDataTableFiles(string $search, string $orderCol, string $orderDir, int $start, int $length, ?string $folder = null): array
{
$builder = $this->where("is_deleted", 0);
if ($folder) {
$builder->where("folder", $folder);
}
if (!empty($search)) {
$builder->groupStart()
->like("original_name", $search)
->orLike("file_ext", $search)
->orLike("mime_type", $search)
->groupEnd();
}
$total = $builder->countAllResults(false);
$files = $builder->orderBy($orderCol, $orderDir)
->limit($length, $start)
->findAll();
return [
"data" => $files,
"recordsTotal" => $this->where("is_deleted", 0)->countAllResults(),
"recordsFiltered" => $total,
];
}
/**
* Get distinct folders
*/
public function getFolders(): array
{
return $this->select("folder, COUNT(*) as file_count")
->where("is_deleted", 0)
->groupBy("folder")
->orderBy("folder", "ASC")
->findAll();
}
/**
* Calculate total storage used
*/
public function getTotalStorageUsed(): int
{
$result = $this->selectSum("file_size")
->where("is_deleted", 0)
->first();
return (int) ($result["file_size"] ?? 0);
}
/**
* Soft delete a file
*/
public function softDelete(int $id): bool
{
return $this->update($id, ["is_deleted" => 1]);
}
}
The model includes DataTables server-side processing with search, ordering, and pagination — plus helper methods for folder listing, storage tracking, and soft delete. 4 File Manager Controller
<?php
namespace App\Controllers;
use App\Models\FileModel;
use App\Libraries\ThumbnailService;
use CodeIgniter\HTTP\ResponseInterface;
class FileManager extends BaseController
{
protected FileModel $fileModel;
protected array $allowedTypes;
protected int $maxFileSize;
public function __construct()
{
$this->fileModel = new FileModel();
$this->maxFileSize = 10; // MB
$this->allowedTypes = [
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/plain", "text/csv",
"application/zip", "application/x-rar-compressed"
];
}
/**
* Display the file manager page
*/
public function index(): string
{
$folder = $this->request->getGet("folder");
$folders = $this->fileModel->getFolders();
$storageUsed = $this->fileModel->getTotalStorageUsed();
return view("file_manager/index", [
"folders" => $folders,
"currentFolder" => $folder,
"storageUsed" => $this->formatFileSize($storageUsed),
"storageQuota" => "500 MB",
"storagePercent" => round(($storageUsed / (500 * 1024 * 1024)) * 100, 1),
]);
}
}
This sets up the base controller with the model, allowed MIME types, and the index method that prepares data for the main file manager view. 4.1 Upload Method (Dropzone Handler)
/**
* Handle file upload from Dropzone.js
*/
public function upload(): ResponseInterface
{
$validationRules = [
"file" => [
"rules" => "uploaded[file]|max_size[file," . ($this->maxFileSize * 1024) . "]",
"errors" => [
"uploaded" => "Please select a file to upload.",
"max_size" => "File exceeds the maximum size of {$this->maxFileSize}MB.",
],
],
];
if (!$this->validate($validationRules)) {
return $this->response->setJSON([
"success" => false,
"message" => implode(", ", $this->validator->getErrors()),
])->setStatusCode(422);
}
$file = $this->request->getFile("file");
if (!$file->isValid() || $file->hasMoved()) {
return $this->response->setJSON([
"success" => false,
"message" => "Invalid file upload.",
])->setStatusCode(400);
}
// Validate MIME type
$mimeType = $file->getMimeType();
if (!in_array($mimeType, $this->allowedTypes, true)) {
return $this->response->setJSON([
"success" => false,
"message" => "File type \"{$mimeType}\" is not allowed.",
])->setStatusCode(422);
}
// Generate secure file name and move
$newName = $file->getRandomName();
$folder = $this->request->getPost("folder") ?: "general";
$folder = preg_replace("/[^a-zA-Z0-9_-]/", "", $folder);
$uploadPath = FCPATH . "uploads/files";
$file->move($uploadPath, $newName);
// Generate thumbnail for images
$hasThumbnail = 0;
if (str_starts_with($mimeType, "image/")) {
$thumbService = new ThumbnailService();
$hasThumbnail = $thumbService->generate(
$uploadPath . "/" . $newName,
FCPATH . "uploads/thumbnails/" . $newName,
200, 200
) ? 1 : 0;
}
// Save to database
$fileData = [
"original_name" => $file->getClientName(),
"file_name" => $newName,
"file_path" => "uploads/files/" . $newName,
"file_ext" => $file->getClientExtension(),
"file_size" => $file->getSize(),
"mime_type" => $mimeType,
"folder" => $folder,
"has_thumbnail" => $hasThumbnail,
];
$this->fileModel->insert($fileData);
return $this->response->setJSON([
"success" => true,
"message" => "File uploaded successfully.",
"file_id" => $this->fileModel->getInsertID(),
"file" => $fileData,
]);
}
Key points:CI4's $file->getRandomName() generates a unique secure filename automatically. The folder name is sanitized to prevent directory traversal. Thumbnails are generated only for image files. Dropzone.js sends files individually, so this method handles one file per request.
4.2 List Files (DataTables Ajax)
/**
* Server-side DataTables handler
*/
public function listFiles(): ResponseInterface
{
$draw = (int) $this->request->getGet("draw");
$start = (int) $this->request->getGet("start");
$length = (int) $this->request->getGet("length");
$search = $this->request->getGet("search")["value"] ?? "";
$folder = $this->request->getGet("folder");
$columns = ["id", "original_name", "file_ext", "file_size", "folder", "created_at"];
$orderIdx = (int) ($this->request->getGet("order")[0]["column"] ?? 5);
$orderDir = $this->request->getGet("order")[0]["dir"] ?? "desc";
$orderCol = $columns[$orderIdx] ?? "created_at";
$result = $this->fileModel->getDataTableFiles(
$search, $orderCol, $orderDir, $start, $length, $folder
);
$data = [];
foreach ($result["data"] as $file) {
$thumbnail = $file["has_thumbnail"]
? base_url("uploads/thumbnails/" . $file["file_name"])
: $this->getFileIcon($file["file_ext"]);
$data[] = [
"id" => $file["id"],
"thumbnail" => $thumbnail,
"is_image" => (bool) $file["has_thumbnail"],
"original_name" => esc($file["original_name"]),
"file_ext" => strtoupper($file["file_ext"]),
"file_size" => $this->formatFileSize($file["file_size"]),
"folder" => esc($file["folder"]),
"created_at" => date("M d, Y H:i", strtotime($file["created_at"])),
];
}
return $this->response->setJSON([
"draw" => $draw,
"recordsTotal" => $result["recordsTotal"],
"recordsFiltered" => $result["recordsFiltered"],
"data" => $data,
]);
}
/**
* Get icon class for non-image file types
*/
private function getFileIcon(string $ext): string
{
$icons = [
"pdf" => "bi-file-earmark-pdf",
"doc" => "bi-file-earmark-word",
"docx" => "bi-file-earmark-word",
"xls" => "bi-file-earmark-excel",
"xlsx" => "bi-file-earmark-excel",
"csv" => "bi-file-earmark-spreadsheet",
"txt" => "bi-file-earmark-text",
"zip" => "bi-file-earmark-zip",
"rar" => "bi-file-earmark-zip",
];
return $icons[$ext] ?? "bi-file-earmark";
}
/**
* Format file size to human-readable string
*/
private function formatFileSize(int $bytes): string
{
$units = ["B", "KB", "MB", "GB"];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . " " . $units[$i];
}
The DataTables handler returns JSON data with thumbnails for images and Bootstrap Icons class names for documents. The server handles all searching, sorting, and pagination for optimal performance with large file collections. 4.3 Download Method
/**
* Download a file
*/
public function download(int $id): ResponseInterface
{
$file = $this->fileModel->find($id);
if (!$file || $file["is_deleted"]) {
throw new \CodeIgniter\Exceptions\PageNotFoundException("File not found.");
}
$filePath = FCPATH . $file["file_path"];
if (!is_file($filePath)) {
throw new \CodeIgniter\Exceptions\PageNotFoundException("File not found on disk.");
}
return $this->response->download($filePath, null)
->setFileName($file["original_name"]);
}
/**
* Preview a file (inline display)
*/
public function preview(int $id): string|ResponseInterface
{
$file = $this->fileModel->find($id);
if (!$file || $file["is_deleted"]) {
throw new \CodeIgniter\Exceptions\PageNotFoundException("File not found.");
}
$filePath = FCPATH . $file["file_path"];
if (!is_file($filePath)) {
throw new \CodeIgniter\Exceptions\PageNotFoundException("File not found on disk.");
}
// For images and PDFs, serve inline
if (str_starts_with($file["mime_type"], "image/") || $file["mime_type"] === "application/pdf") {
return $this->response
->setHeader("Content-Type", $file["mime_type"])
->setHeader("Content-Disposition", "inline; filename=\"" . $file["original_name"] . "\"")
->setBody(file_get_contents($filePath));
}
// For other files, show preview page
return view("file_manager/preview", [
"file" => $file,
]);
}
CI4's $this->response->download() handles streaming large files with proper Content-Disposition headers. The preview method serves images and PDFs inline in the browser, while other file types display a preview page with metadata. 4.4 Delete Method
/**
* Soft delete a file
*/
public function delete(int $id): ResponseInterface
{
$file = $this->fileModel->find($id);
if (!$file) {
return $this->response->setJSON([
"success" => false,
"message" => "File not found.",
])->setStatusCode(404);
}
$this->fileModel->softDelete($id);
return $this->response->setJSON([
"success" => true,
"message" => "File deleted successfully.",
]);
}
/**
* Permanently delete a file (removes from disk)
*/
public function permanentDelete(int $id): ResponseInterface
{
$file = $this->fileModel->find($id);
if (!$file) {
return $this->response->setJSON([
"success" => false,
"message" => "File not found.",
])->setStatusCode(404);
}
// Delete physical file
$filePath = FCPATH . $file["file_path"];
if (is_file($filePath)) {
unlink($filePath);
}
// Delete thumbnail
$thumbPath = FCPATH . "uploads/thumbnails/" . $file["file_name"];
if (is_file($thumbPath)) {
unlink($thumbPath);
}
// Delete from database
$this->fileModel->delete($id);
return $this->response->setJSON([
"success" => true,
"message" => "File permanently deleted.",
]);
}
/**
* Create a new folder
*/
public function createFolder(): ResponseInterface
{
$folderName = $this->request->getPost("folder_name");
$folderName = preg_replace("/[^a-zA-Z0-9_-]/", "", $folderName);
if (empty($folderName)) {
return $this->response->setJSON([
"success" => false,
"message" => "Invalid folder name. Use only letters, numbers, hyphens, and underscores.",
])->setStatusCode(422);
}
return $this->response->setJSON([
"success" => true,
"message" => "Folder \"" . $folderName . "\" created.",
"folder" => $folderName,
]);
}
Soft delete marks the file as deleted in the database but preserves the physical file — useful for recovery. Permanent delete removes both the database record and the physical file from disk, including any generated thumbnail. 5 Thumbnail Generation Service
<?php
namespace App\Libraries;
use Config\Services;
class ThumbnailService
{
/**
* Generate a thumbnail from an image file
*/
public function generate(string $sourcePath, string $destPath, int $width = 200, int $height = 200): bool
{
try {
$image = Services::image();
$image->withFile($sourcePath)
->fit($width, $height, "center")
->save($destPath, 80);
return true;
} catch (\Exception $e) {
log_message("error", "Thumbnail generation failed: " . $e->getMessage());
return false;
}
}
}
CI4's fit() method resizes and crops the image to exact dimensions while maintaining aspect ratio. The quality is set to 80% to balance file size and visual quality. The service logs errors instead of throwing exceptions, so a failed thumbnail does not break the upload process. 6 Routes Configuration
// File Manager Routes
$routes->group("files", function ($routes) {
$routes->get("/", "FileManager::index");
$routes->post("upload", "FileManager::upload");
$routes->get("list", "FileManager::listFiles");
$routes->get("download/(:num)", "FileManager::download/$1");
$routes->get("preview/(:num)", "FileManager::preview/$1");
$routes->delete("delete/(:num)", "FileManager::delete/$1");
$routes->delete("permanent-delete/(:num)", "FileManager::permanentDelete/$1");
$routes->post("create-folder", "FileManager::createFolder");
});
All file manager routes are grouped under the /files prefix. The delete routes use the DELETE HTTP method for RESTful API design. 7 Views & Frontend
7.1 Layout Template
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="<?= csrf_token() ?>" content="<?= csrf_hash() ?>">
<title><?= $title ?? "CI4 File Manager" ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="https://cdn.datatables.net/1.13.8/css/dataTables.bootstrap5.min.css" rel="stylesheet">
<link href="https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" rel="stylesheet">
<?= $this->renderSection("styles") ?>
</head>
<body class="bg-light">
<nav class="navbar navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/files">
<i class="bi bi-folder2-open"></i> CI4 File Manager
</a>
</div>
</nav>
<div class="container mt-4">
<?= $this->renderSection("content") ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.datatables.net/1.13.8/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.8/js/dataTables.bootstrap5.min.js"></script>
<script src="https://unpkg.com/dropzone@5/dist/min/dropzone.min.js"></script>
<?= $this->renderSection("scripts") ?>
</body>
</html>
The layout loads Bootstrap 5 for styling, Bootstrap Icons for file type icons, DataTables for the file listing, and Dropzone.js for drag-and-drop uploads. The CSRF token is placed in a meta tag so JavaScript can include it in AJAX requests. 7.2 File Manager View (Dropzone + DataTables)
<?= $this->extend("layouts/main") ?>
<?= $this->section("styles") ?>
<style>
.dropzone {
border: 2px dashed #0d6efd;
border-radius: 8px;
background: #f8f9ff;
min-height: 150px;
}
.dropzone .dz-message {
font-size: 1.1rem;
color: #6c757d;
}
.file-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
}
.file-icon {
font-size: 1.8rem;
color: #6c757d;
}
.storage-bar {
height: 8px;
}
</style>
<?= $this->endSection() ?>
<?= $this->section("content") ?>
<!-- Storage Quota -->
<div class="row mb-3">
<div class="col-md-6">
<h4><i class="bi bi-folder2-open"></i> File Manager</h4>
</div>
<div class="col-md-6 text-end">
<small class="text-muted">
Storage: <?= $storageUsed ?> / <?= $storageQuota ?>
</small>
<div class="progress storage-bar mt-1">
<div class="progress-bar" style="width: <?= $storagePercent ?>%"></div>
</div>
</div>
</div>
<!-- Folder Badges -->
<div class="mb-3">
<span class="badge bg-primary folder-badge" data-folder="" onclick="filterByFolder('')">
<i class="bi bi-folder"></i> All Files
</span>
<?php foreach ($folders as $f): ?>
<span class="badge bg-secondary folder-badge"
data-folder="<?= esc($f['folder']) ?>"
onclick="filterByFolder('<?= esc($f['folder']) ?>')">
<i class="bi bi-folder"></i>
<?= esc($f["folder"]) ?> (<?= $f["file_count"] ?>)
</span>
<?php endforeach; ?>
<button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#newFolderModal">
<i class="bi bi-folder-plus"></i> New Folder
</button>
</div>
<!-- Dropzone Upload Area -->
<div class="card mb-4">
<div class="card-body">
<form action="/files/upload" class="dropzone" id="fileDropzone">
<input type="hidden" name="<?= csrf_token() ?>"
value="<?= csrf_hash() ?>">
<input type="hidden" name="folder" id="uploadFolder"
value="<?= esc($currentFolder ?? 'general') ?>">
<div class="dz-message">
<i class="bi bi-cloud-arrow-up fs-1"></i><br>
Drop files here or click to upload<br>
<small class="text-muted">Max 10MB per file</small>
</div>
</form>
</div>
</div>
<!-- Files DataTable -->
<div class="card">
<div class="card-body">
<table id="filesTable" class="table table-hover" style="width:100%">
<thead>
<tr>
<th width="50">#</th>
<th>File</th>
<th width="70">Type</th>
<th width="90">Size</th>
<th width="90">Folder</th>
<th width="130">Uploaded</th>
<th width="130">Actions</th>
</tr>
</thead>
</table>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section("scripts") ?>
<script>
let currentFolder = "";
// Initialize Dropzone
Dropzone.autoDiscover = false;
const myDropzone = new Dropzone("#fileDropzone", {
paramName: "file",
maxFilesize: 10,
acceptedFiles: ".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.zip,.rar",
addRemoveLinks: true,
headers: {
"X-Requested-With": "XMLHttpRequest"
},
success: function(file, response) {
if (response.success) {
$("#filesTable").DataTable().ajax.reload();
}
},
error: function(file, message) {
let msg = typeof message === "object" ? message.message : message;
file.previewElement.querySelector(".dz-error-message span").textContent = msg;
},
queuecomplete: function() {
setTimeout(() => {
this.removeAllFiles();
}, 2000);
}
});
// Initialize DataTable
const filesTable = $("#filesTable").DataTable({
processing: true,
serverSide: true,
ajax: {
url: "/files/list",
data: function(d) {
d.folder = currentFolder;
}
},
columns: [
{ data: "id" },
{
data: "original_name",
render: function(data, type, row) {
let thumb = row.is_image
? '<img src="' + row.thumbnail + '" class="file-thumb me-2">'
: '<i class="bi ' + row.thumbnail + ' file-icon me-2"></i>';
return thumb + '<span>' + data + '</span>';
}
},
{ data: "file_ext" },
{ data: "file_size" },
{ data: "folder" },
{ data: "created_at" },
{
data: null,
orderable: false,
render: function(data) {
return '<div class="btn-group btn-group-sm">' +
'<a href="/files/preview/' + data.id + '"' +
' class="btn btn-outline-info" target="_blank">' +
'<i class="bi bi-eye"></i></a>' +
'<a href="/files/download/' + data.id + '"' +
' class="btn btn-outline-success">' +
'<i class="bi bi-download"></i></a>' +
'<button class="btn btn-outline-danger"' +
' onclick="deleteFile(' + data.id + ')">' +
'<i class="bi bi-trash"></i></button>' +
'</div>';
}
}
],
order: [[5, "desc"]],
pageLength: 15
});
// Filter by folder
function filterByFolder(folder) {
currentFolder = folder;
$(".folder-badge").removeClass("active");
$(".folder-badge[data-folder='" + folder + "']").addClass("active");
$("#uploadFolder").val(folder || "general");
filesTable.ajax.reload();
}
// Delete file
function deleteFile(id) {
if (!confirm("Are you sure you want to delete this file?")) return;
$.ajax({
url: "/files/delete/" + id,
type: "DELETE",
headers: {
"X-Requested-With": "XMLHttpRequest"
},
success: function(res) {
if (res.success) {
filesTable.ajax.reload();
} else {
alert(res.message);
}
}
});
}
</script>
<?= $this->endSection() ?>
The view combines Dropzone.js for drag-and-drop uploads with server-side DataTables for file listing. Folder badges provide quick filtering, and the action buttons allow preview, download, and delete operations inline. 7.3 File Preview View
<?= $this->extend("layouts/main") ?>
<?= $this->section("content") ?>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-file-earmark"></i> <?= esc($file["original_name"]) ?>
</h5>
<a href="/files/download/<?= $file["id"] ?>" class="btn btn-sm btn-success">
<i class="bi bi-download"></i> Download
</a>
</div>
<div class="card-body">
<table class="table table-sm">
<tr><td><b>Original Name:</b></td><td><?= esc($file["original_name"]) ?></td></tr>
<tr><td><b>File Type:</b></td><td><?= esc($file["mime_type"]) ?></td></tr>
<tr><td><b>Extension:</b></td><td><?= strtoupper(esc($file["file_ext"])) ?></td></tr>
<tr><td><b>Size:</b></td><td><?= number_format($file["file_size"] / 1024, 2) ?> KB</td></tr>
<tr><td><b>Folder:</b></td><td><?= esc($file["folder"]) ?></td></tr>
<tr><td><b>Uploaded:</b></td><td><?= $file["created_at"] ?></td></tr>
</table>
<a href="/files" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to File Manager
</a>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
This preview page is used for non-image, non-PDF files (Word, Excel, ZIP, etc.) that cannot be displayed inline in the browser. It shows file metadata with a download button. 8 Folder Structure
9 Run and Test
php spark serve
Open your browser and go to http://localhost:8080/files to see the file manager.Test cases to verify:
1. Drag and drop a JPG image
→ Should upload with success feedback, appear in the table with a thumbnail preview
2. Upload multiple files at once
→ Dropzone queues and uploads each file individually. All should appear in the table
3. Upload a PDF document
→ Should upload successfully. Click Preview — PDF opens inline in a new tab
4. Upload a .exe or .php file
→ Should be rejected by Dropzone (client-side) and server validation
5. Create a new folder
→ Folder appears in the badge bar. Upload files into it and verify filtering
6. Click Download on a file
→ File downloads with the original filename
7. Delete a file
→ File disappears from the list (soft delete). Database record is preserved
8. Search for a file
→ DataTables search bar filters by filename, extension, and MIME type
10 Conclusion
- Drag-and-drop file uploads with Dropzone.js and server-side validation
- Database-backed file storage with CI4 migrations and models
- Server-side DataTables with search, sort, and pagination
- Automatic thumbnail generation for uploaded images
- Inline preview for images and PDFs, metadata view for documents
- File download with original filename preservation
- Soft delete and permanent delete operations
- Folder organization with filtering and new folder creation
- Storage quota tracking with visual progress bar
This file manager module is designed as a standalone component that integrates into any CI4 application. For production use, consider adding user authentication (CI4 Shield), per-user storage quotas, image resizing presets, virus scanning with ClamAV, and cloud storage integration (AWS S3, Google Cloud Storage).
Written by Revathi M
PHP Developer & Technical Writer · 10+ years building web applications with CodeIgniter and Laravel
Revathi specializes in PHP backend development, authentication systems, and REST API design. She writes practical, production-tested tutorials at Get Sample Code to help developers build secure applications faster.
Frequently Asked Questions
You need to change both PHP and CI4 settings. In php.ini, increase upload_max_filesize and post_max_size. In your controller, update the max_size validation rule. Remember that post_max_size should be larger than upload_max_filesize to account for form data overhead.
Yes. Add the Shield auth filter to the file manager route group in Routes.php. You can then track which user uploaded each file by adding a user_id column to the files migration and saving the logged-in user ID during upload.
Server-side processing sends only the visible page of data from the database, making it efficient for large file collections (thousands of files). Client-side processing loads all records at once, which becomes slow and memory-intensive beyond a few hundred rows.
Dropzone.js uploads each file as a separate AJAX request, not all at once. This means your server-side upload handler processes one file per request. The parallelUploads option controls how many files upload simultaneously (default is 2).
Soft delete sets an is_deleted flag in the database but keeps the physical file on disk — useful for recovery or audit trails. Permanent delete removes both the database record and the physical file from the server, freeing up storage space.
Install the AWS SDK via Composer (aws/aws-sdk-php), create an S3 service library, and modify the upload method to store files to S3 instead of the local filesystem. Save the S3 URL in the file_path column. Use pre-signed URLs for downloads and set appropriate bucket policies for security.
