Laravel 10 CRUD app using VILT stack

In this tutorial we’ll create Laravel 10 CRUD app using VILT (Vue Inertia Laravel Tailwind) stack. Laravel 10.x requires a minimum PHP version of 8.1. This tutorial assumes that you are using Linux, macOS or WSL on Windows and Node.js, Composer and PHP 8.1 along with required modules i.e bcmatch, sqlite, mbstring, xml, zip, gd, mcrypt, curl 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 the same will require the corresponding PHP module installed too.

Using Composer, enter following command in terminal to install Laravel 10:

composer create-project laravel/laravel app 10.*

Go inside app folder:

cd app

If you want to open the project in VSCode, enter following command:

code .

Now, add Breeze package by executing following command:

composer require laravel/breeze:* --dev

Then install frontend scaffolding based on VILT stack:

php artisan breeze:install vue

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

Next, setup database and Laravel environment file. Here, we’re using SQLite database for simplicity purposes. Being inside parent folder (app), execute following command to create new SQLite database file:

touch database/database.sqlite

Then find .env config file in the parent folder, 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.

Initial setup of Laravel app is done! we can now start building the CRUD. First enter the following command to create Post model along with the migration and controller:

php artisan make:model Post -mrc

Open database/migrations/(date_stamp)_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(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('body');
            $table->timestamps();
        });
    }
    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};

Enter following command to populate the database with tables:

php artisan migrate

Next, in order to enable mass-assignment for relevant data fields, 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'
    ];
}

Then, open 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 App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;

class PostController extends Controller
{

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

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

    public function store(Request $request): RedirectResponse
    {
        $validated = $request->validate([
            'title' => ['required', 'max:50'],
            'body' => ['required'],
        ]);

        $item = Post::create($validated);
 
        return redirect(route('posts.index'))->with('success', 'Post created.');
    }

    public function show(Post $post): Response
    {
        //
    }

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

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

        $post->update($validated);

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

    public function destroy(Post $post): RedirectResponse
    {
        $post->delete();
        return redirect(route('posts.index'))->with('success', 'Post deleted.');
    }

}

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

use App\Http\Controllers\PostController;

And then, insert following resource route down somewhere inside this routes/web.php:

Route::resource('posts', PostController::class)->middleware(['auth', 'verified']);

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:

<?php

namespace App\Http\Middleware;

use Illuminate\Http\Request;
use Inertia\Middleware;
use Tightenco\Ziggy\Ziggy;

class HandleInertiaRequests extends Middleware
{
    protected $rootView = 'app';

    public function version(Request $request): string|null
    {
        return parent::version($request);
    }

    public function share(Request $request): array
    {
        return array_merge(parent::share($request), [
            'auth' => [
                'user' => $request->user(),
            ],
            'ziggy' => function () use ($request) {
                return array_merge((new Ziggy)->toArray(), [
                    'location' => $request->url(),
                ]);
            },
            'flash' => function () use ($request) {
                return [
                    'success' => $request->session()->get('success'),
                    'error' => $request->session()->get('error'),
                ];
            },
        ]);
    }
}

Now, create Posts folder inside resources/js/Pages. Being inside parent folder (app), you may execute following command to create it:

mkdir resources/js/Pages/Posts

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

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

You can execute following command inside parent folder to create these files in one go:

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

Go inside resources/js/Pages/Posts folder and insert following code in Index.vue:

<script setup>
    import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.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>
    <Head title="Posts" />
    <AuthenticatedLayout>
        <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>
    </AuthenticatedLayout>
</template>

Then, insert following code into Create.vue:

<script setup>
    import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.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, useForm } from '@inertiajs/vue3'
    const form = useForm({
        title: null,
        body: null,
    })
</script>
<template>
    <Head title="Posts" />
    <AuthenticatedLayout>
        <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>
    </AuthenticatedLayout>
</template>

And then, insert following code into Edit.vue:

<script setup>
    import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.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, useForm } from '@inertiajs/vue3'

    const props = defineProps({
        post: Object,
    })
    const form = useForm({
        title: props.post.title,
        body: props.post.body,
    })
</script>
<template>
    <Head title="Posts" />
    <AuthenticatedLayout>
        <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>
    </AuthenticatedLayout>
</template>

Finally, compile client side assets (JS and CSS files) by executing following command:

npm run build

Run PHP dev server by executing:

php artisan serv

Enter following URL in the browser and test CRUD functions (don’t forget to create a user login first):

localhost:8000/posts

Leave a Comment