Bundle of products added to cart in one click

This post is available in french

In this article we will create an action that will add a list of products to the cart. We will also create the management of products by pack in the dashboard.

We will start by creating an entity that we can manage in the dashboard and which will allow us to select the products we want to put in the pack.

src/Entity/ProductBundle.php

<?php

namespace App\Entity;

use App\Entity\Product\Product;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="App\Repository\ProductBundleRepository")
 */
class ProductBundle implements ProductBundleInterface
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @var string|null
     *
     * @ORM\Column(type="string")
     */
    private $name;

    /**
     * @ORM\ManyToMany(targetEntity="App\Entity\Product\Product")
     */
    private $products;

    public function __construct()
    {
        $this->products = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getName(): ?string
    {
        return $this->name;
    }

    /**
     * @param string $name
     */
    public function setName(?string $name): void
    {
        $this->name = $name;
    }

    public function countProducts(): int
    {
        return $this->products->count();
    }

    /**
     * @return Collection|Product[]
     */
    public function getProducts(): Collection
    {
        return $this->products;
    }

    public function addProduct(Product $product): self
    {
        if (!$this->products->contains($product)) {
            $this->products[] = $product;
        }

        return $this;
    }

    public function removeProduct(Product $product): self
    {
        if ($this->products->contains($product)) {
            $this->products->removeElement($product);
        }

        return $this;
    }
}

src/Repository/ProductBundleRepository.php

<?php

namespace App\Repository;

use App\Entity\ProductBundle;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @method ProductBundle|null find($id, $lockMode = null, $lockVersion = null)
 * @method ProductBundle|null findOneBy(array $criteria, array $orderBy = null)
 * @method ProductBundle[]    findAll()
 * @method ProductBundle[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
 */
class ProductBundleRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, ProductBundle::class);
    }

    // /**
    //  * @return ProductBundle[] Returns an array of ProductBundle objects
    //  */
    /*
    public function findByExampleField($value)
    {
        return $this->createQueryBuilder('b')
            ->andWhere('b.exampleField = :val')
            ->setParameter('val', $value)
            ->orderBy('b.id', 'ASC')
            ->setMaxResults(10)
            ->getQuery()
            ->getResult()
        ;
    }
    */

    /*
    public function findOneBySomeField($value): ?ProductBundle
    {
        return $this->createQueryBuilder('b')
            ->andWhere('b.exampleField = :val')
            ->setParameter('val', $value)
            ->getQuery()
            ->getOneOrNullResult()
        ;
    }
    */
}

src/Entity/ProductBundleInterface.php

<?php

namespace App\Entity;

use App\Entity\Product\Product;
use Doctrine\Common\Collections\Collection;
use Sylius\Component\Resource\Model\ResourceInterface;

interface ProductBundleInterface extends ResourceInterface
{
    /**
     * @return string
     */
    public function getName(): ?string;

    /**
     * @param string $name
     */
    public function setName(?string $name): void;

    public function countProducts(): int;

    /**
     * @return Collection|Product[]
     */
    public function getProducts(): Collection;

    public function addProduct(Product $product): \App\Entity\ProductBundle;

    public function removeProduct(Product $product): \App\Entity\ProductBundle;
}

We declare this resource in config/packages/resources.yaml

sylius_resource:
    resources:
        app.product_bundle:
            classes:
                model: App\Entity\ProductBundle

Its grid in config/packages/grids.yaml

sylius_grid:
    grids:
        app_admin_product_bundle:
            driver:
                name: doctrine/orm
                options:
                    class: App\Entity\ProductBundle
            fields:
                name:
                    type: string
            actions:
                main:
                    create:
                        type: create
                item:
                    update:
                        type: update
                    delete:
                        type: delete

As well as its route in config/routes.yaml

app_product_bundle:
    resource: |
        alias: app.product_bundle
        section: admin
        templates: SyliusAdminBundle:Crud
        grid: app_admin_product_bundle
        redirect: index
    type: sylius.resource
    prefix: admin

We update the database

php bin/console doctrine:migration:diff

php bin/console doctrine:migration:migrate

Now when we go to the dashboard at the address admin/product-bundles/ and we click on Create we have this result:

Image Title

We would like to have the titles of the Products, so we will create our own form and we will choose the products with a checkbox list

src/Form/Type/BundleType.php

<?php

namespace App\Form\Type;

use App\Entity\Product\Product;
use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;

class BundleType extends AbstractResourceType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, ['label' => 'sylius.ui.name'])
            ->add('products', EntityType::class, array(
                'class' => Product::class,
                'choice_label' => 'name',
                'label'        => 'Product',
                'expanded'     => true,
                'multiple'     => true,
            ))
        ;
    }
}

We declare our new form as a service in config/services.yaml

services:
    app.form.type.bundle:
        class: App\Form\Type\BundleType
        tags:
            - { name: form.type}
        arguments: ['App\Entity\ProductBundle']

And we add in our resource config/packages/resources.yaml the way to our new form

sylius_resource:
    resources:
        app.product_bundle:
            classes:
                model: App\Entity\ProductBundle
                form: App\Form\Type\BundleType # we add this line

If we refresh the page admin/product-bundles/new the result is as follows :

Image Title

We choose our products, then we put a title to our pack and we click on Create, here I will create the pack "Special"

Image Title

Now we will display this pack in the homepage,

src/Controller/ProductBundleController.php

<?php

namespace App\Controller;

use App\Repository\ProductBundleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class ProductBundleController extends AbstractController
{

    public function index($bundleName, ProductBundleRepository $bundles)
    {
        $bundle = $bundles->findOneBy(array('name' => $bundleName));

        return $this->render('ProductBundle/_products.html.twig',
            array('bundle' => $bundle)
        );
    }
}

We create the view which is returned by the controller templates/ProductBundle/_products.html.twig

<div class="ui very padded center aligned inverted segment">
    <h2 class="ui center aligned huge header">Bundle product</h2>

    <div class="ui three odd doubling cards segment centered">
        {% for product in bundle.products %}
            {% include '@SyliusShop/Product/_box.html.twig' %}
        {% endfor %}
    </div>
</div>

We add its route in routes.yaml

config/routes.yaml

app_page_product_bundle:
    path: /bundle
    methods: [GET]
    controller: App\Controller\ProductBundleController::index

The return of our action index of our controller will be rendered in a template, so we create this one templates/ProductBundle/_index.html.twig being careful to put the name of our pack

{{ render(controller('App\\Controller\\ProductBundleController::index', {'bundleName' : 'Special'})) }}

We add our template in the event rendering of sylius config/packages/sylius_ui.yaml

sylius_ui:
    events:
        sylius.shop.homepage:
            blocks:
                bundle:
                    template: "ProductBundle/_index.html.twig"
                    priority: 70

priority indicates the order of rendering of the called templates. They can be found by inspecting the HTML rendering:

Image Title

We now have this result on the home page

Image Title

We still have to create an action that will recover the products in the loop. For the example we will take their first variant (that is to say, for a T-shirt for example, the first size that will come out therefore size S)

src/Controller/ProductBundleAction.php

<?php

namespace App\Controller;

use App\Repository\ProductBundleRepository;
use Doctrine\Common\Persistence\ObjectManager;
use Sylius\Bundle\ResourceBundle\Controller\ResourceController;
use Sylius\Component\Core\Context\ShopperContextInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\OrderItemInterface;
use Sylius\Component\Core\Model\ProductVariantInterface;
use Sylius\Component\Core\Repository\ProductVariantRepositoryInterface;
use Sylius\Component\Order\Context\CartContextInterface;
use Sylius\Component\Order\Modifier\OrderItemQuantityModifierInterface;
use Sylius\Component\Resource\Factory\FactoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

class ProductBundleAction extends ResourceController
{
    /** @var ProductVariantRepositoryInterface */
    private $productVariantRepository;

    /** @var FactoryInterface */
    private $orderItemFactory;

    /** @var OrderItemQuantityModifierInterface */
    private $orderItemQuantityModifier;

    /** @var ShopperContextInterface */
    private $shopperContext;

    /** @var ObjectManager */
    private $orderManager;

    /** @var UrlGeneratorInterface */
    private $router;

    /**
     * QuickCheckoutAction constructor.
     * @param ProductVariantRepositoryInterface $productVariantRepository
     * @param FactoryInterface $orderFactory
     * @param FactoryInterface $orderItemFactory
     * @param OrderItemQuantityModifierInterface $orderItemQuantityModifier
     * @param ShopperContextInterface $shopperContext
     * @param ObjectManager $orderManager
     * @param UrlGeneratorInterface $router
     */
    public function __construct(ProductVariantRepositoryInterface $productVariantRepository,
                                FactoryInterface $orderItemFactory,
                                OrderItemQuantityModifierInterface $orderItemQuantityModifier,
                                ShopperContextInterface $shopperContext,
                                ObjectManager $orderManager,
                                UrlGeneratorInterface $router)
    {
        $this->productVariantRepository = $productVariantRepository;
        $this->orderItemFactory = $orderItemFactory;
        $this->orderItemQuantityModifier = $orderItemQuantityModifier;
        $this->shopperContext = $shopperContext;
        $this->orderManager = $orderManager;
        $this->router = $router;
    }

    public function __invoke(Request $request, ProductBundleRepository $productBundleRepository): Response
    {
        $bundleName = $request->attributes->get('name');
        $bundle = $productBundleRepository->findOneBy(array('name' => $bundleName));

        /** @var OrderInterface $order */
        $order = $this->getCurrentCart();

        $channel = $this->shopperContext->getChannel();
        foreach ($bundle->getProducts() as $product)
        {
            $productId = (int) $product->getVariants()->first()->getId();
            /** @var ProductVariantInterface $variant */
            $variant = $this->productVariantRepository->find($productId);

            /** @var OrderItemInterface $orderItem */
            $orderItem = $this->orderItemFactory->createNew();
            $variant->setShippingRequired(false);
            $orderItem->setVariant($variant);
            $price = $variant->getChannelPricingForChannel($channel)->getPrice();
            $orderItem->setUnitPrice($price);

            $this->orderItemQuantityModifier->modify($orderItem, 1);

            $order->addItem($orderItem);
        }

        $order->setChannel($channel);
        $order->setLocaleCode($this->shopperContext->getLocaleCode());
        $order->setCurrencyCode($this->shopperContext->getCurrencyCode());

        $this->orderManager->persist($order);
        $this->orderManager->flush();

        $cartPage = $this->router->generate('sylius_shop_cart_summary');
        return new RedirectResponse($cartPage);
    }

    protected function getCurrentCart(): \Sylius\Component\Order\Model\OrderInterface
    {
        return $this->getContext()->getCart();
    }

    protected function getContext(): CartContextInterface
    {
        return $this->get('sylius.context.cart');
    }
}

It is declared as a service config/services.yaml

services:
    App\Controller\ProductBundleAction:
        arguments:
            - '@sylius.repository.product_variant'
            - '@sylius.factory.order_item'
            - '@sylius.order_item_quantity_modifier'
            - '@sylius.context.shopper'
            - '@sylius.manager.order'
            - '@router'
        public: true

We define its route config/routes.yaml

app_add_bundle:
    path: /bundle/{name}
    methods: [POST]
    defaults:
        _controller: App\Controller\ProductBundleAction

We add our form to our template templates/ProductBundle/_products.html.twig

<div class="ui very padded center aligned inverted segment">
    <h2 class="ui center aligned huge header">Bundle product</h2>

    <div class="ui three odd doubling cards segment centered">
        {% for product in bundle.products %}
            {% include '@SyliusShop/Product/_box.html.twig' %}
        {% endfor %}
    </div>

    <form action="{{ path('app_add_bundle', {name: 'Special' }) }}" method="post">
        <button type="submit" class="ui huge yellow icon labeled button"><i class="bolt icon"></i> Buy this pack</button>
    </form>
</div>

We now have the button "Buy this pack"

Image Title

Now when we click on it we are redirected to the basket with the added products

Image Title