Back to Course |
Practical Livewire 3: Order Management System Step-by-Step

Orders Table: Filtering and Ordering

In this tutorial, we will make a table or Orders, with filtering and ordering.

orders table

First, let's start by creating a Model with Migration.

php artisan make:model Order -m

database/migrations/xxxx_create_orders_table.php:

return new class extends Migration
{
public function up()
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->date('order_date');
$table->integer('subtotal');
$table->integer('taxes');
$table->integer('total');
$table->timestamps();
});
}
};

app/Models/Order.php:

use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
class Order extends Model
{
protected $fillable = ['user_id', 'order_date', 'subtotal', 'taxes', 'total'];
 
protected $casts = [
'order_date' => 'date:m/d/Y'
];
 
public function products(): belongsToMany
{
return $this->belongsToMany(Product::class)->withPivot('price', 'quantity');
}
 
public function user(): belongsTo
{
return $this->belongsTo(User::class);
}
}

As you can see, the order has ManyToMany relation to products, we need to create migration for that, but also we will save price and quantity in the pivot table because the price of the product can change, but we don't want to change products price in the order.

php artisan make:migration "create order product table"

database/migrations/xxxx_create_order_product_table.php:

return new class extends Migration
{
public function up()
{
Schema::create('order_product', function (Blueprint $table) {
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->integer('price');
$table->integer('quantity');
});
}
};

Now it's time to create Livewire Component, and register it in the routes and the navigation.

php artisan make:livewire OrdersList

routes/web.php:

// ...
Route::middleware('auth')->group(function () {
Route::get('categories', CategoriesList::class)->name('categories.index');
 
Route::get('products', ProductsList::class)->name('products.index');
Route::get('products/create', ProductForm::class)->name('products.create');
Route::get('products/{product}', ProductForm::class)->name('products.edit');
 
Route::get('orders', OrdersList::class)->name('orders.index');
 
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
// ...

resources/views/layouts/navigation.blade.php:

<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-nav-link>
<x-nav-link :href="route('categories.index')" :active="request()->routeIs('categories.index')">
{{ __('Categories') }}
</x-nav-link>
<x-nav-link :href="route('products.index')" :active="request()->routeIs('products.*')">
{{ __('Products') }}
</x-nav-link>
<x-nav-link :href="route('orders.index')" :active="request()->routeIs('orders.*')">
{{ __('Orders') }}
</x-nav-link>
</div>

For orders we will calculate taxes. It's value we will set in the config/app.php file.

// ...
'orders' => [
'taxes' => 21,
],
];

Now for the OrdersList component, it is the same as ProductsList, the main differences are the variables. The only new feature here is, we will use Pikaday for date inputs. For that, we will just use Alpine.js to initialize Pikaday for that input and use the @js() blade directive to add Pikaday itself using CDN.

Below is the full code for the component and view file.

app/Livewire/OrdersList.php:

use App\Models\Order;
use Livewire\Component;
use Livewire\Attributes\On;
use Livewire\WithPagination;
use Illuminate\Support\Carbon;
use Illuminate\Contracts\View\View;
 
class OrdersList extends Component
{
use WithPagination;
 
public array $selected = [];
 
public string $sortColumn = 'orders.order_date';
 
public string $sortDirection = 'asc';
 
public array $searchColumns = [
'username' => '',
'order_date' => ['', ''],
'subtotal' => ['', ''],
'total' => ['', ''],
'taxes' => ['', ''],
];
 
public function render(): View
{
$orders = Order::query()
->select(['orders.*', 'users.name as username'])
->join('users', 'users.id', '=', 'orders.user_id')
->with('products');
 
foreach ($this->searchColumns as $column => $value) {
if (!empty($value)) {
$orders->when($column == 'order_date', function ($orders) use ($value) {
if (!empty($value[0])) {
$orders->whereDate('orders.order_date', '>=', Carbon::parse($value[0])->format('Y-m-d'));
}
if (!empty($value[1])) {
$orders->whereDate('orders.order_date', '<=', Carbon::parse($value[1])->format('Y-m-d'));
}
})
->when($column == 'username', fn($orders) => $orders->where('users.name', 'LIKE', '%' . $value . '%'))
->when($column == 'subtotal', function ($orders) use ($value) {
if (is_numeric($value[0])) {
$orders->where('orders.subtotal', '>=', $value[0] * 100);
}
if (is_numeric($value[1])) {
$orders->where('orders.subtotal', '<=', $value[1] * 100);
}
})
->when($column == 'taxes', function ($orders) use ($value) {
if (is_numeric($value[0])) {
$orders->where('orders.taxes', '>=', $value[0] * 100);
}
if (is_numeric($value[1])) {
$orders->where('orders.taxes', '<=', $value[1] * 100);
}
})
->when($column == 'total', function ($orders) use ($value) {
if (is_numeric($value[0])) {
$orders->where('orders.total', '>=', $value[0] * 100);
}
if (is_numeric($value[1])) {
$orders->where('orders.total', '<=', $value[1] * 100);
}
});
}
}
 
$orders->orderBy($this->sortColumn, $this->sortDirection);
 
return view('livewire.orders-list', [
'orders' => $orders->paginate(10)
]);
}
 
 
public function deleteConfirm(string $method, $id = null)
{
$this->dispatch('swal:confirm', [
'type' => 'warning',
'title' => 'Are you sure?',
'text' => '',
'id' => $id,
'method' => $method,
]);
}
 
#[On('delete')]
public function delete(int $id): void
{
Order::findOrFail($id)->delete();
}
 
#[On('deleteSelected')]
public function deleteSelected(): void
{
$orders = Order::whereIn('id', $this->selected)->get();
 
$orders->each->delete();
 
$this->reset('selected');
}
 
public function getSelectedCountProperty(): int
{
return count($this->selected);
}
 
public function sortByColumn($column): void
{
if ($this->sortColumn == $column) {
$this->sortDirection = $this->sortDirection == 'asc' ? 'desc' : 'asc';
} else {
$this->reset('sortDirection');
$this->sortColumn = $column;
}
}
}

resources/views/livewire/orders-list.blade.php:

<div>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('Orders') }}
</h2>
</x-slot>
 
<div class="py-12">
<div class="mx-auto max-w-screen-2xl sm:px-6 lg:px-8">
<div class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
<div class="p-6 bg-white border-b border-gray-200">
 
<div class="mb-4">
<div class="mb-4">
<a class="inline-flex items-center px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase bg-gray-800 rounded-md border border-transparent hover:bg-gray-700">
Create Order
</a>
</div>
 
<button type="button"
wire:click="deleteConfirm('deleteSelected')"
wire:loading.attr="disabled"
{{ $this->selectedCount ? '' : 'disabled' }}
class="px-4 py-2 mr-5 text-xs text-red-500 uppercase bg-red-200 rounded-md border border-transparent hover:text-red-700 hover:bg-red-300 disabled:opacity-50 disabled:cursor-not-allowed">
Delete Selected
</button>
</div>
 
<div class="overflow-hidden overflow-x-auto mb-4 min-w-full align-middle sm:rounded-md">
<table class="min-w-full border divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 text-left bg-gray-50">
</th>
<th wire:click="sortByColumn('order_date')" class="px-6 py-3 w-40 text-left bg-gray-50">
<span class="text-xs font-medium tracking-wider leading-4 text-gray-500 uppercase">Order date</span>
@if ($sortColumn == 'order_date')
@include('svg.sort-' . $sortDirection)
@else
@include('svg.sort')
@endif
</th>
<th class="px-6 py-3 text-left bg-gray-50">
<span class="text-xs font-medium tracking-wider leading-4 text-gray-500 uppercase">User Name</span>
</th>
<th class="px-6 py-3 text-left bg-gray-50 w-fit">
<span class="text-xs font-medium tracking-wider leading-4 text-gray-500 uppercase">Products</span>
</th>
<th wire:click="sortByColumn('subtotal')" class="px-6 py-3 w-36 text-left bg-gray-50">
<span class="text-xs font-medium tracking-wider leading-4 text-gray-500 uppercase">Subtotal</span>
@if ($sortColumn == 'subtotal')
@include('svg.sort-' . $sortDirection)
@else
@include('svg.sort')
@endif
</th>
<th wire:click="sortByColumn('taxes')" class="px-6 py-3 w-32 text-left bg-gray-50">
<span class="text-xs font-medium tracking-wider leading-4 text-gray-500 uppercase">Taxes</span>
@if ($sortColumn == 'taxes')
@include('svg.sort-' . $sortDirection)
@else
@include('svg.sort')
@endif
</th>
<th wire:click="sortByColumn('total')" class="px-6 py-3 w-32 text-left bg-gray-50">
<span class="text-xs font-medium tracking-wider leading-4 text-gray-500 uppercase">Total</span>
@if ($sortColumn == 'total')
@include('svg.sort-' . $sortDirection)
@else
@include('svg.sort')
@endif
</th>
<th class="px-6 py-3 w-44 text-left bg-gray-50">
</th>
</tr>
<tr>
<td>
</td>
<td class="px-1 py-1 text-sm">
<div>
From
<input x-data
x-init="new Pikaday({ field: $el, format: 'MM/DD/YYYY' })"
wire:model.blur="searchColumns.order_date.0"
type="text"
placeholder="MM/DD/YYYY"
class="mr-2 w-full text-sm rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" />
</div>
<div>
to
<input x-data
x-init="new Pikaday({ field: $el, format: 'MM/DD/YYYY' })"
wire:model.blur="searchColumns.order_date.1"
type="text"
placeholder="MM/DD/YYYY"
class="w-full text-sm rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" />
</div>
</td>
<td class="px-1 py-1 text-sm">
<input wire:model.live.debounce="searchColumns.username" type="text" placeholder="Search..."
class="w-full text-sm rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" />
</td>
<td class="px-1 py-1">
</td>
<td class="px-1 py-1 text-sm">
From
<input wire:model.live.debounce="searchColumns.subtotal.0" type="number"
class="mr-2 w-full text-sm rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" />
to
<input wire:model.live.debounce="searchColumns.subtotal.1" type="number"
class="w-full text-sm rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" />
</td>
<td class="px-1 py-1 text-sm">
From
<input wire:model.live.debounce="searchColumns.taxes.0" type="number"
class="mr-2 w-full text-sm rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" />
to
<input wire:model.live.debounce="searchColumns.taxes.1" type="number"
class="w-full text-sm rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" />
</td>
<td class="px-1 py-1 text-sm">
From
<input wire:model.live.debounce="searchColumns.total.0" type="number"
class="mr-2 w-full text-sm rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" />
to
<input wire:model.live.debounce="searchColumns.total.1" type="number"
class="w-full text-sm rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" />
</td>
</tr>
</thead>
 
<tbody class="bg-white divide-y divide-gray-200 divide-solid">
@foreach($orders as $order)
<tr class="bg-white" wire:key="order-{{ $order->id }}">
<td class="px-4 py-2 text-sm leading-5 text-gray-900 whitespace-no-wrap">
<input type="checkbox" value="{{ $order->id }}" wire:model.live="selected">
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
{{ $order->order_date->format('m/d/Y') }}
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
{{ $order->username }}
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
@foreach($order->products as $product)
<span class="px-2 py-1 text-xs text-indigo-700 bg-indigo-200 rounded-md">{{ $product->name }}</span>
@endforeach
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
${{ number_format($order->subtotal / 100, 2) }}
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
${{ number_format($order->taxes / 100, 2) }}
</td>
<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
${{ number_format($order->total / 100, 2) }}
</td>
<td>
<a class="inline-flex items-center px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase bg-gray-800 rounded-md border border-transparent hover:bg-gray-700">
Edit
</a>
<button wire:click="deleteConfirm('delete', {{ $order->id }})" class="px-4 py-2 text-xs text-red-500 uppercase bg-red-200 rounded-md border border-transparent hover:text-red-700 hover:bg-red-300">
Delete
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
 
{{ $orders->links() }}
 
</div>
</div>
</div>
</div>
</div>
 
@push('js')
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/pikaday/pikaday.js"></script>
@endpush

Also, we need to add Pikday styles to app.blade.php:

resources/views/layouts/app.blade.php:

<!-- Scripts -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/pikaday/css/pikaday.css" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>

Visit the orders page and you will see the working table.

orders table

One thing we need to add is to the Products component. If we try to delete the product, but it is in order, we need to throw an error and don't allow to delete it. This check needs to be done in both the delete() and deleteSelected() methods. Before that, we need to add the orders relationship to the Product model.

app/Models/Product.php:

class Product extends Model
{
// ...
 
public function orders(): belongsToMany
{
return $this->belongsToMany(Order::class);
}
}

app/Livewire/ProductsList.php:

class ProductsList extends Component
{
// ...
 
public function delete($id): void
{
$product = Product::findOrFail($id);
 
if ($product->orders()->exists()) {
$this->addError('orderexist', 'This product cannot be deleted, it already has orders');
return;
}
 
$product->delete();
}
 
public function deleteSelected(): void
{
$products = Product::whereIn('id', $this->selected)->get();
$products = Product::with('orders')->whereIn('id', $this->selected)->get();
 
foreach ($products as $product) {
if ($product->orders()->exists()) {
$this->addError("orderexist", "Product <span class='font-bold'>{$product->name}</span> cannot be deleted, it already has orders");
return;
}
}
 
$products->each->delete();
 
$this->reset('selected');
}
}

resources/views/livewire/products-list.blade.php:

@error('orderexist')
<div class="p-3 mb-4 text-green-700 bg-green-200">
{!! $message !!}
</div>
@enderror
 
<div class="mb-4">
<div class="mb-4">
<a href="{{ route('products.create') }}" class="inline-flex items-center px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase bg-gray-800 rounded-md border border-transparent hover:bg-gray-700">
Create Product
</a>
</div>
// ...

product cannot be deleted