Single Table Inheritance (STI) stores entities from different classes with a common ancestor in a single database table. This creates one table for the entire class hierarchy, with a discriminator column to differentiate between entity types.
The ORM provides the ability to store multiple model variations inside one table. To achieve this, extend your parent entity and declare relations/columns specific to each child.
In STI, the discriminator column is a special database column that identifies which type of entity each row represents. Think of it as a "type tag" that tells Cycle ORM which PHP class to instantiate when loading data.
For example, in a persons table with the discriminator column type:
type = 'employee' are loaded as Employee objectstype = 'customer' are loaded as Customer objectstype = 'admin' are loaded as Admin objectsThis allows one table to store multiple entity types while Cycle ORM automatically handles the conversion.
When you save an entity, Cycle ORM:
When you query entities, Cycle ORM:
Use Single Table Inheritance when your entities are more similar than different:
✅ Good Use Cases:
❌ Avoid STI When:
#[SingleTable] Attribute
Applied to child entity classes to indicate they participate in Single Table Inheritance.
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
value |
string|int|float|\Stringable|\BackedEnum|null |
No | Entity role | The discriminator value for this entity type. Defaults to the entity's role name. |
#[DiscriminatorColumn] Attribute
Applied to the root entity class to specify the discriminator column name.
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
name |
non-empty-string |
Yes | - | The database column name that stores the entity type discriminator. |
Note
Make sure the parent class properties are not private so child entities can inherit and access them.
The discriminator column attribute is mandatory on the root entity. By default, the discriminator value for each entity is its role name:
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Inheritance\SingleTable;
use Cycle\Annotated\Annotation\Inheritance\DiscriminatorColumn;
#[Entity]
#[DiscriminatorColumn(name: 'type')] // Required on root entity
class Person
{
#[Column(type: 'primary')]
protected int $id;
#[Column(type: 'string')]
protected string $name;
#[Column(type: 'string')]
protected string $type; // Discriminator column
}
#[Entity]
#[SingleTable] // Discriminator value: "employee" (entity role)
class Employee extends Person
{
#[Column(type: 'int')]
protected int $salary;
}
#[Entity]
#[SingleTable] // Discriminator value: "customer" (entity role)
class Customer extends Person
{
#[Column(type: 'json')]
protected array $preferences;
}
Read more about entity definition in the Entity Attributes section.
You can specify custom discriminator values instead of using the default entity role:
#[Entity]
#[SingleTable(value: 'super_customer')]
class Customer extends Person
{
#[Column(type: 'json')]
protected array $preferences;
}
The value parameter accepts various types including BackedEnum for type-safe discriminator values:
enum PersonType: string
{
case Employee = 'emp';
case Customer = 'cust';
}
#[Entity]
#[SingleTable(value: PersonType::Employee)]
class Employee extends Person
{
#[Column(type: 'int')]
protected int $salary;
}
#[Entity]
#[SingleTable(value: PersonType::Customer)]
class Customer extends Person
{
#[Column(type: 'json')]
protected array $preferences;
}
Single Table Inheritance supports multiple levels of inheritance. All entities in the hierarchy share the same table:
#[Entity]
#[DiscriminatorColumn(name: 'type')]
class Person
{
#[Column(type: 'primary')]
protected int $id;
#[Column(type: 'string')]
protected string $name;
#[Column(type: 'string')]
protected string $type;
}
#[Entity]
#[SingleTable]
class Employee extends Person
{
#[Column(type: 'int')]
protected int $salary;
}
// CEO inherits from Employee, which inherits from Person
#[Entity]
#[SingleTable] // Discriminator value: "ceo"
class Ceo extends Employee
{
#[Column(type: 'int')]
protected int $stocks;
#[Column(type: 'int')]
protected int $bonus;
}
In this example, the persons table will contain columns for all three entity types: id, name, type, salary,
stocks, and bonus.
You can configure Single Table Inheritance programmatically without attributes by defining the
SchemaInterface::CHILDREN and SchemaInterface::DISCRIMINATOR segments for the root entity:
use Cycle\ORM\SchemaInterface;
$schema = [
'person' => [
// ... other schema configuration
SchemaInterface::CHILDREN => [
// discriminator value => Entity class
'employee' => Employee::class,
'foo_customer' => Customer::class,
'ceo' => Ceo::class,
],
SchemaInterface::DISCRIMINATOR => 'type' // Discriminator column name
],
'employee' => [
// ... other schema configuration
SchemaInterface::ENTITY => Employee::class,
],
'customer' => [
// ... other schema configuration
SchemaInterface::ENTITY => Customer::class,
],
'ceo' => [
// ... other schema configuration
SchemaInterface::ENTITY => Ceo::class,
]
];
The CHILDREN array maps discriminator values to entity class names. The DISCRIMINATOR defines the column name used
to identify entity types.
When querying a parent entity repository, Cycle ORM automatically returns instances of the appropriate child entity based on the discriminator value:
// Returns Person, Employee, Customer, and Ceo instances
// based on the 'type' discriminator column value
$people = $orm->getRepository(Person::class)->findAll();
foreach ($people as $person) {
if ($person instanceof Employee) {
echo "Salary: " . $person->salary;
} elseif ($person instanceof Customer) {
echo "Preferences: " . json_encode($person->preferences);
}
}
You can filter by discriminator value in queries:
// Get only employees
$employees = $orm->getRepository(Person::class)
->select()
->where('type', 'employee')
->fetchAll();
// Using custom discriminator values
$customers = $orm->getRepository(Person::class)
->select()
->where('type', 'super_customer')
->fetchAll();
Child entity repositories work as expected:
// Returns only Employee instances
$employees = $orm->getRepository(Employee::class)->findAll();
Read more about querying entities in the Select Queries section.
Parent class properties must be protected or public, not private. Private properties cannot be inherited and will cause issues:
// ✅ Correct
#[Entity]
class Person
{
#[Column(type: 'primary')]
protected int $id; // Protected - accessible to children
}
// ❌ Incorrect
#[Entity]
class Person
{
#[Column(type: 'primary')]
private int $id; // Private - NOT accessible to children
}
The resulting database table contains:
Child-specific columns should generally be nullable since not all rows will use them:
#[Entity]
#[SingleTable]
class Employee extends Person
{
#[Column(type: 'int', nullable: true)] // Nullable for non-employee rows
protected ?int $salary = null;
}
Advantages:
Disadvantages:
Choose STI when:
See also: For hierarchies with significantly different structures, consider Joined Table Inheritance instead.