![Building Simple File Manager With Laravel and Vue 3](https://webmobtuts.com/wp-content/uploads/2025/02/Building-Simple-File-Manager-With-Laravel-and-Vue-3-800x400.jpg)
In part 2 of building a file manager with Laravel and Vue 3 we will continue and in this part we will manipulate directory navigation actions like navigation to particular directory, back, forward and more.
In the previous part we created the laravel project and installed the npm dependencies alongside Vuejs. Also we prepared the project structure and added some styling for the file manager.
Listing Directory Contents
Let’s begin by viewing current directory contents(files, sub-folders) or in other words this section of the file manager screen:
To start let’s begin with the server side code and create the necessary Server side API. So create a new directory FileManager/
inside of app/Http/Controllers
. This directory will encapsulate all the file manager controller. Then inside of it create the DirectoryNavigationController.php
app/Http/Controllers/FileManager/DirectoryNavigationController.php
<?php namespace App\Http\Controllers\FileManager; use App\Http\Controllers\Controller; use App\Services\DirectoryNavigationService; use Illuminate\Http\Request; class DirectoryNavigationController extends Controller { public function __construct(protected DirectoryNavigationService $directoryNavigationService) { } public function navigate(Request $request) { return $this->directoryNavigationService->navigate($request->input('dir')); } }
The DirectoryNavigationController
contains the Api’s responsible for navigating to directory. In the class constructor i injected a service DirectoryNavigationService
which we will see below that contains all the methods for directory navigation.
Create a new folder Services/
in app/
directory and create the DirectoryNavigationService.php
app/Services/DirectoryNavigationService.php
<?php namespace App\Services; use Illuminate\Support\Facades\File; class DirectoryNavigationService { protected string $startDir = ""; public function __construct() { $this->startDir = env("UPLOADS_DIR"); } public function navigate($directory) { try { $directory = strtolower($directory); if($directory === DIRECTORY_SEPARATOR || $directory === DIRECTORY_SEPARATOR . $this->startDir || $directory === str_replace(DIRECTORY_SEPARATOR, "", $this->startDir) ) { $directory = $this->startDir; } // Confirming that user must not go outside of uploads dir if(!str_starts_with($directory, $this->startDir)) throw new \Exception('Invalid directory!'); if(!File::exists($directory)) { throw new \Exception('Directory not exists'); } if(!File::isDirectory($directory)) { throw new \Exception('Not a directory'); } $fileList = $this->listFiles($directory); return response()->json(data: ['status' => 'success', 'data' => $fileList]); } catch (\Throwable $ex) { return response()->json(data: ['status' => 'error', 'message' => $ex->getMessage()], status:500); } } public function listFiles($directory) { $dirs = []; $files = []; foreach (new \FilesystemIterator(public_path($directory), 0) as $file) { if($file->getFileName() === ".") continue; // Skip Dot if($directory === $this->startDir && $file->getFileName() == '..') continue; if($file->isDir()) { $dirs[] = $this->toFileArr($file); } if($file->isFile()) { $files[] = $this->toFileArr($file); } } return [...$dirs, ...$files]; } private function toFileArr(\SplFileInfo $file) { $type = $file->getType() === 'dir' ? 'directory' : (@is_array(getimagesize($file->getPathname()))? 'file-image' : 'file-' . pathinfo($file->getFilename(), PATHINFO_EXTENSION)); return [ 'name' => $file->getFilename(), 'type' => $type, 'size' => $this->formatBytes($file->getSize()), 'size_in_bytes' => $file->getSize(), 'modified_date' => date('Y-m-d h:i:s', $file->getMTime()), 'mime_type' => mime_content_type($file->getPathname()), 'permission' => substr(sprintf('%o', $file->getPerms()), -4), ]; } private function formatBytes($bytes, $precision = 2) { $kilobyte = 1024; $megabyte = $kilobyte * 1024; $gigabyte = $megabyte * 1024; if ($bytes < $kilobyte) { return $bytes . ' B'; } elseif ($bytes < $megabyte) { return round($bytes / $kilobyte, $precision) . ' KB'; } elseif ($bytes < $gigabyte) { return round($bytes / $megabyte, $precision) . ' MB'; } else { return round($bytes / $gigabyte, $precision) . ' GB'; } } }
The navigate()
method does the job of navigation by passing the directory path and return a json response containing the list of files and directories. At first i added some basic validations to confirm that the user is sending a correct directory path:
if($directory === DIRECTORY_SEPARATOR || $directory === DIRECTORY_SEPARATOR . $this->startDir || $directory === str_replace(DIRECTORY_SEPARATOR, "", $this->startDir) ) { $directory = $this->startDir; }
The protected $startDir
property stores the uploads/
dir path and is initialized in the class constructor. Next i check for the $directory to be start with uploads/
if(!str_starts_with($directory, $this->startDir)) throw new \Exception('Invalid directory!');
This check is important to ensure that the user not passing a path outside of the application, thereby this is a security consideration.
After that we do a check that the directory exist and it’s not a file:
if(!File::exists($directory)) { throw new \Exception('Directory not exists'); } if(!File::isDirectory($directory)) { throw new \Exception('Not a directory'); }
Finally we invoke the listFiles()
method which return an array of files and dirs. To read the filesystem files there are a lot of ways. In our example we are using the FilesystemIterator
built-in php class in the listFiles()
method:
foreach (new \FilesystemIterator(public_path($directory), 0) as $file) { // }
Inside the foreach loop i stored the $files and $dirs arrays and then merged them using php spread operator:
return [...$dirs, ...$files];
The toFileArr()
method return a single file info and accepts an SplFileInfo
instance. The array returned contains the file name, type, size, mime-type, etc. we will need these info when rendering the file list in the vue app.
The formatBytes()
method return a formatted file size like 100 KB, 2 MB depending on the file size.
Now let’s add the api route for navigation. In your laravel project create the api.php
if not exists and add this code:
routes/api.php
<?php use Illuminate\Support\Facades\Route; use App\Http\Controllers\FileManager\DirectoryNavigationController; Route::get('navigate', [DirectoryNavigationController::class, 'navigate']);
Ensure the api.php
is registered in bootstrap/app.php
inside the withRouting()
method:
return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php', // Add this line commands: __DIR__.'/../routes/console.php', health: '/up', ) .... .... ...
Now when retrieving files you have to send a request to <host>/api/navigate?dir=uploads/ ending sending the “dir” query string with the directory path.
Directory Navigation Client Side
Now let’s work on the client-side code for directory navigation which is done in Vuejs.
Open resources/js/app/App.vue
and update as shown:
<template> <div id="file-manager"> <nav class="navbar navbar-expand-lg bg-body-tertiary"> <div class="container-fluid"> <a class="navbar-brand" href="#">File Manager</a> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse" id="navbarNav"> <FileActions /> </div> </div> </nav> <DirectoryNavigation /> <FileList :file_list="file_list" /> </div> </template> <script setup> import {ref} from "vue"; import FileActions from "./components/file-actions.vue"; import DirectoryNavigation from "./components/directory-navigation.vue"; import FileList from "./components/file-list.vue"; import {useNavigation} from "./composables/useNavigation.js"; const navigation_dir = ref(import.meta.env.VITE_UPLOADS_DIR); const {file_list, fetchFiles} = useNavigation(navigation_dir.value); const navigateToDir = (ev) => { fetchFiles(ev); navigation_dir.value = ev; // update the navigation_dir value } </script>
Also create this file resources/js/app/composables/useNavigation.js
import {onMounted, ref} from "vue"; import axios from "axios"; export const useNavigation = (navigation_dir)=> { const file_list = ref([]); const fetchFiles = (navigation_dir) => { axios.get(`/api/navigate?dir=${navigation_dir}`) .then(({data}) => { if(data.status === 'success') { file_list.value = data.data; } }).catch(({response}) => { if (response) { alert(response.data.message); } }); }; onMounted(() => { fetchFiles(navigation_dir); }); return { file_list, fetchFiles } }
useNavigation.js is a composable file which exports useNavigation()
function. composables is a term used in Vue 3 composition Api which allows to reuse functionality in several components.
In this composable i declared a file_list
ref variable which holds the file list. Then i added a function fetchFiles()
which makes the axios http request to the /api/navigate end-point and stores the response in the file_list
variable.
Next we invoke the fetchFiles()
on the onMounted()
hook to load the file list when the application first loads. And finally we return an object with the file_list
and fetchFiles()
function.
In the App.vue component we imported the useNavigation
composable extracting the file_list
and fetchFiles
:
const {file_list, fetchFiles} = useNavigation(navigation_dir.value);
In the template we passed the file_list
as a prop to <FileList />
component.
Now open FileList.vue
and update with this code:
<script setup> import FileRow from "./file-row.vue"; const props = defineProps({ file_list: { type: Array } }); </script> <template> <div id="file-list" class="mt-3"> <table class="table table-hover"> <thead class="table-light"> <tr> <th>Name</th> <th>Size</th> <th>Last Modified</th> <th>Type</th> <th>Permissions</th> </tr> </thead> <tbody class="table-group-divider"> <FileRow v-for="(file_item, index) in file_list" :key="index" :fileItem="file_item" /> </tbody> </table> </div> </template>
The file_list now is passed as a prop from the <App/>
component and removed the file_list as shown i declared a props
variable using defineProps()
To test this create an uploads/ directory inside of laravel public/ directory and add some files and directories
and run the application:
npm run dev
php artisan serve
Directory Navigation Handling
The next step is to handle the directory navigation controls like viewing specific directory contents, go backward, selecting files and directories as shown in this figure:
These actions located in directory-navigation.vue
component. I already added the necessary code, so open this file and update with this code
resources/js/app/components/directory-navigation.vue
<script setup> import {ref} from "vue"; import {removeLastPathSegment} from "../helpers/functions.js"; const props = defineProps({ nav_dir: { type: String } }) const emits = defineEmits(['navigate']); const navigation_dir = ref(props.nav_dir); const baseDir = import.meta.env.VITE_UPLOADS_DIR; const navigate = () => { if(!navigation_dir.value) return; if(navigation_dir.value === "/" || navigation_dir.value === "\\") { navigation_dir.value = baseDir; } emits('navigate', navigation_dir.value); } const navigateToHome = () => { navigation_dir.value = baseDir; emits('navigate', navigation_dir.value); } const goUp = () => { if(navigation_dir === '/' || navigation_dir === baseDir) { return; } navigation_dir.value = removeLastPathSegment(navigation_dir.value) + "/"; emits('navigate', navigation_dir.value); } const reload = () => { emits('navigate', navigation_dir.value); } watch(() => props.nav_dir, (newVal, oldVal) => { if(newVal !== oldVal) { navigation_dir.value = newVal; } }); </script> <template> <div id="navigator" class="d-flex gap-2 pt-2 pb-2"> <div class="directory-path"> <form @submit.prevent="navigate"> <input type="text" placeholder="/" class="px-1" v-model="navigation_dir" /> </form> </div> <div id="directory-home"> <a href="#" class="text-decoration-none" @click.prevent="navigateToHome"> <i class="bi bi-house-fill mx-1"></i> <span>Home</span> </a> </div> <div id="directory-up"> <a href="#" class="text-decoration-none" @click.prevent="goUp" :class="{disabled: navigation_dir === '/' || navigation_dir === baseDir}"> <i class="bi bi-arrow-up mx-1"></i> <span>Up one level</span> </a> </div> <div id="directory-reload"> <a href="#" class="text-decoration-none" @click.prevent="reload"> <i class="bi bi-arrow-repeat mx-1"></i> <span>Reload</span> </a> </div> <div id="directory-select-all"> <a href="#" class="text-decoration-none"> <i class="bi bi-check-square mx-1"></i> <span>Select all</span> </a> </div> <div id="directory-unselect-all"> <a href="#" class="text-decoration-none"> <i class="bi bi-square mx-1"></i> <span>Unselect all</span> </a> </div> </div> </template>
Also update the <DirectoryNavigation />
component in App.vue component as follows:
<DirectoryNavigation :nav_dir="navigation_dir" @navigate="navigateToDir($event)" />
The <DirectoryNavigation />
contains the controls needed to navigate to directories. In the component <template>
there is a simple input that allows us to input a directory path. This input is bound using v-model directive to navigation_dir
ref variable.
The navigation_dir
variable is initially set to props.nav_dir
which holds the current uploads/ directory value.
When submitting the form input using keyboard enter key the the navigate()
function is triggered:
const navigate = () => { if(!navigation_dir.value) return; if(navigation_dir.value === "/" || navigation_dir.value === "\\") { navigation_dir.value = baseDir; } emits('navigate', navigation_dir.value); }
The navigate() function emits an event called “navigate
” with the navigation_dir
value which is sent to the upper component to call the server side api to fetch the file list for this path.
The navigateToHome()
function is called when we click on the home link which does a similar job like the input field, however this navigate to the base directory which is uploads/ dir.
The goUp()
function goes one level up of the current directory by removing the last segment of the current path:
navigation_dir.value = removeLastPathSegment(navigation_dir.value) + "/";
We will define removeLastPathSegment()
function below in the functions.js file.
The reload()
function just emit the same navigate event to refetch the file list of the current path.
Create the functions.js file:
resources/js/app/helpers/functions.js
export const removeLastPathSegment = (path_str) => { let path_segments = path_str.split("/"); if(path_segments[path_segments.length-1] === "") { path_segments.pop(); path_segments.pop(); } else { path_segments.pop(); } return path_segments.join("/"); }
Now check the result right now by checking the directory navigation actions by clicking home link, go back, entering specific directory.