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

Orders List and Manage Orders

It is time to implement the last Order Management feature for the staff role.

The page will contain two lists of orders.

Current Orders Table

  • When a customer places an order, it will appear with the status pending.
  • Pending orders can be updated to the status preparing or rejected with the status cancelled.
  • Order in production - have status preparing and can be marked as ready for delivery or cancelled.

Past Orders Table

Lists completed orders with the status ready or rejected with the status cancelled. In other words, these are fulfilled orders, and no further action can be taken.

Order Management


Seeds

Let's update the createStaffRole() method in RoleSeeder and add order.update permission to the staff role.

database/seeders/RoleSeeder.php

public function createStaffRole()
{
$permissions = Permission::whereIn('name', [
'order.update',
])->get();
 
$this->createRole(RoleName::STAFF, $permissions);
}

To make things easier to test, let's add the staff() method to UserFactory so we can easily assign that role through Factory.

database/factories/UserFactory.php

public function staff()
{
return $this->afterCreating(function (User $user) {
$user->roles()->sync(Role::where('name', RoleName::STAFF->value)->first());
});
}

Then update the seedDemoRestaurants() method in DatabaseSeeder. This will create staff users for every restaurant seeded.

database/seeders/DatabaseSeeder.php

public function seedDemoRestaurants()
{
$products = Product::factory(7);
$categories = Category::factory(5)->has($products);
$staffMember = User::factory()->staff();
$restaurant = Restaurant::factory()->has($categories)->has($staffMember, 'staff');
 
User::factory(50)->vendor()->has($restaurant)->create();
}

Because the relationship and Factory Model are different and can't be matched automatically, we specify the relationship name as a second argument in the ->has(..., 'staff') method.

Order Model And OrderStatus

Add static methods toArray() and values() to the OrderStatus enum.

app/Enums/OrderStatus.php

public static function toArray(): array
{
$cases = [];
 
foreach (self::cases() as $case) {
$cases[$case->name] = $case->value;
}
 
return $cases;
}
 
public static function values(): array
{
return array_column(self::cases(), 'value');
}

The toArray() method will help transform the enum into an array. We will pass it to the view to let the front end know our possible statuses. It will return the following array.

> OrderStatus::toArray()
= [
"PENDING" => "pending",
"PREPARING" => "preparing",
"READY" => "ready",
"CANCELLED" => "cancelled",
]

The values() method will return only values of the OrderStatus enum. It will become handy when we validate the order status in the update request.

> OrderStatus::values()
= [
"pending",
"preparing",
"ready",
"cancelled",
]

Now, add two Eloquent Local Scopes to the Order Model. Scopes help us filter orders by their status by calling a single current() or past() method.

app/Models/Order.php

public function scopeCurrent($query): void
{
$query->whereIn('status', [
OrderStatus::PENDING,
OrderStatus::PREPARING,
]);
}
 
public function scopePast($query): void
{
$query->whereIn('status', [
OrderStatus::READY,
OrderStatus::CANCELLED,
]);
}

Controller

Create an OrderController for staff members.

php artisan make:controller Staff/OrderController

app/Http/Controllers/Staff/OrderController.php

namespace App\Http\Controllers\Staff;
 
use App\Enums\OrderStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Staff\UpdateOrderRequest;
use App\Models\Order;
use Inertia\Inertia;
use Inertia\Response;
 
class OrderController extends Controller
{
public function index(): Response
{
$currentOrders = Order::current()
->with(['customer', 'products'])
->where('restaurant_id', auth()->user()->restaurant_id)
->latest()
->get();
 
$pastOrders = Order::past()
->with(['customer', 'products'])
->where('restaurant_id', auth()->user()->restaurant_id)
->latest()
->get();
 
return Inertia::render('Staff/Orders', [
'current_orders' => $currentOrders,
'past_orders' => $pastOrders,
'order_status' => OrderStatus::toArray(),
]);
}
 
public function update(UpdateOrderRequest $request, $orderId)
{
$order = Order::where('restaurant_id', $request->user()->restaurant_id)
->findOrFail($orderId);
 
$order->update($request->validated());
 
return back();
}
}

In the index() method, we can use current() and past() methods on the Order Model to query orders that fall into constraints we defined in the scope.

The OrderStatus is transformed to an array using the toArray() method we just added to that enum class.

'order_status' => OrderStatus::toArray(),

We can re-use that enum like this order_status.PREPARING in Vue files.

Now let's talk about the update() method. Why didn't we resolve the Order Model like usual?

public function update(UpdateOrderRequest $request, Order $order) {
$order->update($request->validated());
 
//...
}

It would help if you carefully used Route Model Binding with tenancy features. Tenancy refers to architecture when a single instance serves multiple independent clients or tenants. In our case, the order can belong to a different restaurant.

The problem is that using the update(..., Order $order) method signature, ANY existing order in the database can be updated. We want everyone to refrain from messing with other restaurants' orders.

To fix this problem, we resolve the order we want to update by the id and the restaurant of the currently logged-in staff member.

public function update(UpdateOrderRequest $request, $orderId)
{
$order = Order::where('restaurant_id', $request->user()->restaurant_id)
->findOrFail($orderId);
 
// ...
}

Create a new Request class.

app/Http/Requests/Staff/UpdateOrderRequest.php

namespace App\Http\Requests\Staff;
 
use App\Enums\OrderStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
 
class UpdateOrderRequest extends FormRequest
{
public function authorize(): bool
{
return Gate::allows('order.update');
}
 
public function rules(): array
{
return [
'status' => ['required', Rule::in(OrderStatus::values())],
];
}
}

Create new routes file for staff.

routes/staff.php

use App\Http\Controllers\Staff\OrderController;
use Illuminate\Support\Facades\Route;
 
Route::group([
'prefix' => 'staff',
'as' => 'staff.',
'middleware' => ['auth'],
], function () {
Route::resource('orders', OrderController::class);
});

And include it in the web.php file.

routes/web.php

require __DIR__ . '/admin.php';
require __DIR__ . '/vendor.php';
require __DIR__ . '/customer.php';
require __DIR__ . '/staff.php';

Views

It is time to create the Orders page.

resources/js/Pages/Staff/Orders.vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, useForm } from '@inertiajs/vue3'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import SecondaryButton from '@/Components/SecondaryButton.vue'
import DangerButton from '@/Components/DangerButton.vue'
 
defineProps({
current_orders: {
type: Array
},
past_orders: {
type: Array
},
order_status: {
type: Object
}
})
 
const form = useForm({
status: null
})
 
const updateStatus = (order, status) => {
form.status = status
form.put(route('staff.orders.update', order), {
preserveScroll: true
})
}
</script>
 
<template>
<Head title="Restaurant Orders" />
 
<AuthenticatedLayout>
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Restaurant Orders</h2>
</template>
 
<div class="py-12">
<!-- Current Orders -->
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 mb-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 overflow-x-scroll">
<header>
<h2 class="text-lg font-medium text-gray-900">Current Orders</h2>
</header>
 
<table class="table mt-6">
<thead>
<tr>
<th>Order ID</th>
<th>Items</th>
<th>Customer</th>
<th>Total</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="align-top">
<tr v-for="order in current_orders" :key="order.id">
<td>{{ order.id }}</td>
<td>
<div v-for="product in order.products" :key="product.id" class="border-b">
{{ product.name }}
</div>
</td>
<td>{{ order.customer.name }}</td>
<td>
<div class="whitespace-nowrap">{{ (order.total / 100).toFixed(2) }} &euro;</div>
</td>
<td>
<div
class="badge"
:class="{
'badge-yellow': order.status === order_status.PENDING,
'badge-secondary': order.status === order_status.PREPARING
}"
>
{{ order.status }}
</div>
</td>
<td>
<div class="flex flex-row gap-2 justify-end">
<SecondaryButton
v-if="order.status === order_status.PENDING"
@click="updateStatus(order, order_status.PREPARING)"
class="btn-sm"
type="button"
>
Prepare
</SecondaryButton>
<PrimaryButton
v-if="order.status === order_status.PREPARING"
@click="updateStatus(order, order_status.READY)"
class="btn-sm"
type="button"
>
Ready
</PrimaryButton>
<DangerButton
@click="updateStatus(order, order_status.CANCELLED)"
class="btn-sm"
type="button"
>
Cancel
</DangerButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
 
<!-- Past Orders -->
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 mb-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 overflow-x-scroll">
<header>
<h2 class="text-lg font-medium text-gray-900">Past Orders</h2>
</header>
 
<table class="table mt-6">
<thead>
<tr>
<th>Order ID</th>
<th>Items</th>
<th>Customer</th>
<th>Total</th>
<th>Status</th>
</tr>
</thead>
<tbody class="align-top">
<tr v-for="order in past_orders" :key="order.id">
<td>{{ order.id }}</td>
<td>
<div v-for="product in order.products" :key="product.id" class="border-b">
{{ product.name }}
</div>
</td>
<td>{{ order.customer.name }}</td>
<td>
<div class="whitespace-nowrap">{{ (order.total / 100).toFixed(2) }} &euro;</div>
</td>
<td>
<div
class="badge"
:class="{
'badge-primary': order.status === order_status.READY,
'badge-danger': order.status === order_status.CANCELLED
}"
>
{{ order.status }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

Then insert the Restaurant Orders navigation link to the top bar.

resources/js/Layouts/AuthenticatedLayout.vue

<template>
<!-- ... -->
Staff Management
</NavLink>
<NavLink
v-if="can('order.update')"
:href="route('staff.orders.index')"
:active="route().current('staff.orders.index')"
>
Restaurant Orders
</NavLink>
</div>
</div>
<!-- ... -->
</template>

Optional: Apply CSS Styles

If you want your buttons to be consistent with the primary button and add more badge styles, feel free to apply these changes.

resources/css/app.css

.badge-danger {
@apply bg-danger-50 text-danger-700 border-danger-300;
}
 
.badge-secondary {
@apply bg-gray-50 text-gray-700 border-gray-300;
}
 
.badge-yellow {
@apply bg-yellow-50 text-yellow-700 border-yellow-300;
}

resources/js/Components/DangerButton.vue

<template>
<button class="btn btn-danger">
<slot />
</button>
</template>

resources/js/Components/SecondaryButton.vue

<template>
<button :type="type" class="btn btn-secondary">
<slot />
</button>
</template>

Congratulations, you've completed the course!

The code is in the repository on GitHub.