Laravel Projects Examples

Order Management with Livewire

Example Livewire project for managing Orders, Products in Categories with quite complex tables and dynamic elements.

How to install

  • Clone the repository with git clone
  • Copy the .env.example file to .env and edit database credentials there
  • Run composer install
  • Run php artisan key:generate
  • Run php artisan storage:link
  • Run php artisan migrate --seed (it has some seeded data for your testing)
  • Run npm ci and npm run build
  • Launch the main URL /. Log in with any seeded user or register with a new user.
  • That's it.

How It Works

This project uses Livewire. Livewire components are found in the default app/Livewire directory.

app/
└── Livewire/
├── CategoriesList.php
├── OrderForm.php
├── OrdersList.php
├── ProductForm.php
├── ProductsList.php
├── RegisterPassword.php
└── TotalRevenueChart.php

In the registration page, instead of password inputs, the Livewire RegisterPasswords component is added to:

  1. Show or hide the password
  2. Generate a random password
  3. Show password strength

In the dashboard, we show a TotalRevenueChart Livewire component, which shows the revenue from the last seven days' orders. The Livewire polling feature auto-updates the chart every minute.

When polling occurs, it calls the updateChartData() method. In this method, an event is fired with the new chart data.

app/Livewire/TotalRevenueChart.php:

use App\Models\Order;
use Livewire\Component;
use Illuminate\Contracts\View\View;
 
class TotalRevenueChart extends Component
{
public function render(): View
{
return view('livewire.total-revenue-chart');
}
 
public function updateChartData(): void
{
$this->dispatch('updateChartData', data: $this->getData())->self();
}
 
protected function getData(): array
{
$data = Order::query()
->select('order_date', \DB::raw('sum(total) as total'))
->where('order_date', '>=', now()->subDays(7))
->groupBy('order_date')
->get();
 
return [
'datasets' => [
[
'label' => 'Total revenue from last 7 days',
'data' => $data->map(fn (Order $order) => $order->total / 100),
],
],
'labels' => $data->map(fn (Order $order) => $order->order_date->format('d/m/Y')),
];
}
}

In the View file event is listened using Alpine.js and then chart data gets updated.

<div wire:poll.60s="updateChartData">
<canvas
x-data="{
chart: null,
 
init: function () {
let chart = new Chart($el, {
type: 'line',
data: @js($this->getData()),
options: {
plugins: {
tooltip: {
callbacks: {
label: function (context) {
return '$' + context.formattedValue
}
}
}
}
}
})
 
$wire.on('updateChartData', async ({ data }) => {
chart.data = data
chart.update('resize')
})
}
}"
style="height: 320px;"
wire:ignore
>
</canvas>
</div>

The categories page uses the reordering package wotzebra/livewire-sortablejs. Instead of a full page, a modal is used for creating and editing categories.

On all pages, before deleting a record, a confirmation modal is shown. For this modal, a sweetalert2 is used.

When the delete button is clicked, a deleteConfirm() method is fired, which sends an event to open sweetalert2.

<button wire:click="deleteConfirm('delete', {{ $category->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>
public function deleteConfirm(string $method, $id = null): void
{
$this->dispatch('swal:confirm', [
'type' => 'warning',
'title' => 'Are you sure?',
'text' => '',
'id' => $id,
'method' => $method,
]);
}

On the front end, we listen for the event. When the user confirms deletion, another event is fired to delete a record. The second event can be deleted or deleteSelected.

<script>
document.addEventListener('livewire:init', () => {
Livewire.on('swal:confirm', (event) => {
swal.fire({
title: event[0].title,
text: event[0].text,
icon: event[0].type,
showCancelButton: true,
confirmButtonColor: 'rgb(239 68 6)',
confirmButtonText: 'Yes, delete it!'
})
.then((willDelete) => {
if (willDelete.isConfirmed) {
Livewire.dispatch(event[0].method, { id: event[0].id });
}
});
})
});
</script>

For these two events, we listen to the Livewire component.

use Livewire\Attributes\On;
 
#[On('delete')]
public function delete(int $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();
}
 
#[On('deleteSelected')]
public function deleteSelected(): void
{
$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');
}

Products can be exported to a PDF, XlSX, or CSV using maatwebsite/excel package.

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

// ...
 
<x-primary-button wire:click="export('csv')">CSV</x-primary-button>
<x-primary-button wire:click="export('xlsx')">XLSX</x-primary-button>
<x-primary-button wire:click="export('pdf')">PDF</x-primary-button>
 
// ...

app/Livewire/ProductsList.php:

use App\Exports\ProductsExport;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
 
class ProductsList extends Component
{
// ...
 
public function export(string $format): BinaryFileResponse
{
abort_if(! in_array($format, ['csv', 'xlsx', 'pdf']), Response::HTTP_NOT_FOUND);
 
return Excel::download(new ProductsExport($this->selected), 'products.' . $format);
}
 
// ...
}

app/Exports/ProductsExport.php:

use App\Models\Product;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\FromCollection;
 
class ProductsExport implements FromCollection, WithHeadings, WithMapping
{
public function __construct(private array $productIDs) {}
 
public function headings(): array
{
return [
'Name',
'Categories',
'Country',
'Price'
];
}
 
public function map($product): array
{
return [
$product->name,
$product->categories->pluck('name')->implode(', '),
$product->country->name,
'$' . number_format($product->price, 2)
];
}
 
public function collection(): Collection
{
return Product::with('categories', 'country')->find($this->productIDs);
}
}

Some columns can be sorted and searched in the Orders and Products pages.

app/Livewire/OrdersList.php:

use App\Models\Order;
use Livewire\Component;
use Livewire\WithPagination;
use Livewire\Attributes\Url;
use Illuminate\Support\Carbon;
use Illuminate\Contracts\View\View;
 
class OrdersList extends Component
{
use WithPagination;
 
public array $selected = [];
 
#[Url]
public string $sortColumn = 'orders.order_date';
 
#[Url]
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 sortByColumn($column): void
{
if ($this->sortColumn == $column) {
$this->sortDirection = $this->sortDirection == 'asc' ? 'desc' : 'asc';
} else {
$this->reset('sortDirection');
$this->sortColumn = $column;
}
}
}

app/Livewire/ProductsList.php:

use Livewire\Component;
use App\Models\Product;
use App\Models\Country;
use App\Models\Category;
use Livewire\Attributes\On;
use Livewire\WithPagination;
use Livewire\Attributes\Url;
use App\Exports\ProductsExport;
use Illuminate\Contracts\View\View;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
 
class ProductsList extends Component
{
// ...
 
#[Url]
public string $sortColumn = 'products.name';
 
#[Url]
public string $sortDirection = 'asc';
 
public array $searchColumns = [
'name' => '',
'price' => ['', ''],
'description' => '',
'category_id' => 0,
'country_id' => 0,
];
 
// ...
 
public function sortByColumn(string $column): void
{
if ($this->sortColumn == $column) {
$this->sortDirection = $this->sortDirection == 'asc' ? 'desc' : 'asc';
} else {
$this->reset('sortDirection');
$this->sortColumn = $column;
}
}
 
public function render(): View
{
$products = Product::query()
->select(['products.*', 'countries.id as countryId', 'countries.name as countryName',])
->join('countries', 'countries.id', '=', 'products.country_id')
->with('categories');
 
foreach ($this->searchColumns as $column => $value) {
if (! empty($value)) {
$products->when($column == 'price', function ($products) use ($value) {
if (is_numeric($value[0])) {
$products->where('products.price', '>=', $value[0] * 100);
}
if (is_numeric($value[1])) {
$products->where('products.price', '<=', $value[1] * 100);
}
})
->when($column == 'category_id', fn ($products) => $products->whereRelation('categories', 'id', $value))
->when($column == 'country_id', fn ($products) => $products->whereRelation('country', 'id', $value))
->when($column == 'name', fn ($products) => $products->where('products.' . $column, 'LIKE', '%' . $value . '%'));
}
}
 
$products->orderBy($this->sortColumn, $this->sortDirection);
 
return view('livewire.products-list', [
'products' => $products->paginate(10),
]);
}
}

The product form has CKEditor for textarea input.

resources/views/livewire/product-form.blade.php:

// ...
 
<div class="mt-4">
<x-input-label for="description" :value="__('Description')" />
 
<div wire:ignore>
<textarea wire:model="description" data-description="@this" id="description" class="block mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"></textarea>
</div>
<x-input-error :messages="$errors->get('description')" class="mt-2" />
</div>
 
// ...
@push('js')
<script src="https://cdn.ckeditor.com/ckeditor5/31.1.0/classic/ckeditor.js"></script>
<script>
document.addEventListener('livewire:init', () => {
ClassicEditor
.create(document.querySelector('#description'))
.then(editor => {
editor.model.document.on('change:data', () => {
@this.set('description', editor.getData());
})
Livewire.on('reinit', () => {
editor.setData('', '')
})
})
.catch(error => {
console.error(error);
});
})
</script>
@endpush

Also, there is a Blade component for the select2.

resources/views/components/select2.blade.php:

<div>
<div wire:ignore class="w-full">
<select class="select2" data-placeholder="{{ __('Select your option') }}" {{ $attributes }}>
@if(!isset($attributes['multiple']))
<option></option>
@endif
@foreach($options as $key => $value)
<option value="{{ $key }}" @selected(in_array($key, \Illuminate\Support\Arr::wrap($selectedOptions)))>{{ $value }}</option>
@endforeach
</select>
</div>
</div>
 
@push('js')
<script>
document.addEventListener('livewire:init', () => {
let el = $('#{{ $attributes['id'] }}')
function initSelect() {
el.select2({
placeholder: '{{ __('Select your option') }}',
allowClear: !el.attr('required')
})
}
initSelect()
Livewire.hook('message.processed', (message, component) => {
initSelect()
});
el.on('change', function (e) {
let data = $(this).select2("val")
if (data === "") {
data = null
}
@this.set('{{ $attributes['wire:model'] }}', data)
});
});
</script>
@endpush

app/View/Components/Select2.php:

use Illuminate\View\Component;
use Illuminate\Contracts\View\View;
 
class Select2 extends Component
{
public function __construct(public mixed $options, public mixed $selectedOptions)
{}
 
public function render(): View
{
return view('components.select2');
}
}

The Select2 Blade component is used in products and orders forms. To use this component, options and selectedOptions must be passed as an array and a wire:model for binding to a property.

<x-select2 class="mt-1" id="categories" name="categories" :options="$this->listsForFields['categories']" wire:model="categories" :selectedOptions="$categories" multiple />