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

Placing Orders

In the last lesson, we created cart functionality for customers and left Place order button non-functional. This lesson will show how to implement an ordering system.

Customers will be able to order items from the cart.

Place Orders

And after placing an order customer will be able to see a list of orders with details and current order status.

My Orders


Create Models and Migrations For Order

Here's the database schema we're aiming for. We need two new tables, orders and order_items.

DB Schema Orders

The orders table will store a reference to the restaurants table as restaurant_id. And another reference to the users table as customer_id. Orders will also contain the total sum for the order and status.

The order_items table will store all the order details as products. We avoid making a direct relationship to the products table and copy products to the order_items table because we want to have a snapshot of a product when the order was created. If a vendor decides to change the pricing of a product, it won't affect any past orders.

Let's create Order and OrderItem Models with Migrations.

php artisan make:model Order -m
php artisan make:model OrderItem -m

database/migrations/2023_07_19_141346_create_orders_table.php

use App\Models\Restaurant;
use App\Models\User;
 
// ...
 
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Restaurant::class)->constrained();
$table->foreignIdFor(User::class, 'customer_id')
->references('id')
->on('users')
->constrained();
 
$table->unsignedBigInteger('total');
$table->string('status');
 
$table->timestamps();
});
}

database/migrations/2023_07_19_141353_create_order_items_table.php

use App\Models\Order;
 
// ...
 
public function up(): void
{
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Order::class)->constrained()->cascadeOnDelete();
$table->string('name');
$table->unsignedBigInteger('price');
$table->timestamps();
});
}

Then update the Order Model as follows:

app/Models/Order.php

namespace App\Models;
 
use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
 
class Order extends Model
{
use HasFactory;
 
protected $fillable = [
'restaurant_id',
'customer_id',
'total',
'status',
];
 
protected $casts = [
'status' => OrderStatus::class,
];
 
public function customer(): BelongsTo
{
return $this->belongsTo(User::class, 'customer_id');
}
 
public function restaurant(): BelongsTo
{
return $this->belongsTo(Restaurant::class);
}
 
public function products(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}

The order status will be cast to the OrderStatus enum, so we can easily adjust all possible stages of the order at any time. Create an OrderStatus enum.

app/Enums/OrderStatus.php

namespace App\Enums;
 
enum OrderStatus: string
{
case PENDING = 'pending';
case PREPARING = 'preparing';
case READY = 'ready';
case CANCELLED = 'cancelled';
}

And update the OrderItem Model.

app/Models/OrderItem.php

namespace App\Models;
 
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
class OrderItem extends Model
{
use HasFactory;
 
protected $fillable = ['name', 'price'];
 
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}

Add the orders() relationship to the User Model.

app/Models/User.php

use Illuminate\Database\Eloquent\Relations\HasMany;
 
// ...
 
public function orders(): HasMany
{
return $this->hasMany(Order::class, 'customer_id');
}

Update Customer Permissions

Add order to available $resources on PermissionSeeder.

database/seeders/PermissionSeeder.php

$resources = [
// ...
'category',
'product',
'order',
];

Then attach order.viewAny and order.create permissions to the customer role in RoleSeeder.

database/seeders/RoleSeeder.php

protected function createCustomerRole(): void
{
$permissions = Permission::where('name', 'cart.add')->get();
$permissions = Permission::whereIn('name', [
'cart.add',
'order.viewAny',
'order.create',
])->get();
 
$this->createRole(RoleName::CUSTOMER, $permissions);
}

Now you can run migrate:fresh -seed command to refresh the database with new permissions.

php artisan migrate:fresh --seed

Controller and Views

Create a new OrderController for Customer.

php artisan make:controller Customer/OrderController

app/Http/Controllers/Customer/OrderController.php

namespace App\Http\Controllers\Customer;
 
use App\Enums\OrderStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Customer\StoreOrderRequest;
use App\Models\Order;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
 
class OrderController extends Controller
{
public function index(): Response
{
$this->authorize('order.viewAny');
 
$orders = Order::with(['restaurant', 'products'])
->where('customer_id', auth()->id())
->latest()
->get();
 
return Inertia::render('Customer/Orders', [
'orders' => $orders,
]);
}
 
public function store(StoreOrderRequest $request): RedirectResponse
{
$user = $request->user();
$attributes = $request->validated();
 
DB::transaction(function () use ($user, $attributes) {
$order = $user->orders()->create([
'restaurant_id' => $attributes['restaurant_id'],
'total' => $attributes['total'],
'status' => OrderStatus::PENDING,
]);
 
$order->products()->createMany($attributes['items']);
});
 
session()->forget('cart');
 
return to_route('customer.orders.index')
->withStatus('Order accepted.');
}
}

We create Order and OrderItems to ensure data integrity using transactions. We do not want Order with the total sum without OrderItems if an error happens with the database.

After a successful transaction, we can clear cart contents by calling session()->forget('cart').

Create a new StoreOrderRequest class.

app/Http/Requests/Customer/StoreOrderRequest.php

namespace App\Http\Requests\Customer;
 
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;
 
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return Gate::allows('order.create');
}
 
public function rules(): array
{
return [
'restaurant_id' => ['required', 'exists:restaurants,id'],
'items' => ['required', 'array'],
'items.*.id' => ['required', 'exists:products,id'],
'items.*.name' => ['required', 'string'],
'items.*.price' => ['required', 'integer'],
'items.*.restaurant_id' => ['required', 'exists:restaurants,id', 'in:' . $this->restaurant_id],
'total' => ['required', 'integer', 'gt:0'],
];
}
 
protected function prepareForValidation(): void
{
$cart = session('cart');
 
$this->merge([
'restaurant_id' => $cart['restaurant_id'],
'items' => $cart['items'],
'total' => $cart['total'],
]);
}
}

Even though we validated cart contents before adding the product to it, we want to be sure that in case another developer did change some crucial parts of cart functionality, we want to be sure that all data is correct.

Not that there's no data sent from the user side. We can prepare data for validation using the prepareForValidation() method.

Now we can add customer routes for the OrderController.

routes/customer.php

use App\Http\Controllers\Customer\CartController;
use App\Http\Controllers\Customer\OrderController;
use Illuminate\Support\Facades\Route;
 
// ...
 
Route::post('cart/{product}/add', [CartController::class, 'add'])->name('cart.add');
Route::post('cart/{uuid}/remove', [CartController::class, 'remove'])->name('cart.remove');
Route::delete('cart', [CartController::class, 'destroy'])->name('cart.destroy');
 
Route::get('orders', [OrderController::class, 'index'])->name('orders.index');
Route::post('orders', [OrderController::class, 'store'])->name('orders.store');
});

Create an Orders.vue page. This page is only used to represent Order data.

resources/js/Pages/Customer/Orders.vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head } from '@inertiajs/vue3'
 
defineProps({
orders: {
type: Array
}
})
</script>
 
<template>
<Head title="My Orders" />
 
<AuthenticatedLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">My Orders</h2>
</template>
 
<div class="py-12">
<div class="max-w-2xl 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 flex flex-col gap-8">
<div v-for="order in orders" :key="order.id">
<div class="flex items-center justify-between text-2xl font-bold">
<div>Order #{{ order.id }}</div>
<div>{{ (order.total / 100).toFixed(2) }} &euro;</div>
</div>
 
<div class="text-lg mb-6">{{ order.restaurant.name }}</div>
 
<div
v-for="product in order.products"
:key="product.id"
class="flex items-center justify-between border-b mb-2 pb-2"
>
<div>{{ product.name }}</div>
<div>{{ (product.price / 100).toFixed(2) }} &euro;</div>
</div>
<div class="text-gray-500">
{{ new Date(order.created_at).toLocaleDateString() }}
{{ order.status }}
</div>
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

Add the My Orders menu button to the top navigation bar.

resources/js/Layouts/AuthenticatedLayout.vue

<template>
<!-- ... -->
<NavLink
v-if="can('product.viewAny') && can('category.viewAny')"
:href="route('vendor.menu')"
:active="route().current('vendor.menu')"
>
Restaurant menu
</NavLink>
<NavLink
v-if="can('order.viewAny')"
:href="route('customer.orders.index')"
:active="route().current('customer.orders.index')"
>
My Orders
</NavLink>
<!-- ... -->
</template>

Add the placeOrder function to the Cart.vue file.

resources/js/Pages/Customer/Cart.vue

<script>
// ...
 
const removeProduct = (uuid) => {
form.post(route('customer.cart.remove', uuid), { preserveScroll: true })
}
 
const order = useForm({})
 
const placeOrder = () => {
order.post(route('customer.orders.store'))
}
</script>

Finally, bind the placeOrder function to the @click event on the Place Order button.

resources/js/Pages/Customer/Cart.vue

<template>
<!-- ... -->
<div class="mt-4" v-if="$page.props.cart.items.length">
<PrimaryButton type="button">Place Order</PrimaryButton>
<PrimaryButton type="button" @click="placeOrder">Place Order</PrimaryButton>
</div>
<!-- ... -->
</template>

After placing an order, you should be redirected to the customer.orders.index route with an "Order accepted." message.

My Orders