Laravel 10 Inertia Vue 3 CRUD Tutorial with Example

- LAST UPDATED

Let's build a simple crud application using Laravel. This is an absolute beginner tutorial to build a Laravel crud application. It uses vue 3 and Inertia. Here it is some details about the structure of the application.

Laravel 10

MySql 

Vue 3(Vuejs)

Inertia for Vue 3

Vite

Laravel Set Up

Need to setup Laravel project using laravel 10 if it is not already done. 

composer create-project laravel/laravel laravel-inertia-vue3-crud

Now let's start setting up the project

Make sure that Laravel storage and bootstrap folders have the proper right permissions. 

Create a new database in a preferred database system and update .env files with correct details. If you can't find the .env file in the root folder of the project. Can create by copy-pasting the .env.example file. 

An example .env file is given below for reference.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=Database name
DB_USERNAME=Database username
DB_PASSWORD=Database password

Starter Kit Installation Breeze with Vue

There are some packages available for Laravel that we can use to set up authentication and application starter kits. Let's install Laravel Breeze and set up the vue js project.

It can be achieved by installing the breeze composer package and installing the breeze with vue 3.

composer require laravel/breeze --dev
php artisan breeze:install vue

php artisan breeze:install vue command will set up the vue project and install the required dependencies and npm packages required for Inertia and vue 3.

Blog Crud Application using Laravel and Vue 3

Here we are building a small setup for adding blog posts crud application using Laravel and the vue js 3. Let's begin by generating the model, migration, and controller for the same.

php artisan make:model Blog --migration --controller --resource

The above code will generate files needed for migration, models, and the Laravel resource controller with its methods. Now it's time to add codes for these files to generate a blog setup.

Migration xxx_xx_xx_xxx_create_blogs_table.php

Let's update the above class with new fields that are needed. Please note that we have added a softDeletes field to enable trash setup for the crud application.

<?php

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

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('blogs', function (Blueprint $table) {
            $table->id();
            $table->string('heading');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->timestamps();
            $table->softDeletes();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('blogs');
    }
};

Modal Blog.php

<?php

namespace App\Models;

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

class Blog extends Model
{
    use HasFactory;
    use SoftDeletes;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'heading',
        'slug',
        'description',
    ];

    /**
     * Filter to fetch the trashed items
     *
     * @var $query, array $filters
     */
    public function scopeFilter($query, array $filters)
    {
        $query->when($filters['filter'] ?? null, function ($query, $filter) {
            if ($filter === 'only') {
                $query->onlyTrashed();
            }
        });
    }
}

SoftDeletes trait - Used for trash option.

scopeFilter method - Used for filtering the trashed posts.

Route web.php

Along with other routes add resource route for blogs as shown below. [For better understanding we omitted the other routes and classes from the file.]

<?php

use App\Http\Controllers\BlogController;


Route::middleware('auth')->group(function () {
    Route::resource('blogs', BlogController::class);
});

Now its time to update the controller methods. Let's update the BlogController.php file that we generated.

Controller BlogController.php

The blogcontroller contains all the methods to list, add, update, and trash posts from the blogs table. 

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Inertia\Inertia;
use App\Models\Blog;

class BlogController extends Controller
{
    /**
     * Display a listing of the resource.
     */
    public function index(Request $request)
    {
        $blogs = Blog::query()
            ->orderBy('created_at', 'DESC')
            ->filter($request->only('filter'))
            ->paginate(10)
            ->withQueryString();

        return Inertia::render('Blog/Index', [
            'blogs' => $blogs,
            'filters' => $request->all('filter'),
            'message' => session('message'),
        ]);
    }

    /**
     * Show the form for creating a new resource.
     */
    public function create()
    {
        return Inertia::render(
            'Blog/Create'
        );
    }

    /**
     * Store a newly created resource in storage.
     */
    public function store(Request $request)
    {
        $request->validate([
            'heading' => 'required|string|max:255',
            'slug' => 'required|unique:blogs|string|max:255'
        ]);
        Blog::create([
            'heading' => $request->heading,
            'slug' => Str::slug($request->slug),
            'description' => $request->description
        ]);

        return redirect()->route('blogs.index')->with('message', 'Blog Post Created Successfully');
    }

    /**
     * Display the specified resource.
     */
    public function show(Blog $blog)
    {
        return Inertia::render(
            'Blog/View',
            [
                'blog' => $blog
            ]
        );
    }

    /**
     * Show the form for editing the specified resource.
     */
    public function edit(Blog $blog)
    {
        return Inertia::render(
            'Blog/Edit',
            [
                'blog' => $blog
            ]
        );
    }

    /**
     * Update the specified resource in storage.
     */
    public function update(Request $request, Blog $blog)
    {
        $request->validate([
            'heading' => 'required|string|max:255',
            'slug' => 'required||unique:blogs,slug,'.$blog->id.',id|string|max:255'
        ]);
        $blog->update([
            'heading' => $request->heading,
            'slug' => Str::slug($request->slug),
            'description' => $request->description
        ]);

        return redirect()->route('blogs.index')->with('message', 'Blog Post Updated Successfully');
    }

    /**
     * Remove the specified resource from storage.
     */
    public function destroy(Blog $blog)
    {
        $blog->delete();
        return redirect()->route('blogs.index')->with('message', 'Blog Post Deleted Successfully');
    }
}

Inertia vue 3 View Files

Let's add the necessary vue pages needed for the application. We need to have four files for the purpose. We need to add these files inside the resources/js/Pages folder. 

Blog/Index.vue

Here we are using <script setup>

<script setup>
import { Head, Link, useForm, usePage } from '@inertiajs/vue3';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import DangerButton from '@/Components/DangerButton.vue';
import PrimaryLink from '@/Components/PrimaryLink.vue';
import Pagination from '@/Components/Pagination.vue';
import TextInput from '@/Components/TextInput.vue';

const props = defineProps({
    blogs : Object,
    filters : Object,
    message : String
});

const filters = {
    filter: props.filters.filter,
}
const form = useForm(filters);

const deleteTrade = (id) => {
    if (confirm("Are you sure you want to move this to trash")) {
	   form.delete(route('blogs.destroy',{id:id}), {
		  preserveScroll: true,
	   });
    }
};
</script>
<template>
	<Head title="Blogs" />
	<AuthenticatedLayout>
		<template #header>
		<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight"><a :href="route('blogs.index')">Blogs</a> <PrimaryLink :href="route('blogs.index', {filter:'only'})" class="max-w-xl ml-2" >View Trashed</PrimaryLink></h2>
		</template>
		<div class="py-12">
			<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
			  <div
				 v-if="props.message"
				 class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
				 role="alert"
			  >
				 <span class="font-medium">
					{{ props.message }}
				 </span>
			  </div>
			   <div class="bg-white rounded-md shadow overflow-x-auto">
				<table class="w-full whitespace-nowrap">
				  <thead>
				    <tr class="text-left font-bold">
					 <th class="pb-4 pt-6 px-6">ID</th>
					 <th class="pb-4 pt-6 px-6">Heading</th>
					 <th class="pb-4 pt-6 px-6">Publsihed Date</th>
					 <th class="pb-4 pt-6 px-6">Actions</th>
				    </tr>
				  </thead>
				  <tbody>
				    <tr v-for="entry in props.blogs.data" :key="entry.id" class="hover:bg-gray-100 focus-within:bg-gray-100">
					 <td class="border-t">
					   <span class="flex items-center px-6 py-4 focus:text-indigo-500">
						{{ entry.heading }}
					   </span>
					 </td>
					 <td class="border-t">
					   <span class="flex items-center px-6 py-4 focus:text-indigo-500">
						{{ entry.slug }}
					   </span>
					 </td>
					 <td class="border-t">
					   <span class="flex items-center px-6 py-4 focus:text-indigo-500">
						{{ entry.created_at }}
					   </span>
					 </td>
					 <td class="border-t" >
					 	<PrimaryLink v-if="entry.deleted_at == null" :href="route('blogs.show', {'id': entry.id})" class="max-w-xl ml-2" >VIEW</PrimaryLink>
					   	<PrimaryLink v-if="entry.deleted_at == null" :href="route('blogs.edit', {'id': entry.id})" class="max-w-xl ml-2" >EDIT</PrimaryLink>
					   	<DangerButton
						class="ml-3"
						@click="deleteTrade(entry.id)" v-if="entry.deleted_at == null"
						>
						Trash
					   </DangerButton>
					 </td>
				    </tr>
				    <tr v-if="props.blogs.data.length === 0">
					 <td class="px-6 py-4 border-t" colspan="4">No posts found.</td>
				    </tr>
				  </tbody>
				</table>
			   </div>
			   <pagination class="mt-6" :links="props.blogs.links" />
			</div>
		</div>
	</AuthenticatedLayout>
</template>

There are some extra components we added in the components section. It can be found in the vue components folder [Github Link].

Blog/Create.vue

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


const form = useForm({
    heading: '',
    slug: '',
    description : ''
});

const submit = () => {
    form.post(route("blogs.store"));
};
</script>
<template>
    <Head title="Create Blog" />

    <AuthenticatedLayout>
        <template #header>
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">Create Blog</h2>
        </template>

        <div class="py-12">
            <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
                <section>
                    <form @submit.prevent="submit" class="mt-6 space-y-6">
                        <div>
                            <InputLabel for="heading" value="Heading" />

                            <TextInput
                                id="heading"
                                type="text"
                                class="mt-1 block w-full"
                                v-model="form.heading"
                                required
                                autofocus
                            />

                            <InputError class="mt-2" :message="form.errors.heading" />
                        </div>

                        <div>
                            <InputLabel for="slug" value="Slug" />

                            <TextInput
                                id="slug"
                                type="text"
                                class="mt-1 block w-full"
                                v-model="form.slug"
                                required
                            />

                            <InputError class="mt-2" :message="form.errors.slug" />
                        </div>
                        <div>
                            <InputLabel for="description" value="Description" />

                            <TextArea
                                id="description"
                                type="text"
                                class="mt-1 block w-full"
                                v-model="form.description"
                            />

                            <InputError class="mt-2" :message="form.errors.email" />
                        </div>

                        <div class="flex items-center gap-4">
                            <PrimaryButton :disabled="form.processing">Save</PrimaryButton>

                            <Transition
                                enter-active-class="transition ease-in-out"
                                enter-from-class="opacity-0"
                                leave-active-class="transition ease-in-out"
                                leave-to-class="opacity-0"
                            >
                                <p v-if="form.recentlySuccessful" class="text-sm text-gray-600">Saved.</p>
                            </Transition>
                        </div>
                    </form>
                </section>
            </div>
        </div>
    </AuthenticatedLayout>
</template>

Blog/Edit.vue

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


const props = defineProps({
    blog : Object
});

const form = useForm({
    id: props.blog.id,
    heading: props.blog.slug,
    slug: props.blog.slug,
    description : props.blog.description,
});

const submit = () => {
     form.put(route("blogs.update", props.blog.id));
};
</script>
<template>
    <Head title="Edit Blog" />

    <AuthenticatedLayout>
        <template #header>
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">Edit Blog</h2>
        </template>

        <div class="py-12">
            <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
                <section>
                    <form @submit.prevent="submit" class="mt-6 space-y-6">
                        <div>
                            <InputLabel for="heading" value="Heading" />

                            <TextInput
                                id="heading"
                                type="text"
                                class="mt-1 block w-full"
                                v-model="form.heading"
                                required
                                autofocus
                            />

                            <InputError class="mt-2" :message="form.errors.heading" />
                        </div>

                        <div>
                            <InputLabel for="slug" value="Slug" />

                            <TextInput
                                id="slug"
                                type="text"
                                class="mt-1 block w-full"
                                v-model="form.slug"
                                required
                            />

                            <InputError class="mt-2" :message="form.errors.slug" />
                        </div>
                        <div>
                            <InputLabel for="description" value="Description" />

                            <TextArea
                                id="description"
                                type="text"
                                class="mt-1 block w-full"
                                v-model="form.description"
                            />

                            <InputError class="mt-2" :message="form.errors.email" />
                        </div>

                        <div class="flex items-center gap-4">
                            <PrimaryButton :disabled="form.processing">Save</PrimaryButton>

                            <Transition
                                enter-active-class="transition ease-in-out"
                                enter-from-class="opacity-0"
                                leave-active-class="transition ease-in-out"
                                leave-to-class="opacity-0"
                            >
                                <p v-if="form.recentlySuccessful" class="text-sm text-gray-600">Saved.</p>
                            </Transition>
                        </div>
                    </form>
                </section>
            </div>
        </div>
    </AuthenticatedLayout>
</template>

Blog/View.vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';

const props = defineProps({
    blog : Object
});

</script>
<template>
    <Head :title="props.blog.heading" />
    <AuthenticatedLayout>
        <div class="py-12">
            <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
                <h1 class="text-2xl">{{props.blog.heading}}</h1>
                <div>
                    {{props.blog.description}}
                </div>
            </div>
        </div>
    </AuthenticatedLayout>
</template>

Running Application

To run the application we can either use a virtual host or we can use serve command. php artisan serve

To run the vue js application using the vite. use the command npm run dev

Now the application can be viewed by visiting http://127.0.0.1:8000/blogs . You may need to create an account or login to the application to view and update the crud application.

Application Screenshots

Index page

Index page

Create/Edit page

Create Page

Full source code of the application. Download from Github