Courses

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/[email protected]/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

Previous: Products Create: Select2 and CKEditor
avatar

Hello, I have a question for this function:

	public function deleteSelected(): void
	{
	
			$orders = Order::whereIn('id', $this->selected)->get();

			$orders->each->delete();

			$this->reset('selected');
	}
	

why in this function we loop over the orders then delete them instead of deleting them directly like this:

	Order::whereIn('id', $this->selected)->delete();

As I know that deleting them directly has better performance right? Correct me if I'm wrong please.

Thanks.

avatar

Because for each of the deleted object, the OBSERVERS would get fired, or other Eloquent events in the future. If we just mass-delete them, yes it's faster but it wouldn't trigger the observers.

avatar
You can use Markdown
avatar

I have been experimenting with two aspects of this tutorial, and I Have created a need for something outside the scope but I still want to learn how to do it! We select can select multiple items, and then delete them. I would like to pass an array of names of each item to sweet alert, so that the user can stop the delete if they see something in the displayed list of what is about to be deleted in case they realize that is wrong. I tried a couple of things for this, I was able to adjust the code for a single delete, and pass the name of individual item to sweet alert. I think I need to build an array of these selected names, and pass them to the alert. does this logic exist elsewhere? does this make sense as a feature / concept?

avatar
You can use Markdown
avatar
Luis Antonio Parrado

TailwindCSS overrides some styles of Pikaday, you must put pikaday styles after @vite([ ... ])

@vite(['resources/css/app.css', 'resources/js/app.js'])
<link href="https://cdn.jsdelivr.net/npm/pikaday/css/pikaday.css" rel="stylesheet">
avatar
You can use Markdown
avatar
Enrique De Jesus Robledo Camacho

What about query strings with the filters?

avatar

What about them?

avatar
You can use Markdown
avatar
Ikaro Campos Laborda

the blade file includes another "svg.sort", in which part of this course is that created?

avatar

Sort was added in the 11th lesson. Have you skipped it? In that lesson there is a link.

avatar
You can use Markdown
avatar

In order to make delete selected button to be able to be activated when orders are checked, change

<input type="checkbox" value="{{ $order->id }}" wire:model="selected">

to

<input type="checkbox" value="{{ $order->id }}" wire:model.live="selected">
avatar
You can use Markdown
avatar
You can use Markdown