Laravel Ecommerce Tutorial: Part 6.1, Creating Products

Laravel Ecommerce Tutorial: Part 6.1, Creating Products

Given Ncube

In the previous tutorial we laid down the ground work to allow the shop administrator to add new products, set categories and brands and in this tutorial, we will add the ability to manage products in the ecommerce site. This part of the series will be split in parts as well, with this part only focusing on creating products, the next tutorial will continue the from there

To start with, let's create the models

Models and migrations

We will start by creating the product models and everything else like attributes and variations will follow after this. To create the product models use the following artisan command

php artisan make:model Product -msf --policy

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

Next, let's generate the controller for this model, like always we will be using resourceful controllers, because they are cool!

php artisan make:controller ProductController --model=Product -R --test

This will give us a nice resourceful controller, form requests and a test file

Before we move to start writing our tests, let's define the product model's migration file

<?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()
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->string('sku');
            $table->decimal('price', 10);
            $table->decimal('discounted_price', 10)->default(0);
            $table->decimal('cost', 10)->default(0);
            $table->integer('quantity')->default(0);
            $table->boolean('track_quantity')->default(true);
            $table->boolean('sell_out_of_stock')->default(false);
            $table->text('description')->nullable();
            $table->string('status');
            $table->foreignId('category_id')->onDelete('set null');
            $table->timestamps();
        });
    }

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

Now migrate the database

php artisan migrate

After the database is migrated let's continue to configure our product model, first mass assignment, I prefer the guarded route because it's clean and minimalistic, however you are welcome to use the fillable method

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

Let's make sure slugs are generated on the fly with spatie sluggable, start by using the Spatie\Sluggable\HasSlug trait above

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

Now let's add some relationships, we know that a product belongs to a category and a category has many products. We have a one to many relationship.

Now in our product model, let's define the relationship

/**
 * Get the category that own this product
 *
 * @return BelongsTo
 */
public function category(): BelongsTo
{
    return $this->belongsTo(Category::class);
}

And we do the same in the Category model

/**
 * The products that belong to this category
 * 
 * @return HasMany
 */
public function products(): HasMany
{
    return $this->hasMany(Product::class);
}

Next, register the routes for the products in the web routes file in the admin group

 Route::resource('products', ProductController::class);

This is the part where we start writing tests but like the previous tutorial we won't.

Let's generate the authorization code for this model

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

Product media

Of course our product needs to have images. For this project, we are going to use Cloudinary, which is a content delivery and digital media management service to store our images

Start by creating an account on Cloudinary

After creating an account, install the laravel sdk using composer

composer require cloudinary-labs/cloudinary-laravel

In the cloudinary dash, copy the config url and add it to your .env file like this

CLOUDINARY_URL=XXXXXX

Next publish it's assets by running artisan vendor:publish then select CloudinaryLabs package. After, that run the migrations.

Now let's tell the product model that we want to use cloudinary to store images by adding the following trait

use CloudinaryLabs\CloudinaryLaravel\MediaAlly;

class Product extends Model
{
    use MediaAlly;
...

Controllers and views

Now that the backend side of this is all set up let's add the controllers and views for the products. As always we use the resourceful architecture for both view and controller

Let's generate the controller

php artisan make:controller ProductController --model=Product -R --test

This will give us a controller and the form requests and a nice starting point.

Let's generate the views while we are here

php artisan make:view admin.products -r

Now that's in place let's add some flesh to this controller starting with create action. We want to select a category a product will belong to during create so we get all categories and include them in the create view like in the snippet below

/**
 * Show the form for creating a new resource.
 *
 * @return Renderable
 */
public function create(): Renderable
{
    return view('admin.products.create', [
        'categories' => Category::all(),
    ]);
}

Now open the admin.products.create view and the following blade code to allow us to create new products.

However this page will have lots of moving parts so I've broken it into digestible bits, first let's load out a skeleton and fill out each section one by one until the whole thing is working.

@extends('layouts.app')

@section('title')
    Add New Product
@endsection

@section('content')
    <section class="section">
        <div class="section-header">
            <div class="section-header-back">
                <a href="{{ route('admin.products.index') }}"
                   class="btn btn-icon"><i class="fas fa-arrow-left"></i></a>
            </div>
            <h1>Create New Product</h1>
            <div class="section-header-breadcrumb">
                <div class="breadcrumb-item active"><a href="#">Dashboard</a></div>
                <div class="breadcrumb-item"><a href="{{ route('admin.products.index') }}">Products</a></div>
                <div class="breadcrumb-item">Create New Product</div>
            </div>
        </div>

        <div class="section-body">
            <h2 class="section-title">Create New Product</h2>

            <p class="section-lead">
                On this page you can create a new product and fill in all fields.
            </p>

            <div class="container">
                <div class="row">
                    <div class="col-12 col-md-7">
                        <div class="card rounded-lg">
                            <div class="card-header">
                                <h4>Basic Info</h4>
                            </div>
                            <div class="card-body">
                                <form class=""
                                      action="{{ route('admin.products.store') }}"
                                      method="post"
                                      id="storeProduct">
                                    @csrf
                                    {{--                                    Basic form fields here --}}
                                </form>
                            </div>
                        </div>

                        <div class="card rounded-lg">
                            <div class="card-header">
                                <h4>Media</h4>
                            </div>
                            <div class="card-body">
                                {{--                            Media form fields here --}}
                            </div>
                        </div>

                        <div class="card rounded-lg">
                            <div class="card-header">
                                <h4>Pricing</h4>
                            </div>
                            <div class="card-body">
                                {{--                            pricing form fields here --}}
                            </div>
                        </div>

                        <div class="card rounded-lg"
                             data-controller="inventory">
                            <div class="card-header">
                                <h4>Inventory</h4>
                            </div>
                            <div class="card-body">
                                {{--         Inventory fields here,  quantity sku and what not                   --}}
                            </div>
                        </div>

                        <div class="card rounded-lg">
                            <div class="card-header">
                                <h4>Variants</h4>
                            </div>
                            <div class="card-body">
                                {{--             Product variation fields here, color and stuff              --}}
                            </div>
                        </div>

                        <div class="card rounded-lg">
                            <div class="card-header">
                                <h4>Search Engine Optimization</h4>
                            </div>
                            <div class="card-body">
                                {{--              SEO Fields here              --}}
                            </div>
                        </div>
                    </div>
                    <div class="col-12 col-md-5">
                        <div class="card rounded-lg">
                            <div class="card-header">
                                <h4>Product status</h4>
                            </div>
                            <div class="card-body">
                                {{--           product status fields here                --}}
                            </div>
                        </div>

                        <div class="card rounded-lg">
                            <div class="card-header">
                                <h4>Product organization</h4>
                            </div>
                            <div class="card-body">
                                {{-- product organization fields here, category, collections, tags, etc --}}
                            </div>
                        </div>
                    </div>
                </div>

                <div class="row">
                    <div class="col-12">
                        <div class="form-group text-right">
                            <input type="submit"
                                   class="btn btn-primary btn-lg"
                                   value="Save"
                                   form="storeProduct">
                        </div>
                    </div>
                </div>

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

This skeletal layout should give you a layout like the one in the image below

So let's start adding fields for the basic info part which is just the product name and the description.

In the admin.products.create view find the comment that says basic info fields here and replace that with the following html snippet

<div class="form-group mb-3">
    <label class="col-form-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')
        <div class="invalid-feedback">
            {{ $message }}
        </div>
    @enderror
</div>

<div class="form-group mb-3">
    <label for='description'
           class="col-form-label">
        Description
    </label>

    <textarea name="description"
              id='description'
              rows="8"
              cols="80"></textarea>
</div>

Save and refresh your browser, notice the description field is just a simple text area. We want to add a rich text editor. We will be using ckeditor in this tutorial, however, you are welcome to use any rich text editor of your choice.

Let's install ckeditor

yarn add @ckeditor/ckeditor5-build-classic

We are going to wrap ckeditor inside a stimulus controller so we just plug and play in our html. Let's create the controller

php artisan stimulus:make ckeditor

Now open the ckeditor_controller.js file and add the following snippet

import { Controller } from '@hotwired/stimulus';
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';

// Connects to data-controller="ckeditor"
export default class extends Controller {
    connect() {
        ClassicEditor.create(this.element).catch((error) => {
            console.error(error);
        });
    }
}

When this controller is connected to an html element, it creates the ClassicEditor by passing the connected element as the element to replace. Let's connect the controller to our textarea and that's all we need to use a rich text editor

<textarea name="description"
          id='description'
          rows="8"
          cols="80"
          {{ stimulus_controller('ckeditor') }}>{{ old('description') }}</textarea>

By now we have a working rich text editor, You should have a screen that looks like the one below after this.

Let's move to the next bit of our create product form, the media. This one is not as straight forward but we will get there

In short, we will use a package called filepond to upload our images. The idea is that during the create process we temporarily upload the images to the local server then when we actually "create" the product we associate the images with the product and upload them to Cloudinary

Let's make it work, start by installing filepond packages

yarn add filepond filepond-plugin-image-preview filepond-plugin-image-exif-orientation filepond-plugin-image-validate-size filepond-plugin-file-validate-type

After installation is complete, we will need a stimulus controller to wrap all this into a nice API that we can reuse anywhere. Let's make the controller

php artisan stimulus:make filepond

When we upload files via xhr with filepond we will get back the id of the temporary, we store those IDs on an HTML hidden input to submit along with the rest of the fields and then associate those images with the newly created product, wow!

Ok, let's start with the markup, according to filepond documentation we need to define links to /process (upload), revert (cancel file upload) restore (load old or already uploaded images). We will pass these as values to our controller along with an array of already uploaded images.

Since filepond can replace a file input, we need a target as well for the input

Okay let's define our controller as follows

import { Controller } from '@hotwired/stimulus';
import * as FilePond from 'filepond';
import FilePondPluginImagePreview from 'filepond-plugin-image-preview';
import FilePondPluginImageExifOrientation from 'filepond-plugin-image-exif-orientation';
import FilePondPluginImageValidateSize from 'filepond-plugin-image-validate-size';
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type';

import 'filepond/dist/filepond.min.css';
import 'filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css';

FilePond.registerPlugin(
    FilePondPluginImagePreview,
    FilePondPluginImageExifOrientation,
    FilePondPluginImageValidateSize,
    FilePondPluginFileValidateType
);

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

    static values = {
        process: String,
        restore: String,
        revert: String,
        current: Array,
    };

    connect() {
        const pond = FilePond.create(this.inputTarget, {
            name: 'image',
            credits: false,
            acceptedFileTypes: ['image/png', 'image/jpeg'],
        });
    }
}

Now let's add some markup, In the admin.products.create view in the media card, replace the comment with the following markup

<div class="form-group"
     data-controller="filepond"
     data-filepond-process-value="/"
     data-filepond-restore-value="/"
     data-filepond-revert-value="/"
     data-filepond-current-value="{{ json_encode(old('images', [])) }}">

    <input type="file"
           data-filepond-target="input">
</div>

For now we just have dummy urls because we haven't implemented that yet. If you save and refresh you should be able to upload files but they won't upload to anywhere since we don't have a backend ready to upload files yet.

Your screen should look the one below if you did this correctly

Now let's add the ability to temporarily upload images. Let's generate the controller

php artisan make:controller Admin\\ImageController -r --test --model=Media -R

When prompted to create a new model just say now, we will replace the App\Model\Media class with CloudinaryLabs\CloudinaryLaravel\Model\Media

Edit the StoreMediaRequest with the following to validate the incoming files

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreMediaRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'image' => 'required|image|max:5120',
        ];
    }
}

Then define the controller as follows

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreMediaRequest;
use CloudinaryLabs\CloudinaryLaravel\CloudinaryEngine;
use CloudinaryLabs\CloudinaryLaravel\Model\Media;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;

class ImageController extends Controller
{
    /**
     * Store a newly created resource in storage.
     *
     * @param StoreMediaRequest $request
     * @return Response
     */
    public function store(StoreMediaRequest $request)
    {
        $path = $request->validated('image')->store('products');

        return response($path, 200)->header('Content-Type', 'text/plain');
    }

    /**
     * Display the specified resource.
     *
     * @param Request $request
     * @return StreamedResponse
     */
    public function show(Request $request)
    {
        $path = $request->query('path', '');

        if (!Storage::exists($path)) {
            abort(404);
        }

        return Storage::download($path, Str::afterLast($path, '/'), [
            'Content-Disposition' => 'inline',
            'filename' => Str::afterLast($path, '/'),
        ]);
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param Request $request
     * @return Response
     */
    public function destroy(Request $request)
    {
        $media = $request->query('media');

        if (Storage::exists($media)) {
            Storage::delete($media);

            return response()->noContent();
        }

        $media = Media::where('file_url', $media)->firstOrFail();

        resolve(CloudinaryEngine::class)->destroy($media->getFileName());

        $media->delete();

        return response()->noContent();
    }
}

We first, upload the file and return the path in the store method, to show we just return a download response and to destroy we first check if the file exists in file storage and delete it else we delete it from Cloudinary.

Let's add the routes for this controller in the admin route group

Route::post('/images/{path?}', [ImageController::class, 'store'])->name(
    'images.store',
);
Route::get('/images', [ImageController::class, 'show'])->name(
    'images.show',
);
Route::delete('/images', [ImageController::class, 'destroy'])->name(
    'images.destroy',
);

The backend is now ready to upload images let's get back to the stimulus controller on the frontend. First let's replace the dummy link with our newly defined routes

<div class="form-group"
     data-controller="filepond"
     data-filepond-process-value="{{ route('admin.images.store') }}"
     data-filepond-restore-value="{{ route('admin.images.show') }}"
     data-filepond-revert-value="{{ route('admin.images.destroy') }}"
     data-filepond-current-value="{{ json_encode(old('images', [])) }}">

    <input type="file"
           data-filepond-target="input">
</div>

Now let's tell filepond how to upload, revert and restore our images starting with upload. To upload we have to define the process config. In the connect method add the following snippet

let token = document.head.querySelector('meta[name="csrf-token"]');
let submitter = document.querySelector(
    'input[type="submit"][form="storeProduct"]'
);

pond.setOptions({
    allowMultiple: true,
    files: this.currentValue.map((image) => ({
        source: typeof image === 'string' ? image : image.file_url,

        options: {
            type: typeof image === 'string' ? 'limbo' : 'local',
        },
    })),

    server: {
        process: {
            url: this.processValue,
            headers: {
                'X-CSRF-Token': token.content,
            },
        },
    },
});

First

  • we get the CSRF token from the meta element
  • get the submitter, the button that submits the form so we can disable it when other operations are pending
  • then call the pond.setOptions method first allowing multiple files
  • set current files, if the value is a string it means the file hasn't been uploaded to Cloudinary yet so we set type to limbo meaning it's an old image else we set it to local meaning it's an already uploaded file
  • set the server config, the process key setting the url to the value we passed to this controller and setting the CSRF token header

Save the file and refresh your page. You should be able to upload images to your backend right now. Your screen should look something like the one below

Now after uploading the images we get the ID back (file path), we need the ability to submit that file with the rest of the input.To do that we listen for the processfile event and then append markup to the page first we need a template to use and let's add that to as follows. In the media card add the following markup just below the input tag

@foreach (old('images', []) as $image)
    <input data-filepond-target="upload"
           type="hidden"
           name="images[]"
           form="storeProduct"
           value="{{ $image }}">
@endforeach

<template data-filepond-target="template">
    <input data-filepond-target="upload"
           type="hidden"
           name="NAME"
           form="storeProduct"
           value="VALUE">
</template>

First we loop through old images in case we had a validation error or something and set input with name images[] because it's an array and set target to "upload" then we have a template target with the same markup as the input above it.

When a new image is uploaded we just replace the VALUE with the path to new image

pond.on('processfile', (error, event) => {
    const template = this.templateTarget.innerHTML
        .replace('NAME', 'images[]')
        .replace('VALUE', event.serverId);

    this.element.insertAdjacentHTML('beforeend', template);
    submitter.removeAttribute('disabled');
});

We append the piece of HTML to the DOM and remove the disabled attribute from the submit button.

Let's then register the targets to stimulus

static targets = ['input', 'template', 'upload'];

Add these event handlers to disable the submit button when files are uploading to prevent unwanted side effects

pond.on('processfilestart', () => {
    submitter.setAttribute('disabled', true);
});

pond.on('addfilestart', () => {
    submitter.setAttribute('disabled', true);
});

pond.on('addfile', () => {
    submitter.removeAttribute('disabled');
});

pond.on('processfilerevert', () => {
    submitter.removeAttribute('disabled');
});

pond.on('processfileabort', () => {
    submitter.removeAttribute('disabled');
});

Notice the X icon on the image preview that says "tap to undo" let's implement that feature, we are basically undoing the file upload

in the server config add the following key

revert: (uniqueFileId, load, error) => {
    axios
        .delete(`${this.revertValue}?media=${uniqueFileId}`)
        .then((response) => {
            this.uploadTargets.forEach((el) => {
                if (el.value == uniqueFileId) {
                    el.remove();
                }
            });

            load();
        })
        .catch((err) => {
            error({
                type: 'error',
                body: err.message,
                code: err.response?.status,
            });
        });
},

We makea delete request to the server with the file path, after getting a response we then loop the through the html form inputs and remove the one that matches the removed image so it won't be submitted with other fields

Add the restore method to restore the temporary files in case there was a file validation error

restore: (
    uniqueFileId,
    load,
    error,
    progress,
    abort,
    headers
) => {
    axios
        .get(`${this.restoreValue}?path=${uniqueFileId}`, {
            onDownloadProgress: (event) => {
                progress(true, event.loaded, event.total);
            },
            responseType: "blob",
        })
        .then((response) => {
            headers(response.request.getAllResponseHeaders());

            load(response.data);
        })
        .catch((err) => error(err.message));

    // Should expose an abort method so the request can be cancelled
    return {
        abort: () => {
            // User tapped abort, cancel our ongoing actions here

            // Let FilePond know the request has been cancelled
            abort();
        },
    };
},

The remove method to delete already uploaded file say on the edit page

remove: (source, load, error) => {
    axios
        .delete(`${this.revertValue}?media=${source}`)
        .then((response) => {
            load();
        })
        .catch((err) => {
            error({
                type: 'error',
                body: err.message,
                code: err.response?.status,
            });
        });
},

Lastly, we need the ability to load already uploaded files with the load method

load: (source, load, error, progress, abort, headers) => {
    axios
        .get(source, {
            onDownloadProgress: (event) => {
                progress(true, event.loaded, event.total);
            },
            responseType: 'blob',
        })
        .then((response) => {
            headers(response.request.getAllResponseHeaders());

            load(response.data);
        })
        .catch((err) => {
            if (err.response) {
                return error({
                    type: 'error',
                    body: err.response.statusText,
                    code: err.response.status,
                });
            }

            return error({
                type: 'error',
                body: err.message,
                code: 400,
            });
        });

    return {
        abort: () => {
            // User tapped cancel, abort our ongoing actions here

            // Let FilePond know the request has been cancelled
            abort();
        },
    };
},

And with that our media card is complete,we should be able to upload files,undo uploads and delete them.

To add a bit of styling to filepond add this css snippet to your resources/sass/app.scss file

.filepond--item {
    width: calc(50% - 0.5em);
}

@media (min-width: 30em) {
    .filepond--item {
        width: calc(50% - 0.5em);
    }
}

@media (min-width: 50em) {
    .filepond--item {
        width: calc(33.33% - 0.5em);
    }
}

Now add the markup for pricing, in the pricing card, replace the comment with the following snippet

<div class="row mb-3">

    <div class="form-group col-md-6">
        <label class="form-label"
               for='price'>Price</label>

        <div class="input-group">
            <div class="input-group-text">
                $
            </div>

            <input form="storeProduct"
                   type="text"
                   name="price"
                   id='price'
                   placeholder="0.00"
                   class="form-control @error('price') is-invalid @enderror"
                   value="{{ old('price') }}">
            @error('price')
                <div class="invalid-feedback">
                    {{ $message }}
                </div>
            @enderror
        </div>

    </div>

    <div class="form-group col-md-6">
        <label class="form-label"
               for='discounted_price'>Discounted price</label>

        <div class="input-group">
            <div class="input-group-text">
                $
            </div>

            <input form="storeProduct"
                   type="text"
                   name="discounted_price"
                   id='discounted_price'
                   placeholder="0.00"
                   class="form-control @error('compare_price') is-invalid @enderror"
                   value="{{ old('compare_price') }}">
            @error('compare_price')
                <div class="invalid-feedback">
                    {{ $message }}
                </div>
            @enderror
        </div>

    </div>
</div>

<div class="form-group">
    <label class="form-label"
           for='cost'>Cost per item</label>

    <div class="input-group">
        <div class="input-group-text">
            $
        </div>

        <input form="storeProduct"
               type="text"
               name="cost"
               id='cost'
               placeholder="0.00"
               class="form-control @error('cost') is-invalid @enderror"
               value="{{ old('cost') }}">
        @error('cost')
            <div class="invalid-feedback">
                {{ $message }}
            </div>
        @enderror
    </div>

    <span class="text-sm text-secondary d-block mt-2">Customers won't see this</span>

</div>

Jump over to the inventory card and replace the comment with the following snippet

<div class="form-group">
    <label class="form-label"
           for='sku'>SKU</label>

    <input id='sku'
           form="storeProduct"
           type="text"
           name="sku"
           class="form-control @error('sku') is-invalid @enderror"
           value="{{ old('sku') }}">

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

<div class="form-group mb-3">
    <label class="custom-switch pl-0">
        <input form="storeProduct"
               checked
               type="checkbox"
               name="track_quantity"
               data-action="input->inventory#toggle"
               class="custom-switch-input @error('track_quantity')
is-invalid
@enderror">
        <span class="custom-switch-indicator"></span>
        <span class="custom-switch-description">Track quantity</span>
    </label>
    @error('track_quantity')
        <div class="invalid-feedback">
            {{ $message }}
        </div>
    @enderror
</div>

<div class="form-group"
     data-inventory-target="checkbox">
    <label class="custom-switch pl-0">
        <input form="storeProduct"
               type="checkbox"
               name="sell_out_of_stock"
               class="custom-switch-input @error('sell_out_of_stock') is-invalid @enderror">
        <span class="custom-switch-indicator"></span>
        <span class="custom-switch-description">Continue selling when out of stock</span>
    </label>
    @error('sell_out_of_stock')
        <div class="invalid-feedback">
            {{ $message }}
        </div>
    @enderror
</div>

<div class="form-group"
     data-inventory-target="quantity">
    <label class="form-label"
           for='quantity'>Quantity</label>

    <input form="storeProduct"
           type="text"
           name="quantity"
           id='quantity'
           class="form-control @error('quantity') is-invalid @enderror"
           value="{{ old('quantity') }}">

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

You may have have noticed the data-inventory-xxx attributes, the card container has the data-controller="inventory".

If the track quantity toggle is turned on, we should display the quantity input field else we hide it and we add that feature using a stimulus controller

php artisan stimulus:make inventory

Open the newly created controller and add the following snippet

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

export default class extends Controller {
    static targets = ['quantity', 'checkbox']

    state = true

    toggle(e) {
        this.state = !this.state

        if (this.state) {
            this.quantityTarget.classList.remove('d-none')
            this.checkboxTarget.classList.remove('d-none')
        } else {
            this.quantityTarget.classList.add('d-none')
            this.checkboxTarget.classList.add('d-none')
        }
    }
}

In the product status card replace the comment with the following snippet

<div class="form-group">
    <select form="storeProduct"
            name="status"
            class="form-select @error('status') is-invalid @enderror">
        <option value="draft">Draft</option>
        <option value="review">Review</option>
        <option value="active">Active</option>
    </select>

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

Finally, in the product organization card replace the comment with the following snippet

<div class="form-group">
    <label class="form-label"
           for='category_id'>Category</label>
    <select form="storeProduct"
            name="category_id"
            id='category_id'
            class="form-select @error('category_id') is-invalid @enderror">
        @foreach ($categories as $category)
            @if ($category->id == old('category_id') || strtolower($category->name) == 'default')
                <option selected
                        value="{{ $category->id }}">{{ $category->name }}</option>
            @else
                <option value="{{ $category->id }}">{{ $category->name }}</option>
            @endif
        @endforeach
    </select>

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

You should have a page that looks the one below

The views are now ready, we need the ability to actually store the products in the database. First thing we need is to implement the StoreProductRequest

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    /**
     * Prepare input for validation
     *
     * @return void
     */
    protected function prepareForValidation(): void
    {
        $this->merge([
            'track_quantity' =>
                $this->has('track_quantity') &&
                $this->input('track_quantity') == 'on',
            'sell_out_of_stock' =>
                $this->has('sell_out_of_stock') &&
                $this->input('sell_out_of_stock') == 'on',
        ]);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'name' => 'required|string|max:255',
            'description' => 'required|string',
            'sku' => 'nullable|string|unique:products,sku',
            'track_quantity' => 'nullable|boolean',
            'quantity' => 'required_if:track_quantity,true|nullable|int',
            'sell_out_of_stock' => 'required_if:track_quantity,true|boolean',
            'category_id' => 'required|int|exists:categories,id',
            'price' => 'required|numeric',
            'cost' => 'nullable|numeric',
            'discounted_price' => 'nullable|numeric',
            'status' => 'required|string|in:active,draft,review',
            'images' => 'nullable|array',
            'images.*' => 'string',
        ];
    }
}

The prepare for validation method simply makes sure that we get boolean values for the toggle fields.

In the ProductController, implement the store method by adding the following snippet

/**
 * Store a newly created resource in storage.
 *
 * @param StoreProductRequest $request
 * @return RedirectResponse
 * @throws Exception
 */
public function store(StoreProductRequest $request)
{
    $product = Product::create($request->safe()->except(['images']));

    collect($request->validated('images'))->each(function ($image) use (
        $product,
    ) {
        $product->attachMedia(new File(storage_path('app/' . $image)));
        Storage::delete($image);
    });

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

After creating a product, we loop through the images and attach them to the model while storing them on cloudinary, then delete the temporary file from storage

Before we wrap up this post, add the sidebar for products in the layouts.partials.sidebar just below the brands

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

Then tell the product controller's index action to render the correct file

/**
 * Display a listing of the resource.
 *
 * @return Renderable
 */
public function index()
{
    $products = Product::paginate();

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

This post became longer than I expected, so I decide to split it into parts, In the upcoming post, we will add the ability to view all products in a table, sort, search and what not. In the next one after that, we add the ability to edit products then delete and so on

This is part 6 of the Laravel Ecommerce Tutorial series, make sure to check the previous tutorials if you missed them.

To make sure you don't miss the next part in this series when it's out, subscribe to the newsletter below and get notified when it's published!

[convertkit=2542481]