Laravel E-Commerce: Complete Guide & PyroCMS Example

In a rush? Skip to list of tools, tutorial steps or live demo.

"I'm sick & tired of all the PHP hate. You can write sh*tty code in any language."

That's our only PHP defender in the team, ranting.

“Most times, problem's the programmer, not the programming language."

Him again—mostly right.

"Ever tried building something with Laravel? Awesome framework & community."

PHP's made gigantic leaps since my early programming days. And I haven't taken the time to jump back into its modern environment (been busy dabbling 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 an online store.

Then I’ll present my own handcrafted solution. It’s a mix of Laravel-powered PyroCMS & Snipcart resulting in a neat e-commerce app.

It just so happens that I made a huge batch of spaghetti sauce this weekend. I'll never be able to eat all of it, so why not sell the surplus online!

In the tutorial, I'll show you:

  • How to set up a PyroCMS demo site
  • How to add an e-commerce panel to PyroCMS
  • How to display Snipcart products on your project's frontend
  • How to use webhooks & Laravel controllers inside PyroCMS
  • How to use Snipcart's custom validation during checkout

Let's see if my rusty PHP skills can outshine my cooking skills.

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.

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 will 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.

Tools for e-commerce on Laravel?

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

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

Aimeos — A Laravel e-commerce package and online shop solution. Free & open-source.

AvoRed — A modular and customizable PHP shopping cart. Free & open-source.

RedminPortal — A Laravel package as a flexible content management system for developers. It’s strictly a backend administrative tool, suitable for e-commerce.

GetCandy — E-commerce API & admin hub built with Laravel. Open-source, in Alpha release at the time of writing.

Vanilo — This one’s actually an e-commerce framework for Laravel. In development, not yet fully stable.

So, where does that lead us?

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

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

First one? Time-consuming. I could've done a whole e-commerce app in Laravel. But I didn't have time to code everything from scratch—I needed an MVP to test the spaghetti sauce market ASAP!

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

Third one? This is what we're doing here. It'll fast-track our development with useful shortcuts (PyroCMS scaffolding the site; Snipcart abstracting e-comm. logic & backend).

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.

A word on PyroCMS


My previous experience with a PHP CMS wasn't that great. However, I'd heard good stuff about Pyro here and there. And since I needed a Laravel-powered tool for this tuts, picking it was a no-brainer.

PyroCMS is a PHP CMS built specifically for Laravel. It's open source under the MIT license and comes equipped with a bunch of useful features.

In the spirit of transparency, here are other Laravel-based CMS to consider for e-commerce:

Laravel e-commerce tutorial: building a PyroCMS demo shop



For this tutorial, you'll need:

  • A basic understanding of PHP as a programming language
  • A web server set up with PHP and Composer installed
  • A database for this project (I'm using MySQL)
  • A Snipcart account (forever free in Test mode)

Step 1: Installing PyroCMS

Start by scaffolding a PyroCMS project with Composer.

composer create-project pyrocms/pyrocms pyro_demo

Set the root folder of your web server to the project's public folder. Make sure it can write to public/app, bootstrap/cache and storage folders. Those familiar with Laravel you'll recognize the directory structure. If you're new to PyroCMS, prepare to be impressed by how fast it is to have something up and running.

Visit your website and use the built-in installer to configure Pyro with your site info and database settings.


You can now remove the anomaly/installer-module dependency from composer.json, then run composer update.

Step 2: Building an e-commerce panel for products

Although we have a Laravel project to play with, PyroCMS suggests slicing new functionalities into add-ons. For a big project, making a few add-ons for non-related features would be a good idea.

Here, we'll create a products module using the artisan command:

php artisan make:addon snipcart.module.products

That will generate the folder addons/{your site slug}/snipcart/products-module. Most of the path from now on will be relative to this folder.

At the core of Pyro is a concept of stream—essentially a collection of content. Our addon defines a products stream that we scaffold with artisan:

php artisan make:stream products snipcart.module.products

Those commands have generated some new elements. Let's first look at the migrations folder in our addon, where we only need to add a few things to have a complete product management backend.

Go ahead and define the fields of a product inside {date}_snipcart.module.products__create_products_fields.php. They are defined using the built-in field types of Pyro.

Our products will have a SKU, name, price, description, image, and tags:


use Anomaly\Streams\Platform\Database\Migration\Migration;

class SnipcartModuleProductsCreateProductsFields extends Migration

     * The addon fields.
     * @var array
    protected $fields = [
        'name' => 'anomaly.field_type.text',
        'description' => 'anomaly.field_type.text',
        'sku' => [
            'type' => 'anomaly.field_type.slug',
            'config' => [
                'slugify' => 'name',
                'type' => '-'
        'price'  => [
            "type"   => "anomaly.field_type.decimal",
            "config" => [
                "min"       => 0
        'image' => 'anomaly.field_type.file',
        'tags'  => [
            'type'   => 'anomaly.field_type.tags',
            'config' => [
                'free_input'    => true


We must also add them to the $assignments parameter inside {date}_snipcart.module.products__create_products_stream.php so they show up in our admin form.

 * The stream assignments.
 * @var array
 protected $assignments = [
     'name' => [
         'required' => true,
     'sku' => [
         'unique' => true,
         'required' => true,
     'price' => [
         'required' => true

We can type php artisan addon:install products in the console to install the addon. That's enough to be able to add/edit products in the CMS admin!

Should you make changes to the migrations later on, you can refresh them using php artisan migrate:refresh --addon=products.

But there's a lot more we can do to improve the editing experience.

Let's add the product image in the product listing. Edit $columns in src/Product/Table/ProductTableBuilder.php:

protected $columns = [

We can show the stored value of the field by using its name, or format it by using valuation. The latter evaluates the string and allows us to call presenter methods for our fields' values (here: currency to show a dollar sign and preview to show a resized image).

Looking good!


Step 3: Displaying products on the PyroCMS website

We'll add a view using Twig's templating engine, then wire it with a controller and route. Create the product view that uses the default layout with a few Bootstrap classes in resources/views/products/index.twig:

{% extends 'theme::layouts/default' %}
{% block content %}
    {% set products = entries('products').paginate() %}
<div class="row">
    {% for product in products %}
      <div class="col-sm-4">
        <div class="card" style="margin-bottom: 2rem;">
          {{ product.image.make.class("card-img-top").fit(300,300) | raw }}
          <div class="card-body">
            <h4 class="card-title">{{ }}</h4>
            <p class="card-text">{{ product.description }}</p>
            <a href="#" class="btn btn-primary">Buy for {{ product.price.currency() }}</a>
    {% endfor %}
{% endblock %}

Followed by a really simple controller in src/Http/Controller/ProductsController.php:

<?php namespace Snipcart\ProductsModule\Http\Controller;
use Anomaly\Streams\Platform\Http\Controller\PublicController;

class ProductsController extends PublicController
    public function index()
        $this->breadcrumbs->add('Home', '/');
        $this->breadcrumbs->add('Products', '/products');

        return $this->view->make('snipcart.module.products::products/index');

For the route, add the products route into src/ProductsModuleServiceProvider.php to the ones generated for the admin:

protected $routes = [
    // new route to display our products
    'products' => [
        'as' => 'snipcart.module.products::products.index',
        'uses' => 'Snipcart\ProductsModule\Http\Controller\ProductsController@index'
    // routes automatically generated for our admin
    'admin/products'           => 'Snipcart\ProductsModule\Http\Controller\Admin\ProductsController@index',
    'admin/products/create'    => 'Snipcart\ProductsModule\Http\Controller\Admin\ProductsController@create',
    'admin/products/edit/{id}' => 'Snipcart\ProductsModule\Http\Controller\Admin\ProductsController@edit',

There we go! Looks nice if we visit /products:


We also need a link to this page. Override the navigation template by creating the file {project root}/resources/{site slug}/addons/pyrocms/starter-theme/views/partials/navigation.twig:

<!-- Navigation -->
<nav class="navbar navbar-default navbar-fixed-top">
    <div class="container">

        <!-- Brand and toggle get grouped for better mobile display -->
        <div class="navbar-header page-scroll">
            <button type="button" class="navbar-toggle" data-toggle="collapse"
                <span class="sr-only">Toggle navigation</span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
            <a class="navbar-brand" href="{{ url("/") }}">
                {{ config_get("") }}
                {{ image("theme::img/icon.svg").height("50")|raw }}

        <!-- Collect the nav links, forms, and other content for toggling -->
        <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">

            <ul class="nav navbar-nav navbar-right">
                    <a href="{{ url_route('snipcart.module.products::products.index') }}">Products</a>
                    <a href="{{ url_route('anomaly.module.posts::posts.index') }}">Posts</a>
                    {% if auth_check() %}
                        <a href="{{ url_route('anomaly.module.users::logout') }}">Logout</a>
                    {% else %}
                        <a href="{{ url_route('anomaly.module.users::login') }}">Login</a>
                    {% endif %}

            {{ structure()
            .linkAttributesDropdown({'data-toggle': 'dropdown'})
            .listClass('nav navbar-nav navbar-right')
            .render()|raw }}
        <!-- /.navbar-collapse -->

    <!-- /.container-fluid -->

Here we copied the original template and simply added:

    <a href="{{ url_route('snipcart.module.products::products.index') }}">Products</a>

Step 4: Integrating Snipcart with PyroCMS

Just a few more steps to transform those buy buttons into properly defined Snipcart products.

First we need somewhere to store our API Key. Add a setting entry for the module by creating the file resources/config/setting.php:

return [
    'api_key'      => [
        'type'     => 'anomaly.field_type.text',
        'required' => true,

This setting is accessible in the admin under Settings Module > Modules > Products Module.

We're ready to override the scripts template in {project root}/resources/{site slug}/addons/pyrocms/starter-theme/views/partials/scripts.twig to add Snipcart's required files:

<script src="" id="snipcart" data-api-key="{{ setting_value('snipcart.module.products::api_key') }}"></script>

<link href="" type="text/css" rel="stylesheet" />

To inject Snipcart's item attributes to buy buttons, modify ProductPresenter to add a buyButton method inside src/Product/ProductPresenter.php:

<?php namespace Snipcart\ProductsModule\Product;

use Anomaly\Streams\Platform\Entry\EntryPresenter;
use Anomaly\FilesModule\File\Command\GetType;
use Collective\Html\HtmlBuilder;

class ProductPresenter extends EntryPresenter
     * The HTML builder.
     * @var HtmlBuilder
    protected $html;

     * Create a new ProductPresenter instance.
     * @param HtmlBuilder $html
     * @param             $object
    public function __construct(HtmlBuilder $html, $object)
        $this->html = $html;


     * Return the full name.
     * @return string
    public function buyButton($text, array $attributes = [])
        $class     = trim(array_get($attributes, 'class', '') . ' snipcart-add-item');
        $imageType = $this->dispatch(new GetType($this->object->image)) ?: 'document';

        $imageUrl = $this->object->image

        return $this->html->link(
            array_merge($attributes, [
                'class'                 => $class,
                'data-item-id'          => $this->object->sku,
                'data-item-name'        => $this->object->name,
                'data-item-description' => $this->object->description,
                'data-item-price'       => $this->object->price,
                'data-item-url'         => '/products',
                'data-item-image'       => $imageUrl,
                'data-item-metadata'    => json_encode(array_combine(
                    array_map(function ($tag) { return true; }, $this->object->tags)

There's a lot going on in this file (especially if you're new to Pyro):

  1. Receiving the HtmlBuilder through dependency injection in the Presenter's constructor
  2. Using our Product entry from $this->object
  3. Building a cropped image's URL using utilities from Pyro's FilesModule
  4. Transferring all product data to their respective Snipcart attributes
  5. Finally building the button (<a ...>) element

We can now update our product view in resources/views/products/index.twig to call the presenter:

{{ product.buyButton('Buy for '~product.price.currency(), {'class': 'btn btn-primary'})|raw }}

Aaaand my delicious spaghetti sauce is now buyable!

Step 5: Adding custom validation during checkout

Selling food online—or anything else for that matter—often comes with various challenges. Frozen stuff can't be shipped over long distances, for instance. Extra spicy stuff must come with a warning. And so on.

So first let's add a few dummy checks by using our new custom validation feature. We'll prevent customers from ordering products too spicy for them:

document.addEventListener('snipcart.ready', function() {
    Snipcart.subscribe('page.validating', function(ev, data) {
        if(ev.type == 'cart-content') {
            for(var i=0; i<data.length; i++) {
                if(data[i].metadata && data[i].metadata.spicy) {
                    ev.addError(data[i].uniqueId, "Oops. I'm not sure you can handle that spicy taste.");

That code filters for validation events on the cart-content page and adds an error to products with a spicy tag. It goes into resources/js/validation.js and we add another line to scripts.twig: {{ asset_add("scripts.js", "snipcart.module.products::js/validation.js") }}.

On to Frozen stuff checks and Laravel land now

The ProductsController we made earlier actually extends a Laravel controller with a few goodies from PyroCMS added. When you look at the sources of BaseController from PyroCMS, you'll see all the stuff from the Illuminate namespace wired to the controller. Now let's create a simple shipping webhook by extending the base Laravel controller directly and adding only the stuff we need.

Create this controller in src/Http/Controller/ShippingController.php:

<?php namespace Snipcart\ProductsModule\Http\Controller;

use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;

class ShippingController extends Controller
    protected $request;
    protected $response;

    public function __construct()
        $this->request     = app('Illuminate\Http\Request');
        $this->response    = app('Illuminate\Contracts\Routing\ResponseFactory');

    public function webhook()
        $country = $this->request->input('');
        $province = $this->request->input('content.shippingAddress.province');

        $items = $this->request->input('content.items');

        $hasFrozenFood = false;
        foreach ($items as $item) {
            if($item['metadata'] != null && $item['metadata']['frozen']) {
                $hasFrozenFood = true;

        if($hasFrozenFood && ($country != 'CA' || $province != 'QC')) {
            return $this->response->json([
              "errors" => [[
                "key"=> "invalid_shipping_address",
                "message"=> "We can only ship frozen food to the Province of Quebec."

        return $this->response->json([
            "rates"=> [[
                "cost"=> 10,
                "description"=> "10$ shipping"

So ShippingController only uses classes from Illuminate. It's a plain old Laravel controller that checks Snipcart's input data and determines if we must return an error message or a shipping rate. Like for the ProductsController, we add a route inside our module's service provider: 'webhooks/shipping' => 'Snipcart\ProductsModule\Http\Controller\ShippingController@webhook',.

Live demo & GitHub repo

And we're done!

We covered lots of stuff here. But it should give you a broad overview of how to integrate Snipcart with PyroCMS + use more regular features of Laravel.


See the open source repo on GitHub.

See live demo here!

Closing thoughts

The project and the admin interface were running in less than 3 hours, without any prior experience with Pyro on my end. The whole tuts ended up taking two days. Mostly because I really wanted to integrate Snipcart by injecting our scripts tags from the module instead of having to edit theme templates.

I had a blast building the admin for this tuts. I don't think that process can be made easier, so kudos to Pyro!

Not knowing Laravel much, it was a bit hard for me to differentiate where PyroCMS ends and Laravel begins. You have to get familiar with PyroCMS' source code to picture ways of how to accomplish things that aren't easy to understand from only looking at the documentation.

If I had more time to spend on this demo, I'd explore the many content management features of the admin. Lots can be accomplished directly from the admin without much code. A cool test would be to assign products to specifically tagged blog posts!

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

Suggested posts: