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
andnpm 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:
- Show or hide the password
- Generate a random password
- 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 />