- Generating entities
- Migrating the Database
- Setting the default Order status
- Avoiding adding duplicate item
- Removing all items from the Order
- Calculating the Order totals
- Creating the 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
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
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;
}
}
Generating the Order
entity
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
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
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
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;
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
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');
}
}
Now, we can update the local database schema:
$ symfony console doctrine:migrations:migrate
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';
// ...
}
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();
}
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;
}
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;
}
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();
}
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;
}
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;
}
}
Let's go to the next step to manage cart storage.
Top comments (7)
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
Fixed! Thanks for your review. I've been working on a new post to introduce Symfony UX :) Stay tuned!
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 ;)
Hello @pelkas. The price is always calculated at runtime by the
getTotal()
method in theOrderItem
entity. The price is therefore up to date. AnOrderItem
contains a product and a quantity. The item price depends on the quantity of products that the user has chosen.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.
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.
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 :)