Laravel Ecommerce Tutorial: Part 8, Product Variations
Given Ncube
In the last post we added the ability to create product options like color, size, specs or whatever. Now, let's say if the product is black it costs a bit more than others and we want to keep track of how many black items we have in stock and give the customer the ability to select a specific variation when adding to cart.
This is part 8 of the on going tutorial series on building an ecommerce store in Laravel from start to deployment. If you want to jump to a specific section use the following table of contents
- Laravel Ecommerce Tutorial: Part 1, Introduction
- Laravel Ecommerce Tutorial: Part 2, Users And Authorization
- Laravel Ecommerce Tutorial: Part 3, Managing Roles and Permissions
- Laravel Ecommerce Tutorial: Part 4, Managing Product Categories
- Laravel Ecommerce Tutorial: Part 5, Managing Brands
- Laravel Ecommerce Tutorial: Part 6.1, Creating Products
- Laravel Ecommerce Tutorial: Part 6.2, Listing And Deleting Products
- Laravel Ecommerce Tutorial: Part 6.3, Editing Products
- Laravel Ecommerce Tutorial: Part 6.4, Refactoring Products
- Laravel Ecommerce Tutorial: Part 7, Product Options
- Laravel Ecommerce Tutorial: Part 8, Product Variations
To accomplish this we add product variations to our store. In summary we create a model, Variation
that belongs to a product and also refers to a specific option like color, with attributes like variation name, price, quantity, etc
Creating the models and migration
As always we start by creating the required models and the migration. Create the variation model using the following artisan command
php artisan make:model Variation -msf
Define the migration as follows
<?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('variations', function (Blueprint $table) {
$table->id();
$table->string('variant');
$table->decimal('price', 20, 2)->default(0);
$table->decimal('cost', 20, 2)->default(0);
$table->integer('quantity')->default(0);
$table->string('sku')->nullable();
$table->foreignId('option_id')->constrained()->onDelete('cascade')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('variations');
}
};
So each variation will have a variant to identify it, a price with default zero quantity and SKU
migrate the database
php artisan migrate
Next, we generate the authorization code
php artisan authorizer:permissions:generate -m Varation
php artisan authorizer:policies:generate -m Varation
Configure the model
class Variation extends Model
{
use HasFactory;
/**
* The attributes that should not be mass assignable
*
* @var array
*/
protected $guarded = [];
...
We want this model to have a many to many relation with the product model, let's create the pivot table
php artisan make:migration create_product_variation_table
And define it as follows
<?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('product_variation', function (Blueprint $table) {
$table->id();
$table
->foreignId('product_id')
->constrained()
->onDelete('cascade');
$table
->foreignId('variation_id')
->constrained()
->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('product_variation');
}
};
Tell the variation model that it has relationship with the product and option model
/**
* Get the options that belong to this variant
*
* @return BelongsTo
*/
public function option(): BelongsTo
{
return $this->belongsTo(Option::class);
}
/**
* Get the products that belong to this variant
*
* @return BelongsToMany
*/
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class);
}
In the product model define the relationship
/**
* Get the variations that belong to this product
*
* @return BelongsToMany
*/
public function variations(): BelongsToMany
{
return $this->belongsToMany(Variation::class);
}
We do the same for the options model
/**
* Get the variations that belong to this option
*
* @return HasMany
*/
public function variations(): HasMany
{
return $this->hasMany(Variation::class);
}
Finally, migrate the database
php artisan migrate
Let's now move on to the meat of this section
Controllers and views
We want to create the variation as soon as we create an option then send a turbo stream that shows a form to add more info on the variant. To add this feature we are going to modify the store
method of Admin\OptionController
to create the variations from option values .
Open Admin\OptionController
and replace the store()
method with the one below
/**
* 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']);
$option
->variations()
->createMany(
$option->values->map(
fn($value) => ['variant' => $value->value],
),
);
return response()->turboStream([
response()
->turboStream()
->target("turbo{$request->input('turbo')}")
->action('replace')
->view('admin.options.show', ['option' => $option]),
response()
->turboStream()
->target('variations')
->action('append')
->view('admin.variations.index', [
'variations' => $option->variations,
]),
]);
}
Notice that we do not have the admin.variations.index
view, let's generate that
php artisan make:view admin.variations -r
Originally we returned just one turbo streams but in this case we return 2, one would be for options and the other to display the viriations
Let's add the markup for the admin.variations.index
@foreach ($variations as $key => $variation)
<x-turbo-frame :id="$variation" src="{{ route('admin.variations.show', $variation) }}">
</x-turbo-frame>
@endforeach
@foreach (old('variations', []) as $variation)
@if (!$variations->contains(fn ($value, $key) => $variation == $value->id))
<x-turbo-frame :id="'variation_' . $variation" src="{{ route('admin.variations.show', $variation) }}">
</x-turbo-frame>
@endif
@endforeach
We are simply looping through all the variations and displaying them in a turbo frame
Open the admin.variations.show
view and add the following snippet
<x-turbo-frame :id="$variation">
<form id="@domid($variation)"
class="row mb-2"
data-controller="variations"
action="{{ route('admin.variations.update', $variation) }}"
method="post">
@csrf
@method('PATCH')
<div class="col-md-3">
{{ $variation->variant }}
</div>
<div class="col-md-3">
<x-input name='price'
placeholder="0.00"
data-action="change->variations#submit"
error='variation.price'
:value="old('variation.price', $variation->price)" />
</div>
<div class="col-md-3">
<x-input name='quantity'
placeholder="0"
data-action="change->variations#submit"
error='variation.quantity'
:value="old('variation.quantity', $variation->quantity)" />
</div>
<div class="col-md-3">
<x-input name='sku'
data-action="change->variations#submit"
error='variation.sku'
:value="old('variation.sku', $variation->sku)" />
</div>
</form>
<input form="storeProduct"
type="hidden"
name="variations[]"
value="{{ $variation->id }}">
</x-turbo-frame>
Notice we registered a variations
stimulus controllers that listens for change events on the inputs and invokes the submit method. This is to create an auto save feature to update the variation as the user types in the values
We also added a form input with attribute form=storeProduct
which is what will be submitted with the main form to attach variations to the product model
Let's create the variation controller
php artisan stimulus:make variation
Open the recently created controller and add the following snippet
import { Controller } from '@hotwired/stimulus';
import { useDebounce } from 'stimulus-use';
export default class extends Controller {
static debounces = ['submit'];
connect() {
useDebounce(this);
}
submit(e) {
e.target.form.requestSubmit();
}
}
We debounce the submit method to give the server a break and the submit method "requests submit" from the form on which the input belongs to. This allows us to submit the form via Turbo
Now let's create the http controller to show and update the variations
php artisan make:controller Admin\\VariationController --model=Variation --test -R
Open the recently created controller and replace the stub with
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreVariationRequest;
use App\Http\Requests\UpdateVariationRequest;
use App\Models\Variation;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\RedirectResponse;
class VariationController extends Controller
{
public function __construct()
{
$this->authorizeResource(Variation::class, 'variation');
}
/**
* Display the specified resource.
*
* @param Variation $variation
* @return Renderable
*/
public function show(Variation $variation)
{
return view('admin.variations.show', ['variation' => $variation]);
}
/**
* Update the specified resource in storage.
*
* @param UpdateVariationRequest $request
* @param Variation $variation
* @return RedirectResponse
*/
public function update(
UpdateVariationRequest $request,
Variation $variation,
) {
$variation->update($request->validated());
return to_route('admin.variations.show', $variation);
}
}
Let's add some validation rules to UpdateVariationRequest
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateVariationRequest 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('variation'));
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'price' => 'nullable|numeric',
'quantity' => 'nullable|int',
'sku' => 'nullable|string',
];
}
}
Let's register the route for variations, in your web routes add this to your admin routes group
Route::resource('variations', VariationController::class);
To make this feature work, open the admin.products.create
view and find the variations card and replace it with the following snippet
<div class="card rounded-lg">
<div class="card-header">
<h4>Variations</h4>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-3 font-weight-bold">
Variation
</div>
<div class="col-md-3 font-weight-bold">
Price
</div>
<div class="col-md-3 font-weight-bold">
Quantity
</div>
<div class="col-md-3 font-weight-bold">
SKU
</div>
</div>
<div class=""
id="variations">
</div>
</div>
</div>
Same goes for the edit page, open the find the variations card in admin.products.edit
view and replace it with
<div class="card rounded-lg">
<div class="card-header">
<h4>Variations</h4>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-3 font-weight-bold">
Variation
</div>
<div class="col-md-3 font-weight-bold">
Price
</div>
<div class="col-md-3 font-weight-bold">
Quantity
</div>
<div class="col-md-3 font-weight-bold">
SKU
</div>
</div>
@include('admin.variations.index', [
'variations' => $product->variations ?? [],
])
<div class=""
id="variations">
</div>
</div>
</div>
The id="variations"
is what Turbo will use to append streams of recently created variations to the DOM.
At this point we can create variations after adding an option, now we need to attach those option to the product that we're going to create and like the options we will use an action that we will add to the pipeline
Let's start by creating the action
php artisan make:action Product\\AttachVariations --test
Open the recently created action and add the following piece of code
<?php
namespace App\Actions\Product;
use App\Models\Product;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\Concerns\AsAction;
class AttachVariations
{
use AsAction;
public function handle(
Product $product,
array|Collection $variations,
): Product {
$product->variations()->sync($variations);
return $product;
}
}
We simply sync the variations from the requests with the product.
Now open the Admin\ProductController
and add this action to the pipleline
Modify the store
method to
/**
* 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', 'variations'])
->all(),
),
fn($passable) => LinkOption::run(
$passable,
$request->validated('options'),
),
fn($passable) => AttachImages::run(
$passable,
$request->validated('images'),
),
fn($passable) => AttachVariations::run(
$passable,
$request->validated('variations', []),
),
])
->then(
fn() => to_route('admin.products.index')->with(
'success',
'Product was successfully created',
),
);
}
We do the same for the update()
method
/**
* 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', 'variations'])
->all(),
);
return $product;
},
fn($passable) => LinkOption::run(
$passable,
$request->validated('options'),
),
fn($passable) => AttachImages::run(
$passable,
$request->validated('images', []),
),
fn($passable) => AttachVariations::run(
$passable,
$request->validated('variations', []),
),
])
->then(
fn() => to_route('admin.products.index')->with(
'success',
'Product was successfully updated',
),
);
}
Lastly, let's the validation rules for variations in the store and update product requests, add this add this at the end of the rules array
'variations' => 'nullable|array',
'variations.*' => 'int|exists:variations,id',
That's it about adding product variations, however there's room for improvement, for instance if we edit the option we should be able to update the corresponding variation name but that's for you to tinker with, just play around with Turbo frames and Turbo streams.
In the next post we will wrap up anything to do with managing products by adding the ability to add SEO settings like meta title, description, canonical urls and such
To make sure you don't miss the next post when it's out, subscribe to the newsletter below and receive an email soon as I make a new post.
Until then, happy codding!