Laravel Ecommerce Tutorial: Part 5, Managing Brands

Laravel Ecommerce Tutorial: Part 5, Managing Brands

Given Ncube

Took me a while to make this tutorial (procrastination, I know, right?), but in the end I decided to just do it. Where do you get motivation to keep coding, sometimes I just don't feel like writing code, let me know your answers on Twitter @ncubegiven_.

Anyway...

In the previous tutorial, we added the ability to manage product categories and features like searching and filtering. Up to this point, we have added the ability to:

In almost every e-commerce site there's some concept of brands, for example, Nike, HP, or something like that. This will give the ability to group products by their brands, and that's what we'll implement in this post.

This is going to be very similar to the product categories we did in the last tutorial, just basic CRUD.

I know you're dying to write some code, so let's get started

We'll start by creating the model

php artisan make:model Brand -msf

This will create the model, a seeder, a migration, and a factory.

Let's define the migration.

Basically for a brand, we just need its name, a slug for friendly URLs, and probably the description, but it's not required

<?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('brands', static function (Blueprint $table) {
            $table->id();

            $table->string('name')->unique();
            $table->string('slug')->unique();
            $table->string('description')->nullable();

            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down(): void
    {
        Schema::dropIfExists('brands');
    }
};

And migrate the database

php artisan migrate

Next, let's configure our model. We will have to search this model with Laravel Scout, we'll configure that just like we did with categories in the previous post.

We also need the ability to generate slugs using Spatie Sluggable, we will configure that as well

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Laravel\Scout\Searchable;
use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;

class Brand extends Model
{
    use HasFactory;
    use HasSlug;
    use Searchable;

    /**
     * The attributes that should not be mass assignable
     *
     * @var array
     */
    protected $guarded = [];

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

    /**
     * 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'),
        );
    }
}

and perhaps also configure the factory

<?php

namespace Database\Factories;

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

/**
 * @extends Factory<Brand>
 */
class BrandFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'name' => $this->faker->company(),
            'description' => $this->faker->paragraphs(asText: true),
        ];
    }
}

We also need to generate the authorization policies and permissions for this model

php artisan authorizer:policies:generate -m Brand --force
php artisan authorizer:permissions:generate -m Brand

Next, let's the create the controller to handle the brands

php artisan make:controller Admin\\BrandController --model=Brand --test -R

Add this to the constructor to authorize the controller

/**
 * Create a new controller instance
 */
public function __construct()
{
    $this->authorizeResource(Brand::class, 'brand');
}

Create the views for this resource

php artisan make:view admin.brands.create
php artisan make:view admin.brands.index
php artisan make:view admin.brands.edit

And then register the resource route inside the admin group

Route::resource('brands', BrandController::class);

This is the part where you start to write the tests for the controller, I've already written mine separately, but I won't be sharing them in this tutorial because that would make this post really long

So, first, we need the ability to create a brand, let's implement the create action of the brand controller and simply return the view with the form to create a brand

/**
 * Show the form for creating a new resource.
 *
 * @return Renderable
 */
public function create(): Renderable
{
    return view('admin.brands.create');
}

Add the following to the admin.brands.create view to display the form to create a new brand

@extends('layouts.app')

@section('title')
    Create Brand
@endsection

@section('content')
    <section class="section">
        <div class="section-header">
            <h1>Brands</h1>
            <div class="section-header-breadcrumb breadcrumb">
                <div class="breadcrumb-item active"><a href="{{ route('admin.home.index') }}">Dashboard</a></div>
                <div class="breadcrumb-item"><a href="{{ route('admin.brands.index') }}">Brands</a></div>
                <div class="breadcrumb-item">Create Brand</div>
            </div>
        </div>

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

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

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

                            <div class="card-body">
                                <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-primary btn-lg">Create Brand</button>
                                </div>

                            </div>

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

            </form>
        </div>
    </section>
@endsection

If you visit /admin/brands/create you should be able to see a form to create a new brand.

However, submitting the form won't do anything, and that's what we'll implement next.

First, let's define the StoreBrandRequest

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

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

The authorize method makes sure that only users with the permission to create brand can submit this form.

Next, let's define the controller action to store the brand in the database

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

    return to_route('admin.brands.index')->with(
        'success',
        'Brand was successfully created',
    );
}

Now, if we submit the form, we should be able to create a new brand, but the action redirects to the brands index page and it's currently empty, let's tell Laravel to return the brands index view on this route.

While we're here we'll use spatie laravel query builder to add the ability to search for brands

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

    return view('admin.brands.index', [
        'brands' => $brands,
    ]);
}

In the admin.brands.index view

@extends('layouts.app')

@section('title')
    Brands
@endsection

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

            <div class="row mt-4">
                <div class="col-12">
                    <div class="card">
                        <div class="card-header">
                            <h4>Brands</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"
                                               {{ stimulus_controller('filter', [
                                                   'route' => 'admin.brands.index',
                                                   'filter' => 'search',
                                               ]) }}
                                               {{ stimulus_action('filter', 'change', 'input') }}>
                                    </div>
                                </form>
                            </div>

                            <div class="clearfix mb-3"></div>
                            <turbo-frame class='w-full'
                                         id='categories'
                                         target="_top"
                                         {{ stimulus_controller('reload') }}
                                         {{ stimulus_actions([
                                             [
                                                 'reload' => ['filterChange', 'filter:change@document'],
                                             ],
                                             [
                                                 'reload' => ['sortChange', 'sort:change@document'],
                                             ],
                                         ]) }}>
                                <div class="table-responsive">
                                    <table class="table table-borderless">
                                        <tr>
                                            <th>Name</th>
                                            <th>Description</th>
                                            <th>Created At</th>
                                        </tr>
                                        @foreach ($brands as $brand)
                                            <tr>
                                                <td
                                                    {{ stimulus_controller('obliterate', ['url' => route('admin.brands.destroy', $brand)]) }}>
                                                    {{ Str::title($brand->name) }}
                                                    <div class="table-links">
                                                        <a class="btn btn-link"
                                                           href="{{ route('admin.brands.edit', $brand) }}">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.brands.destroy', $brand) }}">
                                                            @csrf
                                                            @method('DELETE')
                                                        </form>
                                                    </div>
                                                </td>
                                                <td>
                                                    {!! Str::limit($brand->description, 90) !!}
                                                </td>
                                                <td>{{ $brand->created_at->diffForHumans() }}</td>
                                            </tr>
                                        @endforeach
                                    </table>
                                </div>

                                <div class="float-right">
                                    <nav>
                                        {{ $brands->links('vendor.pagination.bootstrap-5') }}
                                    </nav>
                                </div>
                            </turbo-frame>

                        </div>
                    </div>
                </div>
            </div>
        </div>
    </section>
@endsection

Now if you visit /admin/brands you should be able to see all the brands and be able to perform a search. Let's add the link to sidebar so we can easily navigate to brands

In the layouts.partials.sidebar just below the categories link, add this

<li class="nav-item @if (Route::is('admin.brands.*')) active @endif">
    <a href="{{ route('admin.brands.index') }}"
       class="nav-link">
        <i class="fas fa-hashtag"></i> <span>Brands</span>
    </a>
</li>

At this point, we can create, list brands, let's add the ability to edit them.

To do that, let's edit the edit action to return the edit view

/**
 * Show the form for editing the specified resource.
 *
 * @param Brand $brand
 * @return Renderable
 */
public function edit(Brand $brand): Renderable
{
    return view('admin.brands.edit', [
        'brand' => $brand,
    ]);
}

And the in the admin.brands.edit let's add the following

@extends('layouts.app')

@section('title')
    Edit Brand
@endsection

@section('content')
    <section class="section">
        <div class="section-header">
            <h1>Brands</h1>
            <div class="section-header-breadcrumb breadcrumb">
                <div class="breadcrumb-item active"><a href="{{ route('admin.home.index') }}">Dashboard</a></div>
                <div class="breadcrumb-item"><a href="{{ route('admin.brands.index') }}">Brands</a></div>
                <div class="breadcrumb-item">Edit Brand</div>
            </div>
        </div>

        <div class="section-body">
            <h2 class="section-title">Edit Brand</h2>
            <p class="section-lead mb-5">On this page you can edit the brand.</p>

            <form method="post"
                  action="{{ route('admin.brands.update', $brand) }}">
                @csrf
                @method('PATCH')
                <div class="row">
                    <div class="col-12 col-md-6 col-lg-6">
                        <p class="section-lead">Add basic information about the brand.</p>
                    </div>
                    <div class="col-12 col-md-6 col-lg-6">
                        <div class="card">

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

                            <div class="card-body">
                                <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', $brand->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', $brand->description) }}</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 Brand</button>
                                </div>

                            </div>

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

            </form>
        </div>
    </section>
@endsection

For the form submission to work, let's first define the UpdateBrandRequest with some validations

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

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

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'name' => [
                'required',
                'string',
                'max:255',
                Rule::unique('brands')->ignore($this->route('brand')->id),
            ],
            'description' => 'sometimes|nullable|string',
        ];
    }
}

Finally, let's store the update the database

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

    return to_route('admin.brands.index')->with(
        'success',
        'Brand was successfully updated',
    );
}

Let's wrap this up by adding the ability to delete brands, we have already added the links in the view, let's simply implement the destroy action in the controller

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

    return to_route('admin.brands.index')->with(
        'success',
        'Brand was successfully deleted',
    );
}

With that, we should be able to fully manage brands in this ecommerce store, we added the ability to

  • create brands
  • list all created brands
  • search available brands using scout
  • edit brands
  • delete brands
  • of course authorize all this with spatie permissions and authorizer

We now have everything we need to add products in the store. In the upcoming tutorial, we will allow users to create products and assign them to different categories and brands.

In the meantime, make sure to subscribe to the newsletter and get notified when the next post is up.

[convertkit=2542481]