Back to Course |
Laravel Vue Inertia: Food Ordering Project Step-By-Step

Create Restaurant Form

Now let's create a new restaurant form.

Restaurant Create Form

This form has the following workflow:

  • Admin enters details of a restaurant and its owner
  • After submission: a new user with the role vendor is created for the owner
  • Create a restaurant for that vendor user

This will be a pretty long lesson: we will create a form, additional Vue components for it, and some extra styling, and then process the form and submit it on the back end.


Create Method in Controller

In RestaurantController, add a new create() method to render a new restaurant form:

app/Http/Controllers/Admin/RestaurantController.php

use App\Models\City;
 
// ...
 
public function create()
{
$this->authorize('restaurant.create');
 
return Inertia::render('Admin/Restaurants/Create', [
'cities' => City::get(['id', 'name']),
]);
}

We pass cities to the Admin/Restaurants/Create Component for dropdown city selection in the form. Now, let's go to the Vue part and create that form.


Restaurant Create View Component

Let's define our form component, which will be rendered when we visit the /admin/restaurants/create route.

Along the way, we must create a few extra Vue components. Laravel Breeze provides functional components for our application like InputLabel, TextInput, InputError, PrimaryButton etc., but also we will manually create the SelectInput and TextareaInput.

This is the complete code of the Create component, with comments below.

resources/js/Pages/Admin/Restaurants/Create.vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, useForm } from '@inertiajs/vue3'
import InputError from '@/Components/InputError.vue'
import InputLabel from '@/Components/InputLabel.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import SelectInput from '@/Components/SelectInput.vue'
import TextareaInput from '@/Components/TextareaInput.vue'
import TextInput from '@/Components/TextInput.vue'
 
defineProps({
cities: {
type: Array
}
})
 
const form = useForm({
restaurant_name: '',
email: '',
owner_name: '',
city_id: '',
address: ''
})
 
const submit = () => {
form.post(route('admin.restaurants.store'))
}
</script>
 
<template>
<Head title="Add New Restaurant" />
 
<AuthenticatedLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Add New Restaurant</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-sm sm:rounded-lg">
<div class="p-6 text-gray-900 overflow-x-scroll">
<div class="p-6 text-gray-900 overflow-x-scroll">
<form @submit.prevent="submit" class="flex flex-col gap-4">
<div class="form-group">
<InputLabel for="restaurant_name" value="Restaurant Name" />
<TextInput
id="restaurant_name"
type="text"
v-model="form.restaurant_name"
:disabled="form.processing"
/>
<InputError :message="form.errors.restaurant_name" />
</div>
 
<div class="form-group">
<InputLabel for="email" value="Owner Email" />
<TextInput
id="email"
type="email"
v-model="form.email"
:disabled="form.processing"
/>
<InputError :message="form.errors.email" />
</div>
 
<div class="form-group">
<InputLabel for="owner_name" value="Owner Name" />
<TextInput
id="owner_name"
type="text"
v-model="form.owner_name"
:disabled="form.processing"
/>
<InputError :message="form.errors.owner_name" />
</div>
 
<div class="form-group">
<InputLabel for="city_id" value="City" />
<SelectInput
id="city_id"
v-model="form.city_id"
:options="cities"
option-value="id"
option-label="name"
:default-option="{
id: '',
name: 'City Name'
}"
:disabled="form.processing"
/>
<InputError :message="form.errors.city_id" />
</div>
 
<div class="form-group">
<InputLabel for="address" value="Address" />
<TextareaInput
id="address"
v-model="form.address"
class="resize-none"
rows="3"
:disabled="form.processing"
/>
<InputError :message="form.errors.address" />
</div>
 
<div>
<PrimaryButton :disabled="form.processing">Create New Restaurant</PrimaryButton>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

As we did earlier, we define the cities property we pass to this view using the defineProps() method.

defineProps({
cities: {
type: Array
}
})

Form field data is defined using Inertia's useForm() method.

const form = useForm({
restaurant_name: '',
email: '',
owner_name: '',
city_id: '',
address: ''
})

Now we have a form object that we can bind data to our form components using v-model:

<InputLabel for="restaurant_name" value="Restaurant Name" />
<TextInput
id="restaurant_name"
type="text"
v-model="form.restaurant_name"
:disabled="form.processing"
/>
<InputError :message="form.errors.restaurant_name" />

v-model is used to implement two-way data binding. The form.restaurant_name value is updated automatically when you enter text into the TextInput component.

The form object created using useForm() method also provides processing property to disable buttons or input fields when the form is being processed, like this :disabled="form.processing".

We can easily submit data of the form object using the form.post() method. We can use the route() method the same way we use it with Blade files.

const submit = () => {
form.post(route('admin.restaurants.store'))
}

Then we bind the submit method to the <form> element. It means "On submit, prevent default html form behavior and call submit method".

<form @submit.prevent="submit">

Add some CSS classes for the form so all the elements flow nicely. When the form is processed, input fields and buttons will be transparent.

resources/css/app.css

@layer components {
// ...
 
.form-group {
@apply flex flex-col gap-2
}
 
input[type="text"],
input[type="email"],
select,
textarea {
@apply disabled:opacity-50;
}
}

Now, you saw that SelectInput and TextareaInput components I mentioned earlier? Let's define those two.


SelectInput Component

For a reusable Vue component for select dropdowns, ideally, we would like to have this structure:

<SelectInput
v-model="form.some_field"
:options="array_of_objects"
option-value="id"
option-label="name"
:default-option="{ id: '', name: 'Please select' }"
/>

We want it to be able to:

  • use v-model to bind the value to the form object
  • accept an array of objects as select options
  • provide which properties of an array object to use for the v-model value and which to display
  • provide the default selected option

We will mimic the behavior of already existing Laravel Breeze components from resources/js/Components folder. Let's create a new SelectInput.vue component there:

resources/js/Components/SelectInput.vue

<script setup>
import { onMounted, ref } from 'vue'
 
defineProps({
modelValue: {
type: String,
required: true
},
options: {
type: Array,
required: true
},
optionValue: {
type: String,
required: true
},
optionLabel: {
type: String,
required: true
},
defaultOption: {
type: Object,
required: false
}
})
 
defineEmits(['update:modelValue'])
 
const input = ref(null)
 
onMounted(() => {
if (input.value.hasAttribute('autofocus')) {
input.value.focus()
}
})
 
defineExpose({ focus: () => input.value.focus() })
</script>
 
<template>
<select
class="border-gray-300 focus:border-primary-500 focus:ring-primary-500 rounded-md shadow-sm"
:value="modelValue"
@change="$emit('update:modelValue', $event.target.value)"
ref="input"
>
<option v-if="defaultOption" disabled hidden :value="defaultOption[optionValue]">
{{ defaultOption[optionLabel] }}
</option>
<option v-for="option in options" :key="option[optionValue]" :value="option[optionValue]">
{{ option[optionLabel] }}
</option>
</select>
</template>

TextareaInput Component

In the same way, we define a new TextareaInput component. It is primarily a copy of Laravel Breeze's TextInput component with minor changes.

resources/js/Components/TextareaInput.vue

<script setup>
import { onMounted, ref } from 'vue'
 
defineProps({
modelValue: {
type: String,
required: true
}
})
 
defineEmits(['update:modelValue'])
 
const input = ref(null)
 
onMounted(() => {
if (input.value.hasAttribute('autofocus')) {
input.value.focus()
}
})
 
defineExpose({ focus: () => input.value.focus() })
</script>
 
<template>
<textarea
class="border-gray-300 focus:border-primary-500 focus:ring-primary-500 rounded-md shadow-sm"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
ref="input"
></textarea>
</template>

Here's the visual result of the form:


Add a Create Button to Restaurant Index

Ok, so we have built our form. Now let's add a [Add New Restaurant] button to our Restaurant index page to navigate to it.

You will typically use the Inertia <Link> component to create links to other pages within an Inertia app. This component is a light wrapper around a standard anchor <a> link that intercepts click events and prevents full page reloads.

resources/js/Pages/Admin/Restaurants/Index.vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head } from '@inertiajs/vue3'
import { Head, Link } from '@inertiajs/vue3'
 
// ...
<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-sm sm:rounded-lg">
<div class="p-6">
<Link class="btn btn-primary" :href="route('admin.restaurants.create')">
Add New Restaurant
</Link>
</div>
<div class="p-6 text-gray-900 overflow-x-scroll">
<table class="table">
<thead>
<!-- ... -->
</template>

And finally, add CSS classes for the button.

resources/css/app.css

.btn {
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-full font-semibold focus:outline-none focus:ring-2 focus:ring-offset-2 transition ease-in-out duration-150 w-full sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed;
}
 
.btn-primary {
@apply bg-primary-500 text-white hover:bg-primary-600 focus:bg-primary-600 active:bg-primary-500 focus:ring-primary-500
}

Optional: CSS Styles For Consistency

These CSS changes are optional, but apply them if you want all components to look consistent.

resources/js/Components/InputError.vue

<template>
<div v-show="message">
<p class="text-sm text-red-600">
<p class="text-sm text-danger-600">
{{ message }}
</p>
</div>

resources/js/Components/PrimaryButton.vue

<template>
<button
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150"
>
<button class="btn btn-primary">
<slot />
</button>
</template>

resources/js/Components/TextInput.vue

<template>
<input
class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
class="border-gray-300 focus:border-primary-500 focus:ring-primary-500 rounded-md shadow-sm"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
ref="input"

The visual result for the button above the table:


Form Submit: Validation in Form Request

Finally, let's return to the back end and submit the form data.

Before creating the store() method in the Controller, let's generate a Form Request class and add validation rules there.

php artisan make:request Admin/StoreRestaurantRequest

app/Http/Requests/Admin/StoreRestaurantRequest.php

namespace App\Http\Requests\Admin;
 
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;
 
class StoreRestaurantRequest extends FormRequest
{
public function authorize(): bool
{
return Gate::allows('restaurant.create');
}
 
public function rules(): array
{
return [
'restaurant_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:' . User::class],
'owner_name' => ['required', 'string', 'max:255'],
'city_id' => ['required', 'numeric', 'exists:cities,id'],
'address' => ['required', 'string', 'max:1000'],
];
}
}

In the authorize() method using the Gate facade, we check if the user has permission to create a restaurant.

We define field names and their validation rules in the rules() method. A few of them explained:

  • unique:<table>,<column> - must be unique in the database table and column. The trick we did here is that it also accepts a Class name, and Laravel automatically resolves the table name from the User Model.
  • exists:<table>,<column> - The field value must exist in the database. This means you cannot assign a non-existent city_id.

All available validation rules can be found in the Laravel Documentation.


Store Method in Controller

Then add a new store() method to the Controller. It will create a new user, assign a role and create a restaurant for the user.

app/Http/Controllers/Admin/RestaurantController.php

use App\Enums\RoleName;
use App\Http\Requests\Admin\StoreRestaurantRequest;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
 
// ...
 
public function store(StoreRestaurantRequest $request): RedirectResponse
{
$validated = $request->validated();
 
DB::transaction(function () use ($validated) {
$user = User::create([
'name' => $validated['owner_name'],
'email' => $validated['email'],
'password' => '',
]);
 
$user->roles()->sync(Role::where('name', RoleName::VENDOR->value)->first());
 
$user->restaurant()->create([
'city_id' => $validated['city_id'],
'name' => $validated['restaurant_name'],
'address' => $validated['address'],
]);
});
 
return to_route('admin.restaurants.index');
}

We use the DB::transaction() method to run a set of operations within a Database Transaction. If an Exception is thrown within the transaction closure, the transaction will automatically be rolled back. If the closure executes successfully, the transaction will automatically be committed.

The $validated variable is invisible in the DB::transaction() closure, so we need to import it with the use ($validated) statement.

To redirect to a specific route after successful operations, we use the to_route() helper in Laravel.

If we fill in the form successfully, we get redirected to the index view and see our new record!

In case of any validation errors, they will be shown near each of the fields.


Sending Email Invitation to Restaurant Owner

The final thing in this lesson is to invite the new user - the restaurant owner - to use the system.

There are multiple ways to accomplish this, I suggest this:

  • When the restaurant is created, an email is sent to the owner
  • That email contains a link to the "Reset password" page
  • They reset (well, actually, CREATE) their password and can use the system now

Let's create an email text, I made it in Markdown:

resources/views/mail/restaurant/owner-invitation.blade.php:

<x-mail::message>
# Hello!
 
{{ __('This email is being sent to you as a notification that an account for :restaurant has been created for you at :app.', [
'restaurant' => $restaurant,
'app' => config('app.name'),
]) }}
 
{{ __('Please note that new accounts do not have a pre-set password. Therefore, it is necessary for you to set your password manually.') }}
 
<x-mail::button :url="$setUrl">
Set Password
</x-mail::button>
 
{{ __('This password reset link will expire in :count minutes.', ['count' => config('auth.passwords.' . config('auth.defaults.passwords') . '.expire')]) }}
 
{{ __('In the event that the link has expired, you have the option to request a new one.') }}
 
<x-mail::button :url="$requestNewUrl">
Request New Link
</x-mail::button>
 
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

What parameters do we have inside?

  • $restaurant - restaurant name
  • $setUrl - URL to reset the password
  • $requestNewUrl - informational URL

To pass those parameters to Blade, let's create a Notification class to send the email:

php artisan make:notification RestaurantOwnerInvitation

We will pass the restaurant name to that class from the Controller, everything else will be generated inside the Notification class.

app/Notifications/RestaurantOwnerInvitation.php:

namespace App\Notifications;
 
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Password;
 
class RestaurantOwnerInvitation extends Notification
{
use Queueable;
 
/**
* Create a new notification instance.
*/
public function __construct(public string $restaurantName)
{ }
 
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
 
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$url = route('password.reset', [
'token' => Password::createToken($notifiable),
'email' => $notifiable->getEmailForPasswordReset(),
]);
 
return (new MailMessage)
->subject(__('We invite you to join :app to manage :restaurant', [
'restaurant' => $this->restaurantName,
'app' => config('app.name'),
]))
->markdown('mail.restaurant.owner-invitation', [
'setUrl' => $url,
'restaurant' => $this->restaurantName,
'requestNewUrl' => route('password.request'),
]);
}
}

Finally, from the Controller, we fire that Notification, like this:

use App\Notifications\RestaurantOwnerInvitation;
 
// ...
 
class RestaurantController extends Controller
{
public function store(StoreRestaurantRequest $request): RedirectResponse
{
$validated = $request->validated();
 
DB::transaction(function () use ($validated) {
$user->restaurant()->create([
'city_id' => $validated['city_id'],
'name' => $validated['restaurant_name'],
'address' => $validated['address'],
]);
 
$user->notify(new RestaurantOwnerInvitation($validated['restaurant_name']));
});
 
return to_route('admin.restaurants.index');
}
}

And that's it, now restaurant owner would get the notification email and would be able to click the link and set their password.