DEV Community

Cover image for Creating Cart Entities | Building a Shopping Cart with Symfony
Quentin Ferrer
Quentin Ferrer

Posted on • Edited on

Creating Cart Entities | Building a Shopping Cart with Symfony


Throughout the tutorial, we will talk about a cart as an Order. It's an Order that is in progress, not placed yet.

Actually, a cart is an Order in a cart status.

Generating entities

The Order entity contains:

  • a collection of OrderItem entity: it represents products as its physical copies, with chosen quantities,
  • a status: it will be initialized to cart,
  • a creation date: the creation date of the order,
  • a modification date: the date the order was last modified.

Generating the OrderItem entity

Use the Maker bundle to generate the OrderItem entity:

$ symfony console make:entity OrderItem
Enter fullscreen mode Exit fullscreen mode

Add the fields that we need:

  • product, relation, related to the Product entity, ManyToOne, no, no
  • quantity, integer, no
<?php

namespace App\Entity;

use App\Repository\OrderItemRepository;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=OrderItemRepository::class)
 */
class OrderItem
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity=Product::class)
     * @ORM\JoinColumn(nullable=false)
     */
    private $product;

    /**
     * @ORM\Column(type="integer")
     */
    private $quantity;

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

    public function getProduct(): ?Product
    {
        return $this->product;
    }

    public function setProduct(?Product $product): self
    {
        $this->product = $product;

        return $this;
    }

    public function getQuantity(): ?int
    {
        return $this->quantity;
    }

    public function setQuantity(int $quantity): self
    {
        $this->quantity = $quantity;

        return $this;
    }
}
Enter fullscreen mode Exit fullscreen mode

Generating the Order entity

One more time, use Maker bundle to generate the Order entity:

$ symfony console make:entity Order
Enter fullscreen mode Exit fullscreen mode

Add the fields that we need:

  • items, relation, related to the OrderItem entity, OneToMany, orderRef (order is a reserved word in Mysql), no, yes
  • status, string, 255, no
  • createdAt, datetime, no
  • updatedAt, datetime, no
<?php

namespace App\Entity;

use App\Repository\OrderRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=OrderRepository::class)
 * @ORM\Table(name="`order`")
 */
class Order
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\OneToMany(targetEntity=OrderItem::class, mappedBy="orderRef", orphanRemoval=true)
     */
    private $items;

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $status;

    /**
     * @ORM\Column(type="datetime")
     */
    private $createdAt;

    /**
     * @ORM\Column(type="datetime")
     */
    private $updatedAt;

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

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

    /**
     * @return Collection|OrderItem[]
     */
    public function getItems(): Collection
    {
        return $this->items;
    }

    public function addItem(OrderItem $item): self
    {
        if (!$this->items->contains($item)) {
            $this->items[] = $item;
            $item->setOrderRef($this);
        }

        return $this;
    }

    public function removeItem(OrderItem $item): self
    {
        if ($this->items->removeElement($item)) {
            // set the owning side to null (unless already changed)
            if ($item->getOrderRef() === $this) {
                $item->setOrderRef(null);
            }
        }

        return $this;
    }

    public function getStatus(): ?string
    {
        return $this->status;
    }

    public function setStatus(string $status): self
    {
        $this->status = $status;

        return $this;
    }

    public function getCreatedAt(): ?\DateTimeInterface
    {
        return $this->createdAt;
    }

    public function setCreatedAt(\DateTimeInterface $createdAt): self
    {
        $this->createdAt = $createdAt;

        return $this;
    }

    public function getUpdatedAt(): ?\DateTimeInterface
    {
        return $this->updatedAt;
    }

    public function setUpdatedAt(\DateTimeInterface $updatedAt): self
    {
        $this->updatedAt = $updatedAt;

        return $this;
    }

}
Enter fullscreen mode Exit fullscreen mode

The OrderItem has been updated with the following changes:

<?php

namespace App\Entity;

use App\Repository\OrderItemRepository;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass=OrderItemRepository::class)
 */
class OrderItem
{
    // ...
    /**
     * @ORM\ManyToOne(targetEntity=Order::class, inversedBy="items")
     * @ORM\JoinColumn(nullable=false)
     */
    private $orderRef;
    // ...
    public function getOrderRef(): ?Order
    {
        return $this->orderRef;
    }

    public function setOrderRef(?Order $orderRef): self
    {
        $this->orderRef = $orderRef;

        return $this;
    }
}
Enter fullscreen mode Exit fullscreen mode

Setting Cascade Operations

When we add a new OrderItem entity to an Order entity, we should persist it before persisting the Order entity. Also, when we will want to remove an Order entity, we should remove all OrderItem entities before removing the Order entity.

It can be really boring, so we will let Doctrine take care of this internally by using the cascade operations. To do that, add a new cascade option on the items property of the Order entity:

/**
 * @ORM\OneToMany(targetEntity=OrderItem::class, mappedBy="orderRef", cascade={"persist", "remove"}, orphanRemoval=true)
 */
private $items;
Enter fullscreen mode Exit fullscreen mode

Therefore, when we will persist/remove an Order entity, Doctrine will automatically call persist/remove on each of the OrderItem objects associated with the Order entity.

Migrating the Database

Create a migration file via the Maker bundle:

$ symfony console make:migration
Enter fullscreen mode Exit fullscreen mode

A migration file has been stored under the migrations/ directory:

<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
 * Auto-generated Migration: Please modify to your needs!
 */
final class Version20201215150141 extends AbstractMigration
{
    public function getDescription() : string
    {
        return '';
    }

    public function up(Schema $schema) : void
    {
        // this up() migration is auto-generated, please modify it to your needs
        $this->addSql('CREATE TABLE `order` (id INT AUTO_INCREMENT NOT NULL, status VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
        $this->addSql('CREATE TABLE order_item (id INT AUTO_INCREMENT NOT NULL, product_id INT NOT NULL, order_ref_id INT NOT NULL, quantity INT NOT NULL, INDEX IDX_52EA1F094584665A (product_id), INDEX IDX_52EA1F09E238517C (order_ref_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
        $this->addSql('ALTER TABLE order_item ADD CONSTRAINT FK_52EA1F094584665A FOREIGN KEY (product_id) REFERENCES product (id)');
        $this->addSql('ALTER TABLE order_item ADD CONSTRAINT FK_52EA1F09E238517C FOREIGN KEY (order_ref_id) REFERENCES `order` (id)');
    }

    public function down(Schema $schema) : void
    {
        // this down() migration is auto-generated, please modify it to your needs
        $this->addSql('ALTER TABLE order_item DROP FOREIGN KEY FK_52EA1F09E238517C');
        $this->addSql('DROP TABLE `order`');
        $this->addSql('DROP TABLE order_item');
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, we can update the local database schema:

$ symfony console doctrine:migrations:migrate
Enter fullscreen mode Exit fullscreen mode

Setting the default Order status

All new orders must be in the default cart state. In the generated Order entity, initialize the status using a STATUS_CART constant:

<?php

namespace App\Entity;

// ...

/**
 * @ORM\Entity(repositoryClass=OrderRepository::class)
 * @ORM\Table(name="`order`")
 */
class Order
{
    // ...

    /**
     * @ORM\Column(type="string", length=255)
     */
    private $status = self::STATUS_CART;

    /**
     * An order that is in progress, not placed yet.
     *
     * @var string
     */
    const STATUS_CART = 'cart';

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Avoiding adding duplicate OrderItem

Currently, it's possible to add duplicate OrderItem entities (with the same Product) to the Order entity. Let's fix it.

Add a equals() method to the OrderItem class to know if the item corresponds to an item given as an argument.

/**
 * Tests if the given item given corresponds to the same order item.
 *
 * @param OrderItem $item
 *
 * @return bool
 */
public function equals(OrderItem $item): bool
{
    return $this->getProduct()->getId() === $item->getProduct()->getId();
}
Enter fullscreen mode Exit fullscreen mode

Update the addItem() method of the Order entity to sum the quantity if the item already exists:

public function addItem(OrderItem $item): self
{
    foreach ($this->getItems() as $existingItem) {
        // The item already exists, update the quantity
        if ($existingItem->equals($item)) {
            $existingItem->setQuantity(
                $existingItem->getQuantity() + $item->getQuantity()
            );
            return $this;
        }
    }

    $this->items[] = $item;
    $item->setOrderRef($this);

    return $this;
}
Enter fullscreen mode Exit fullscreen mode

Removing all items from the Order

Add a removeItems() method to the Order class to remove all items from the Order:

/**
 * Removes all items from the order.
 *
 * @return $this
 */
public function removeItems(): self
{
    foreach ($this->getItems() as $item) {
        $this->removeItem($item);
    }

    return $this;
}
Enter fullscreen mode Exit fullscreen mode

Calculating the Order totals

We will need to show the cart summary. Note that we ignore adjustments that order could have such as shipping cost, promo code, etc.

First, add a getTotal() method to the OrderItem entity to calculate the item total:

/**
 * Calculates the item total.
 *
 * @return float|int
 */
public function getTotal(): float
{
    return $this->getProduct()->getPrice() * $this->getQuantity();
}
Enter fullscreen mode Exit fullscreen mode

Finally, add a getTotal() method to the Order entity to calculate the order total:

/**
 * Calculates the order total.
 *
 * @return float
 */
public function getTotal(): float
{
    $total = 0;

    foreach ($this->getItems() as $item) {
        $total += $item->getTotal();
    }

    return $total;
}
Enter fullscreen mode Exit fullscreen mode

Creating the Factory

The OrderFactory factory will help us to create Order and OrderItem entities with default data. It also allows you to change the Order entity easily.

<?php

namespace App\Factory;

use App\Entity\Order;
use App\Entity\OrderItem;
use App\Entity\Product;

/**
 * Class OrderFactory
 * @package App\Factory
 */
class OrderFactory
{
    /**
     * Creates an order.
     *
     * @return Order
     */
    public function create(): Order
    {
        $order = new Order();
        $order
            ->setStatus(Order::STATUS_CART)
            ->setCreatedAt(new \DateTime())
            ->setUpdatedAt(new \DateTime());

        return $order;
    }

    /**
     * Creates an item for a product.
     *
     * @param Product $product
     *
     * @return OrderItem
     */
    public function createItem(Product $product): OrderItem
    {
        $item = new OrderItem();
        $item->setProduct($product);
        $item->setQuantity(1);

        return $item;
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's go to the next step to manage cart storage.

Top comments (7)

Collapse
 
furopi profile image
furopi

I just realized... There is a small mistake in the line:

product, relations, related to the Product entity, ManyToOne, no, no

It should mean "relation", not "relations"...

But no offense, keep up the good work! Btw, when is your @next tutorial incoming, Quentin? :D

Collapse
 
qferrer profile image
Quentin Ferrer

Fixed! Thanks for your review. I've been working on a new post to introduce Symfony UX :) Stay tuned!

Collapse
 
pelkas profile image
Łukasz Kuliński

Hi, I think you should store price of single product in OrderItem entity, because when the price of the product changes, the order will show the wrong price ;)

Collapse
 
qferrer profile image
Quentin Ferrer

Hello @pelkas. The price is always calculated at runtime by the getTotal() method in the OrderItem entity. The price is therefore up to date. An OrderItem contains a product and a quantity. The item price depends on the quantity of products that the user has chosen.

Collapse
 
pelkas profile image
Łukasz Kuliński

The price of the product may change between placing an order and payment. If we do not save the price of the order when placing the order, we will calculate it again from the changed price of the product when paying. Additionally, if we display orders in some administration panel, their price will change if we recalculate them each time using the getTotal() method.

Thread Thread
 
qferrer profile image
Quentin Ferrer

The price should always be recalculated until checkout. This tutorial is about the shopping cart and should be kept simple for learning to code with Symfony. As I wrote in the introduction, the checkout and order process are not supported.

Thread Thread
 
pelkas profile image
Łukasz Kuliński

Oh, sure. My brain seeing the Order and OrderItem entities automatically found that this is the place to hold the order price. Sorry to bother you.

Cheers :)