Entities That Stay Honest

A constructor that cannot build an invalid object, methods named after events rather than setters, and no persistence inside the entity.

An entity with a public setter for every property is a typed array. Any code anywhere can put it into a state your business rules forbid, and the rule that should have stopped it ends up copied into three controllers. The alternative costs little: make the constructor the only way in, and name each method after the thing that happens.

One way to build it

The constructor takes everything the object needs to be valid and nothing it does not. A Subscription without a plan or a start date should be impossible to construct, not merely unusual. Where several ways of creating one exist, static named constructors (Subscription::trial(...)) say which is which, while the real constructor stays private or narrow.

Methods, not setters

cancel($at) can refuse to cancel twice and can refuse a date before the start. setCancelledAt() cannot refuse anything, and it tells a reader nothing about what cancelling means. A getter is fine; it exposes a value without inviting a half-made change. Not every property needs one of those either.

Keep persistence out

The entity should not know that a database exists. No entity manager inside it, no queries, no flushing. A service does that: load the entity, call the method that expresses the change, flush. The entity stays a plain object you can construct and assert on in a test with no database at all.

What Doctrine does not do

When Doctrine hydrates a row it creates the object without calling your constructor and writes the properties directly. So a constructor guard protects new objects only. A rule that must hold for stored data belongs in the database as a constraint as well.

Example

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

declare(strict_types=1);

namespace App\Entity;

use App\Subscription\Plan;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

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

    #[ORM\Column(enumType: Plan::class)]
    private Plan $plan;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
    private \DateTimeImmutable $startsAt;

    #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
    private ?\DateTimeImmutable $cancelledAt = null;

    // Everything required, nothing optional. There is no valid Subscription
    // without a plan and a start date, so there is no way to make one.
    public function __construct(Plan $plan, \DateTimeImmutable $startsAt)
    {
        $this->plan = $plan;
        $this->startsAt = $startsAt;
    }

    // Named after the event, and able to refuse. A setCancelledAt() could not.
    public function cancel(\DateTimeImmutable $at): void
    {
        if (null !== $this->cancelledAt) {
            throw new \DomainException('Subscription is already cancelled.');
        }

        if ($at < $this->startsAt) {
            throw new \DomainException('Cancellation cannot precede the start date.');
        }

        $this->cancelledAt = $at;
    }

    // A question the entity can answer about itself.
    public function isActiveOn(\DateTimeImmutable $moment): bool
    {
        return null === $this->cancelledAt && $moment >= $this->startsAt;
    }

    public function getPlan(): Plan
    {
        return $this->plan;
    }
}

When to use it

  • Stop a subscription being cancelled twice, in the one place that knows what cancelling means.
  • Make an entity's rules testable without booting the kernel or touching a database.
  • Replace a controller full of setters with one method named after the change it makes.

More examples

The rules hold with no database in sight

No entity manager, no connection, no fixtures: the entity is just an object, so its rules can be asserted directly. Every date here is a fixed value, which is what makes the result repeatable. If this file needed a database to run, the entity would be carrying persistence it should not have.

Example · php
<?php
// Exercising the entity as a plain object, as a unit test would.

declare(strict_types=1);

use App\Entity\Subscription;
use App\Subscription\Plan;

$start = new \DateTimeImmutable('2026-01-01 00:00:00');
$subscription = new Subscription(Plan::Standard, $start);

var_dump($subscription->isActiveOn(new \DateTimeImmutable('2026-02-01 00:00:00')));
// Output:
// bool(true)

$subscription->cancel(new \DateTimeImmutable('2026-02-15 00:00:00'));

var_dump($subscription->isActiveOn(new \DateTimeImmutable('2026-03-01 00:00:00')));
// Output:
// bool(false)

try {
    $subscription->cancel(new \DateTimeImmutable('2026-03-02 00:00:00'));
} catch (DomainException $e) {
    echo $e->getMessage();
}
// Output:
// Subscription is already cancelled.

The service does the persistence

This is the division of labour: the service knows about loading and flushing, the entity knows what cancelling means. Note that persist() is only for objects Doctrine has not seen; for a loaded entity, flush() alone writes the change. A brand-new object needs both, persist() then flush(), or nothing is saved.

Example · php
<?php
// src/Subscription/CancelSubscription.php

declare(strict_types=1);

namespace App\Subscription;

use App\Entity\Subscription;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface;

final class CancelSubscription
{
    // Injected on the interface, never pulled out of the container.
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly ClockInterface $clock,
    ) {
    }

    public function __invoke(int $subscriptionId): void
    {
        $subscription = $this->entityManager->find(Subscription::class, $subscriptionId);

        if (null === $subscription) {
            throw new \RuntimeException(sprintf('Subscription %d not found.', $subscriptionId));
        }

        // The decision lives in the entity; this class only orchestrates.
        $subscription->cancel($this->clock->now());

        // The entity came from find(), so it is already managed: no persist()
        // is needed. flush() is what writes the UPDATE.
        $this->entityManager->flush();
    }
}

Promoted and longhand constructors

A data object that carries a request from the outside world into a service has no behaviour and no identity, so promotion plus readonly says everything in four lines. Keep this shape for input objects and keep real entities in the longhand form, where each mapped property can carry its own attributes and the file reads as a description of a table.

Example · php
<?php
// src/Subscription/CancellationRequest.php

declare(strict_types=1);

namespace App\Subscription;

// Constructor property promotion: the parameter declares the property,
// assigns it, and types it, all at once.
final class CancellationRequest
{
    public function __construct(
        public readonly int $subscriptionId,
        public readonly string $reason,
    ) {
    }
}

// The same class written out. These two are the same thing; recognising both
// matters because entity mapping attributes are usually written longhand.
final class CancellationRequestLonghand
{
    public readonly int $subscriptionId;

    public readonly string $reason;

    public function __construct(int $subscriptionId, string $reason)
    {
        $this->subscriptionId = $subscriptionId;
        $this->reason = $reason;
    }
}

Discussion

  • Be the first to comment on this lesson.