Creating Cart Entities | Building a Shopping Cart with Symfony
Tags: php, symfony, tutorial, doctrine
Published: 2020-12-21 12:54:20

- [Generating entities](#entities)
  - [Generating the `OrderItem` entity](#order-item-entity)
  - [Generating the `Order` entity](#order-entity)
  - [Setting Cascade Opeations](#cascade-operations)
- [Migrating the Database](#migrating-database)
- [Setting the default Order status](#default-order-status)
- [Avoiding adding duplicate item](#avoiding-duplicate)
- [Removing all items from the Order](#clear)
- [Calculating the Order totals](#calculate-order-totals)
- [Creating the Factory](#entity-factory)

------

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 <a name="entities"></a>
-------------------

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 <a name="order-item-entity"></a>

Use the Maker bundle to generate the `OrderItem` entity:

```
$ symfony console make:entity OrderItem
```

Add the fields that we need:
- product, relation, related to the Product entity, ManyToOne, no, no
- quantity, integer, no

```php
<?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;
    }
}
```

### Generating the `Order` entity <a name="order-entity"></a>
One more time, use Maker bundle to generate the `Order` entity:

```
$ symfony console make:entity Order
```

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
<?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;
    }

}
```

The `OrderItem` has been updated with the following changes:

```php
<?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;
    }
}
```

### Setting Cascade Operations <a name="cascade-operations"></a>
 
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:

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

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 <a name="migrating-database"></a>
----------------------
Create a migration file via the Maker bundle:

```
$ symfony console make:migration
```

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

```php
<?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');
    }
}
```

Now, we can update the local database schema:

```
$ symfony console doctrine:migrations:migrate
```

Setting the default Order status <a name="default-order-status"></a>
-----------------------------
All new orders must be in the default `cart` state. In the generated `Order` entity, initialize the status using a `STATUS_CART` constant:

```php
<?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';
    
    // ...
}
```

Avoiding adding duplicate `OrderItem` <a name="avoiding-duplicate"></a>
-------------------------------
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.

```php
/**
 * 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();
}
```

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

```php
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;
}
```

Removing all items from the Order <a name="clear"></a>
-----------------------------
Add a `removeItems()` method to the `Order` class to remove all items from the Order:

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

    return $this;
}
```

Calculating the Order totals <a name="calculate-order-totals"></a>
----------------------------------------------
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:

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

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

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

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

    return $total;
}
```

Creating the Factory <a name="entity-factory"></a>
------------------------------
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
<?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;
    }
}
```

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