Modelling Without Regret

When a relation should be a plain id, why most associations need only one side, and where to draw an aggregate boundary.

Syntax#[ORM\Column] private int $customerId;

Every association you map is a promise: that the two objects are loaded, changed and saved near each other. Mapping all of them because the database has the foreign keys is how a small application ends up loading half its schema to render a page.

Sometimes an id is the right answer

Store a plain id column instead of an association when the other object is not yours to manage. An order references a customer, but it does not own the customer, never edits them, and only needs the identifier to look them up when something asks. A plain int column cannot be lazily loaded by accident, cannot be cascaded into, and cannot drag a second table into a query. Also store a copy rather than a link for anything that must not change under you: the price on an order line is the price at the time of sale, not the product's price today.

Map one side unless you use both

An inverse collection is a convenience, not a requirement. If no code asks a tag for its articles, leave Tag::$articles out: it is one less collection to keep in step and one less path for a lazy load. You can always ask the repository.

Aggregate boundaries

An aggregate is the set of entities you load, change and save as a unit, entered through one object, its root. An invoice and its lines are one aggregate: a line has no meaning alone, so cascade and orphan removal belong there. The customer is a different aggregate, reached by id. Draw that line once and the cascade and fetching questions mostly answer themselves.

Example

Example · php
<?php
// src/Entity/Order.php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
// "order" is a reserved word in SQL, so name the table explicitly.
#[ORM\Table(name: 'orders')]
class Order
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    // A plain id, not an association. This order never navigates to the
    // customer and never changes one, so it does not hold the object.
    #[ORM\Column]
    private int $customerId;

    // The price at the time of sale, copied rather than linked, in minor
    // units. A relation to Product would show today's price instead.
    #[ORM\Column]
    private int $unitPriceCents;

    #[ORM\Column]
    private int $quantity;

    public function __construct(int $customerId, int $unitPriceCents, int $quantity)
    {
        $this->customerId = $customerId;
        $this->unitPriceCents = $unitPriceCents;
        $this->quantity = $quantity;
    }

    public function totalCents(): int
    {
        return $this->unitPriceCents * $this->quantity;
    }
}

// (new Order(42, 1250, 3))->totalCents();
// Output: 3750

When to use it

  • Reference a customer from an order without letting the order load or edit customers.
  • Keep the price charged on an order line fixed when the product's price changes.
  • Drop an inverse collection nothing reads and replace it with a repository method.
  • Decide where cascade and orphanRemoval belong by naming the aggregate root first.

More examples

One-sided association plus a repository method

A one-to-many mapping gives you every child, in whatever order the mapping says, loaded whole. A query gives you the ones you asked for, with a limit that works because nothing is fetch-joined. Passing the Article as a bound parameter is enough: Doctrine binds its identifier. Prefer this whenever the association exists mainly to be filtered, sorted or paginated.

Example · php
<?php
// src/Repository/CommentRepository.php

declare(strict_types=1);

namespace App\Repository;

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

/**
 * @extends ServiceEntityRepository<Comment>
 */
class CommentRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Comment::class);
    }

    /**
     * Article has no $comments property at all. This replaces it, and the
     * caller decides the order and the limit rather than the mapping.
     *
     * @return list<Comment>
     */
    public function findApprovedFor(Article $article, int $limit): array
    {
        return $this->createQueryBuilder('c')
            ->andWhere('c.article = :article')
            ->andWhere('c.approved = true')
            ->setParameter('article', $article)
            ->orderBy('c.createdAt', 'DESC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();
    }
}

An aggregate root that owns its lines

One entry point, one unit of work: callers never persist an InvoiceLine themselves, and the total is computed from objects already in memory rather than by a query. Because the lines are always loaded with the invoice for editing, this is the association to fetch-join. The customerId keeps the boundary visible in the code: there is no property to accidentally traverse.

Example · php
<?php
// src/Entity/Invoice.php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Invoice
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    // The customer is a different aggregate: referenced by id, never
    // loaded or changed through the invoice.
    #[ORM\Column]
    private int $customerId;

    // The lines are inside this aggregate, so their lifetime is the
    // invoice's: cascade persist and orphanRemoval belong here.
    /** @var Collection<int, InvoiceLine> */
    #[ORM\OneToMany(
        targetEntity: InvoiceLine::class,
        mappedBy: 'invoice',
        cascade: ['persist'],
        orphanRemoval: true,
    )]
    private Collection $lines;

    public function __construct(int $customerId)
    {
        $this->customerId = $customerId;
        $this->lines = new ArrayCollection();
    }

    public function addLine(InvoiceLine $line): void
    {
        $this->lines->add($line);
        $line->setInvoice($this);
    }

    public function totalCents(): int
    {
        return array_sum(
            array_map(
                static fn (InvoiceLine $line): int => $line->totalCents(),
                $this->lines->toArray(),
            ),
        );
    }
}

// $invoice = new Invoice(42);
// $invoice->addLine(new InvoiceLine('Design', 50_000, 2));
// $invoice->addLine(new InvoiceLine('Hosting', 12_500, 1));
// $invoice->totalCents();
// Output: 112500

Resolving ids in one query

Referencing another aggregate by id does not mean fetching it row by row. Collect the ids, fetch them in one findBy() with an array value, index the result, then read from the map. This is the same two-query shape a fetch join gives you inside an aggregate, done explicitly across a boundary, and it keeps the N+1 problem out of code that has no association to join.

Example · php
<?php
// src/Service/OrderListBuilder.php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Order;
use App\Repository\CustomerRepository;

final class OrderListBuilder
{
    public function __construct(
        private readonly CustomerRepository $customers,
    ) {
    }

    /**
     * @param list<Order> $orders
     *
     * @return list<array{total: int, customer: string}>
     */
    public function build(array $orders): array
    {
        $ids = array_values(array_unique(
            array_map(static fn (Order $order): int => $order->getCustomerId(), $orders),
        ));

        // One query for every customer named, instead of one per order.
        $byId = [];

        foreach ($this->customers->findBy(['id' => $ids]) as $customer) {
            $byId[$customer->getId()] = $customer->getName();
        }

        return array_map(
            static fn (Order $order): array => [
                'total' => $order->totalCents(),
                'customer' => $byId[$order->getCustomerId()] ?? 'unknown',
            ],
            $orders,
        );
    }
}

Discussion

  • Be the first to comment on this lesson.