Courses

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

Products Table Main Structure

Now we will move on to products. In this lesson, we will create a table of Products, powered by Livewire. For now, we will just show a list of products, and in other lessons, we will add features to it.

products table with pagination

First, we will create a Livewire component.

php artisan make:livewire ProductsList

We use all Livewire components, as full-page, so we need to register the route using Livewire Component instead of Controller inside the middleware group, with the name of products.index, and add a link to the navigation.

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('/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>
</div>

The product will have a country. For this, we will create a Model and Migration, but data will just be seeded.

php artisan make:model Country -m

database/migrations/xxxx_create_countries_table.php:

return new class extends Migration
{
public function up()
{
Schema::create('countries', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('short_code');
$table->timestamps();
});
}
}

app/Models/Country.php:

class Country extends Model
{
protected $fillable = ['name', 'short_name'];
}

Seeder can be found here, don't forget to call it in DatabaseSeeder, so that after every migration you could seed it easier.

database/seeders/DatabaseSeeder.php:

public function run(): void
{
$this->call([
CountriesSeeder::class,
]);
}

Next, as always, Products Model and Migrations:

php artisan make:model Product -m

database/migrations/xxxx_create_products_table.php:

return new class extends Migration
{
public function up()
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description');
$table->foreignId('country_id')->constrained();
$table->integer('price')->default(0);
$table->timestamps();
});
}
}

app/Models/Product.php:

class Product extends Model
{
use HasFactory;
 
protected $fillable = ['name', 'description', 'country_id', 'price'];
 
public function country(): BelongsTo
{
return $this->belongsTo(Country::class);
}
 
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class);
}
}

And, because Product has ManyToMany relation to Categories, we need to create migration for this relation.

php artisan make:migration "create category product table"

database/migrations/xxxx_create_category_product_table.php:

return new class extends Migration
{
public function up()
{
Schema::create('category_product', function (Blueprint $table) {
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
});
}
}

Now, let's show the products in the table. First, in the component, we need to add the WithPagination trait and query Products.

app/Livewire/ProductsList.php:

class ProductsList extends Component
{
use WithPagination;
 
public function render(): View
{
$products = Product::paginate(10);
 
return view('livewire.products-list', [
'products' => $products,
]);
}
}

And blade for now would look like below.

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

<div>
<x-slot name="header">
<h2 class="text-xl font-semibold leading-tight text-gray-800">
{{ __('Products') }}
</h2>
</x-slot>
 
<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">
Create Product
</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>
</thead>
 
<tbody class="bg-white divide-y divide-gray-200 divide-solid">
@foreach($products as $product)
<tr class="bg-white" wire:key="product-{{ $product->id }}">
<td class="px-4 py-2 text-sm leading-5 text-gray-900 whitespace-no-wrap">
<input type="checkbox" value="{{ $product->id }}" wire:model.live="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->country->name }}
</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>
</div>

Yes, for now, we have an N+1 problem, we will fix that in other lessons. For now, after adding manually at least 11 products you should see a similar working table with pagination:

products table with pagination

Previous: Category Delete with SweetAlert Confirm
avatar

when I was trying to give product model command this error is given ErrorException

include(C:\Users\HP\Documents\Project\project_one\vendor\composer/../../app/Http/Livewire/ProductList.php): Failed to open s stream: No such file or directory

at C:\Users\HP\Documents\Project\project_one\vendor\composer\ClassLoader.php:578 574▕ * @param string $file 575▕ * @return void 576▕ */ 577▕ self::$includeFile = static function($file) { ➜ 578▕ include $file; 579▕ }; 580▕ } 581▕ } 582▕

1 C:\Users\HP\Documents\Project\project_one\vendor\composer\ClassLoader.php:578 include()

2 C:\Users\HP\Documents\Project\project_one\vendor\composer\ClassLoader.php:432 Composer\Autoload\ClassLoader::Composer\Autoload{closure}("C:\Users\HP\Documents\Project\project_one\vendor\composer/.../../app/Http/Livewire/ProductList.php")

avatar

What do you mean by "give product model command"? It seems like the file of ProductList.php is not found, can you double check it actually exists? Sorry, I can't really debug it for you, the error message is pretty clear, I won't help you more than that.

avatar

sorry I figure it out bug was because I did not give the data of product country in right sequence in database migration thats why i could not migrate it

avatar
You can use Markdown
avatar

To seed database in this step, open CategoryFactory.php and amend:

public function definition(): array
    {
        $city = fake()->unique()->city();

        return [
            'name' => $city,
            'slug' => Str::slug($city),
            'position' => fake()->numberBetween(1, 10),
        ];
    }

Create ProductFactory.php and amend:

public function definition(): array
    {
        return [
            'name' => fake()->catchPhrase(),
            'description' => fake()->realText(),
            'country_id' => fake()->numberBetween(1, 240),
            'price' => fake()->numberBetween(100, 500),
        ];
    }

And amend in DatabaseSeeder.php

public function run(): void
    {
        User::factory(10)->create();

        $this->call([
            CountriesSeeder::class,
        ]);

        Product::factory(10)->create()->each(function ($product) {
            $product->categories()
                ->saveMany(Category::factory(mt_rand(1, 2))->make());
        });
    }

We are seeding products with belongs to many relationship. Execute to seed the data:

php artisan migrate:fresh --seed
👍 4
avatar

Correct me please, if I'm wrong but It generates a unique category/categories for each product, right?

Product::factory(10)->create()->each(function ($product) {
	$product->categories()
		->saveMany(Category::factory(mt_rand(1, 2))->make());
});

my approach is :

// ...
	Category::factory()->count(11)->create();
	$categories = Category::all();
	Product::factory()->count(11)->create()->each(
		function($product) use ($categories) {
			$product->categories()->attach($categories->random(random_int(1,3)));
		}
	);
avatar

@Eduardo, both your approach and mine are producing the same result. I am seeding at least 2 random categories with products, and you are seeding 3. I find my code to be cleaner compared to yours. As you can see in the screenshot:

Seeding code

avatar
You can use Markdown
avatar

Dear @Povilas Korop,

I'm afraid there are typos in your code. Shouldn't we type hint the relationship in pascal case like this?

use Illuminate\Database\Eloquent\Relations\BelongsToMany;

public function categories(): BelongsToMany
    {
        return $this->belongsToMany(Category::class);
    }

Reference

👍 1
avatar

Yeah, well spotted! Will fix. Thanks for taking the time.

avatar
You can use Markdown
avatar

Hello,

I have installed the code from the repository (laravel 10.45 but I encounter the same problem with laravel 11) and migrated/seeded the database on a brand new one.

On a listing page (products or orders), I have found a weird behavior concerning the checkboxes on the left when going from one page to another using the pagination below the table.

If I check some checkboxes on the first page for example and then go to the second page (or any other), the same rows are checked on the second page and if I uncheck one of them on the second page, all become unchecked.

Looking forward for a solution

👍 1
avatar

Found the solution: One needs to add wire:key in the loop line 116 of resources/views/livewire/products-list.blade.php

@foreach($products as $product)
<tr class="bg-white" wire:key="product-{{ $product->id }}">
avatar

And obviously the same correction line 136 of resources/views/livewire/orders-list.blade.php

@foreach($orders as $order)
<tr class="bg-white" wire:key="order-{{ $order->id }}">
avatar

That's a really great catch! Updated lessons and repo

avatar
You can use Markdown
avatar
You can use Markdown