File upload in Laravel 9 app (VILT stack) – Advanced tutorial

In this tutorial, we’ll create typical Laravel 9 app using VILT (Vue Inertia Laravel Tailwind) stack and alongside we’ll incorporate the capability to upload file which could be attached to an already entered record. The data will be presented in a classic table form with file upload capability for each record and which each file upload, state and scroll position will be maintained – so the experience for end user will be like the behavior of a desktop app.

Laravel 9.x requires a minimum PHP version of 8.0. This tutorial assumes that you are using Linux, MacOS or WSL on Windows and Node.js, Composer and PHP 8 along with required modules i.e bcmatch, sqlite, mbstring, xml, zip, gd, mcrypt are properly installed. Also make sure that SQLite is installed as we’ll be using SQLite to keep things simple and quick; you may use MySQL or PostgreSQL as you like, however make sure the corresponding PHP module is installed too.

Using Composer, enter following command in terminal to install Laravel 9;

composer create-project laravel/laravel app 9.*

Now go inside the newly created folder app by entering following command;

cd app

Enter following command to add Jetstream package:

composer require laravel/Jetstream

Now install Jetstream assets by entering following command:

php artisan jetstream:install inertia

Assuming node.js and npm are installed, enter the following command to install required javascript dependencies and build client-side assets:

npm install && npm run build

Next, setup database and Laravel environment file. Here, I’m configuring SQLite database for simplicity purposes. While inside parent directory app, execute following command to create new SQLite database file;

touch database/database.sqlite

Find .env config file in the parent directory, open it and change DB_CONNECTION line as follows;

DB_CONNECTION = sqlite

You can remove DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD from .env when using SQLite database.

Since, initial setup of Laravel app is complete, we can move towards MVC (Model, View, Controller). First enter the following command in parent directory to create Complaint model along with the migration:

php artisan make:model Complaint -m

Open newly created migration file from database/migrations/ which is named something like …create_complaints_table.php and then insert the table fields as follows;

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up()
    {
        Schema::create('complaints', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('description');
            $table->string('name')->nullable();
            $table->string('path')->nullable();
            $table->timestamps();
        });
    }
    public function down()
    {
        Schema::dropIfExists('complaints');
    }
};

Enter following command in parent directory to migrate the above migration along with Jetstream authentication migrations;

php artisan migrate

Since our database part is done, let’s move onto Model and Controller. First, open app/Models/Complaint.php model file and insert protected fillable array as follows;

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Complaint extends Model
{
    use HasFactory;

    protected $fillable = [
        'title', 'description', 'name', 'path'
    ];
}

Having done with the Model part of our app, we move onto Controller. Execute following command in parent directory to create ComplaintController.php;

php artisan make:controller ComplaintController

Go ahead and open this controller file which is located at app/Http/Controllers/ComplaintController.php and amend it as follows in order to define basic CRUD (Create, Read, Update, Delete) functions together with upload function;

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Request;
use Inertia\Inertia;
use App\Models\Complaint;
use Illuminate\Support\Facades\Storage;

class ComplaintController extends Controller
{
    public function index()
    {
        $data = Complaint::paginate(10)
	       ->through(fn ($complaint) => [
                    'id' => $complaint->id,
                    'title' => $complaint->title,
                    'description' => $complaint->description,
                    'name' => $complaint->name,
                    'path' => asset('storage/' . $complaint->path),
                ]);
        return Inertia::render('Complaints/Index', ['data' => $data]);
    }

    public function create()
    {
        return Inertia::render('Complaints/Create');
    }

    public function store()
    {
        Complaint::create(
            Request::validate([
            'title' => ['required', 'max:50'],
            'description' => ['required'],
            ])
        );
        return Redirect::route('complaints')->with('success', 'Complaint created.');
    }

    public function edit(Complaint $complaint)
    {
        return Inertia::render('Complaints/Edit', [
            'complaint' => [
                'id' => $complaint->id,
                'title' => $complaint->title,
                'description' => $complaint->description,
            ],
        ]);
    }

    public function update(Complaint $complaint)
    {
        $complaint->update(
            Request::validate([
            'title' => ['required', 'max:50'],
            'description' => ['required'],
            ])
        );

        return Redirect::route('complaints')->with('success', 'Complaint updated.');
    }

    public function destroy(Complaint $complaint)
    {
	if($complaint->path){
	    Storage::delete('public/' . $complaint->path);
	}
        $complaint->delete();
        return Redirect::back()->with('success', 'Complaint deleted.');
    }
    
    public function upload(Complaint $complaint)
    {
        $name = time() . '_' . Request::file('evidence')->getClientOriginalName();
        $path = Request::file('evidence')->storeAs('uploads', $name, 'public');
        $complaint->name = $name;
        $complaint->path = $path;
        $complaint->save(); 
   
        return Redirect::back()->with('success', 'File uploaded');
    }
}

In order to define routes, open routes/web.php and put the following line at the upper side of it;

use App\Http\Controllers\ComplaintController;

And then insert following CRUD and upload routes into this routes/web.php just after dashboard route;

Route::get('complaints', [ComplaintController::class, 'index'])
    ->name('complaints')
    ->middleware('auth');

Route::get('complaints/create', [ComplaintController::class, 'create'])
    ->name('complaints.create')
    ->middleware('auth');

Route::post('complaints', [ComplaintController::class, 'store'])
    ->name('complaints.store')
    ->middleware('auth');

Route::get('complaints/{complaint}/edit', [ComplaintController::class, 'edit'])
    ->name('complaints.edit')
    ->middleware('auth');

Route::put('complaints/{complaint}', [ComplaintController::class, 'update'])
    ->name('complaints.update')
    ->middleware('auth');

Route::delete('complaints/{complaint}', [ComplaintController::class, 'destroy'])
    ->name('complaints.destroy')
    ->middleware('auth');

Route::put('complaints/{complaint}/upload', [ComplaintController::class, 'upload'])
    ->name('complaints.upload')
    ->middleware('auth');

We’re done with the Model and Controller parts of the app. Now let’s create Views using Vue.

First of all, for the purpose of displaying flash messages in views, let’s change share() function of app/Http/Middleware/HandleInertiaRequests.php as follows:

    public function share(Request $request): array
    {
        return array_merge(parent::share($request), [
            'flash' => function () use ($request) {
                return [
                    'success' => $request->session()->get('success'),
                    'error' => $request->session()->get('error'),
                ];
            },
        ]);
    }

Now, create directory named Complaints inside resources/js/Pages directory as well as Shared inside resources/js respectively. While inside parent directory app, you may execute following command to make these directories;

mkdir resources/js/Pages/Complaints resources/js/Shared

Now create following three files inside resources/js/Pages/Complaints directory:

  1. Index.vue
  2. Create.vue
  3. Edit.vue

As well as Pagination.vue inside resources/js/Shared directory respectively. You can execute following command inside parent directory to create these files in one go;

touch resources/js/Pages/Complaints/{Index,Create,Edit}.vue resources/js/Shared/Pagination.vue

Insert following code in resources/js/Pages/Complaints/Index.vue:

<script setup>
    import AppLayout from '@/Layouts/AppLayout.vue'
    import PrimaryButton from '@/Components/PrimaryButton.vue'
    import DangerButton from '@/Components/DangerButton.vue'
    import { router, Head, Link } from '@inertiajs/vue3'
    import Pagination from '@/Shared/Pagination.vue'

    const props = defineProps({
        data: Object,
    })

    function edit(id) {
        router.visit(route('complaints.edit', id))
    }

    function destroy(id) {
        router.visit(route('complaints.destroy', id), {method: 'delete'})
    }

    function onFileChange(id, e) {
        var files = e.target.files || e.dataTransfer.files;
        if (!files.length)
            return;
        router.post(route('complaints.upload', id), 
            {
            _method: 'put',
            evidence: files[0],
            },
            {
            preserveState: true,
            preserveScroll: true,
            }
        )
	}
	
</script>
<template>
    <AppLayout>
        <Head title="Complaints" />
        <h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Complaints</h1>
        <div v-if="$page.props.flash.success" class="bg-gray-300 font-bold text-blue-900 p-1">
            {{ $page.props.flash.success }}
        </div>
        <div class="m-4">
            <Link :href="route('complaints.create')" class="inline-flex items-center px-4 py-2 bg-green-800 border border-transparent rounded-lg font-semibold text-xs text-white uppercase tracking-widest hover:bg-green-700 active:bg-green-900 focus:outline-none focus:border-green-900 focus:ring ring-green-300 disabled:opacity-25 transition ease-in-out duration-150">
                Create Complaint
            </Link>        
        </div>
        <div class="bg-white rounded-md shadow overflow-x-auto">
            <table class="w-full whitespace-nowrap">
                <thead>
                    <tr class="bg-gray-500 text-white font-extrabold">
                        <th class="px-4 py-1 border">Title</th>
                        <th class="px-4 py-1 border">Description</th>
                        <th class="px-4 py-1 border">File</th>
                        <th class="px-4 py-1 border">Actions</th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="item in data.data" :key="item.id">
                        <td class="px-4 py-1 border">{{item.title}}</td>
                        <td class="px-4 py-1 border">{{item.description}}</td>
                        <td class="px-4 py-1 border"><a :href="item.path" target="_blank">{{item.name}}</a></td>
                        <td class="px-4 py-1 border">
                            <PrimaryButton @click="edit(item.id)" class="mr-4">
                                Edit
                            </PrimaryButton>        
                            <DangerButton @click="destroy(item.id)">
                                Delete
                            </DangerButton>
                            <input type="file" v-on:change="onFileChange(item.id,$event)" class="ml-2"/>  
                        </td>
                    </tr>
                </tbody>
            </table>
            <Pagination :links="data.links"/>
        </div>
    </AppLayout>
</template>

Put following code in resources/js/Pages/Complaints/Create.vue:

<script setup>
    import AppLayout from '@/Layouts/AppLayout.vue'
    import InputError from '@/Components/InputError.vue'
    import InputLabel from '@/Components/InputLabel.vue'
    import TextInput from '@/Components/TextInput.vue'
    import PrimaryButton from '@/Components/PrimaryButton.vue'
    import { Head, Link, useForm } from '@inertiajs/vue3'

    const form = useForm({
        title: null,
        description: null,
    })
    
    function submit() {
      form.post(route('complaints.store'))
    }

</script>
<template>
    <AppLayout>
        <Head title="Complaints" />
        <h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Complaints</h1>
        <div class="min-h-screen flex flex-col sm:justify-start items-center pt-6 sm:pt-0 bg-gray-100">
            <div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
                <form @submit.prevent="submit">
                    <div>
                        <InputLabel for="title" value="Title" />
                        <TextInput id="title" v-model="form.title" type="text" class="mt-1 block w-full" autofocus/>
                        <InputError class="mt-2" :message="form.errors.title" />
                    </div>
                    <div class="mt-4">
                        <InputLabel for="description" value="Description" />
                        <TextInput id="description" v-model="form.description" type="text" class="mt-1 block w-full"/>
                        <InputError class="mt-2" :message="form.errors.description" />
                    </div>
                    <div class="flex items-center justify-end mt-4">
                        <PrimaryButton type="submit" class="ml-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
                            Create Complaint
                        </PrimaryButton>
                    </div>
                </form>
            </div>
        </div>
    </AppLayout>
</template>

Insert following code in resources/js/Pages/Complaints/Edit.vue:

<script setup>
    import AppLayout from '@/Layouts/AppLayout.vue'
    import InputError from '@/Components/InputError.vue'
    import InputLabel from '@/Components/InputLabel.vue'
    import TextInput from '@/Components/TextInput.vue'
    import PrimaryButton from '@/Components/PrimaryButton.vue'
    import { Head, Link, useForm } from '@inertiajs/vue3'

    const props = defineProps({
        complaint: Object,
    })
    const form = useForm({
        title: props.complaint.title,
        description: props.complaint.description,
    })
    
    function submit() {
      form.put(route('complaints.update', props.complaint.id))
    }

</script>
<template>
    <AppLayout>
        <Head title="Complaints" />
        <h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Complaints</h1>
        <div class="min-h-screen flex flex-col sm:justify-start items-center pt-6 sm:pt-0 bg-gray-100">
            <div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
                <form @submit.prevent="submit">
                    <div>
                        <InputLabel for="title" value="Title" />
                        <TextInput id="title" v-model="form.title" type="text" class="mt-1 block w-full" autofocus/>
                        <InputError class="mt-2" :message="form.errors.title" />
                    </div>
                    <div class="mt-4">
                        <InputLabel for="description" value="Description" />
                        <TextInput id="description" v-model="form.description" type="text" class="mt-1 block w-full"/>
                        <InputError class="mt-2" :message="form.errors.description" />
                    </div>
                    <div class="flex items-center justify-end mt-4">
                        <PrimaryButton type="submit" class="ml-4" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
                            Update Complaint
                        </PrimaryButton>
                    </div>
                </form>
            </div>
        </div>
    </AppLayout>
</template>

Insert following code in resources/js/Shared/Pagination.vue:

<template>
  <div v-if="links.length > 3">
    <div class="flex flex-wrap -mb-1">
      <template v-for="(link, k) in links" :key="k">
        <div v-if="link.url === null"  class="mr-1 mb-1 px-4 py-3 text-sm leading-4 text-gray-400 border rounded" v-html="link.label" />
        <Link v-else class="mr-1 mb-1 px-4 py-3 text-sm leading-4 border rounded hover:bg-white focus:border-indigo-500 focus:text-indigo-500" :class="{ 'bg-blue-700 text-white': link.active }" :href="link.url" v-html="link.label" />
      </template>
    </div>
  </div>
</template>
  
<script>
import { Head, Link } from '@inertiajs/vue3'

export default {
  components: {
      Head,
      Link
    },
  props: {
    links: Array,
  },
}
</script>

Finally, compile client side assets (JS and CSS files) by executing following command (make habit of running this command every time you make changes to client side views);

npm run build

Run following command at parent directory to create symbolic link from public/storage to storage/app/public. This is necessary to make files accessible from web:

php artisan storage:link

Run Laravel development server by executing:

php artisan serve

Enter the following URL in your browser to access index of Complaints (don’t forget to create a user login first):

localhost:8000/complaints

Now insert some records – more the better, so that the effects of preseveState and preserveScroll could be observed. Now start uploading files for the records 😉 Go ahead take a look into storage/app/public/uploads directory where the uploaded files are being saved.

Leave a Comment