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.
And after placing an order customer will be able to see a list of orders with details and current order status.
Here's the database schema we're aiming for. We need two new tables, orders
and order_items
.
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 -mphp 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');}
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
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) }} €</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) }} €</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.