
In this part of building a restaurant menu app with Laravel and Inertiajs we will implement the menu sections CRUD.
The menu sections listing page will consist of two parts, the sections list and the section form. The section form will be for adding and updating sections. So all work all we be in the same page.
Sections Controllers
In the terminal let’s create a new controller for sections:
php artisan make:controller SectionsController
Next add a resource route for this controller:
routes/web.php
Route::resource('sections', App\Http\Controllers\SectionsController::class);
Open the SectionsController
and add the below code:
app/Http/Controllers/SectionsController.php
<?php namespace App\Http\Controllers; use App\Models\Section; use Illuminate\Http\Request; use Inertia\Inertia; class SectionsController extends Controller { public function index() { $sections = Section::latest()->paginate(10); return Inertia::render('Sections/Index', [ 'sections' => $sections ]); } public function store(Request $request) { $validated = $request->validate([ 'title' => 'required|string|max:255', 'description' => 'nullable|max:2000', 'identifier' => 'nullable|max:100' ]); Section::create($validated); return redirect() ->back() ->with('success', 'Section created successfully.'); } public function edit(Section $section) { return $section; } public function update(Request $request, Section $section) { $validated = $request->validate([ 'title' => 'required|string|max:255', 'description' => 'nullable|max:2000', 'identifier' => 'nullable|max:100' ]); $section->update($validated); return redirect() ->back() ->with('success', 'Section updated successfully.'); } public function destroy(Section $section) { $section->delete(); return redirect() ->back() ->with('success', 'Section deleted successfully.'); } }
In the above code i added the basic actions and required logic to handle sections CRUD. In the index()
method we retrieve the list of sections ordered in descending order using laravel latest()
method and display 10 items per page and then send the sections list as a prop to Sections/Index page we will see later.
The store()
and update()
methods handle adding and updating sections respectively. First the data is validated before inserted into the database like making the title required and max to be 255, we do the same of the other fields, then assigned the validation result to the $validate
variable which contains the cleaned data. After adding or updating we redirect back with status message.
The destroy()
is straightforward and deletes a menu section from database and also redirect back with a status message.
Sections Frontend
Now let’s move into most of the work of the sections which is the frontend part in Vuejs. First we need to add the link for the Sections in the Navbar.vue, so open Navbar.vue
and update the href
for the sections link to point to “/sections
” url.
resources/js/Layout/Navbar.vue
..... <!-- 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> .... </li> <li> <Link href="/sections" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sections</Link> </li> <li> ..... </li> </ul> </div> ....
Next create the Sections/
directory in the Pages/
directory to wrap the sections pages. Under the Sections/
directory create these files:
resources/js/Pages/Sections/Index.vue
resources/js/Pages/Sections/SectionsList.vue
resources/js/Pages/Sections/SectionsForm.vue
The Index.vue
page will contain both the SectionsList
and SectionsForm
. Open the Index.vue and update it as shown:
<script setup> import { ref } from 'vue'; import Main from '../../Layout/Main.vue'; import SectionsForm from './SectionsForm.vue'; import SectionsList from './SectionsList.vue'; import FlashMessage from '../../Components/FlashMessage.vue'; defineProps({ sections: Object }); const edit_section = ref(null); const getSection = async (id) => { const { data } = await axios.get(`/sections/${id}/edit`); edit_section.value = data; } </script> <template> <Main> <div class="mb-2"> <FlashMessage /> </div> <div class="grid grid-cols-2 gap-4"> <div> <SectionsList :sections="sections" @clickEdit="getSection" /> </div> <div> <SectionsForm :edit_section="edit_section" @onAdd="edit_section = null" /> </div> </div> </Main> </template>
In the <template>
we wrapped the page with The <Main>
layout. The <FlashMessage />
component is used to show the flash messages, so i imported it above in the <script>
tag. Next i added a div which takes a tailwindcss class `grid` which displays a grid with two columns.
In the first column of the screen i added the <SectionsList />
component we will see below. to display the list of sections in a table and takes a prop `sections
` and emit a custom event `@clickEdit
`. The sections
prop in fact comes from the SectionsController
.
In the second column of the screen i added the <SectionsForm/>
which responsible for printing a form to add or update a section and accepts a prop `edit_section
` which is the selected section to update from the grid and emit a custom event `onAdd
` to reset the edit_section
back to null.
The getSection()
function retrieves a single section by id using axios and assigns it’s value to the edit_section
variable.
Displaying Sections List
Let’s work on the <SectionsList />
component to display sections listing, so open the SectionsList.vue
and update like below:
<script setup> import { ref } from 'vue'; import { router } from '@inertiajs/vue3'; import Pagination from '../../Components/Pagination.vue'; import ConfirmDialog from "../../Components/ConfirmDialog.vue"; const props = defineProps({ sections: Object }) const deleteModal = ref(null); const emits = defineEmits(['clickEdit']); const changePage = (page) => { router.get(`/sections?page=${page}`); }; const destroy = () => { router.delete(`/sections/${deleteModal.value}`); deleteModal.value = null; } </script> <template> <div> <h3 class="font-bold text-xl">Sections</h3> <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"> Identifier </th> <th scope="col" class="px-6 py-3"> <span class="sr-only">Actions</span> </th> </tr> </thead> <tbody> <tr v-for="section in sections.data" :key="section.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"> {{ section.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" @click.prevent="$emit('clickEdit', section.id)">{{ section.title }}</a> </td> <td class="px-6 py-4"> {{ section.identifier }} </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="$emit('clickEdit', section.id)">Edit</a> <a href="#" class="font-medium text-red-600 dark:text-red-500 hover:underline" @click.prevent="deleteModal = section.id">Delete</a> </td> </tr> </tbody> </table> <div class="mt-7 mb-4 text-center"> <Pagination :current-page="sections.current_page" :total-pages="sections.last_page" @page-changed="changePage" /> </div> </div> <Teleport to="body"> <ConfirmDialog v-if="deleteModal" confirmText="Are you sure you want to delete?" @close="deleteModal = null" @confirm="destroy" /> </Teleport> </div> </template>
As we do in the previous part for displaying menu items, the same is done here. At the top we imported the needed dependencies ref and router. And also imported the helper components <Pagination />
for pagination links and <ConfirmDialog />
.
To display the sections, we must receive them first with the defineProps()
macro. In the template we displayed a table and iterated over sections.data
collection to display each section in a row with the section title and the action buttons.
<tr v-for="section in sections.data" :key="section.id"> </tr>
The <Pagination /> component is displayed at the bottom of the table and emits the page-changed
custom event and calls changePage()
function to navigate to a new page.
When clicking the edit section link we emit the custom event `clickEdit
` and passing the section id:
<a href="#" @click.prevent="$emit('clickEdit', section.id)">Edit</a>
The clickEdit
event is triggered in the above component to display the section info as shown in the Index.vue page.
When clicking the delete button it sets the deleteModal=section.id
which fires the <ConfirmDialog />
component to delete the section with the destroy()
function.
Sections Form
The other component we will work with is the <SectionsForm >
component which display the form to create and update sections.
Pages/Sections/SectionsForm.vue
<script setup> import { useForm } from '@inertiajs/vue3'; import { watch } from 'vue'; import InputError from '../../Components/InputError.vue'; const props = defineProps({ edit_section: Object }) const emits = defineEmits('onAdd'); const form = useForm({ title: '', description: '', identifier: '' }) const submit = () => { if(!props.edit_section) { form.post('/sections', { onSuccess: () => { form.reset(); emits('onAdd', {form}); } }); return; } form.put(`/sections/${props.edit_section.id}`, { onSuccess: () => { form.reset(); emits('onAdd', {form, id: props.edit_section.id}); } }); } watch(() => props.edit_section, (newVal, oldVal) => { if(newVal != oldVal && newVal) { form.title = newVal.title; form.description = newVal.description; form.identifier = newVal.identifier; } }) </script> <template> <div class="px-2"> <h3 class="font-bold text-xl">{{ !edit_section ? 'Add' : 'Edit' }} Section</h3> <form class="p-4 md:p-5" @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"> <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"> <label for="identifier" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Identifier</label> <input type="text" name="identifier" id="identifier" v-model="form.identifier" 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="identifier" /> <span class="block text-gray-500 text-sm pt-1">The identifier used internally to distinguish sections when assigned in multiple menus</span> </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"> {{ edit_section ? 'Update Section' : 'Add Section' }} </button> </form> </div> </template>
In this component the useForm()
helper and vue watch()
function is imported. Also we import <InputError />
helper component to display input validation errors. Next we declared the defineProps()
to receive prop `edit_section
` which contains the section to be updated in case of edit mode.
The component emits a custom event `onAdd
` after a successful add or update operation. Using the useForm()
helper we declared the form state as an object with title, description, and identifier And bind the form variable in each input using v-model directive like in title field v-model="form.title"
.
In the submit()
function we check for props.edit_section
which refers to if this is update or add operation, then invoke form.post()
for add or form.update()
for edit. On the success callback for form.post and form.update we reset the form and emit the event:
onSuccess: () => { form.reset(); emits('onAdd', {form}); }
To populate the form fields in case of update we use Vue watch api to check for changes in props.edit_section
and bind each field respectively:
watch(() => props.edit_section, (newVal, oldVal) => { if(newVal != oldVal && newVal) { form.title = newVal.title; .. ..
Menus CRUD
The last thing we will implement in this article is the menus CRUD. The menus CRUD will be the same as the sections CRUD in the previous section where we will display both the menu listing and menu form in the same page.
Create a new controller for menus manipulation:
php artisan make:controller MenusController
Add a route for this controller in routes/web.php
Route::resource('menus', App\Http\Controllers\MenusController::class);
Now all the url’s for menus will prefixed with `menus`.
Open this new controller MenusController
and update as below:
<?php namespace App\Http\Controllers; use App\Models\Menu; use App\Models\Section; use Illuminate\Http\Request; use Inertia\Inertia; class MenusController extends Controller { public function index() { $menus = Menu::latest()->paginate(10); $sections = Section::all(); return Inertia::render('Menus/Index', [ 'menus' => $menus, 'sections' => $sections ]); } public function store(Request $request) { $validated = $request->validate([ 'title' => 'required|string|max:255', 'description' => 'nullable|max:2000' ]); $menu = Menu::create($validated); $menu->sections()->sync($request->sections); return redirect() ->back() ->with('success', 'Menu created successfully.'); } public function edit($id) { $menu = Menu::with('sections')->findOrFail($id); return $menu; } public function update(Request $request, Menu $menu) { $validated = $request->validate([ 'title' => 'required|string|max:255', 'description' => 'nullable|max:2000' ]); $menu->update($validated); $menu->sections()->sync($request->sections); return redirect() ->back() ->with('success', 'Menu updated successfully.'); } public function destroy(Menu $menu) { $menu->delete(); return redirect() ->back() ->with('success', 'Menu deleted successfully.'); } }
As you see in this code the same is done like in the SectionsController
. In the index() method beside sending the $menus
collection, we also send all the available $sections
as we need this when adding new menu.
In the store()
and update()
methods after creating or updating menu we do sync the sections to the menu using eloquent sync()
method. The sync()
method is dedicated for many to many relationships to attach items to the relation:
$menu->sections()->sync($request->sections);
Menus Frontend Pages
Let’s add the link for the menus page. So open resources/js/Layout/Navbar.vue
and update the menus link in the dropdown:
<!-- 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="/menus" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Menus</Link> </li> <li> .... </li> <li> .... </li> </ul> </div>
Next create a new directory in Pages/
folder named `Menus
`. Inside of it create these vue files:
Index.vue
MenusList.vue
MenusForm.vue
resources/js/Pages/Index.vue
<script setup> import { ref } from 'vue'; import Main from '../../Layout/Main.vue'; import MenusForm from './MenusForm.vue'; import MenusList from './MenusList.vue'; import FlashMessage from '../../Components/FlashMessage.vue'; defineProps({ menus: Object, sections: Array }); const edit_menu = ref(null); const getMenu = async (id) => { const { data } = await axios.get(`/menus/${id}/edit`); edit_menu.value = data; } </script> <template> <Main> <div class="mb-2"> <FlashMessage /> </div> <div class="grid grid-cols-2 gap-4"> <div> <MenusList :menus="menus" @clickEdit="getMenu" /> </div> <div> <MenusForm :edit_menu="edit_menu" :sections="sections" @onAdd="edit_menu = null" /> </div> </div> </Main> </template>
This page similar to the same page for Sections so i am not going to explain each part of this code. It contains the <MenusList />
and <MenusForm />
components for menus listing and menu form respectively.
Displaying Menus List
Open MenusList.vue
file and add this code:
<script setup> import { ref } from 'vue'; import { router } from '@inertiajs/vue3'; import Pagination from '../../Components/Pagination.vue'; import ConfirmDialog from "../../Components/ConfirmDialog.vue"; const props = defineProps({ menus: Object }) const deleteModal = ref(null); const emits = defineEmits(['clickEdit']); const changePage = (page) => { router.get(`/menus?page=${page}`); }; const destroy = () => { router.delete(`/menus/${deleteModal.value}`); deleteModal.value = null; } </script> <template> <div> <h3 class="font-bold text-xl">Menus</h3> <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"> Description </th> <th scope="col" class="px-6 py-3"> <span class="sr-only">Actions</span> </th> </tr> </thead> <tbody> <tr v-for="menu in menus.data" :key="menu.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"> {{ menu.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" @click.prevent="$emit('clickEdit', menu.id)">{{ menu.title }}</a> </td> <td class="px-6 py-4"> {{ menu.identifier }} </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="$emit('clickEdit', menu.id)">Edit</a> <a href="#" class="font-medium text-red-600 dark:text-red-500 hover:underline" @click.prevent="deleteModal = menu.id">Delete</a> </td> </tr> </tbody> </table> <div class="mt-7 mb-4 text-center"> <Pagination :current-page="menus.current_page" :total-pages="menus.last_page" @page-changed="changePage" /> </div> </div> <Teleport to="body"> <ConfirmDialog v-if="deleteModal" confirmText="Are you sure you want to delete?" @close="deleteModal = null" @confirm="destroy" /> </Teleport> </div> </template>
The MenusList
page displays a table for menus with the menu title and action buttons to update and delete each menu. It similar to the listing page for Sections.
Menus Form
The next thing is the menus form which in MenusForm.vue
. Open this file and update with this code:
<script setup> import { useForm } from '@inertiajs/vue3'; import { watch } from 'vue'; import InputError from '../../Components/InputError.vue'; const props = defineProps({ edit_menu: Object, sections: Array }) const emits = defineEmits('onAdd'); const form = useForm({ title: '', description: '', sections: [] }) const submit = () => { if(!props.edit_menu) { form.post('/menus', { onSuccess: () => { form.reset(); emits('onAdd', {form}); } }); return; } form.put(`/menus/${props.edit_menu.id}`, { onSuccess: () => { form.reset(); emits('onAdd', {form, id: props.edit_menu.id}); } }); } watch(() => props.edit_menu, (newVal, oldVal) => { if(newVal != oldVal && newVal) { form.title = newVal.title; form.description = newVal.description; form.sections = newVal.sections.map(sect => sect.id); } }) </script> <template> <div class="px-2"> <h3 class="font-bold text-xl">{{ !edit_menu ? 'Add' : 'Edit' }} Menu</h3> <form class="p-4 md:p-5" @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"> <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"> <label class="block mb-2 text-lg font-medium text-gray-900 dark:text-white">Menu Sections</label> <div class="flex mb-2" v-for="(section, index) in sections" :key="index"> <div class="flex items-center h-5"> <input :id="'checkbox-'+index" type="checkbox" :value="section.id" v-model="form.sections" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded-sm focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600" > </div> <div class="ms-2 text-sm"> <label :for="'checkbox-'+index" class="ms-2 text-sm text-gray-900 dark:text-gray-300">{{ section.title }} </label> <p class="text-xs font-normal text-gray-500 dark:text-gray-400">{{ section.identifier }}</p> </div> </div> </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"> {{ edit_menu ? 'Update Menu' : 'Add Menu' }} </button> </form> </div> </template>
This component expects the sections prop which is sections to assign this menu to and edit_menu prop which is menu to update in case of edit mode.
In the <template>
we displayed the title and description inputs along with the <InputError />
to display validation errors. In addition to that we displayed the list of sections as checkboxes which allow us to assign this menu to multiple sections as we learned the relation between menus and sections is many to many.
The vue watch
function is where we populate inputs in edit mode. As shown how we set the form.sections
by using javascript map
function on the sections array:
watch(() => props.edit_menu, (newVal, oldVal) => { if(newVal != oldVal && newVal) { .. .. form.sections = newVal.sections.map(sect => sect.id); } })
At this stage we have finished the CRUD for the menus, sections and menu items. You can test and experiment adding data. Just run `npm run dev
` and another terminal launch the project with `php artisan serve
`
Continue to part 4 – Menus Preview >