Courses

Practical Livewire 3: Order Management System Step-by-Step

Products Live Search

In this lesson, we will add live search functionality to the table.

filtered products

First, we will get a list of categories and countries. For this, we will need public properties for both and we will assign them in the mount() method.

app/Http/Livewire/ProductsList.php:

use App\Models\Product;
use App\Models\Country;
 
class ProductsList extends Component
{
use WithPagination;
 
public array $categories = [];
 
public array $countries = [];
 
public function mount(): void
{
$this->categories = Category::pluck('name', 'id')->toArray();
$this->countries = Country::pluck('name', 'id')->toArray();
}
 
public function render(): View
{
$products = Product::paginate(10);
 
return view('livewire.products-list', [
'products' => $products,
]);
}
}

Now let's add options to the frontend. In the blade file, in the tables thead we will add another row.

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

<tr>
<td></td>
<td class="px-2 py-2">
<input wire:model.live.debounce="searchColumns.name" 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-2 py-1">
<select wire:model.live="searchColumns.category_id"
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">
<option value="">-- choose category --</option>
@foreach($categories as $id => $category)
<option value="{{ $id }}">{{ $category }}</option>
@endforeach
</select>
</td>
<td class="px-2 py-1">
<select wire:model.live="searchColumns.country_id"
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">
<option value="">-- choose country --</option>
@foreach($countries as $id => $country)
<option value="{{ $id }}">{{ $country }}</option>
@endforeach
</select>
</td>
<td class="px-2 py-1 text-sm">
<div>
From
<input wire:model.live.debounce="searchColumns.price.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" />
</div>
<div>
to
<input wire:model.live.debounce="searchColumns.price.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" />
</div>
</td>
<td></td>
</tr>

Now table should look like this:

products table with filters

Every filter column is binded to some property, so we need to add them to the component.

app/Livewire/ProductsList.php:

class ProductsList extends Component
{
use WithPagination;
 
public array $categories = [];
 
public array $countries = [];
 
public array $searchColumns = [
'name' => '',
'price' => ['', ''],
'description' => '',
'category_id' => 0,
'country_id' => 0,
];
 
// ...
}

Now we can make a query to filter the table.

app/Livewire/ProductsList.php:

class ProductsList extends Component
{
// ...
 
public function render(): View
{
$products = Product::paginate(10);
$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 . '%'));
}
}
 
return view('livewire.products-list', [
'products' => $products,
'products' => $products->paginate(10)
]);
}
}

Notice: query here is optional, I'm using it just for code formatting.

The query here isn't anything special. The special part here is that we need manually to tell what to select and we need to join countries manually, also we need to rename two fields' names from the countries table. Because we renamed them, also we need to change the value in the blade file.

<td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
{{ $product->country->name }}
{{ $product->countryName }}
</td>

Next, we go through every search column, and if its value isn't empty we continue. Then, we instead of many ifs we use conditional clauses, and if its value is true, only then the query will be made. Also, take a look when we filter by price, we need to multiply by 100, because we save the value in DB to cents, but a user can enter a value with a comma.

filtered products

Previous: Products Table Main Structure
avatar

Can you further explain the need to multiply by 100 in the product price search? It is displaying the price stored in the DB divided by 100. The search works fine on the price that is displayed however because it is dividing by 100 in the search.

avatar

Not sure if I understand the question. We need to search by price in the DATABASE, which is in cents, that's why we need to multiply the search parameters by 100 to have them in cents, to match the database records.

avatar

Very nicen specialy the search parts

avatar

my five cents: there are lots of way to store money in the database, but perhaps the most common - convention adopted by famous examples such as Stripe - is storing in the database as cents, due to the problem around float-poiting numbers: compilers treat them differently and they've got thus no quite precision. Integers are more reliable

avatar
You can use Markdown
avatar

I found the query is bit complex. Can't we simplify it by using Eloquent ORM?

avatar

Not much to simplify, I think, as the task itself is pretty complex. But you can give it a try and post your suggestions :)

avatar

It is not really an improvement but my query looks like this. just got rid of the "foreach" loop. "to" is set to max price in products. If "to" is 0, query doesn't return any product. It is not bug but feature ;)


// ...
public function mount(): void
{
	 // ...
	$this->searchColumns["price"] = [0, Product::max('price') / 100];
}
// ...
public function render()
{
	$products = Product::query()
		->select(['products.*', 'countries.id as countryId', 'countries.name as countryName',])
		->join('countries', 'countries.id', '=', 'products.country_id')
		->whereBetween(
				'products.price',
				[
						floatval($this->searchColumns['price'][0]) * 100,
						floatval($this->searchColumns['price'][1]) * 100
				]
		)
		->with('categories')
		->where('products.name', 'LIKE', '%' . $this->searchColumns['name'] . '%')
		->where('products.description', 'LIKE', '%' . $this->searchColumns['description'] . '%')
		->when($this->searchColumns['category_id'], function ($query) {
				$query->whereRelation('categories', 'id', $this->searchColumns['category_id']);
		})
		->when($this->searchColumns['country_id'], function ($query) {
				$query->whereRelation('country', 'id', $this->searchColumns['country_id']);
		});

	return view('livewire.products-list', [
		'products' => $products->paginate(10),
	]);
}
avatar

I simplified the code by moving the functionality to local scopes.


  #[Layout('layouts.app')]
    public function render()
    {
        $products = Product::query()
            ->select(['products.*', 'countries.id as countryId', 'countries.name as countryName'])
            ->join('countries', 'countries.id', '=', 'products.country_id')
            ->with('categories')
            ->filterByName($this->searchColumns['name'])
            ->filterByPrice($this->searchColumns['price'][0], $this->searchColumns['price'][1])
            ->filterByCategory($this->searchColumns['category_id'])
            ->filterByCountry($this->searchColumns['country_id'])
            ->paginate();

        return view('livewire.products.products-lists', [
            'products' => $products
        ]);
    }
avatar
 public function scopeFilterByName($query, $name)
    {
        return $query->when($name, fn($query) => $query->where('products.name', 'LIKE', '%' . $name . '%'));
    }

    public function scopeFilterByPrice($query, $min, $max)
    {
        return $query->when(is_numeric($min), fn($query) => $query->where('price', '>=', $min * 100))
            ->when(is_numeric($max), fn($query) => $query->where('price', '<=', $max * 100));
    }

    public function scopeFilterByCategory($query, $categoryId)
    {
        return $query->when($categoryId, fn($query) => $query->whereRelation('categories','id', $categoryId));
    }

    public function scopeFilterByCountry($query, $countryId)
    {
        return $query->when($countryId, fn($query) => $query->whereRelation('country','id', $countryId));
    }
avatar
You can use Markdown
avatar

When I search by category I got the following error

select count(*) as aggregate from `products` inner join `countries` on `countries`.`id` = `products`.`country_id` where `products`.`name` LIKE %volup% and exists (select * from `categories` inner join `category_product` on `categories`.`id` = `category_product`.`category_id` where `products`.`id` = `category_product`.`product_id` and `id` = 7)

Integrity constraint violation: 1052 Column 'id' in where clause is ambiguous, I am on Laravel 10

avatar

Post your full query formatted in php markdown

avatar

Solution: replace the last 'id' in the following query with 'categories.id'

$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 == 'name', fn($products) => $products->where('products.'.$column, 'LIKE', '%'.$value.'%' ));
                $products->when($column == 'description', fn($products) => $products->where('products.'.$column, 'LIKE', '%'.$value.'%' ));
                $products->when($column == 'country_id', fn($products) => $products->whereRelation('country', 'id', $value));
                $products->when($column == 'category_id', fn($products) => $products->whereRelation('categories', 'id', $value));
            }
        }
avatar
You can use Markdown
avatar

Hello all! I not think it is a improvement, but I like to have a message when my filter result in no records found.

@forelse($products as $product)
    <tr class="bg-white">
        <td class="px-4 py-2 text-sm leading-5 text-gray-900 whitespace-no-wrap">
            <input type="checkbox" value="{{ $product->id }}" wire:model="selected">
        </td>
        <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
            {{ $product->name }}
        </td>
        <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
            @foreach($product->categories as $category)
                <span class="px-2 py-1 text-xs text-indigo-700 bg-indigo-200 rounded-md">{{ $category->name }}</span>
            @endforeach
        </td>
        <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
            {{ $product->countryName }}
        </td>
        <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
            ${{ number_format($product->price / 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 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>
@empty
    <tr class="bg-gray-300">
        <td colspan="6" class="text-center">No results found!</td>
    </tr>
@endforelse
</tbody>
👍 1
avatar
You can use Markdown
avatar

Tips: If you save your file in views/livewire dont' dorget to include livewire in your includes:

@include('livewire.sort-' . $sortDirection)
@include('livewire.sort')
avatar
You can use Markdown
avatar

A problem that I have is that when I type something in any search field the autofocus is gone (and so is the text coursor) as soon as the search updates, so I have to click the field again to be able to continue to type in that field. Does anyone experience the sam or have I missed something?

avatar

Don't have such problem

avatar
You can use Markdown
avatar

Hello...

Nothing happens when I select from the dropdown or type something on the search inputs... All previuos lessons are working as expected, but not this one...

Here is the code:

--- Component namespace App\Livewire;

use App\Models\Category; use App\Models\Country; use App\Models\Product; use Livewire\Component; use Livewire\WithPagination; use Illuminate\Contracts\View\View;

class ProductList extends Component { use WithPagination;

public array $categories = [];

public array $countries = [];

public array $searchColumns = [
    'name' => '',
    'price' => ['', ''],
    'description' => '',
    'category_id' => 0,
    'country_id' => 0,
];

public function mount(): void
{
    $this->categories = Category::pluck('name', 'id')->toArray();
    $this->countries = Country::pluck('name', 'id')->toArray();
}

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 . '%'));
        }
    }

    return view('livewire.product-list', [
        'products' => $products->paginate(10),
    ]);
}

}

--- View

{{ __('Productos') }}

<div class="py-12">
    <div class="mx-auto max-w-7xl 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">
                            Crear un Producto
                        </a>
                    </div>
                </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 class="px-6 py-3 text-left bg-gray-50">
                                <span class="text-xs font-medium tracking-wider leading-4 text-gray-500 uppercase">Name</span>
                            </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">Categories</span>
                            </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">Country</span>
                            </th>
                            <th 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">Price</span>
                            </th>
                            <th class="px-6 py-3 text-left bg-gray-50">
                            </th>
                        </tr>
                        <tr>
                            <td></td>
                            <td class="px-2 py-2">
                                <input wire:model="searchColumns.name" 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-2 py-1">
                                <select wire:model="searchColumns.category_id"
                                        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">
                                    <option value="">-- choose category --</option>
                                    @foreach($categories as $id => $category)
                                        <option value="{{ $id }}">{{ $category }}</option>
                                    @endforeach
                                </select>
                            </td>
                            <td class="px-2 py-1">
                                <select wire:model="searchColumns.country_id"
                                        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">
                                    <option value="">-- choose country --</option>
                                    @foreach($countries as $id => $country)
                                        <option value="{{ $id }}">{{ $country }}</option>
                                    @endforeach
                                </select>
                            </td>
                            <td class="px-2 py-1 text-sm">
                                <div>
                                    From
                                    <input wire:model="searchColumns.price.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" />
                                </div>
                                <div>
                                    to
                                    <input wire:model="searchColumns.price.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" />
                                </div>
                            </td>
                            <td></td>
                        </tr>
                        </thead>

                        <tbody class="bg-white divide-y divide-gray-200 divide-solid">
                        @foreach($products as $product)
                            <tr class="bg-white">
                                <td class="px-4 py-2 text-sm leading-5 text-gray-900 whitespace-no-wrap">
                                    <input type="checkbox" value="{{ $product->id }}" wire:model="selected">
                                </td>
                                <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
                                    {{ $product->name }}
                                </td>
                                <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
                                    @foreach($product->categories as $category)
                                        <span class="px-2 py-1 text-xs text-indigo-700 bg-indigo-200 rounded-md">{{ $category->name }}</span>
                                    @endforeach
                                </td>
                                <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
                                    {{ $product->countryName }}
                                </td>
                                <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap">
                                    ${{ number_format($product->price / 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 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>

                {{ $products->links() }}

            </div>
        </div>
    </div>
</div>
avatar

Sorry, but if you wan't help edit your message to put code in a gist instead of hear. Other important question are you using livewire v2 or v3? They have some minor but very important differences. The most important is how requests are being sent to the server.

avatar
You can use Markdown
avatar
Ikaro Campos Laborda

This Lesson does not seem to be working when using Livewire 3.4. I went through the docs but couldn't find something that helps

avatar

Because this course was made before v3.

avatar
Ikaro Campos Laborda

@Nerijus, do you know a way to make it work on Livewire 3, or at least point me where in the docs should I go?

avatar

its not just about the docs but you should know how livewire works. Povilas approved to update this course for livewire v3. When it will happen cannot say. Would guess next month.

avatar

Hi Ikaro, we updated the course text in full to Livewire 3, including the repository. Good job, Nerijus.

avatar
You can use Markdown
avatar

Not sure if adding the category_product pivot table happened when the course was updated to Livewire v3 but I had to modify the query to include that table in order to get the category search to work;

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', function ($products) use ($value) {
            $products->join('category_product', 'products.id', '=', 'category_product.product_id')
                     ->where('category_product.category_id', $value);
        })
        ->when($column == 'country_id', fn($products) => $products->whereRelation('country', 'id', $value))
        ->when($column == 'name', fn($products) => $products->where('products.' . $column, 'LIKE', '%' . $value . '%'));
    }
} 
avatar
You can use Markdown
avatar
You can use Markdown