Joined Table Inheritance (JTI) creates separate database tables for each entity in the hierarchy. The only column that appears in all tables is the identifier, which is used to join tables when loading entities.
The ORM provides the ability to map each class in the hierarchy to its own table. To achieve this, extend your parent entity and declare relations/columns specific to each child.
In JTI, each child entity has its own table that connects to the parent table through a foreign key relationship. The child table's primary key serves double duty: it's both a primary key and a foreign key that references the parent table.
For example, with Person as a parent and Employee as a child:
-- Parent table
CREATE TABLE persons
(
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255)
);
-- Child table - 'id' references parent
CREATE TABLE employees
(
id INT PRIMARY KEY, -- Primary key AND foreign key
salary INT,
FOREIGN KEY (id) REFERENCES persons (id)
);
When you load an Employee, Cycle ORM automatically joins both tables to retrieve all data.
When you save an entity, Cycle ORM:
When you query entities, Cycle ORM:
Use Joined Table Inheritance when your entities are more different than similar:
✅ Good Use Cases:
❌ Avoid JTI When:
#[JoinedTable] Attribute
Applied to child entity classes to indicate they use Joined Table Inheritance.
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
outerKey |
non-empty-string|null |
No | Primary key | The parent entity's key column used for joining. Defaults to the parent's primary key. |
fkCreate |
bool |
No | true |
Whether to automatically create a foreign key constraint on the join column. |
fkAction |
'NO ACTION'|'CASCADE'|'SET NULL' |
No | 'CASCADE' |
Foreign key action for both onDelete and onUpdate events. |
Note
Make sure the parent class properties are not private so child entities can inherit and access them.
By default, child entities are joined to the parent via the primary key:
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Inheritance\JoinedTable;
#[Entity]
class Person
{
#[Column(type: 'primary')]
protected int $id;
#[Column(type: 'string')]
protected string $name;
}
#[Entity]
#[JoinedTable] // Joined via Person.id = Employee.id
class Employee extends Person
{
#[Column(type: 'int')]
protected int $salary;
}
#[Entity]
#[JoinedTable] // Joined via Person.id = Customer.id
class Customer extends Person
{
#[Column(type: 'json')]
protected array $preferences;
}
This creates three tables:
persons: Contains id and name
employees: Contains id (FK to persons) and salary
customers: Contains id (FK to persons) and preferences
Read more about entity definition in the Entity Attributes section.
Use the outerKey parameter to join via a different column than the primary key:
#[Entity]
class Person
{
#[Column(type: 'primary')]
protected int $id;
#[Column(type: 'int', unique: true)]
protected int $fooId; // Alternative join column
#[Column(type: 'string')]
protected string $name;
}
#[Entity]
#[JoinedTable(outerKey: 'fooId')] // Join via Person.fooId = Customer.fooId
class Customer extends Person
{
#[Column(type: 'json')]
protected array $preferences;
}
In this example, the customers table's fooId column references the persons table's fooId column instead of the
primary key.
Joined Table Inheritance supports multiple inheritance levels, with each child entity joined to its immediate parent:
#[Entity]
class Person
{
#[Column(type: 'primary')]
protected int $id;
#[Column(type: 'string')]
protected string $name;
}
#[Entity]
#[JoinedTable] // Joined to Person
class Employee extends Person
{
#[Column(type: 'int')]
protected int $salary;
}
#[Entity]
#[JoinedTable] // Joined to Employee (not Person)
class Executive extends Employee
{
#[Column(type: 'int')]
protected int $bonus;
}
This creates three tables:
persons: Contains id and name
employees: Contains id (FK to persons) and salary
executives: Contains id (FK to employees) and bonus
When loading an Executive, Cycle ORM performs joins across all three tables.
Control foreign key creation and behavior with the fkCreate and fkAction parameters:
#[Entity]
#[JoinedTable(
fkCreate: true, // Create foreign key constraint (default)
fkAction: 'CASCADE' // ON DELETE CASCADE, ON UPDATE CASCADE (default)
)]
class Employee extends Person
{
#[Column(type: 'int')]
protected int $salary;
}
#[Entity]
#[JoinedTable(
fkCreate: true,
fkAction: 'SET NULL' // ON DELETE SET NULL, ON UPDATE SET NULL
)]
class Customer extends Person
{
#[Column(type: 'json')]
protected array $preferences;
}
#[Entity]
#[JoinedTable(
fkCreate: false // Don't create foreign key constraint
)]
class Guest extends Person
{
#[Column(type: 'datetime')]
protected \DateTimeImmutable $visitedAt;
}
Foreign Key Actions:
'CASCADE' (default): Delete/update child rows when parent is deleted/updated'SET NULL': Set foreign key to NULL when parent is deleted/updated (requires nullable column)'NO ACTION': Prevent parent deletion/update if children existNote:
WhenfkCreateistrue, Cycle ORM automatically creates a unique index on the parent key column and establishes the foreign key relationship.
You can configure Joined Table Inheritance programmatically without attributes by defining the SchemaInterface::PARENT
and SchemaInterface::PARENT_KEY segments for child entities:
use Cycle\ORM\SchemaInterface;
$schema = [
'person' => [
// ... other schema configuration
],
'employee' => [
// ... other schema configuration
SchemaInterface::PARENT => Person::class, // Parent entity
SchemaInterface::PARENT_KEY => 'id', // Join column
],
'customer' => [
// ... other schema configuration
SchemaInterface::PARENT => Person::class,
SchemaInterface::PARENT_KEY => 'fooId', // Custom join column
],
'executive' => [
// ... other schema configuration
SchemaInterface::PARENT => Employee::class, // Multi-level inheritance
SchemaInterface::PARENT_KEY => 'id',
]
];
The PARENT specifies the parent entity class, and PARENT_KEY defines the column used for joining (defaults to the
parent's primary key if not specified).
When querying a parent entity repository, Cycle ORM automatically performs the necessary joins and returns instances of the appropriate entity type:
// Performs LEFT JOINs and returns Person, Employee, Customer, and Executive instances
$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);
}
}
Child entity repositories work efficiently with automatic joins:
// Automatically joins persons and employees tables
$employees = $orm->getRepository(Employee::class)->findAll();
// Multi-level join: persons → employees → executives
$executives = $orm->getRepository(Executive::class)->findAll();
You can optimize queries by selecting only necessary fields:
// Only load parent fields (no child joins)
$people = $orm->getRepository(Person::class)
->select()
->columns(['id', 'name'])
->fetchAll();
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:
// ✅ 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
}
When a child entity extends a parent with a generated primary key (e.g., auto-increment), the child's primary key column is not generated. It becomes a regular integer column that references the parent:
#[Entity]
class Person
{
#[Column(type: 'primary')] // Auto-increment primary key
protected int $id;
}
#[Entity]
#[JoinedTable]
class Employee extends Person
{
// Inherited 'id' becomes a non-generated primary key
// (foreign key to Person.id)
}
This means:
Person.id uses type: 'primary' (auto-increment)Employee.id uses type: 'integer' with primary: true (foreign key)bigPrimary → bigInteger
The resulting database schema separates data across tables:
Parent Table (persons):
CREATE TABLE persons
(
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
Child Table (employees):
CREATE TABLE employees
(
id INT PRIMARY KEY,
salary INT NOT NULL,
FOREIGN KEY (id) REFERENCES persons (id) ON DELETE CASCADE ON UPDATE CASCADE
);
Advantages:
Disadvantages:
Choose JTI when:
See also: For simpler hierarchies where all entities share similar structures, consider Single Table Inheritance instead.
You can combine both inheritance strategies in a single hierarchy:
#[Entity]
#[DiscriminatorColumn(name: 'type')]
class Person
{
#[Column(type: 'primary')]
protected int $id;
#[Column(type: 'string')]
protected string $name;
#[Column(type: 'string')]
protected string $type;
}
// STI children - share persons table
#[Entity]
#[SingleTable]
class Guest extends Person
{
#[Column(type: 'datetime', nullable: true)]
protected ?\DateTimeImmutable $visitedAt = null;
}
// JTI child - uses separate table
#[Entity]
#[JoinedTable]
class Employee extends Person
{
#[Column(type: 'int')]
protected int $salary;
}
// JTI child of JTI parent
#[Entity]
#[JoinedTable]
class Manager extends Employee
{
#[Column(type: 'string')]
protected string $department;
}
In this example:
Person and Guest share the persons table (STI)Employee has its own employees table (JTI from Person)Manager has its own managers table (JTI from Employee)