
In this part we will continue working on the restaurant menu app using Laravel inertia and Vue 3 and we will implement menu items CRUD operations.
In the previous part we prepared the laravel project and installed the necessary dependencies like Inertia and Vue and tailwindcss. We will proceed in this part by implementing the menu item CRUD.
First let’s create a new controller:
php artisan make:controller MenuItemsController
This will create the MenuItemController
class. Open the controller file and update with this code:
<?php namespace App\Http\Controllers; use App\Models\MenuItem; use App\Models\Section; use Illuminate\Http\Request; use Illuminate\Support\Facades\File; use Inertia\Inertia; class MenuItemsController extends Controller { public function index() { $menuItems = MenuItem::with('sections')->latest() ->paginate(perPage: 10); $sections = Section::all(); return Inertia::render('MenuItems/List', [ 'menuItems' => $menuItems, 'sections' => $sections ]); } public function store(Request $request) { $validated = $request->validate([ 'title' => 'required|max:255', 'price' => 'required', 'description' => 'max:1000', 'section_id' => 'required', 'image' => 'required|mimes:jpg,png,gif,webp|max:5000' ]); if ($validated['image']) { $validated['image'] = $this->upload($request); } $menuItem = MenuItem::create(attributes: $validated); $menuItem->sections()->sync($validated['section_id']); return redirect() ->route(route: 'menu-items.index') ->with('success', 'Menu item created!'); } public function edit($id) { $menuItem = MenuItem::with('sections')->findOrFail(id: $id); return $menuItem; } public function update(Request $request, MenuItem $menuItem) { $validated = $request->validate([ 'title' => 'required|max:255', 'price' => 'required', 'description' => 'max:1000', 'section_id' => 'required', 'image' => 'nullable|mimes:jpg,png,gif,webp|max:5000' ]); if (isset($validated['image']) && $validated['image']) { $validated['image'] = $this->upload($request); } else { unset($validated['image']); } $menuItem->update(attributes: $validated); $menuItem->sections()->sync($validated['section_id']); return redirect() ->route(route: 'menu-items.index') ->with('success', 'Menu item updated!'); } public function destroy(MenuItem $menuItem) { $menuItem->delete(); return redirect() ->route(route: 'menu-items.index') ->with('success', 'Menu item deleted!'); } private function upload(Request $request) { $image = $request->file('image'); if (!File::exists('uploads')) { File::makeDirectory('uploads'); } $name = uniqid() . '-' . time() . '-' . $request->file('image')->getClientOriginalName(); $image->move(public_path('uploads'), $name); return $name; } }
The index()
method retrieves the $menuItems
and $sections
and sends them as props to the Vue page using Inertia::render()
and passing the vue page name and props as array.
The store()
method creates a new menu item. At first we validate the request for the title, price, etc. Next we check if there is an image in the validated data, then we invoke the upload()
to upload the image and return the image name.
After image uploading we create the item using MenuItem::create($validated)
and store the created item in $menuItem. Then we sync the sections to attach the section_id’s with the menu items using many to many sync()
method. Finally we redirect the user to menu-item.index
route with a success message.
The edit()
method return a menu item entity using the id, we will use this to update a particular menu item in frontend. The update()
method like the store()
but updates existing menu item instead of creating a new one.
The destroy()
method delete a menu item. We use laravel route model binding feature and pass $menuItem
instance in the method directly. Then we invoke the $menuItem->delete()
and redirect with a success message.
Now add a new route in web.php for the MenuItemsController
:
routes/web.php
.... .... use App\Http\Controllers\MenuItemsController; Route::resource('menu-items', MenuItemsController::class);
Menu Items Pages
Create a new Directory MenuItems/
inside of resources/js/Pages/
to wrap the menu items pages. Then create these pages:
List.vue
FormModal.vue
Next add the link for the list page in Navbar.vue
component
Navbar.vue
<button id="dropdownNavbarLink" > <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/> </svg></button> <!-- Dropdown menu --> <div id="dropdownNavbar" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600"> <ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="dropdownLargeButton"> <li> <Link href="/menu-items" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Menu Items</Link> <-- Update this link --> </li> </ul> </div>
We will start working on displaying menu items in the list page.
Displaying Menu Items
Open the List.vue
and update with the below code:
resources/js/Pages/MenuItems/List.vue
<script setup> import { router } from '@inertiajs/vue3'; import { ref } from 'vue'; import Main from '../../Layout/Main.vue'; import Pagination from '../../Components/Pagination.vue'; import ConfirmDialog from "../../Components/ConfirmDialog.vue"; import FlashMessage from '../../Components/FlashMessage.vue'; const props = defineProps({ menuItems: { type: Object }, sections: { type: Array } }); const changePage = (page) => { router.get(`/menu-items?page=${page}`); }; </script> <template> <Main> <div class="w-full p-4 border border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-700"> <div class="flex place-content-between justify-items-center"> <h3 class="font-bold text-xl">Menu Items</h3> <button type="button" class="text-white bg-gradient-to-r from-blue-500 via-blue-600 to-blue-700 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-blue-300 dark:focus:ring-blue-800 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 cursor-pointer">Add New Item</button> </div> </div> <FlashMessage /> <div class="relative overflow-x-auto shadow-md mt-5"> <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"> <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"> <tr> <th scope="col" class="px-6 py-3"> # </th> <th scope="col" class="px-6 py-3"> Title </th> <th scope="col" class="px-6 py-3"> Image </th> <th scope="col" class="px-6 py-3"> Price </th> <th scope="col" class="px-6 py-3"> Description </th> <th scope="col" class="px-6 py-3"> Sections </th> <th scope="col" class="px-6 py-3"> <span class="sr-only">Actions</span> </th> </tr> </thead> <tbody> <tr v-for="item in menuItems.data" :key="item.id" class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600"> <td class="px-6 py-4"> {{ item.id }} </td> <td scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> <a href="#" class="font-medium text-blue-600 dark:text-blue-500 hover:underline mx-3">{{ item.title }}</a> </td> <td class="px-6 py-4"> <img v-if="item.image_url" :src="item.image_url" width="50" height="50" /> </td> <td class="px-6 py-4"> {{ item.price }} </td> <td class="px-6 py-4"> {{ item.description }} </td> <td class="px-6 py-4"> {{ item.sections.map(sect => `${sect.title} (${sect.identifier})`).join('/') }} </td> <td class="px-6 py-4 text-right"> <a href="#" class="font-medium text-blue-600 dark:text-blue-500 hover:underline mx-3">Edit</a> <a href="#" class="font-medium text-red-600 dark:text-red-500 hover:underline">Delete</a> </td> </tr> </tbody> </table> <div class="mt-7 mb-4 text-center"> <Pagination :current-page="menuItems.current_page" :total-pages="menuItems.last_page" @page-changed="changePage" /> </div> </div> </Main> </template>
In the above code in the <script setup> we imported router
and ref
dependencies. Then i imported the <Main/>
layout, <Pagination/>
component and <FlashMessage/>
and <ConfirmDialog />
as we will use these components later.
Using the defineProps()
macro we receive the props coming from the backend such as menuItems
and sections
:
defineProps({ menuItems: { type: Object }, sections: { type: Array } });
The changePage()
function triggered when user clicks on pagination link and by using inertia router.get()
method to go to page number.
In the <template> i added a basic template for items listing and styled using tailwindcss and flowbite and wrapped the component html with the <Main>
layout. In the table we displayed the menu items by iterating using Vue v-for
directive:
<tr v-for="item in menuItems.data" :key="item.id" class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 border-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600"> .... .... </tr>
Inside the table row we display the menu item title, price, image, sections, etc.
After that i added <Pagination />
component to display the pagination links.
Creating and Updating Menu Items
For the create and update functionality we will show a modal with a form to create or update. So open the FormModal.vue
file and update with this code:
resources/js/Pages/MenuItems/FormModal.vue
<script setup> import { ref, useTemplateRef, watch } from 'vue'; import { useForm } from '@inertiajs/vue3'; import ModalGeneric from '../../Components/Modal-Generic.vue'; import InputError from "../../Components/InputError.vue"; const props = defineProps({ edit_id: { type: Number }, sections: { type: Array } }); const emits = defineEmits(["close"]); const imageRef = useTemplateRef('image'); const form = useForm({ title: '', price: '', description: '', section_id: [], image: null }); const imageFile = ref(''); const submit = () => { if(props.edit_id) { form.put(`/menu-items/${props.edit_id}`, { onSuccess: () => { form.reset(); emits('close'); } }); return; } form.post('/menu-items', { onSuccess: () => { form.reset(); emits('close'); } }); } const onSelectFile = (ev) => { form.image = ev.target.files[0]; previewImage(ev.target.files[0]); } const previewImage = (file) => { const reader = new FileReader(); reader.onload = function (e) { imageFile.value = e.target.result; }; reader.readAsDataURL(file); } watch(() => props.edit_id, (newVal, oldVal) => { if(newVal != oldVal && newVal) { axios.get(`/menu-items/${newVal}/edit`).then(({data}) => { if(data) { form.title = data.title; form.price = data.price; form.description = data.description; form.section_id = data.sections.map(sect => sect.id); imageFile.value = data.image_url; } }); } }, { immediate: true }); </script> <template> <ModalGeneric :title="edit_id ? 'Edit Item': 'Create New Item'" @close="$emit('close')"> <form class="p-4 md:p-5" method="post" @submit.prevent="submit"> <div class="grid gap-4 mb-4 grid-cols-2"> <div class="col-span-2"> <label for="title" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Title</label> <input type="text" name="title" id="title" v-model="form.title" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"> <InputError :errors="form.errors" field="title" /> </div> <div class="col-span-2 sm:col-span-1"> <label for="section" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Section</label> <select name="section" id="section" v-model="form.section_id" multiple class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 bg-none"> <option v-for="section in sections" :key="section.id" :value="section.id"> {{ `${section.title} - ${section.identifier ? section.identifier : ''}` }} </option> </select> <InputError :errors="form.errors" field="section_id" /> </div> <div class="col-span-2 sm:col-span-1"> <label for="price" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Price</label> <input type="text" name="price" id="price" v-model="form.price" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"> <InputError :errors="form.errors" field="price" /> </div> <div class="col-span-2"> <label for="description" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Description</label> <textarea id="description" rows="4" v-model="form.description" class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-600 dark:border-gray-500 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="Write your description"></textarea> <InputError :errors="form.errors" field="description" /> </div> <div class="col-span-2"> <img :src="imageFile" v-if="imageFile" class="w-40 h-40 rounded-lg border p-1 mb-2" alt="image" /> <label for="image" class="inline mb-2 text-sm font-medium text-gray-900 dark:text-white">Image</label> <button type="button" @click="imageRef.click()" class="mx-3 text-white bg-gray-800 hover:bg-gray-900 focus:outline-none focus:ring-4 focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-gray-800 dark:hover:bg-gray-700 dark:focus:ring-gray-700 dark:border-gray-700">Choose File</button> <input type="file" name="image" class="hidden" id="image" ref="image" @input="onSelectFile" /> <InputError :errors="form.errors" field="image" /> </div> </div> <button type="submit" :disabled="form.processing" class="text-white inline-flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800 mt-3"> <svg v-if="!edit_id" class="me-1 -ms-1 w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"></path></svg> {{ edit_id ? 'Edit Item' : 'Add new item' }} </button> </form> </ModalGeneric> </template>
In the above code i imported the <ModalGeneric/>
component that we described previously to display a modal and the <InputError/>
component. Also i imported the useForm()
helper from inertia to facilitate working with forms and showing validations.
Next i captured some props for this component with defineProps()
macro which are the sections
list and edit_id
. The edit_id is when we do update the form.
In the useForm()
helper we are passing the form attributes like title, price, image, etc. like we do with ref()
. Then in the template we display each input and bind it with v-model
to form.<attribute>. Under each input the validation errors display using the <InputError />
helper component passing the form.errors and field name.
When submitting the form, the submit()
function is triggered. Inside submit()
we are checking for props.edit_id
. If edit_id
is present then we make update to the menu item which is done using form.put()
, otherwise we create a new item using form.post()
.
The purpose of using vue useTemplateRef()
is instead of showing <input type="file" />
we are a simple showing button and referring to this button using the ref directive:
<button type="button" @click="imageRef.click()">Choose File</button> <input type="file" name="image" class="hidden" id="image" ref="image" @input="onSelectFile" />
Now when the user clicks the button to choose a file the imageRef.click()
is triggered which in turn triggers the file choosing window.
Upon selecting a file i display a preview of the selected file. This is done in the previewImage()
function and using javascript FileReader
Api.
Now let’s return to the List.vue
page we need to include the <FormModal />
component:
MenuItems/List.vue
<script setup> ... import FormModal from "./FormModal.vue"; ... const props = defineProps({ .... }); const showModal = ref(false); const edit_id = ref(null); const showCreateModal = () => { showModal.value = true; edit_id.value = null; } const showEditModal = (id) => { showModal.value = true; edit_id.value = id; } </script>
As you see i imported the <FormModal />
component. Then i declared the showModal
and edit_id
ref variables. Also i added the showCreateModal()
and showEditModal()
functions.
In the same file make these updates in the <template>:
<template> <Main> <div > .... <button type="button" @click="showCreateModal" class="text-white bg-gradient-to-r from-blue-500 via-blue-600 to-blue-700 hover:bg-gradient-to-br focus:ring-4 focus:outline-none focus:ring-blue-300 dark:focus:ring-blue-800 font-medium rounded-lg text-sm px-5 py-2.5 text-center me-2 cursor-pointer">Add New Item</button> .... </div> <FlashMessage /> <div class="relative overflow-x-auto shadow-md mt-5"> <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"> <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400"> .... .... </thead> <tbody> <tr v-for="item in menuItems.data" :key="item.id"> <td class="px-6 py-4"> </td> <td scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> </td> <td class="px-6 py-4"> </td> <td class="px-6 py-4"> </td> <td class="px-6 py-4"> </td> <td class="px-6 py-4 text-right"> <a href="#" class="font-medium text-blue-600 dark:text-blue-500 hover:underline mx-3" @click.prevent="showEditModal(item.id)">Edit</a> .... </td> </tr> </tbody> </table> </div> </Main> <Teleport to="body"> <FormModal v-if="showModal" :sections="sections" :edit_id="edit_id" @close="showModal = false" /> </Teleport> </template>
In the template i omitted most of the code and show only the elements that needs updating. First i added @click event on the create button @click="showCreateModal"
. Also i updated the edit button beside each item row in table to show the edit modal @click.prevent="showEditModal(item.id)"
.
Last thing to include the <FormModal/>
at the bottom and wrap it between <Teleport>
component. This guarantee that the modal is not in the vue app DOM.
Now we have implemented the creation and updating of menu items, let’s implement the delete functionality.
Deleting Menu Items
There is no much work for the delete functionality as we have to handle the clicking on delete button beside each item in the menu items listing page.
MenuItems/List.vue
<script setup> const props = defineProps({ .... }); .... const deleteModal = ref(null); const showCreateModal = () => { .... } const showEditModal = (id) => { .... } const destroy = () => { router.delete(`/menu-items/${deleteModal.value}`); deleteModal.value = null; } </script> <template> <Main> <div > <table > <thead > .... .... </thead> <tbody> <tr v-for="item in menuItems.data" :key="item.id"> <td class="px-6 py-4"> </td> <td scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white"> </td> <td class="px-6 py-4"> </td> <td class="px-6 py-4"> </td> <td class="px-6 py-4"> </td> <td class="px-6 py-4 text-right"> .... <a href="#" class="font-medium text-red-600 dark:text-red-500 hover:underline" @click.prevent="deleteModal = item.id">Delete</a> </td> </tr> </tbody> </table> </div> </Main> <Teleport to="body"> .... </Teleport> <Teleport to="body"> <ConfirmDialog v-if="deleteModal" confirmText="Are you sure you want to delete?" @close="deleteModal = null" @confirm="destroy" /> </Teleport> </template>
I declared a new reactive variable deleteModal
which is null by default. When clicking on any delete button we set the deleteModal=item.id
which fires the <ConfirmDialog />
component and tells the user to proceed with deleting or not.
If the user proceeds with deleting then it will trigger the destroy()
function which makes a delete request using router.delete
and then resetting the deleteModal
to be null again.
Continue to part 3 – Menu Sections CRUD >