Laravel Ecommerce Tutorial: Part 4, Managing Product Categories
Given Ncube
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
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');
}
};
And let's migrate our database like that
php artisan migrate
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 = [];
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
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;
Then let's define the required getSlugOptions
method
/**
* @return SlugOptions
*/
public function getSlugOptions(): SlugOptions
{
return SlugOptions::create()
->generateSlugsFrom('name')
->saveSlugsTo('slug');
}
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');
}
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,
];
}
}
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();
}
}
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
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');
}
Now for the routes, make sure to import the controller in the admin namespace
Route::resource('categories', CategoryController::class);
Lastly generate permissions for this Model
php artisan authorizer:permissions:generate -m Category
And the authorization code
php artisan authorizer:policies:generate -m Category --force
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>
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,
]);
}
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
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
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
Next, let's create the obliterate controller
php artisan stimulus:make obliterate
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();
}
});
}
}
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.',
);
}
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
Install iziToast
yarn add izitoast
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',
});
}
}
}
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
And populate it with
<div
{{ stimulus_controller('flash', [
'success' => session()->get('success') ?? '',
'error' => session()->get('error') ?? $errors->any() ? 'Something went wrong' : '',
]) }}>
</div>
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,
]);
}
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
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
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',
];
}
}
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.');
}
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,
]);
}
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
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',
];
}
}
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.');
}
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
Let's publish Scout's configuration
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
Let's tell Scout that we want the Category
model to be searchable
class Category extends Model
{
use HasFactory;
use HasSlug;
use Searchable;
...
Let's also tell Scout that we want to use the database driver by adding this to the .env
file
SCOUT_DRIVER=database
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'),
);
}
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,
]);
}
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
Create the controller
php artisan stimulus:make reload
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: {},
};
}
}
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>
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
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,
},
})
);
}
}
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
<form>
<div class="d-flex">
<input type="text"
class="form-control w-full"
placeholder="Search"
{{ stimulus_controller('filter', [
'route' => 'admin.categories.index',
'filter' => 'search',
]) }}
{{ stimulus_action('filter', 'change', 'input') }}>
</div>
</form>
And finally for the route()
method to work let's install and register ziggy
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. In the meantime, subscribe to the newsletter below and get notified when I release the next post in this series, happy hacking!
[convertkit=2542481]