Database Constraints

Unique indexes and foreign keys are the real guarantee under concurrency, because a check written in PHP is a race.

Syntax#[ORM\UniqueConstraint(name: 'uniq_account_email', fields: ['email'])]

A constraint is a rule the database itself enforces: this column is unique, this column cannot be null, this value must exist in another table. It is the only kind of rule that holds across concurrent connections.

Why a check in PHP is a race

Checking that an email is free means a SELECT, then an INSERT. Between the two statements another connection can insert the same address. The window is a millisecond, which is plenty under load, and the result is two accounts with one email. Doing the check in a validator does not close the window; it only moves it. Keep the validator, because it produces the readable message attached to the right form field, but treat the unique index as the actual guarantee.

Declaring them

#[ORM\Column(unique: true)] is the shorthand: Doctrine creates a single-column unique index and names it for you. #[ORM\UniqueConstraint(name: 'uniq_account_email', fields: ['email'])] on the class is the longhand for the same index, and it is what you need when you want to choose the name or cover several columns at once. Foreign keys come from #[ORM\JoinColumn]: nullable: false makes the column required, and onDelete writes a database-level rule such as 'CASCADE' or 'RESTRICT'.

Catching a violation

DBAL turns driver errors into typed exceptions in Doctrine\DBAL\Exception: UniqueConstraintViolationException, ForeignKeyConstraintViolationException, NotNullConstraintViolationException. Catch them around flush() and translate them into a domain error. The failed flush closes the entity manager, so a retry needs a fresh one.

An attribute is only a description. The index exists because a migration created it.

Example

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

declare(strict_types=1);

namespace App\Service;

use App\Entity\Account;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use RuntimeException;

final class AccountRegistrar
{
    public function __construct(
        private readonly ManagerRegistry $registry,
    ) {
    }

    public function register(string $email): Account
    {
        $em = $this->registry->getManager();
        assert($em instanceof EntityManagerInterface);

        $account = new Account($email);

        try {
            $em->persist($account); // schedules the insert
            $em->flush();           // sends it; the unique index decides
        } catch (UniqueConstraintViolationException $e) {
            // The row was already there by the time we wrote. Nothing was saved,
            // and this flush closed the manager, so replace it before carrying on.
            $this->registry->resetManager();

            throw new RuntimeException(sprintf('%s is already registered.', $email), 0, $e);
        }

        return $account;
    }
}

When to use it

  • Guarantee one account per email address even when two sign-up requests arrive at the same moment.
  • Turn a duplicate-key failure into a readable error on the email field instead of a 500 page.
  • Stop a reference table row being deleted while other rows still point at it.

More examples

The constraints on the entity

Three guarantees in one class: an email can appear once, every account belongs to an organisation, and an organisation with accounts cannot be deleted. Because onDelete is enforced by the database, objects already loaded in PHP know nothing about rows it removed or refused; after such a delete, reload rather than trusting what is in memory. Switch the single-field constraint for a multi-field one when uniqueness is per tenant rather than global.

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

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'account')]
// Longhand: the same index as unique: true, but named by you and able to
// cover more than one field, e.g. fields: ['organisation', 'email'].
#[ORM\UniqueConstraint(name: 'uniq_account_email', fields: ['email'])]
class Account
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    // Shorthand for the index declared above:
    // #[ORM\Column(length: 180, unique: true)]
    #[ORM\Column(length: 180)]
    private string $email;

    #[ORM\ManyToOne(inversedBy: 'accounts')]
    // nullable: false is NOT NULL on the column; onDelete is a rule the
    // database applies itself, without loading any entity.
    #[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
    private Organisation $organisation;

    public function __construct(string $email, Organisation $organisation)
    {
        $this->email = $email;
        $this->organisation = $organisation;
    }
}

The migration that actually creates them

Adding the attribute changes nothing in the database until a migration runs, which is the single most common surprise here: the code catches a violation that can never happen, and duplicates pile up unnoticed. Creating a unique index also fails outright if duplicate rows already exist, so a migration on a live table usually needs a cleanup statement before the CREATE UNIQUE INDEX line.

Example · php
<?php
// migrations/Version20260401120000.php

declare(strict_types=1);

namespace DoctrineMigrations;

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

final class Version20260401120000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Unique account email and a foreign key to organisation';
    }

    public function up(Schema $schema): void
    {
        $this->addSql('CREATE UNIQUE INDEX uniq_account_email ON account (email)');
        $this->addSql(
            'ALTER TABLE account ADD CONSTRAINT fk_account_organisation '
            . 'FOREIGN KEY (organisation_id) REFERENCES organisation (id)'
        );
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE account DROP CONSTRAINT fk_account_organisation');
        $this->addSql('DROP INDEX uniq_account_email');
    }
}

Discussion

  • Be the first to comment on this lesson.