DEV Community

Cover image for Laravel Ecommerce Tutorial: Part 4, Managing Product Categories
Given Ncube
Given Ncube

Posted on • Originally published at flixtechs.co.zw

Laravel Ecommerce Tutorial: Part 4, Managing Product Categories

From the last 3 tutorials we laid all the foundational features of an ecommerce store from manage users to role based access control. In this post, we'll start to build the "ecommerce" part of this application.

In every ecommerce application, there's a concept of grouping products by similarities or departments. In this case, we call them categories. Oftentimes these categories are nested, for example Electronics → Laptops → Gaming Laptops or something like that.

To achieve this, we'll use a hasMany relation on the category, and it will relate to itself.

Open your favorite text editor, let's write some code.

To begin let's start by generating the model along with the seeder, migration, factory, let's generate everything.

php artisan make:model Category -a
Enter fullscreen mode Exit fullscreen mode

Let's define the migration as follows, we need

  • the category name,
  • a slug for friendly URLs,
  • a description of the category,
  • the category id of the parent
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up(): void
    {
        Schema::create('categories', static function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table
                ->foreignId('parent_id')
                ->nullable()
                ->constrained('categories')
                ->onDelete('set null');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down(): void
    {
        Schema::dropIfExists('categories');
    }
};
Enter fullscreen mode Exit fullscreen mode

And let's migrate our database like that

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Now to the Category model, let's start by configuring mass assignment, I prefer the guarded method, you can go the fillable route.

/**
 * The attributes that are not mass assignable.
 *
 * @var array
 */
protected $guarded = [];
Enter fullscreen mode Exit fullscreen mode

Remember, I mentioned slugs and friendly URLs. A slugged URL is basically the name of the category joined by strokes. We could manually implement this by creating a mutator on the slug attribute, but luckily for us, there's a package for that, Spatie Laravel Sluggable

This package will handle slugs for us, and let us install it with composer

composer require spatie/laravel-sluggable
Enter fullscreen mode Exit fullscreen mode

Let's continue to configure the package. First tell the package that we want this model to have slugs.

class Category extends Model
{
    use HasFactory;
    use HasSlug;
Enter fullscreen mode Exit fullscreen mode

Then let's define the required getSlugOptions method

/**
 * @return SlugOptions
 */
public function getSlugOptions(): SlugOptions
{
    return SlugOptions::create()
        ->generateSlugsFrom('name')
        ->saveSlugsTo('slug');
}
Enter fullscreen mode Exit fullscreen mode

Remember we talked about nested categories, let's define that relationship

/**
 * Get the parent category.
 *
 * @return BelongsTo
 */
public function parent(): BelongsTo
{
    return $this->belongsTo(self::class, 'parent_id');
}

/**
 * Get the child categories.
 *
 * @return HasMany
 */
public function children(): HasMany
{
    return $this->hasMany(self::class, 'parent_id');
}

Enter fullscreen mode Exit fullscreen mode

We will need some fake data for testing, let's define the CategoryFactory

<?php

namespace Database\Factories;

use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends Factory<Category>
 */
class CategoryFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'name' => $this->faker->unique()->word,
            'parent_id' => $this->faker->randomElement([
                Category::factory(),
                null,
            ]),
            'description' => $this->faker->sentence,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

We might need to seed the database, let's define the CategorySeeder

<?php

namespace Database\Seeders;

use App\Models\Category;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class CategorySeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run(): void
    {
        Category::factory(5)->create();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now that this is all done, let's move to registering the routes, We will be using the resourceful routes. But first let's generate another CategoryController which is in the Admin namespace.

php artisan make:controller Admin\\CategoryController -m Category
Enter fullscreen mode Exit fullscreen mode

Let's add a constructor with code to perform authorization

/**
 * Create a new controller instance.
 *
 * @return void
 */
public function __construct()
{
    $this->authorizeResource(Category::class, 'category');
}
Enter fullscreen mode Exit fullscreen mode

Now for the routes, make sure to import the controller in the admin namespace

Route::resource('categories', CategoryController::class);
Enter fullscreen mode Exit fullscreen mode

Lastly generate permissions for this Model

php artisan authorizer:permissions:generate -m Category
Enter fullscreen mode Exit fullscreen mode

And the authorization code

php artisan authorizer:policies:generate -m Category --force
Enter fullscreen mode Exit fullscreen mode

All categories

This is the part where you write the tests for this controller, I already wrote mine but won't share them in this tutorial.

Let's go ahead and add the categories link to the dashboard sidebar above the users links

<li class="menu-header">Products</li>
<li class="nav-item @if (Route::is('admin.categories.*')) active @endif">
    <a href="{{ route('admin.categories.index') }}"
       class="nav-link">
        <i class="fas fa-folder"></i> <span>Categories</span>
    </a>
</li>
Enter fullscreen mode Exit fullscreen mode

Now let's define the index action of the category controller

/**
 * Display a listing of the resource.
 *
 * @return Renderable
 */
public function index(): Renderable
{
    $categories = Category::with('parent')->paginate(10);

    return view('admin.categories.index', [
        'categories' => $categories,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

This basically returns the categories index view with paginated categories. Create the admin.categories.index view and populate it with

@extends('layouts.app')

@section('title')
    All Categories
@endsection

@section('content')
    <section class="section">
        <div class="section-header">
            <h1>Categories</h1>
            <div class="section-header-button">
                <a href="{{ route('admin.categories.create') }}"
                   class="btn btn-primary">Create Category</a>
            </div>
            <div class="section-header-breadcrumb">
                <div class="breadcrumb-item active"><a href="/administration">Dashboard</a></div>
                <div class="breadcrumb-item"><a href="{{ route('admin.categories.index') }}">Categories</a></div>
                <div class="breadcrumb-item">All Categories</div>
            </div>
        </div>
        <div class="section-body">
            <h2 class="section-title">Categories</h2>
            <p class="section-lead">
                You can manage all categories, such as editing, deleting and more.
            </p>

            <div class="row mt-4">
                <div class="col-12">
                    <div class="card">
                        <div class="card-header">
                            <h4>All Categories</h4>
                        </div>
                        <div class="card-body">
                            <div class="float-end">
                                 <form>
                                    <div class="d-flex">
                                        <input type="text"
                                               class="form-control w-full"
                                               placeholder="Search">
                                        <button class="btn btn-primary ms-1"><i class="fas fa-search"></i></button>
                                    </div>
                                </form>
                            </div>

                            <div class="clearfix mb-3"></div>

                            <div class="table-responsive">
                                <table class="table table-borderless">
                                    <tr>
                                        <th>Name</th>
                                        <th>Description</th>
                                        <th>Parent Category</th>
                                        <th>Created At</th>
                                    </tr>
                                    @foreach ($categories as $category)
                                        <tr>
                                            <td
                                                {{ stimulus_controller('obliterate', ['url' => route('admin.categories.destroy', $category)]) }}>
                                                {{ Str::title($category->name) }}
                                                <div class="table-links">
                                                    <a class="btn btn-link"
                                                       href="{{ route('admin.categories.edit', $category) }}">Edit</a>
                                                    <div class="bullet"></div>
                                                    <button {{ stimulus_action('obliterate', 'handle') }}
                                                            class="btn btn-link text-danger">Trash</button>
                                                    <form {{ stimulus_target('obliterate', 'form') }}
                                                          method="POST"
                                                          action="{{ route('admin.categories.destroy', $category) }}">
                                                        @csrf
                                                        @method('DELETE')
                                                    </form>
                                                </div>
                                            </td>
                                            <td>
                                                {!! Str::limit($category->description, 90) !!}
                                            </td>
                                            <td>
                                                {{ $category?->parent?->name }}
                                            </td>
                                            <td>{{ $category->created_at->diffForHumans() }}</td>
                                        </tr>
                                    @endforeach
                                </table>
                            </div>
                            <div class="float-right">
                                <nav>
                                    {{ $categories->links('vendor.pagination.bootstrap-5') }}
                                </nav>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </section>
@endsection
Enter fullscreen mode Exit fullscreen mode

This will show all the categories with pagination and everything.

Since we have no way of creating categories at this moment, let's seed the database

php artisan db:seed CategorySeeder
Enter fullscreen mode Exit fullscreen mode

You may have noticed the calls to stimulus_controller etc, let's create the stimulus controller called obliterate that we will use to delete models and use sweetalert2 to confirm. We could just use bootstrap modals, but where's the fun in that, and this makes our code DRY.

Let's start by installing sweetalert2

yarn add sweetalart2
Enter fullscreen mode Exit fullscreen mode

Next, let's create the obliterate controller

php artisan stimulus:make obliterate
Enter fullscreen mode Exit fullscreen mode

This will create the obliterate controller in resources/js/controllers/obliterate_controller.js . If you have no idea what a stimulus controller is, checkout the stimulus homepage, it should have everything you need to know. Also checkout the hotwired stack's homepage

Okay, now let's define the controller as follows

import { Controller } from '@hotwired/stimulus';
import Swal from 'sweetalert2';
import 'sweetalert2/dist/sweetalert2.css';

// Connects to data-controller="obliterate"
export default class extends Controller {
    static targets = ['form'];

    static values = {
        url: String,
        trash: Boolean,
    };

    handle() {
        Swal.fire({
            title: 'Are you sure?',
            text:
                this.trashValue === true
                    ? 'This item will be sent to your trash. It will be permanently deleted after 30 days'
                    : "You won't be able to revert this!",
            icon: 'warning',
            showCancelButton: true,
            confirmButtonColor: '#dd3333',
            cancelButtonColor: '#3085d6',
            confirmButtonText: 'Yes, Delete!',
        }).then((result) => {
            if (result.isConfirmed) {
                this.formTarget.requestSubmit();
            }
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, the handle method is an event handler triggered by adding a stimulus action in the HTML, we did that by adding this helper {{ stimulus_action('obliterate', 'handle') }} in our view. It just fires swal to confirm the deletion and submits the delete form. The trashValue checks if the item is already in the trash.

Back in our form now if you click the trash link it will fire up a prompt if you really want to delete, but clicking it won't do anything since we haven't implemented that yet.

Deleting categories

Speaking of, let's implement the delete action

/**
 * Remove the specified resource from storage.
 *
 * @param Category $category
 * @return RedirectResponse
 */
public function destroy(Category $category): RedirectResponse
{
    $category->delete();

    return to_route('admin.categories.index')->with(
        'success',
        'Category deleted successfully.',
    );
}
Enter fullscreen mode Exit fullscreen mode

So far we've been returning redirect responses with flash messages, but we were not showing the flash anywhere. Let's show flash messages using iziToast

We will wrap iziToast around a stimulus controller, and include it everywhere in our application.

Let's create the the controller

 php artisan stimulus:make flash
Enter fullscreen mode Exit fullscreen mode

Install iziToast

yarn add izitoast
Enter fullscreen mode Exit fullscreen mode

Define the controller as follows

import { Controller } from '@hotwired/stimulus';
import iziToast from 'izitoast';
import 'izitoast/dist/css/iziToast.min.css';

// Connects to data-controller="flash"
export default class extends Controller {
    static values = {
        success: String,
        error: String,
    };

    connect() {
        if (this.successValue) {
            iziToast.success({
                title: 'Success',
                message: this.successValue,
                position: 'topRight',
            });
        }

        if (this.errorValue) {
            iziToast.error({
                title: 'Error',
                message: this.errorValue,
                position: 'topRight',
            });
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If there's a flash message we show it, if there's an error we toast it as well.

Let's register the controller, we will use a partial

php artisan make:view layouts.partials.flash
Enter fullscreen mode Exit fullscreen mode

And populate it with

<div
     {{ stimulus_controller('flash', [
         'success' => session()->get('success') ?? '',
         'error' => session()->get('error') ?? $errors->any() ? 'Something went wrong' : '',
     ]) }}>

</div>
Enter fullscreen mode Exit fullscreen mode

This basically checks for a flash message in the session, a success or error and pass it as a stimulus value, the error goes an extra step to include a custom error message when there are form errors.

Include this partial in the layout.

Now if you delete a category you should see a nice flash message.

Okay now we can see all the categories and delete them, let's add the ability to create them

Creating Categories

Let's start by implementing the create action

/**
 * Show the form for creating a new resource.
 *
 * @return Renderable
 */
public function create(): Renderable
{
    $categories = Category::all();

    return view('admin.categories.create', [
        'categories' => $categories,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

We are including the categories here because we want the ability to set the parent category.

Let's create the admin.categories.create view

php artisan make:view admin.categories.create -e layouts.app
Enter fullscreen mode Exit fullscreen mode

Let's then populate it with

@extends('layouts.app')

@section('title')
    Create Category
@endsection

@section('content')
    <section class="section">
        <div class="section-header">
            <h1>Categories</h1>
            <div class="section-header-breadcrumb">
                <div class="breadcrumb-item active"><a href="#">Dashboard</a></div>
                <div class="breadcrumb-item"><a href="#">Categories</a></div>
                <div class="breadcrumb-item">Create Category</div>
            </div>
        </div>

        <div class="section-body">
            <h2 class="section-title">Create Category</h2>
            <p class="section-lead mb-5">On this page you can create categories or departments for your products.</p>

            <form method="post"
                  action="{{ route('admin.categories.store') }}">
                @csrf
                <div class="row">
                    <div class="col-12 col-md-6 col-lg-6">
                        <p class="section-lead">Add basic information about the category or department.</p>
                    </div>
                    <div class="col-12 col-md-6 col-lg-6">
                        <div class="card">

                            <div class="card-header">
                                <h4>Category details</h4>
                            </div>

                            <div class="card-body">
                                <div class="form-group">
                                    <label for="parent_id">Parent</label>
                                    <select id="parent_id"
                                            class="form-select custom-control custom-select @error('parent_id') is-invalid @enderror"
                                            name="parent_id">
                                        @foreach ($categories as $category)
                                            @if ($category->id === 0)
                                                <option selected
                                                        value="{{ $category->id }}">{{ $category->name }}</option>
                                            @else
                                                <option value="{{ $category->id }}">{{ $category->name }}</option>
                                            @endif
                                        @endforeach
                                    </select>

                                    @error('parent_id')
                                        <span class="invalid-feedback">
                                            {{ $message }}
                                        </span>
                                    @enderror
                                </div>

                                <div class="form-group">
                                    <label for="name">Name</label>
                                    <input type="text"
                                           name="name"
                                           id="name"
                                           class="form-control @error('name') is-invalid @enderror"
                                           value="{{ old('name') }}">

                                    @error('name')
                                        <span class="invalid-feedback">
                                            {{ $message }}
                                        </span>
                                    @enderror
                                </div>

                                <div class="form-group">
                                    <label for="description">Description</label>
                                    <textarea name="description"
                                              id="description"
                                              rows="8"
                                              class="form-control @error('description') is-invalid @enderror ">{{ old('description') }}</textarea>

                                    @error('description')
                                        <span class="invalid-feedback">
                                            {{ $message }}
                                        </span>
                                    @enderror
                                </div>

                                <div class="form-group text-right">
                                    <button type="submit"
                                            class="btn btn-dark btn-lg">Create Category</button>
                                </div>

                            </div>

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

            </form>
        </div>
    </section>
@endsection
Enter fullscreen mode Exit fullscreen mode

And then to be able to submit this form, we need to define the validation rules in the StoreCategoryRequest

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreCategoryRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return $this->user()->can('create category');
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'name' => 'required|string|unique:categories|max:255',
            'description' => 'nullable|string',
            'parent_id' => 'sometimes|nullable|integer|exists:categories,id',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

And then the store action in the controller

/**
 * Store a newly created resource in storage.
 *
 * @param StoreCategoryRequest $request
 * @return RedirectResponse
 */
public function store(StoreCategoryRequest $request): RedirectResponse
{
    Category::create($request->validated());

    return redirect()
        ->route('admin.categories.index')
        ->with('success', 'Category created successfully.');
}
Enter fullscreen mode Exit fullscreen mode

Now that we can create categories, let's add the ability to edit categories.

Edit categories

To do this, let's implement the edit action

/**
 * Show the form for editing the specified resource.
 *
 * @param Category $category
 * @return Renderable
 */
public function edit(Category $category): Renderable
{
    $categories = Category::all();

    return view('admin.categories.edit', [
        'category' => $category,
        'categories' => $categories,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

And generate the view

@extends('layouts.app')

@section('title')
    Edit Category
@endsection

@section('content')
    <section class="section">
        <div class="section-header">
            <h1>Categories</h1>
            <div class="section-header-breadcrumb">
                <div class="breadcrumb-item active"><a href="#">Dashboard</a></div>
                <div class="breadcrumb-item"><a href="#">Categories</a></div>
                <div class="breadcrumb-item">Edit Category</div>
            </div>
        </div>

        <div class="section-body">
            <h2 class="section-title">Edit Category</h2>
            <p class="section-lead mb-5">On this page you can create categories or departments for your products.</p>

            <div class="row">
                <div class="col-12 col-md-6 col-lg-6">
                    <p class="section-lead">Add basic information about the category or department.</p>
                </div>
                <div class="col-12 col-md-6 col-lg-6">
                    <div class="card">

                        <div class="card-header">
                            <h4>Category details</h4>
                        </div>

                        <div class="card-body">
                            <form class=""
                                  action="{{ route('admin.categories.update', $category) }}"
                                  method="post">
                                @csrf
                                @method('PATCH')
                                <div class="form-group">
                                    <label for="parent_id">Parent</label>
                                    <select id="parent_id"
                                            class="form-select custom-control custom-select @error('parent_id') is-invalid @enderror"
                                            name="parent_id">
                                        @foreach ($categories as $parent)
                                            @if ($parent->id === $category?->parent?->id)
                                                <option selected
                                                        value="{{ $parent->id }}">{{ $parent->name }}</option>
                                            @elseif ($parent->id === 0)
                                                <option selected
                                                        value="{{ $parent->id }}">{{ $parent->name }}</option>
                                            @else
                                                <option value="{{ $parent->id }}">{{ $parent->name }}</option>
                                            @endif
                                        @endforeach
                                    </select>

                                    @error('parent_id')
                                        <span class="invalid-feedback">
                                            {{ $message }}
                                        </span>
                                    @enderror
                                </div>

                                <div class="form-group">
                                    <label for="name">Name</label>
                                    <input type="text"
                                           id="name"
                                           name="name"
                                           class="form-control @error('name') is-invalid @enderror"
                                           value="{{ old('name', $category->name) }}">

                                    @error('name')
                                        <span class="invalid-feedback">
                                            {{ $message }}
                                        </span>
                                    @enderror
                                </div>

                                <div class="form-group">
                                    <label for="description">Description</label>
                                    <textarea name="description"
                                              id="description"
                                              class="form-control @error('description') is-invalid @enderror ">{{ old('description', $category->descriptions) }}</textarea>

                                    @error('description')
                                        <span class="invalid-feedback">
                                            {{ $message }}
                                        </span>
                                    @enderror
                                </div>

                                <div class="form-group text-right">
                                    <button type="submit"
                                            class="btn btn-primary btn-lg">Update Category</button>
                                </div>
                            </form>
                        </div>
                    </div>
                </div>
            </div>

        </div>
    </section>
@endsection
Enter fullscreen mode Exit fullscreen mode

Let's move on to the UpdateCategoryRequest and define the validation

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UpdateCategoryRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return $this->user()->can('update category');
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'name' => 'required|string|unique:categories|max:255',
            'description' => 'sometimes|nullable|string',
            'parent_id' => 'sometimes|nullable|integer|exists:categories,id',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

And finally the update action

/**
 * Update the specified resource in storage.
 *
 * @param UpdateCategoryRequest $request
 * @param Category $category
 * @return RedirectResponse
 */
public function update(UpdateCategoryRequest $request, Category $category): RedirectResponse
{
    $category->update($request->validated());

    return redirect()
        ->route('admin.categories.index')
        ->with('success', 'Category updated successfully.');
}
Enter fullscreen mode Exit fullscreen mode

At this point, we now have the ability to manage categories, create, delete, update and read. As a bonus for this post, let's add the ability to search categories using that search form with Laravel Scout and Spatie Laravel Query Builder

We will be using the database driver with scout, you could use algolia but we'll just keep things basic in this tutorial

Let's start by installing both packages

composer require laravel/scout spatie/laravel-query-builder 
Enter fullscreen mode Exit fullscreen mode

Let's publish Scout's configuration

php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
Enter fullscreen mode Exit fullscreen mode

Let's tell Scout that we want the Category model to be searchable

class Category extends Model
{
    use HasFactory;
    use HasSlug;
    use Searchable;
    ...
Enter fullscreen mode Exit fullscreen mode

Let's also tell Scout that we want to use the database driver by adding this to the .env file

SCOUT_DRIVER=database
Enter fullscreen mode Exit fullscreen mode

At the point of this writing, Spatie Laravel Query Builder and Laravel Scout are not compatible they throw type errors, however I found a work around by using a scope in the model

Let's add the scope in the Category controller

/**
 * Get the indexable data array for the model.
 *
 * @return array
 */
public function toSearchableArray(): array
{
    return [
        'name' => $this->name,
        'description' => $this->description,
    ];
}

/**
 * Scope a query to only include listings that are returned by scout
 *
 * @param Builder $query
 * @param string $search
 * @return Builder
 */
public function scopeWhereScout(Builder $query, string $search): Builder
{
    return $query->whereIn(
        'id',
        self::search($search)
            ->get()
            ->pluck('id'),
    );
}
Enter fullscreen mode Exit fullscreen mode

Then let's modify the index action a bit to allow spatie query builder to do the searching for us

/**
 * Display a listing of the resource.
 *
 * @return Renderable
 */
public function index(): Renderable
{
    $categories = QueryBuilder::for(Category::class)
        ->allowedFilters([AllowedFilter::scope('search', 'whereScout')])
        ->with('parent')
        ->paginate(10)
        ->appends(request()->query());

    return view('admin.categories.index', [
        'categories' => $categories,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Now if you visit http://localhost:8000/admin/categories?filter[search]=helloworld nothing will show if you don't have any category with "hello world" in it.

Finally let's add a stimulus controller on the form. To make this easier, searching like this is actually filtering according to spatie query builder, in the future, we might want to filter by product, or filter products by price or something. Let's create a reusable base controller.

This controller will just reload the current page when a filter or sort event dispatched in the DOM. But, in order to not trigger a full page reload we will wrap part of the HTML we want to change in a turbo-frame. Learn more about turbo frames here.

Let's also install the stimulus-use package to get some utilities like debounce, click outside an element, etc

yarn add stimulus-use
Enter fullscreen mode Exit fullscreen mode

Create the controller

php artisan stimulus:make reload
Enter fullscreen mode Exit fullscreen mode

Define as follows

import { Controller } from '@hotwired/stimulus';
import { useDebounce } from 'stimulus-use';

export default class extends Controller {
    static debounces = ['filterChange', 'sortChange'];

    payload = {
        filter: {},
        sort: {},
    };

    connect() {
        useDebounce(this, { wait: 500 });
    }

    filterChange(event) {
        this.payload.filter = {
            ...this.payload.filter,
            [event.detail.filter]: event.detail.value,
        };
        console.log(event.detail.route, event.detail.filter);
        this.element.src = route(event.detail.route, this.payload);
        this.element.reload();
    }

    sortChange(event) {
        this.payload.sort = event.detail.value;

        this.element.src = route(event.detail.route, this.payload);
        this.element.reload();
    }

    clear() {
        this.payload = {
            filter: {},
            sort: {},
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, modify the admin.categories.index view a bit by wrapping the table and the pagination in a turbo frame like this

<turbo-frame class='w-full'
             id='categories'
             target="_top"
             {{ stimulus_controller('reload') }}
             {{ stimulus_actions([
                 [
                     'reload' => ['filterChange', 'filter:change@document'],
                 ],
                 [
                     'reload' => ['sortChange', 'sort:change@document'],
                 ],
             ]) }}>
//your code here
</turbo-frame>
Enter fullscreen mode Exit fullscreen mode

setting the target to '_top' ensures that when any link is clicked it triggers a full page reload to a different page not just inside the frame. The controller listens for filter change or sort change events in the DOM and changes the src attribute of the frame to the new query filter url.

Now the filter controller which will work for everything from prices, size or what, but in this case it's searching. let's create the controller

php artisan stimulus:make filter
Enter fullscreen mode Exit fullscreen mode

And let's define it as follows

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static values = {
        filter: String,
        route: String,
    };

    change(event) {
        console.log(this.filterValue, 'hsh');
        document.dispatchEvent(
            new CustomEvent('filter:change', {
                detail: {
                    filter: this.filterValue,
                    route: this.routeValue,
                    value: event.target.value,
                },
            })
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The filterValue is the parameter we want to filter, for example, filter[search] then search is the filterValue. Finally let's connect the controller to the DOM, and we will do this whenever we want to filter something by any parameter.

Replace the search form with this on`e

`blade

     'admin.categories.index',
               'filter' =&gt; 'search',
           ]) }}
           {{ stimulus_action('filter', 'change', 'input') }}&gt;
Enter fullscreen mode Exit fullscreen mode

`

And finally for the route() method to work let's install and register ziggy

bash
composer require tightenco/ziggy

Add the @routes directive at the top of the layout before Vite. Now we have searching feature that works like magic.

To sum up this tutorial we added the ability to create, edit, read and delete categories, we added a nested categories feature, we added a search feature using Laravel Scout and Spatie Query Builder, created reusable stimulus controllers that are compatible with Spatie Query Builder.

In the next tutorial we will add the ability to manage brands in the ecommerce site, happy hacking!

Top comments (0)