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