Building a custom file manager in Laravel

A file manager sounds straightforward until you start building one. Upload a file, put it in a folder and let someone download it again. Then you need to think about who owns that folder, who can change its contents, what happens when it is deleted and whether a user can reach another account’s files by changing a URL.

I’ve been working on a custom Laravel file manager for a client project, using a tutorial as a starting point, Stack Overflow when I needed help with particular problems, and my own coding experience to bring it together. It has been one of the more interesting projects I’ve worked on. There is a lot of useful application logic behind an interface that most people already understand.

This is a look at the structure I built, how the main actions work and the security checks I would strengthen as it develops. The code uses Laravel 9, Jetstream and Livewire. The excerpts are shortened from the project unless I’ve marked them as proposed changes; they aren’t a complete installation guide.

A workspace for each team

Jetstream gives the application its account and team structure. A team acts as a workspace with its own files and folders. Users can work within a shared team, so sharing is based on membership rather than making a file publicly available to anybody with a link.

The application has separate models for users, teams, files and folders. When a team is created, a model event creates its root folder too. That means each workspace has somewhere to start without asking the user to set up the tree themselves.

A file record stores its display name, size, storage path and team. A folder record stores its name and team. Both also have UUIDs. The record describes the item, while Laravel’s filesystem handles the actual uploaded bytes. Keeping those responsibilities separate makes the rest of the application easier to follow.

Files and folders share one tree

The most useful decision in the database is a separate objects table. Each object points to either a file or a folder through an Eloquent polymorphic relationship, and has a parent_id pointing back to another object. A root object has no parent.

These are the central columns from the migration:

<?php
Schema::create('objects', function (Blueprint $table) {
    $table->id();
    $table->uuid('uuid');
    $table->morphs('objectable');
    $table->foreignId('parent_id')
        ->nullable()
        ->constrained('objects');
    $table->foreignId('team_id')->constrained('teams');
    $table->timestamps();
});

This gives the browser one collection of children to display, whether those children are documents or more folders. The objectable relationship supplies the item’s details. A morph map stores the simple values file and folder instead of full PHP class names, using Laravel’s polymorphic relationship support.

I used the Laravel Adjacency List package for recursive relationships. It provides the children, descendants and ancestors used to move through the tree. The controller loads the current folder’s children and their underlying records, and uses the ancestors to build the breadcrumb path back to the root.

These are logical folders in the database. Creating a folder does not need to create a matching directory on the server. The uploaded file can keep its generated storage path while its object moves around the interface. Renaming a folder therefore doesn’t mean moving every physical file beneath it.

Keeping the interface close to the PHP

Livewire handles the browser’s changing state: whether the new-folder form is open, which object is being renamed, the search term and the current upload. Blade renders the list, and Livewire calls the PHP methods behind each action. That suited the project because I could build a responsive interface without maintaining a separate JavaScript application for the same business logic.

Creating a folder validates its name, creates the folder record for the current team and associates it with a new object under the selected parent. This is the relationship code from that method:

<?php
$object = $this->currentTeam->objects()->make([
    'parent_id' => $this->object->id,
]);

$folder = $this->currentTeam->folders()->create(
    $this->newFolderState
);

$object->objectable()->associate($folder);
$object->save();

Afterwards, the component refreshes the current object and closes the form. Renaming follows a similar pattern: validate the new name, find the object within the current team and update the underlying file or folder. This excerpt shows the data relationships, not all the permission checks a production action needs.

For uploads, I connected FilePond to Livewire’s WithFileUploads support. FilePond manages the selection and progress display, then passes each file to Livewire. The component stores it, records the original display name and size, and adds it to the current folder’s object tree.

The search uses Laravel Scout and includes the item’s name, team ID and its folder path in the searchable data. The project includes a TNTSearch driver and configuration. Search results are restricted to the current team, while an empty search returns the current folder’s children. That is useful when someone remembers a filename but not where they put it.

A login is only the first check

The file controller applies authentication middleware. Its download action then calls a policy before returning anything from storage. The existing policy checks whether the file belongs to the user’s current team. This is the important part of the controller:

<?php
public function download(File $file)
{
    $this->authorize('download', $file);

    return Storage::disk('local')->download(
        $file->path,
        $file->name
    );
}

The storage path comes from the file record, rather than a path supplied in the request. Laravel’s policy authorisation keeps the access decision in a named place. A UUID helps identify an item, but it doesn’t prove that the person requesting it has permission to see it.

Team membership and permission to change files are separate questions. The project defines administrator permissions for create, read, update and delete, and editor permissions for read, create and update. Those definitions give the application a vocabulary, but they don’t automatically protect every Livewire method. I’d make the checks explicit on uploads, folder creation, renaming and deletion, as well as downloads.

I’d also refuse an operation when there is no valid current team, rather than allowing an unscoped query. The target folder needs to be loaded again from that team’s records on the server. A folder ID held in the browser’s component state must not be enough to decide where an upload belongs.

Making upload permissions and validation explicit

For an upload, I want three answers before storing anything: does this user have permission to create files, does the destination folder belong to their workspace, and is the uploaded file acceptable? This is an example of the guard I would add at the start of a Livewire upload action, rather than code already present in the original method:

<?php
$user = auth()->user();
$team = $user?->currentTeam;

abort_unless($team, 403);
abort_unless(
    $user->ownsTeam($team)
        || $user->hasTeamPermission($team, 'create'),
    403
);

$folder = Obj::query()
    ->where('team_id', $team->id)
    ->where('uuid', $folderUuid)
    ->where('objectable_type', 'folder')
    ->firstOrFail();

$this->validate([
    'upload' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
]);

Here, $folderUuid is the requested destination identifier and upload is the Livewire upload property. The allowed types and 10 MB limit are example requirements. They need to match the client’s actual needs. The Jetstream 2 team methods provide the ownership and permission checks; in a larger application I’d move that decision into a policy used by every relevant action.

Laravel’s mimes rule inspects file contents to infer a MIME type. It does not simply trust the extension, and it is not malware scanning. I’d also validate a safe display name and handle storage errors before creating the permanent records. A failed upload should leave a useful error, not an apparently successful row with no file behind it.

Keeping storage separate from public access

The project uses Laravel’s local disk, rooted at storage/app, and serves downloads through the controller. The original upload method calls storePublicly() on that disk. That sets filesystem visibility; it does not itself create a public web URL. For client documents, I’d use explicit private visibility and keep the directory outside the public web root, without a public storage link to it. Laravel’s filesystem documentation explains that distinction.

The original filename is useful for the interface and the download response, but it should not become an arbitrary server path. I’d keep generated storage names, allow only the file types the client actually needs and enforce size and storage limits. The OWASP file upload guidance is a useful reference for those checks, including when scanning or quarantining uploads is appropriate. I’m describing safeguards to build and verify, not claiming that every one is in this version.

Deletion needs more care than it appears

The object model uses a deletion event to remove its underlying file or folder and its descendants. The file model has its own deletion event to remove the stored bytes. That links deleting something in the interface to cleaning up the actual upload, rather than leaving abandoned files on disk.

There are still failure cases to work through. Database transactions can group the record changes, but they cannot undo a physical file deletion. I’d test partial failures, missing files and nested folders, and make sure the interface explains the effect of deleting a folder. For important documents, a recoverable deletion process and backups would be worth planning before relying on permanent removal.

The permission tests matter just as much as the happy path. I’d check that one team cannot download, search, rename or delete another team’s items, that an editor cannot delete, and that invalid uploads leave no permanent records or orphaned files. Those are file-manager-specific tests to add; having the standard authentication tests in the project is not enough.

Why I’ve enjoyed building it

Laravel has been a real joy to work with here. The migrations make the database structure visible, Eloquent lets me express how the records relate, and the controllers, policies and Livewire component give the code clear places to live. I can trace an action from the button to the method, through the relationships and into storage without everything ending up in one file.

The tutorial helped me get moving, and Stack Overflow helped when I got stuck on a particular detail. Understanding the code well enough to adapt it for a client is the part that matters to me. I need to know why a relationship exists, where a permission is checked and what happens when an operation fails.

I enjoy projects like this because the interface is familiar but the decisions underneath it are interesting. Folders, uploads and shared access each look small on their own. Bringing them together into something a client can use has given me a lot to think about, and made me want to keep building applications in Laravel. The source is in my Laravel File Manager repository.

More about me