Laravel 9 CRUD app using VILT stack

In this tutorial we’ll create Laravel 9 CRUD app using VILT (Vue Inertia Laravel Tailwind) stack. 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

The above command will install the required npm modules and then build the UI assets.

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

Remove DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD from .env since we’re using SQLite database.

As 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 Post model along with the migration:

php artisan make:model Post -m

Open newly created migration file from database/migrations/ which is named something like …create_posts_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('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('body');
            $table->timestamps();
        });
    }
    public function down()
    {
        Schema::dropIfExists('posts');
    }
};

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/Post.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 Post extends Model
{
    use HasFactory;

    protected $fillable = [
        'title', 'body'
    ];
}

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

php artisan make:controller PostController

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

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Request;
use Inertia\Inertia;
use App\Models\Post;

class PostController extends Controller
{

    public function index()
    {
        $data = Post::all();
        return Inertia::render('Posts/Index', ['data' => $data]);
    }

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

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

    public function edit(Post $post)
    {
        return Inertia::render('Posts/Edit', [
            'post' => [
                'id' => $post->id,
                'title' => $post->title,
                'body' => $post->body,
            ],
        ]);
    }

    public function update(Post $post)
    {
        $post->update(
            Request::validate([
            'title' => ['required', 'max:50'],
            'body' => ['required'],
            ])
        );

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

    public function destroy(Post $post)
    {
        $post->delete();
        return Redirect::back()->with('success', 'Post deleted.');
    }

}

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

use App\Http\Controllers\PostController;

And then insert following CRUD routes into this routes/web.php;

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

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

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

Route::get('posts/{post}/edit', [PostController::class, 'edit'])
    ->name('posts.edit')
    ->middleware('auth');

Route::put('posts/{post}', [PostController::class, 'update'])
    ->name('posts.update')
    ->middleware('auth');

Route::delete('posts/{post}', [PostController::class, 'destroy'])
    ->name('posts.destroy')
    ->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 Posts inside resources/js/Pages directory. While inside parent directory app, you may execute following command to make this directory;

mkdir resources/js/Pages/Posts 

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

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

You can execute following command inside parent directory to create these files in one go;

touch resources/js/Pages/Posts/{Index,Create,Edit}.vue

Insert following code in 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'

    const props = defineProps({
        data: Object,
    })
    const edit = (id) => {
        router.visit(route('posts.edit', id))
    }
    const destroy = (id) => {
        router.visit(route('posts.destroy', id), {method: 'delete'})
    }
</script>
<template>
    <AppLayout>
        <Head title="Posts" />
        <h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Posts</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('posts.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 Post
            </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">Body</th>
                        <th class="px-4 py-1 border">Actions</th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="item in data" :key="item.id">
                        <td class="px-4 py-1 border">{{item.title}}</td>
                        <td class="px-4 py-1 border">{{item.body}}</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>        
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
    </AppLayout>
</template>

Put following code in 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,
        body: null,
    })
</script>
<template>
    <AppLayout>
        <Head title="Posts" />
        <h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Posts</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="form.post(route('posts.store'))">
                    <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="body" value="Body" />
                        <TextInput id="body" v-model="form.body" type="text" class="mt-1 block w-full"/>
                        <InputError class="mt-2" :message="form.errors.body" />
                    </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 Post
                        </PrimaryButton>
                    </div>
                </form>
            </div>
        </div>
    </AppLayout>
</template>

Insert following code in 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({
        post: Object,
    })
    const form = useForm({
        title: props.post.title,
        body: props.post.body,
    })
</script>
<template>
    <AppLayout>
        <Head title="Posts" />
        <h1 class="pl-6 py-2 text-3xl font-bold bg-gray-500 text-white">Posts</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="form.put(route('posts.update', post.id))">
                    <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="body" value="Body" />
                        <TextInput id="body" v-model="form.body" type="text" class="mt-1 block w-full"/>
                        <InputError class="mt-2" :message="form.errors.body" />
                    </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 Post
                        </PrimaryButton>
                    </div>
                </form>
            </div>
        </div>
    </AppLayout>
</template>

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 Laravel development server by executing:

php artisan serv

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

localhost:8000/posts

It’s worthwhile here to have a deeper look at the Vue files. I’ve followed the syntax as far as possible according to the latest changes in Vue3 and Jetstream. I’ve used Jetstream UI components such as PrimaryButton, TextInput, InputError etc. I’ve also demonstrated both the Link functionality of Inertia.js to call a route as well as Inertia’s direct visits capability in edit and delete functions. Moreover, I’ve used Inertia’s form helper useForm in order to keep code nice and clean.

Leave a Comment