Craft a Custom E-Commerce Website with Laravel [Tutorial & Live Demo]

This is an updated version of an older post. In the first version, we experimented with a shopping cart integration in the Laravel-powered PyroCMS. I’ll do something different here, but if that first iteration sounds interesting to you, you can find the GitHub repo & live demo here.

In a rush? Skip to tutorial steps or live demo.

PHP's made gigantic leaps since my early programming days.

And, apart from a few experiments here & there, I haven't taken much time to jump back into its modern environment (been busy diving in Vue.js refactoring & prototyping).

So today, that's exactly what I'm going to do.

How? By exploring Laravel e-commerce capabilities.

Or more precisely: what are the benefits Laravel can bring to your next e-commerce venture & what tools can help you build it.

Then I’ll present my own handcrafted solution. This time around, I’m getting my hands dirty and really going under the hood of that powerful framework.

In the tutorial, I'll show you:

  • How to build a basic controller and model to expose a list of products in an API.
  • How to craft advanced controller and models to serve a custom product builder’s API.
  • How to quickly wire Snipcart & Vue.js to consume the API in the frontend.

The result? A Laravel-powered e-commerce app with a highly-customizable shopping cart!

Let's put my rusty PHP skills to the test.

Laravel & e-commerce: quick context

For those who didn't know already: Laravel is an open-source PHP web framework used to build sites & web apps.

laravel-ecommerce

It has a smooth learning curve, removes some quirks of building with PHP, has many modern built-in libraries. Some say it's the Ruby on Rails PHP equivalent.

Want to familiarize yourself with Laravel before starting this tuts? Check out this post—shows you how to build a simple MVC app < 10 min.

With Laravel, you can leverage Composer to manage dependencies & packages. Many useful packages allow you to fast-track your Laravel app or site development. Think stuff like debugging, authentication, API interactions, etc. Sites like Packagist & Packalyst are great resources to find helpful Laravel packages.

Since September 2019, Laravel 6 is publicly available. It introduces features such as semantic versioning, improved authorization responses, job middleware & lazy collections, as well as many bug fixes and improvements.

Tools for e-commerce on Laravel?

And of course, there are e-commerce packages for Laravel. They can help to set up e-commerce functionalities on your Laravel app quickly.

I’ve made a curated list of the most popular ones to check out:

Bagisto — Code-driven & multi-featured Laravel package for e-commerce. Free & open-source.
Aimeos — A Laravel e-commerce package and online shop solution. Free & open-source.
AvoRed — A modular and customizable PHP shopping cart. Free & open-source.
Vanilo — This one’s actually an e-commerce framework (like Magento or Prestashop) for Laravel.

So, where does that lead us?

The three e-commerce options with Laravel are pretty much:

  1. Pick a Laravel-powered CMS and add custom e-commerce to it.
  2. Use e-commerce packages built to extend your app.
  3. Build your own e-commerce application from the ground up with Laravel's framework.

First one? Fast & easy. That’s what I did for the first version of this post, using PyroCMS and then integrating Snipcart as the custom shopping cart. If it’s something that sounds interesting to you, I’ve added a link to the Github repo and live demo at the top of this post.

Second one? Actually interesting. We might do it in another post at some point!

Third one? Close to what we're doing here. Building the whole application from the ground up, including e-commerce capabilities, would be highly time-consuming. Instead, I’ll show you how you can build a store from scratch using pure Laravel and then adding Snipcart to enable e-commerce while keeping powerful shopping cart customization.

What all these options have in common are the benefits you’ll win over using a full-on e-commerce CMS—high scalability, easy customization, detached e-commerce functionalities, etc.

I'm thrilled to be operating inside a Laravel project for this demo. Unlike many of our JAMstack/static site tuts, it'll be easy to handle any backend logic for our store.

Editor's note: if you're building a simple subscription business with Laravel, check out Laravel Cashier, or our own subscription feature.

Tutorial: building an e-commerce website with Laravel

laravel-ecommerce-tutorial

Prerequisites

I assume you already have a working installation of PHP and Composer running with a configured web server and a MySQL or PostgreSQL database.

If not, you can do like me and use Homestead from Laravel. It’s an all-in-one vagrant image that allows getting started quickly.

1. Creating the Laravel project

Like many other stacks, Laravel offers a simple command to init a project.

  • First, make it available by running: composer global require laravel/installer
  • Then to create the project: laravel new laravel-project
  • And cd laravel-project to go into the project’s folder
  • From now on, all command will run with artisan which allows scaffolding anything in a Laravel project

I personally used Vue.js for the frontend. You can add a barebone Vue project with the following commands:

  • composer require laravel/ui --dev
  • artisan ui vue

I won’t go into details of the frontend setup, but in a nutshell, this command generates basic Vue.js files in the resources folder that can be compiled in “watch” mode with npm run watch.

2. Generating a basic product listing

For context, my base products in this demo are ingredients for custom oatmeal recipes.

You’ll need a controller and a data model that we can scaffold with the make:model artisan command like this:

artisan make:model -a Ingredient

This generates a few files:

  • a migration, in database/migrations/{date}_create_ingredients_table.php
  • a controller, in app/Http/Controllers/IngredientController.php
  • a model, in app/Ingredient.php

In the controller, only keep your index method. It uses Eloquent ORM to all ingredients in the database and returns them as a JSON list. It looks like this:

<?php
namespace App\Http\Controllers;
    
/**
 * Added by the make:model command
 * This allows to query the DB for ingredients
 */
use App\Ingredient;
use Illuminate\Http\Request;
/**
 * We add this `use` to return our json resonse
 */
use Illuminate\Http\Response;
    
class IngredientController extends Controller
{
    public function index()
    {        
        /**
         * `Ingredient::all()` fetch all ingredients
         * `->jsonSerialize()` format them as a json list
         */
        return response(Ingredient::all()->jsonSerialize(), Response::HTTP_OK);
    }
}

All that is missing is linking this controller method to a route. In routes/api.php, add the following line: Route::get('/ingredients', 'IngredientController@index');

This maps GET requests on /api/ingredients to our index method on the IngredientController. If you’d like to go a more traditional (MVC) way, all you’d have to do is to return a view instead of the json:

view('ingredients.list', ['ingredients' => Ingredient::all()]);

Before trying the endpoint, you need to define what’s an ingredient in the migration file. I changed the up method in mine to be:

Schema::create('ingredients', function (Blueprint $table) {
    $table->bigIncrements('id');
    $table->string('name');
    $table->mediumText('description')->nullable();
    $table->string('category');
    $table->decimal('price_per_gram', 8, 3);
});

You can run that migration with artisan migrate. If you tested the endpoint now, you’d get an empty JSON list.

I’ve added sample ingredients in a Seeder, in database/seeds/IngredientsTableSeeder.php (created by running artisan make:seeder IngredientsTableSeeder):

<?php
use Illuminate\Database\Seeder;
    
class IngredientsTableSeeder extends Seeder
{
    public function run()
    {
        DB::table('ingredients')->insert([
            [
                'name' => 'Quick Oats',
                'description' => '100% whole grain oats (quick cooking)',
                'category' => 'oats',
                'price_per_gram' => 0.007,
            ],
/* ... many more! ... */
       ]);
    }
}

Seeders can be used either in development to have fake data to play with or to feed initial informations in the DB. To put that data in the database, run artisan db:seed --class=IngredientsTableSeeder.

You’ll then receive everything at JSON when fetching /api/ingredients.

3. Using advanced models for custom recipes

You could stop there, and you’d have the basis for a headless product API.

But I’ll go on and add a “Recipe” controller with its model. A recipe will be composed of many ingredients, each with a quantity. I can represent this with the following schema (I promise, it’s the only times I’ll use UML):

Class Diagram of Ingredients and Recipes

Class diagram of Ingredients and Recipes

The IngredientRecipe is a relation table. In a many-to-many relationship, there’s a special table to represent every link between the two tables.

In this case, I’ve also added a quantity to represent “how much” of each ingredient there is in a single recipe.

Start by adding the new tables in {date}_create_recipes_and_relation.php generated by artisan make:migration create_recipes_and_relation, then edit it to contain:

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateRecipesAndRelation extends Migration
{
    public function up()
    {
        /**
         * This is the main table for a recipe
         * created dynamically by a customer on the site
         */
        Schema::create('recipes', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->string('name');
            $table->string('size');
            $table->timestamps();
        });
            
        /**
         * This is the "relation" table which serve to link
         * a recipe to a list of ingredients.
         * That one essentialy have reference
         * to the other tables' ids.
         * But also the quantity of each ingredients
         */
        Schema::create('ingredient_recipe', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->timestamps();
            $table->uuid('recipe_id')->index();
            $table->bigInteger('ingredient_id')->unsigned()->index();
            $table->foreign('recipe_id')->references('id')->on('recipes');
            $table->foreign('ingredient_id')->references('id')->on('ingredients');
            $table->decimal('quantity', 2, 3);
        });
    }
    public function down()
    {
        Schema::dropIfExists('ingredient_recipe');
        Schema::dropIfExists('recipes');
    }
}

The RecipeController needs a few methods.

First, a save method to store customized recipes in the database. It has the following API route Route::post('/recipes', 'RecipeController@save'); and its code looks like this:

$recipe = new Recipe;
$recipe->name = $request->input('name');
$recipe->size = $request->input('size');
$recipe->save();
    
$items = array_map(function($item) use($recipe) {
    return [
        'recipe_id' => $recipe->id,
        'ingredient_id' => $item['id'],
        'quantity' => $item['quantity'],
    ];
}, $request->input('items'));
    
IngredientRecipe::insert($items);
    
$ingredients = Recipe::find($recipe->id)
    ->ingredients->map(function($ingredient) {
        $ingredient->quantity = $ingredient->pivot->quantity;
        return $ingredient;
    });
    
$price = $this->calculatePrice($ingredients, $recipe->size);
    
return response()
    ->json([
        'id' => $recipe->id,
        'name' => 'Recipe '.$recipe->name.' ('.$recipe->size.')',
        'url' => '/api/recipe/'.$recipe->id,
        'price' => $price,
    ]);

Save the recipe and only then the entries in the relation table can be inserted because they depend on both the “Recipe” and each “Ingredient”.

I’ll spare you the price calculation method, but the idea is that it can be totally custom and dynamic because our backend is doing the calculations.

The save method is completed by returning a JSON of a product definition (minimally: ID, name, URL, price) that can be passed directly to the cart in the frontend.

The other important method is fetch, registered as a route like this Route::get('/recipe/{id}', 'RecipeController@fetch'); .

You’ll notice it’s the route for the product’s URL I’ve returned from the previous endpoint. This allows Snipcart's crawler to validate the price of the purchased oatmeal mix (the saved recipe). That one is almost exactly like the end of save—fetching each ingredient of a recipe and its quantity:

public function fetch($id) {
    $recipe = Recipe::find($id);
    $ingredients = $recipe->ingredients
        ->map(function($ingredient) {
            $ingredient->quantity = $ingredient->pivot->quantity;
            return $ingredient;
        });
        
    $price = $this->calculatePrice($ingredients, $recipe->size);
    
    return response()
        ->json([
            'id' => $recipe->id,
            'name' => 'Recipe '.$recipe->name.' ('.$recipe->size.')',
            'url' => '/api/recipe/'.$recipe->id,
            'price' => $price,
        ]);
}

You could actually go back to the end of the save method to reduce our code by calling fetch. DRY (Don't Repeat Yourself) FTW!

These controller methods contain some interesting uses of Eloquent. The map method. for instance, allows you to transform queried data cleanly and functionally and accessing ingredients to fetch the ingredients associated with the recipe.

All that’s needed to make the ORM understand the relation in our schemas is, first to make a IngredientRecipe model that extends Pivot instead of Model like this (app/IngredientRecipe.php):

<?php
namespace App;
use Illuminate\Database\Eloquent\Relations\Pivot;
class IngredientRecipe extends Pivot
{
}

And to specify how ingredients are retrieved in the Recipe model:

<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Recipe extends Model
{
    public function ingredients()
    {
        return $this->belongsToMany('App\Ingredient')
            ->using('App\IngredientRecipe')
            ->withPivot(['quantity']);
    }
}

4. Wiring this all in the frontend

To recapitulate, we now have a JSON API that provides:

  • an endpoint to list available ingredients
  • an endpoint to save a Recipe with all of its ingredients’ quantity
  • an endpoint to retrieve the saved recipe’s price (as expected by Snipcart’s crawler)

I’ve used the Axios library in that project to query the API. The frontend essentially works by loading the ingredient list from /api/ingredients and letting the customer select which one should go in the oatmeal.

Once that’s done, they can click a buy button which executes this code:

async buy() {
    const payload = {
        name: this.name,
        size: this.size,
        items: this.ingredients.map(x => ({
            id: x.id,
            quantity: x.quantity,
        })),
    };
    const response = await axios.post('/api/recipes', payload);
    const host = window.location.protocol+'//'+window.location.host;
    Snipcart.api.cart.items.add({
        ...response.data,
        url: host+response.data.url,
    });
}

It simply builds the request body expected by POST /api/recipes and uses the returned product definition to pass it to the cart Javascript API.

Live demo & GitHub repo

C'mon guys, Baby Yoda needs his oatmeal. Have a look at the demo!

laravel-ecommerce-website

See the live demo here.

See the GitHub repo here.

Closing thoughts

The artisan commands make it easy and quick to scaffold any part of the project and the ORM, Eloquent, is capable of modelizing quite complex schemas!

Building an e-commerce site on top of Laravel allows us to take full control over how to manage and handle your data. And being a fully-featured MVC framework with first-class support for JSON, adding other integrations as webhooks would then be a breeze.

My PHP skills were indeed quite rusty, and I’m sure Laravel’s utilities saved me from some pain. They have great documentation, but what truly shines is the community that provides so much content for everybody to learn.

I hope this piece can help as well. Happy PHP coding, folks!


If you've enjoyed this post, please take a second to share it on Twitter. Got comments, questions? Hit the section below!

Suggested posts: