DEV Community

Cover image for Laravel Ecommerce Tutorial: Part 7, Product Options
Given Ncube
Given Ncube

Posted on • Originally published at flixtechs.co.zw

Laravel Ecommerce Tutorial: Part 7, Product Options

In the last tutorials we added the ability to manage products in the ecommerce site from, creating, editing and deleting products. In this post we will add product options. If you noticed most ecommerce sites has this feature where users select the color, size etc of a product and that's what we will implement in this part of the tutorial

This is part 7 of the on going tutorial on building an ongoing series on building an ecommerce site in Laravel

In this post we will add the ability to create product options like color, size, make etc

To make this work we need a number of models first

  • Each product will have options/attributes like color, size, material or whatever.
  • The option is a model with only a name and it has a many-to-many relationship with a product.
  • Each option has values say color has blue, red, etc.
  • Option also has a corresponding variant of the product say if it's blue then might have a different price or something

Creating the required models

Let's start by creating the options model along with it's migrations, factory

php artisan make:model Option -msf
Enter fullscreen mode Exit fullscreen mode

After creating populate the migration file with

<?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('options', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }

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

The options table only contain a name which is all we need.

In the options model configure mass assignment

class Option extends Model
{
    use HasFactory;

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

Since the product and options has a many-to-many relationship, let's create the pivot table

php artisan make:migration create_option_product_table
Enter fullscreen mode Exit fullscreen mode

Then add this to the 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('option_option', function (Blueprint $table) {
            $table->id();
            $table
                ->foreignId('product_id')
                ->constrained()
                ->onDelete('cascade');
            $table
                ->foreignId('option_id')
                ->constrained()
                ->onDelete('cascade');
            $table->timestamps();
        });
    }

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

Now let's define the relationships in the product model

/**
 * Get the options that belong to this product
 *
 * @return BelongsToMany
 */
public function options(): BelongsToMany
{
    return $this->belongsToMany(Option::class);
}
Enter fullscreen mode Exit fullscreen mode

We do the same for the Options model

/**
 * Get the products that belong to this option
 *
 * @return BelongsToMany
 */
public function products(): BelongsToMany
{
    return $this->belongsToMany(Product::class);
}
Enter fullscreen mode Exit fullscreen mode

For the options we are done, let's migrate the database

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Now for the values

So each option attached to a product will it's corresponding value which will have just one attribute which is the value of the option, like black, XL, nylon or something

Let's create the model

php artisan make:model Value -msf
Enter fullscreen mode Exit fullscreen mode

Add the following to the 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('values', function (Blueprint $table) {
            $table->id();
            $table->string('value');
            $table
                ->foreignId('option_id')
                ->constrained()
                ->onDelete('cascade');
            $table->timestamps();
        });
    }

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

The value belongs to an option and each option has many values. Let's define those relations in the Options model

/**
 * Get the values that belong to this option
 *
 * @return HasMany
 */
public function values(): HasMany
{
    return $this->hasMany(Value::class);
}
Enter fullscreen mode Exit fullscreen mode

We do the same for the Value model while configuring mass assignment

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

/**
 * The option that owns this value
 *
 * @return BelongsTo
 */
public function option(): BelongsTo
{
    return $this->belongsTo(Option::class);
}
Enter fullscreen mode Exit fullscreen mode

Finally, let's migrate the database

php artisan migrate
Enter fullscreen mode Exit fullscreen mode

Creating the controllers and views

Now we need to create the frontend which allow us to create these attributes

The idea is this, we want the user to say "this product has options" by toggling a switch, when that switched is toggled on we show a form to create an attribute along it's corresponding value. When user clicks add we make an XHR to the server to store the options, show the created option on the view with an option to delete, add hidden fields with ids of created options. sounds easy right?

To achieve this we need

  • Laravel controllers to create options
  • Stimulus controllers to handle that toggling and submitting thing
  • Lots of coffee

To be able to store the options and values in the database we need a controller, let's create the options controllers

php artisan make:controller Admin\\OptionController --model=Option --test -R
Enter fullscreen mode Exit fullscreen mode

This will give a controller and the corresponding form requests. Let's first define the request we're expecting in the StoreOptionRequest

<?php

namespace App\Http\Requests;

use App\Models\Option;
use Illuminate\Foundation\Http\FormRequest;

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

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

    protected function passedValidation()
    {
        $this->replace([
            'turbo' => $this->turbo,
            'name' => $this->option['name'],
            'values' => collect($this->option['values'])
                ->filter()
                ->map(fn($value) => ['value' => $value])
                ->toArray(),
        ]);
    }

    /**
     * Get the error messages for the defined validation rules.
     *
     * @return array
     */
    public function messages(): array
    {
        return [
            'option.name.required' => 'The option name is required',
            'option.values.*.string' => 'The option value must be a string',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

This is a standard form request you'd expect from a typical Laravel application except a few changes we made, replace the submitted input after validation in the passedValidation() method

  • The turbo field will be used track the current turbo frame to replace in the view
  • the values field is modified into an array of 'value' => 'arbitrary value' arrays which will allow us to easily insert into the database
  • and of course the option name as normal

Let's also update the UpdateOptionRequest to tell Laravel how to handle update option requests

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

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

    /**
     * Handle a passed validation attempt.
     *
     * @return void
     */
    protected function passedValidation(): void
    {
        $this->replace([
            'name' => $this->option['name'],
            'values' => collect($this->option['values'])
                ->filter()
                ->map(fn($value) => ['value' => $value])
                ->toArray(),
        ]);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'option.name' => 'required|string',
            'option.values' => 'required|array|min:1',
            'option.values.*' =>
                'sometimes|nullable|string|distinct:ignore_case|max:255',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

This is prettey much the same as the StoreOptionRequest

Before we continue let's generate the authorization code for the options model

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

Now let's add some code to the controller to allow us to create and delete the options from the database. In the Admin\OptionController add the following snippet

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreOptionRequest;
use App\Http\Requests\UpdateOptionRequest;
use App\Models\Option;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;

class OptionController extends Controller
{
    /**
     * Store a newly created resource in storage.
     *
     * @param StoreOptionRequest $request
     * @return RedirectResponse
     */
    public function store(StoreOptionRequest $request)
    {
        $option = Option::create($request->only(['name']));

        $option->values()->createMany($request->all()['values']);

        return response()->turboStream([
            response()
                ->turboStream()
                ->target("turbo{$request->input('turbo')}")
                ->action('replace')
                ->view('admin.options.show', ['option' => $option]),
        ]);
    }

    /**
     * Display the specified resource.
     *
     * @param Option $option
     * @return Renderable
     */
    public function show(Option $option)
    {
        return view('admin.options.show', [
            'option' => $option,
        ]);
    }

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

    /**
     * Update the specified resource in storage.
     *
     * @param UpdateOptionRequest $request
     * @param Option $option
     * @return Renderable
     */
    public function update(UpdateOptionRequest $request, Option $option)
    {
        $option->update($request->only(['name']));

        $option->values()->delete();

        $option->values()->createMany($request->all()['values']);

        return response()->turboStream([
            response()
                ->turboStream()
                ->target(dom_id($option))
                ->action('replace')
                ->view('admin.options.show', ['option' => $option]),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Breakdown of this controller:

  • In the store method, we simply create an option and it's values and then return a response but this we are returning a turbo stream response. Read more about turbo streams how they work, and when to use them here and here.
  • The show and edit method returns the views to show and edit an option
  • the update method updates the option, deletes already existing values, create new ones then return another turbo stream only this the target is a dom_id of the option

We use the turbo field we received from the frontend to tell turbo which part of the page to replace with new markup.

Now go to the admin.products.create view add the following snippet between the inventory and variants card

<div class="card rounded-lg">
    <div class="card-header">
        <h4>Options</h4>
    </div>
    <div class="card-body">
        <div class="form-group mb-3">
            <label class="custom-switch pl-0">
                <input type="checkbox"
                       name="custom-switch-checkbox"
                       class="custom-switch-input">
                <span class="custom-switch-indicator"></span>
                <span class="custom-switch-description">This product has options, like size or
                    color</span>
            </label>
        </div>

    </div>

    <div class="card-footer border-top">
        <button class="btn btn-link btn-lg">
            <i class="fa fa-plus"></i> Add another one
        </button>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

So what we want is that if the user toggles the "product has options" switch we show a form to add the first option, once that is added we show the footer with a button to add another one otherwise we hide everything

To accomplish this let's first create the stimulus controller we will be using

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

Register the controller the card we just added by adding this attribute to the parent card container like this

<div class="card rounded-lg"
     {{ stimulus_controller('options') }}>
...
</div>
Enter fullscreen mode Exit fullscreen mode

When we click the toggle switch we want to be able to show and hide the options form, let's register a stimulus action on the checkbox input like this

<input type="checkbox"
       name="custom-switch-checkbox"
       class="custom-switch-input"
       {{ stimulus_action('options', 'toggle') }}>
Enter fullscreen mode Exit fullscreen mode

Now let's add the toggle method to the options_controller, open resources/js/controllers/options_controller.js and add the following method

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="options"
export default class extends Controller {
    static values = {
        state: Boolean
    }

    state

    connect() {
        this.state = this.stateValue
    }

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

        if (this.state) {
            //show form
        } else {
            //hide options and form
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The state values tells whether there were options to begin with,

  • the state attribute is used to track the state of the toggle switch
  • if it's true we show the form
  • else we hide it

At this point we will be manipulating the DOM a bit, inorder to insert and remove the form we need a template that we can add to the dom and remove it whenever we want

This is might be a bit complicated to put in one chunk of code so bear with me here

First create the options views

php artisan make:view admin.options -r
Enter fullscreen mode Exit fullscreen mode

Then from the admin.products.create view in the options card include the options create view as a partial

<div class="card rounded-lg" 
    {{ stimulus_controller('options', [
        'state' => is_array(old('options', null)),
    ]) }}>
    <div class="card-header">
        <h4>Options</h4>
    </div>
    <div class="card-body">
        <div class="form-group mb-3">
            <label class="custom-switch pl-0">
                <input type="checkbox"
                       name="custom-switch-checkbox"
                       class="custom-switch-input"
                       @checked(is_array(old('options', null)))
                       {{ stimulus_action('options', 'toggle') }}>
                <span class="custom-switch-indicator"></span>
                <span class="custom-switch-description">This product has options, like size or
                    color</span>
            </label>
        </div>

    </div>

    @include('admin.options.create')

    @foreach (old('options', []) as $option)
        <turbo-frame id='option_{{ $option }}'
                 src="{{ route('admin.options.show', $option) }}"
                 loading="lazy">
        </turbo-frame>
    @endforeach    

    <div class="card-footer border-top">
        <button class="btn btn-link btn-lg">
            <i class="fa fa-plus"></i> Add another one
        </button>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Open the partial admin.options.create and add the following snippet

<turbo-frame id="turboTURBOID"
             {{ stimulus_target('options', 'form') }}>

    <form action="{{ route('admin.options.store') }}"
          method="post"
          {{ stimulus_controller('fields', [
              'old' => old('option', ['' => '']),
          ]) }}>
        @csrf
        <input type='hidden'
               name='turbo'
               value='TURBOID'>
        <div class="row flex align-items-start ">
            <label class="form-label">Option name</label>

            <div class="form-group col-11 position-relative">
                <x-input name="option[name]"
                         :value="old('option.name', '')"
                         data-action="focus->fields#showTypeahead"
                         data-fields-target="input"
                         error='option.name' />

                <div {{ stimulus_target('fields', 'typeahead') }}
                     {{ stimulus_action('fields', 'closeTypeahead', 'fields:click:outside') }}
                     class="position-absolute row bg-white shadow rounded-lg w-100 p-1 d-none"
                     style="z-index:10">
                    <div class="col-12 class p-2 rounded-lg mb-1 bg-secondary-hover text-white-hover rounded cursor-pointer-hover"
                         {{ stimulus_action('fields', 'updateInput', 'click', [
                             'update' => 'Size',
                         ]) }}>
                        Size
                    </div>

                    <div class="col-12 p-2 rounded-lg mb-1 bg-secondary-hover text-white-hover rounded cursor-pointer-hover"
                         {{ stimulus_action('fields', 'updateInput', 'click', [
                             'update' => 'Color',
                         ]) }}>
                        Color
                    </div>

                    <div class="col-12 p-2 rounded-lg mb-1 bg-secondary-hover text-white-hover rounded cursor-pointer-hover"
                         {{ stimulus_action('fields', 'updateInput', 'click', [
                             'update' => 'Material',
                         ]) }}>
                        Material
                    </div>

                    <div class="col-12 p-2 rounded-lg bg-secondary-hover text-white-hover rounded cursor-pointer-hover"
                         {{ stimulus_action('fields', 'updateInput', 'click', [
                             'update' => 'Style',
                         ]) }}>
                        Style
                    </div>
                </div>

            </div>
            <div class="col-1">
                <span class='btn btn-outline-primary'
                      {{ stimulus_action('options', 'removeForm', 'click') }}>
                    <i class="fas fa-trash-alt"></i>
                </span>
            </div>
            <div class="form-group mb-0">
                <label class="form-label">Option values</label>
            </div>
        </div>

        @foreach (old('option.values', []) as $key => $option)
            @if (!is_null($option))
                <div class="flex row align-items-start ">
                    <div class="form-group col-11">
                        <x-input name="option[values][]"
                                 :error="'option.values.' . $key"
                                 data-action="input->fields#addOptionValue:once"
                                 :value="$option" />
                    </div>
                    <div class="col-1">
                        <span class='btn btn-danger'
                              {{ stimulus_action('fields', 'removeOptionValue', 'click') }}>
                            <i class="fas fa-trash"></i>
                        </span>
                    </div>
                </div>
            @endif
        @endforeach

        <template {{ stimulus_target('fields', 'template') }}>
            <div class="flex row align-items-start ">
                <div class="form-group col-11">
                    <x-input name="option[values][]"
                             data-action="input->fields#addOptionValue:once" />
                </div>
                <div class="col-1 d-none">
                    <span class='btn btn-danger'
                          {{ stimulus_action('fields', 'removeOptionValue', 'click') }}>
                        <i class="fas fa-trash"></i>
                    </span>
                </div>
            </div>
        </template>

        <div class="form-group ">
            <button type="submit"
                    class="btn btn-primary">Done</button>
        </div>
    </form>
</turbo-frame>
Enter fullscreen mode Exit fullscreen mode

If laravel complains about route not defined add the resource route in the admin group

Here is what the snippet above does

  • When a user focus on the option name field we want to show a typehead showing a list of possible fields
  • when when a user clicks the first trash button we remove everything
  • When a user starts typing the in the value field we add another field and show the trash icon
  • When a user clicks the trash icon on the value field we remove that value
  • So that we can add as many values as we want and delete them as we wish

To make this feature work let's create another stimulus controller, let's call it fields controller

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

Add the following snippet in the resources/js/controllers/fields_controller.js

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

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

    connect() {
        useClickOutside(this, { element: this.typeaheadTarget });

        const template = this.templateTarget.innerHTML;
        this.templateTarget.insertAdjacentHTML('beforebegin', template);
    }

    addOptionValue(e) {
        const template = this.templateTarget.innerHTML;
        e.target.parentElement.nextElementSibling.classList.remove('d-none');
        e.target.parentElement.parentElement.insertAdjacentHTML(
            'afterend',
            template
        );
    }

    removeOptionValue(e) {
        e.target.closest('div').parentElement.remove();
    }

    updateInput({ params: { update } }) {
        this.inputTarget.value = update;
        this.typeaheadTarget.classList.add('d-none');
    }

    closeTypeahead(event) {
        if (this.inputTarget !== document.activeElement) {
            this.typeaheadTarget.classList.add('d-none');
        }
    }

    showTypeahead() {
        this.typeaheadTarget.classList.remove('d-none');
    }
}
Enter fullscreen mode Exit fullscreen mode

At this point you should be able to add options, edit them all in one page like magic

Now what we need is to toggle this functionality, when the toggle is on we show the form, otherwise we hide it, when a user clicks another one we add another form.

Let's get back to our options controller

Open the resources/js/controllers/options_controller.js and modify it as follows

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

// Connects to data-controller="options"
export default class extends Controller {
    static targets = ['formTemplate', 'card', 'footer', 'form'];

    static values = {
        state: Boolean,
    };

    state;

    connect() {
        this.state = this.stateValue;

         if (this.state) {
            this.footerTarget.classList.remove('d-none');
        }
    }

    toggle(e) {
        this.state = !this.state;
        if (this.state) {
            this.addForm();
        } else {
            this.formTargets.forEach((item, i) => {
                item.remove();
            });

            this.footerTarget.classList.add('d-none');
        }
    }

    addForm() {
        const template = this.formTemplateTarget.innerHTML;
        this.cardTarget.insertAdjacentHTML('beforeend', template);
        this.footerTarget.classList.remove('d-none');
    }

    removeForm(e) {
        e.target.closest('form').parentElement.remove();
        if (e.target.dataset.option) {
            axios
                .delete(route('admin.options.destroy', e.target.dataset.option))
                .then((resp) => {})
                .catch((err) => {});
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's move to add the stimulus targets to the markup starting with products create page, replace the options card with

<div class="card rounded-lg"
     {{ stimulus_controller('options') }}>
    <div class="card-header">
        <h4>Options</h4>
    </div>
    <div class="card-body"
         {{ stimulus_target('options', 'card') }}>
        <div class="form-group mb-3">
            <label class="custom-switch pl-0">
                <input type="checkbox"
                       name="custom-switch-checkbox"
                       class="custom-switch-input"
                       {{ stimulus_action('options', 'toggle') }}>
                <span class="custom-switch-indicator"></span>
                <span class="custom-switch-description">This product has options, like size or
                    color</span>
            </label>
        </div>

        @include('admin.options.create')

    </div>

    <div class="card-footer border-top d-none"
         {{ stimulus_target('options', 'footer') }}>
        <button class="btn btn-link btn-lg"
                {{ stimulus_action('options', 'addForm') }}>
            <i class="fa fa-plus"></i> Add another one
        </button>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Then in the admin.options.create view wrap the form like this

<template id="form"
          {{ stimulus_target('options', 'formTemplate') }}>
    <turbo-frame id="turboTURBOID"
                 {{ stimulus_target('options', 'form') }}>
...
</template>
Enter fullscreen mode Exit fullscreen mode

Well, at this point you should be able to just toggle the switch to show or hide the options form

Attaching options to products using a pipeline

The last step is to actually link the new created options with the product we are about to create. Inorder to declutter our controller we will use a pipeline that accepts the request and the created product and decorate it until re return a response. Each pipeline stage will be handled by an action and we will require 2 packages to accomplish this one for actions and another for pipelines

Let's first install the supercharged pipelines package

composer require chefhasteeth/pipeline
Enter fullscreen mode Exit fullscreen mode

This package will allow us to pipe input through a series of actions which have a handle method. The pipe's input is the output of the previous pipe.

Let's install laravel actions package to help us with crafting actions that we can use anywhere

composer require lorisleiva/laravel-actions
Enter fullscreen mode Exit fullscreen mode

Now let's modify the StoreProductRequest to allow us to validate product options. Add this snippet at the end of the array returned in the rules() method

public function rules()
{
    return [
        ...
        'options' => 'nullable|array',
        'options.*' => 'int|exists:options,id',
    ];
}
Enter fullscreen mode Exit fullscreen mode

Let's create the action that links options to a product

php artisan make:action Product\\LinkOption --test
Enter fullscreen mode Exit fullscreen mode

The option's handle method will accept a product and an array/request/collection of id's of options to sync to the product model

Open the new created option and populate it with

<?php

namespace App\Actions\Product;

use App\Models\Product;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\Concerns\AsAction;

class LinkOption
{
    use AsAction;

    public function handle(Product $product, Collection|array $data): Product
    {
        $product->options()->sync($data);

        return $product;
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's modify the Admin\ProductController's store() method to use a pipeline to attach options like in the snippet below

* Store a newly created resource in storage.
 *
 * @param StoreProductRequest $request
 * @return RedirectResponse
 * @throws Exception
 */
public function store(StoreProductRequest $request)
{
    return Pipeline::make()
        ->send($request->safe()->collect())
        ->through([
            fn($passable) => Product::create(
                $passable
                    ->filter(fn($value) => !is_null($value))
                    ->except(['images', 'options'])
                    ->all(),
            ),
            fn($passable) => LinkOption::run(
                $passable,
                $request->validated('options'),
            ),
            fn($passable) => collect($request->validated('images'))->each(
                function ($image) use ($passable) {
                    $passable->attachMedia(
                        new File(storage_path('app/' . $image)),
                    );
                    Storage::delete($image);
                },
            ),
        ])
        ->then(
            fn() => to_route('admin.products.index')->with(
                'success',
                'Product was successfully created',
            ),
        );
}
Enter fullscreen mode Exit fullscreen mode

This is still a bit verbose, let's refactor the ability to create products and attach images to their individual actions, starting with attaching images

php artisan make:action Product\\AttachImages --test
Enter fullscreen mode Exit fullscreen mode

Open the newly created option and add the following snippet

<?php

namespace App\Actions\Product;

use App\Models\Product;
use Illuminate\Http\File;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction;

class AttachImages
{
    use AsAction;

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

        return $product;
    }
}
Enter fullscreen mode Exit fullscreen mode

Then rewrite the Admin\ProductController's store() method as follows

* Store a newly created resource in storage.
 *
 * @param StoreProductRequest $request
 * @return RedirectResponse
 * @throws Exception
 */
public function store(StoreProductRequest $request)
{
    return Pipeline::make()
        ->send(
            $request
                ->safe()
                ->collect()
                ->filter(),
        )
        ->through([
            fn($passable) => Product::create(
                $passable->except(['images', 'options'])->all(),
            ),
            fn($passable) => LinkOption::run(
                $passable,
                $request->validated('options'),
            ),
            fn($passable) => AttachImages::run(
                $passable,
                $request->validated('images'),
            ),
        ])
        ->then(
            fn() => to_route('admin.products.index')->with(
                'success',
                'Product was successfully created',
            ),
        );
}
Enter fullscreen mode Exit fullscreen mode

Now this looks a bit more cleaner than what we had. If you're that ambitious you could also refactor the first pipe that creates the product into it's own action but I personally prefer it this way

Options when editing products

At this point after you create the product you're redirected to the product edit page but you can't edit the options. Since most of the work has been done we will just reuse a few snippets in the admin.products.edit view

Let's start by adding the options card in the admin.product.edit view, just above the variants card add the following snippet

<div class="card rounded-lg"
     {{ stimulus_controller('options', [
         'state' => count(old('options', $product->options)),
     ]) }}>
    <div class="card-header">
        <h4>Options</h4>
    </div>
    <div class="card-body"
         {{ stimulus_target('options', 'card') }}>
        <div class="form-group mb-3">
            <label class="custom-switch pl-0">
                <input type="checkbox"
                       name="custom-switch-checkbox"
                       class="custom-switch-input"
                       @checked(count(old('options', $product->options)))
                       {{ stimulus_action('options', 'toggle') }}>
                <span class="custom-switch-indicator"></span>
                <span class="custom-switch-description">This product has options, like size or
                    color</span>
            </label>
        </div>

        @include('admin.options.create')

        @foreach (old('options', $product->options) as $option)
            <turbo-frame id='option_{{ $option?->id ?? $option }}'
                         src="{{ route('admin.options.show', $option) }}"
                         loading="lazy">
            </turbo-frame>
        @endforeach

    </div>

    <div class="card-footer border-top d-none"
         {{ stimulus_target('options', 'footer') }}>
        <button class="btn btn-link btn-lg"
                {{ stimulus_action('options', 'addForm') }}>
            <i class="fa fa-plus"></i> Add another one
        </button>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Modify the UpdateProductRequest to allow options validations, add the following snippet at the end of the array returned in the rules() method

public function rules()
{
    return [
        ...
        'options' => 'nullable|array',
        'options.*' => 'int|exists:options,id',
    ];
}
Enter fullscreen mode Exit fullscreen mode

Then we modify the update method of Admin\ProductController to use the pipeline to update options and images like below

/**
 * Update the specified resource in storage.
 *
 * @param UpdateProductRequest $request
 * @param Product $product
 * @return RedirectResponse
 * @throws Exception
 */
public function update(UpdateProductRequest $request, Product $product)
{
    return Pipeline::make()
        ->send(
            $request
                ->safe()
                ->collect()
                ->filter(),
        )
        ->through([
            function ($passable) use ($product) {
                $product->update(
                    $passable->except(['images', 'options'])->all(),
                );

                return $product;
            },
            fn($passable) => LinkOption::run(
                $passable,
                $request->validated('options'),
            ),
            fn($passable) => AttachImages::run(
                $passable,
                $request->validated('images', []),
            ),
        ])
        ->then(
            fn() => to_route('admin.products.index')->with(
                'success',
                'Product was successfully updated',
            ),
        );
}
Enter fullscreen mode Exit fullscreen mode

This is everything we need to be able to create product options for customers to chose when adding to cart.

In the next post we will add the ability to create product variations for example a color blue might have a different price than a black one.

To make sure you don't miss the next post in this series subscribe to the newsletter below and get an email soon as it's up

Top comments (0)