One-to-One and Inheritance

A profile table beside its owner, plus single-table against joined-table inheritance and what each one costs you.

Syntax#[ORM\OneToOne(inversedBy: 'profile')] #[ORM\JoinColumn(nullable: false)] private ?Author $author = null;

A one-to-one association links one row to exactly one row. The owning side holds the foreign key, and Doctrine puts a UNIQUE index on that column, which is what keeps it one-to-one rather than many-to-one.

It exists for splitting a table without splitting the concept: a long biography and an avatar path that are read on one page in ten, or an optional cluster of fields that would otherwise be a row of nullable columns. If the extra fields are always loaded with the owner and never optional, they belong in the same table.

Two tables through inheritance

Sometimes the second table is not a companion but a subtype. Doctrine can map a class hierarchy onto tables, and the strategy is declared on the base class.

SINGLE_TABLE

Every class in the hierarchy shares one table. A discriminator column holds a short string saying which class a row is, and a discriminator map turns that string into a class name. Queries need no joins, which makes this the fast option. The cost is the schema: a column that belongs to one subclass must allow NULL, because rows of the other subclasses leave it empty. Only columns on the root class can be NOT NULL.

JOINED

Each class gets a table holding its own columns, keyed by the same id. Subclass columns can be NOT NULL, and the schema says what it means. The cost is that almost every read joins, and you cannot select the base type without joining the children.

Few extra fields, read often: single table. Many fields, or constraints you want the database to enforce: joined.

Example

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

declare(strict_types=1);

namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

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

    #[ORM\Column(type: Types::TEXT)]
    private string $bio = '';

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $avatarPath = null;

    // Owning side: author_profile.author_id, with a UNIQUE index that
    // Doctrine adds for a one-to-one owning side.
    // Shorthand:
    #[ORM\OneToOne(inversedBy: 'profile')]
    #[ORM\JoinColumn(nullable: false)]
    // Longhand of the two lines above, nothing left implied:
    // #[ORM\OneToOne(targetEntity: Author::class, inversedBy: 'profile')]
    // #[ORM\JoinColumn(name: 'author_id', referencedColumnName: 'id', nullable: false, unique: true)]
    private ?Author $author = null;

    public function setAuthor(Author $author): void
    {
        $this->author = $author;
    }

    public function setBio(string $bio): void
    {
        $this->bio = $bio;
    }
}

When to use it

  • Move a long biography and avatar into a profile table so the main query stays narrow.
  • Give an order an optional delivery address record without nullable columns everywhere.
  • Store card and bank payments as two classes over one table.
  • Split notification channels into separate tables when each needs its own required fields.

More examples

The inverse side, and why it is nullable

Mapping the inverse side lets you write $author->getProfile(). The type must be nullable, both because the row may not exist and because a setter that accepts null is the only way to clear an association. Note the cost: the inverse side of a one-to-one cannot be lazy. The author row carries no foreign key, so Doctrine cannot tell an absent profile from an unloaded one, nor what id a proxy would hold, and it runs an extra SELECT for the profile while the author is being hydrated. An inverse one-to-one is a hidden second query per author unless you fetch-join it.

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

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

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

    #[ORM\Column(length: 120)]
    private string $name = '';

    // Inverse side: no column on the author table. Nullable because an
    // author may exist before anyone fills in a profile.
    #[ORM\OneToOne(targetEntity: AuthorProfile::class, mappedBy: 'author')]
    private ?AuthorProfile $profile = null;

    public function getProfile(): ?AuthorProfile
    {
        return $this->profile;
    }

    public function setProfile(AuthorProfile $profile): void
    {
        $this->profile = $profile;
        // The owning side is what the INSERT reads.
        $profile->setAuthor($this);
    }
}

Single-table inheritance

One payment table with a kind column. Selecting every payment is a plain SELECT, and selecting one subtype adds a WHERE on the discriminator. This is the cheap strategy for a hierarchy whose subclasses differ by two or three fields. Each concrete class must appear in the discriminator map, and the subclass columns cannot be NOT NULL, so any rule like "a card payment always has last4" has to be enforced in validation instead of the schema.

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

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\InheritanceType('SINGLE_TABLE')]
#[ORM\DiscriminatorColumn(name: 'kind', type: 'string')]
#[ORM\DiscriminatorMap(['card' => CardPayment::class, 'transfer' => BankTransfer::class])]
abstract class Payment
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    // On the root class, so it can be NOT NULL.
    #[ORM\Column]
    private int $amountCents = 0;
}

// src/Entity/CardPayment.php
#[ORM\Entity]
class CardPayment extends Payment
{
    // Must allow NULL: transfer rows share this table and leave it empty.
    #[ORM\Column(length: 4, nullable: true)]
    private ?string $last4 = null;
}

// src/Entity/BankTransfer.php
#[ORM\Entity]
class BankTransfer extends Payment
{
    #[ORM\Column(length: 34, nullable: true)]
    private ?string $iban = null;
}

Joined inheritance, same hierarchy

The same hierarchy stored as payment, card_payment and bank_transfer, each child row sharing the parent's id. The schema now enforces that a card payment has a last4. The price is joins: reading all payments left-joins both child tables, and the child tables need foreign keys back to the parent with ON DELETE CASCADE. Pick this when the subclasses are substantially different, or when you need the database to reject incomplete rows.

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

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\InheritanceType('JOINED')]
#[ORM\DiscriminatorColumn(name: 'kind', type: 'string')]
#[ORM\DiscriminatorMap(['card' => CardPayment::class, 'transfer' => BankTransfer::class])]
abstract class Payment
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column]
    private int $amountCents = 0;
}

// src/Entity/CardPayment.php
#[ORM\Entity]
class CardPayment extends Payment
{
    // A table of its own, so NOT NULL is available here.
    #[ORM\Column(length: 4)]
    private string $last4;

    public function __construct(string $last4)
    {
        $this->last4 = $last4;
    }
}

Discussion

  • Be the first to comment on this lesson.