Laravel Ecommerce Tutorial: Part 7, Product Options

Laravel Ecommerce Tutorial: Part 7, Product Options

Given Ncube

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

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

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 = [];
...

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

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

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);
}

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);
}

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

php artisan migrate

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

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

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);
}

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);
}

Finally, let's migrate the database

php artisan migrate

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

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',
        ];
    }
}

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',
        ];
    }
}

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 
php artisan authorizer:permissions:generate -m Option

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]),
        ]);
    }
}

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>

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

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>

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') }}>

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
        }
    }
}

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

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>

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>

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 

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

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) => {});
        }
    }
}

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>

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>

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

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

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',
    ];
}

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

php artisan make:action Product\\LinkOption --test

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;
    }
}

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

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

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;
    }
}

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

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>

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',
    ];
}

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

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