Laravel jetstream InertiaJS

  • September 10, 2023
  • 232

Laravel jetstream InertiaJS, sử dụng Laravel với Vue 3 tạo single-page app không cần sử dụng api.

What is JetStream

Laravel Jetstream cung cấp sẵn các tính năng đăng nhập, đăng ký, xác minh email, two-factor authentication, quản session, viết API sử dụng Laravel Sanctum và các tính năng quản lý nhóm cho ứng dụng của bạn. Jetstream được thiết kế bằng Tailwind CSS và cung cấp cho bạn sử dụng theo 2 cách phổ biến: dùng blade với Livewire hoặc Vue 3 với Inertia.

What is InertiaJS

InertiaJS giúp xây dựng single-page app không cần sử dụng api mà sử dụng route của server -side hỗ trợ tốt với laravel framework.

Install laravel JetStream InertiaJS

Cài đặt laravel Jetstream với composer


composer create-project laravel/laravel myApp

cd myApp

composer require laravel/jetstream

Cài đặt Jetstream sử dụng InertiaJS


// Use jetstream simple without team feature
php artisan jetstream:install inertia

// Use jetstream simple with team feature
php artisan jetstream:install inertia --teams

Run laravel JetStream InertiaJS

Cài đặt các package laravel


cd myApp
composer install

Tạo file .env thiết lập các biến môi trường cần thiết: connect database, email...
Cài đặt các package cho frontend sử dụng Vue 3


npm install

Chạy ứng dụng của bạn


// Run laravel server
php artisan serve

// Run frontend with dev
npm run dev

// Run frontend with prod
npm run build

Nếu muốn sử dụng ngrok cần build frontend ở locall npm run build các file js sẽ được build vào thư mục public, khi truy cập 1 route từ laravel view tương sẽ được lấy.

CRUD with laravel Inertia

Tạo bảng posts để quản lý bài viết đơn giản.


<?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('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('body');
            $table->timestamps();
        });
    }

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

Tạo Post model


<?php

namespace App\Models;

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

class Post extends Model
{
    use HasFactory;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'title',
        'body',
    ];
}

Tạo post route khai báo các trang quản lý bài viết


Route::resource('posts', PostController::class);

Tạo PostController


<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Http\Requests\IndexPostRequest;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Models\Post;

class PostController extends Controller
{

    /**
     * Show the form for creating a new resource.
     *
     * @param IndexPostRequest $request
     * @return Response
     */
    public function index(IndexPostRequest $request)
    {
        $posts = Post::paginate(20);
        return Inertia::render('Posts/Index', [
            'posts' => $posts,
            'filters' => $request->only(['search']),
        ]);
    }

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

    /**
     * Show the form for creating a new resource.
     *
     * @param StorePostRequest $request
     * @return Response
     */
    public function store(StorePostRequest $request)
    {
        Post::create($request->all());

        return redirect()->route('posts.index')->with('success', 'Post has been created!');
    }

    /**
     * Show the form for editing the specified resource.
     *
     * @param string $id
     * @return Response
     */
    public function edit(string $id)
    {
        $post = Post::find($id);

        return Inertia::render('Posts/Edit', [
            'post' => $post,
        ]);
    }

    /**
     * Show the form for creating a new resource.
     *
     * @param UpdatePostRequest $request
     * @param string $id
     * @return Response
     */
    public function update(UpdatePostRequest $request, string $id)
    {
        Post::where('id', $id)->update($request->all());

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

    /**
     * Show the form for creating a new resource.
     *
     * @param string $id
     * @return Response
     */
    public function destroy(string $id)
    {
        Post::find($id)->delete();

        return redirect()->route('posts.index')->with('success', 'Post has been deleted!');
    }

    /**
     * Show the form for creating a new resource.
     *
     * @param Request $request
     * @return Response
     */
    public function deleteMultiple(Request $request)
    {
        $postIds = $request->input('ids');
        Post::whereIn('id', $postIds)->delete();

        return redirect()->route('posts.index')->with('success', 'Operation successfully!');
    }
}

Tạo FrontendLayout layout cho view trong thư mục resources/js/Layouts/FrontendLayout.vue


<script setup>
import { ref } from 'vue';
import { Head, Link, router } from '@inertiajs/vue3';
import ApplicationMark from '@/Components/ApplicationMark.vue';
import Banner from '@/Components/Banner.vue';
import NavLink from '@/Components/NavLink.vue';

defineProps({
    title: String,
});

const showingNavigationDropdown = ref(false);

const switchToTeam = (team) => {
    router.put(route('current-team.update'), {
        team_id: team.id,
    }, {
        preserveState: false,
    });
};

const logout = () => {
    router.post(route('logout'));
};
</script>

<template>
    <div>
        <Head :title="title" />

        <Banner />

        <div class="min-h-screen bg-gray-100">
            <nav class="bg-white border-b border-gray-100">
                <!-- Primary Navigation Menu -->
                <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
                    <div class="flex justify-between h-16">
                        <div class="flex">
                            <!-- Logo -->
                            <div class="shrink-0 flex items-center">
                                <Link :href="route('dashboard')">
                                    <ApplicationMark class="block h-9 w-auto" />
                                </Link>
                            </div>

                             <div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
                                <NavLink :href="route('posts.index')" :active="route().current('posts.index')">
                                    Post
                                </NavLink>
                            </div>
                        </div>

                        
                    </div>
                </div>
            </nav>

            <!-- Page Heading -->
            <header v-if="$slots.header" class="bg-white shadow">
                <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
                    <slot name="header" />
                </div>
            </header>

            <!-- Page Content -->
            <main>
                <slot />
            </main>
        </div>
    </div>
</template>

Tạo view cho trang quản lí bài viết resources/js/Page/Posts/Index.vue


<script setup>
    import FrontendLayout from '@/Layouts/FrontendLayout.vue';
    import Pagination from '@/Components/Pagination.vue';
    import { Link, useForm, usePage } from '@inertiajs/vue3';
	import { ref } from 'vue';
    import dayjs from 'dayjs';

    const props = defineProps({
        posts: Array,
		filters: Object
    });

	const form = useForm({
		search: props.filters.search || '',
	});

	const selectedUsers = ref([])

	const confirmAction = (message, callback) => {
		if (confirm(message)) {
			callback();
		}
	};

	const deletePost = (post) => {
		confirmAction('Are you sure you want to delete user?', function () {
			form.delete(route('posts.destroy', post.id));
		}.bind(this));
	};

	const search = () => {
		form.get('/posts', {
			preserveState: true,
			replace: true
		})
	};

	const deleteSelectedUsers = async () => {
		if (selectedUsers.value.length === 0) {
			return;
		}

		const confirmed = confirm(`Are you sure you want to delete ${selectedUsers.value.length} post(s)?`);
		if (!confirmed) {
			return;
		}

		try {
			await form.transform(data => ({
				'ids': selectedUsers.value
			}))
			.post('/posts/delete', { 
				preserveState: true,
				replace: true 
			});
			
			selectedUsers.value = [];
		} catch (error) {
			console.error(error);
			alert('An error occurred while deleting the selected users.');
		}
	};
</script>

<template>
    <FrontendLayout title="Post">
        <template #header>
            <h2 class="flex justify-between text-xl font-semibold leading-tight text-gray-800">
                <p>
					Post
					<i class="fa-solid fa-user-group"></i>
				</p>

				<Link href="posts/create">
					<a class="px-4 py-2 mr-3 text-sm text-green-600 transition border border-green-300 rounded-full hover:bg-green-600 hover:text-white hover:border-transparent">
                        Create post
                    </a>
				</Link>
            </h2>
            
        </template>

        <div class="py-12">
            <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
                <div class="bg-white overflow-hidden shadow-xl p-6 sm:rounded-lg">
                    <a href="#" @click="deleteSelectedUsers"
						class="float-left px-4 py-2 mt-3 text-red-400 duration-100 rounded hover:text-red-600">
						<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="w-6 h-6">
							<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" stroke="currentColor"
								fill="none"
								d="M3 6h18M6 6l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 4v-1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1" />
						</svg>
					</a>

                    <div class="flex justify-end mt-3">
						<div class="mb-3 xl:w-96">
							<div class="relative flex items-stretch w-4/5 mb-3 input-group">

								<input id="search" type='text' v-model="form.search" @keyup="search"
									class="outline-none focus:ring-0 rounded-r-none form-control relative min-w-0 block w-full px-3 py-1.5 text-base font-normal text-gray-700 bg-white bg-clip-padding border border-solid border-gray-300 rounded transition ease-in-out m-0"
									placeholder="Search...">

								<button
									class="rounded-l-none btn px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700  focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out flex items-center"
									type="button" id="button-addon2">
									<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search"
										class="w-4" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
										<path fill="currentColor"
											d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z">
										</path>
									</svg>
								</button>
							</div>
						</div>
					</div>

                    <table class="min-w-full divide-y divide-gray-200">
						<thead class="bg-gray-50">
							<tr>
								<th scope="col"></th>
								<th scope="col"
									class="px-6 py-3 text-xs font-medium tracking-wider text-center text-gray-500 uppercase">
									ID
								</th>
								<th scope="col"
									class="px-6 py-3 text-xs font-medium tracking-wider text-center text-gray-500 uppercase">
									Title
								</th>
								<th scope="col"
									class="px-6 py-3 text-xs font-medium tracking-wider text-center text-gray-500 uppercase">
									Body
								</th>
								<th scope="col"
									class="px-6 py-3 text-xs font-medium tracking-wider text-center text-gray-500 uppercase">
									Created at
								</th>
								<th scope="col" class="relative px-6 py-3">
									<span class="sr-only">Edit</span>
								</th>
							</tr>
						</thead>
						<tbody class="bg-white divide-y divide-gray-200">
							<tr v-for="post in posts.data" :key="post.id">
								<td>
									<input type="checkbox" v-model="selectedUsers" :value="post.id"
										class="ml-5 outline-none" />
								</td>
								<td class="px-6 py-4 whitespace-nowrap">
									<div class="text-sm text-center text-gray-900">
                                        {{ post.id }}
									</div>
								</td>
								<td>
									<div class="text-sm text-center text-gray-900">
                                        {{ post.title }}
									</div>
								</td>
								<td class="px-6 py-4 whitespace-nowrap">
									<div class="text-sm text-center text-gray-900">
                                        {{ post.body }}
									</div>
								</td>
								<td class="px-6 py-4 text-center whitespace-nowrap">
									<span
										class="inline-flex px-2 text-xs font-semibold leading-5 text-green-800 bg-green-100 rounded-full">
                                        {{ dayjs(post.created_at).format('YYYY/MM/DD') }}
									</span>
								</td>
								<td class="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">

									<Link :href="`/posts/${post.id}/edit`"
										class="float-left px-4 py-2 text-green-400 duration-100 rounded hover:text-green-600">
										<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none"
											viewBox="0 0 24 24" stroke="currentColor">
											<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
												d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
										</svg>
									</Link>

									<a href="#" @click="deletePost(post)"
										class="float-left px-4 py-2 ml-2 text-red-400 duration-100 rounded hover:text-red-600">
										<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none"
											viewBox="0 0 24 24" stroke="currentColor">
											<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
												d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
										</svg>
									</a>

								</td>
							</tr>
						</tbody>
					</table>
                    <Pagination class="mt-6" :links="posts.links" />
                </div>
            </div>
        </div>
    </FrontendLayout>
</template>

Tạo pagination component resources/js/Components/Pagination.vue


<script setup>
    import { Link } from '@inertiajs/vue3'

    const props = defineProps({
        links: Array,
    });
</script>
<template>
    <div class="m-5" v-if="links.length > 3">
        <div class="flex flex-wrap -mb-1">
            <template v-for="(link, p) in links" :key="p">
                <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  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>

Tạo view cho trang tạo bài viết resources/js/Page/Posts/Create.vue


<script setup>
    import Button from '@/Components/Common/Button.vue';
    import FrontendLayout from '@/Layouts/FrontendLayout.vue';
    import { usePage, useForm } from '@inertiajs/vue3'

    const props = defineProps({
        errors: Object,
    });

    const form = useForm({
        title: '',
        body: '',
    });

    const submit = () => {
        form.post(route('posts.store'), {
            onFinish: () => form.reset('title', 'body'),
        });
    }
</script>

<template>
    <FrontendLayout title="Create post">
        <template #header>
            <h2 class="flex justify-between text-xl font-semibold leading-tight text-gray-800">
                <p>
					Create Post
					<i class="fa-solid fa-user-group"></i>
				</p>
            </h2>
        </template>

        <div class="py-12">
            <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
                <div class="w-full max-w-xs m-auto">

                    <form @submit.prevent="submit" class="px-8 pt-6 pb-8 m-auto mb-4 bg-white rounded shadow-md"
                        enctype="multipart/form-data">

                        <div class="mb-4">
                            <label class="block mb-2 text-sm font-bold text-gray-700" for="name">
                                Title <span class="text-red-500">*</span>
                            </label>
                            <input v-model="form.title"
                                class="w-full px-3 py-2 mb-2 leading-tight text-gray-700 border rounded shadow appearance-none focus:outline-none focus:shadow-outline"
                                id="title" type="text">
                            <span class="text-red-500">{{ errors.title }}</span>
                        </div>

                        <div class="mb-4">
                            <label class="block mb-2 text-sm font-bold text-gray-700" for="name">
                                Body <span class="text-red-500">*</span>
                            </label>
                            <input v-model="form.body"
                                class="w-full px-3 py-2 mb-2 leading-tight text-gray-700 border rounded shadow appearance-none focus:outline-none focus:shadow-outline"
                                id="body" type="text">
                            <span class="text-red-500">{{ errors.body }}</span>
                        </div>

                        <div class="flex items-center justify-between">
                            <Button :form="form"></Button>
                        </div>
                    </form>

                </div>
            </div>
        </div>
    </FrontendLayout>
</template>

Tạo view cho trang update bài viết resources/js/Page/Posts/Edit.vue


<script setup>
    import Breadcrumb from "@/Components/Common/Breadcrumb.vue";
    import Button from "@/Components/Common/Button.vue";
    import FrontendLayout from '@/Layouts/FrontendLayout.vue';
    import { usePage, useForm } from '@inertiajs/vue3'

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

    const form = useForm({
        title: props.post.data.title,
        body: props.post.data.body
    });

    const submit = () => {
        form.put(route('posts.update', props.post.data.id))  
    }
</script>

<template>
    <FrontendLayout title="Edit user">
        <template #header>
            <Breadcrumb :href="'posts'" :title="'Posts'" :property="post" />
        </template>

        <div class="py-12">
            <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
                <div class="w-full max-w-xs m-auto">
                    <form @submit.prevent="submit" class="px-8 pt-6 pb-8 mb-4 bg-white rounded shadow-md">

                        <div class="mb-4">
                            <label class="block mb-2 text-sm font-bold text-gray-700" for="title">
                                Title
                            </label>
                            <input v-model="form.title"
                                class="w-full px-3 py-2 leading-tight text-gray-700 border rounded shadow appearance-none focus:outline-none focus:shadow-outline"
                                id="title" type="text">
                        </div>

                        <div class="mb-4">
                            <label class="block mb-2 text-sm font-bold text-gray-700" for="body">
                                Body
                            </label>
                            <input v-model="form.body"
                                class="w-full px-3 py-2 leading-tight text-gray-700 border rounded shadow appearance-none focus:outline-none focus:shadow-outline"
                                id="body" type="body">
                        </div>

                        <div class="flex items-center justify-between">
                            <Button :form="form"></Button>
                        </div>
                    </form>
                </div>

            </div>
        </div>
    </FrontendLayout>
</template>

Như vậy là chúng ta đã demo xong CRUD sử dụng Laravel và Inertiajs. Từ controller của laravel chúng ta có thể gọi để file vue tương ứng và truyền data cho view, data được truyền là prop của Vue 3.


 public function edit(string $id)
    {
        $post = Post::find($id);

        return Inertia::render('Posts/Edit', [
            'post' => $post,
        ]);
    }

Việc gửi form request từ view cho server cũng khá đơn giản sử dụng useForm inertiajs đã xây dựng sẵn cho Vue 3


const form = useForm({
        title: '',
        body: '',
    });

    const submit = () => {
        form.post(route('posts.store'), {
            onFinish: () => form.reset('title', 'body'),
        });
    }

Tổng Kết

Sử dụng laravel jetstream inertia cực kì hiệu quả khi muốn sử dụng backend laravel, frontend sử dụng Vue 3, chúng ta có thể làm được cả backend và Vue 3 trên 1 repo và không cần viết api. Thanks for reading...