Laravel Ecommerce Tutorial: Part 8, Product Variations

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

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!