Single-Table Inheritance
Single-Table Inheritance is ObjectQuel's approach to mapping a PHP class hierarchy to a single database table. Instead of spreading subclass data across multiple tables, all types share one table and a discriminator column tells ObjectQuel which class to use.
What is Single-Table Inheritance?
Single-Table Inheritance (STI) maps multiple entity classes to one database table. A discriminator column stores a type identifier for each row, and ObjectQuel uses it to filter queries automatically. This pattern is common in legacy databases where related types were collapsed into a single table for simplicity:
/**
* @Orm\Table(name="vehicles")
* @Orm\DiscriminatorColumn(name="type")
*/
class VehicleEntity {
/**
* @Orm\Column(name="id", type="integer", primary_key=true)
*/
private int $id;
/**
* @Orm\Column(name="type", type="string")
*/
private string $type;
/**
* @Orm\Column(name="make", type="string")
*/
private string $make;
/**
* @Orm\Column(name="model", type="string")
*/
private string $model;
}
/**
* @Orm\Table(name="vehicles")
* @Orm\DiscriminatorValue("truck")
*/
class TruckEntity extends VehicleEntity {
/**
* @Orm\Column(name="payload_capacity", type="integer")
*/
private int $payloadCapacity;
}
/**
* @Orm\Table(name="vehicles")
* @Orm\DiscriminatorValue("motorcycle")
*/
class MotorcycleEntity extends VehicleEntity {
/**
* @Orm\Column(name="sidecar", type="boolean")
*/
private bool $sidecar = false;
}
The @Orm\DiscriminatorColumn annotation on the base class names the column that holds the type identifier. Each subclass declares its own type value with @Orm\DiscriminatorValue.
Why Single-Table Inheritance?
STI is the right tool in specific situations:
- Legacy databases — Many existing schemas store related types in a single table with a type column. STI lets you model these cleanly in PHP without changing the schema
- Simple hierarchies — When subclasses share most of their columns and differ only in a few fields, one table is simpler than a joined schema
- Query performance — No joins are needed to retrieve a full entity, since all data lives in one row
- No magic — The discriminator column is visible in the schema and maps directly to your PHP class hierarchy
How ObjectQuel Applies the Discriminator
ObjectQuel injects discriminator conditions automatically during the AST transformation pass, before SQL is generated. You never write the type filter yourself:
// This query:
$results = $entityManager->executeQuery("
range of t is App\\Entity\\TruckEntity
retrieve (t) where t.make = :make
", ['make' => 'Volvo']);
// Automatically becomes equivalent to:
// SELECT * FROM vehicles WHERE type = 'truck' AND make = 'Volvo'
Querying the base class returns all rows without any type filter, always hydrated as the base class:
// Returns all vehicles as VehicleEntity instances, regardless of type
$results = $entityManager->executeQuery("
range of v is App\\Entity\\VehicleEntity
retrieve (v)
");
Inserting STI Entities
When persisting a subclass, ObjectQuel writes the discriminator value automatically. You do not need to set the type column yourself:
// The discriminator column is filled in automatically on INSERT
$truck = new TruckEntity();
$truck->setMake('Volvo');
$truck->setModel('FH');
$truck->setPayloadCapacity(20000);
$entityManager->persist($truck);
$entityManager->flush();
// Results in: INSERT INTO vehicles SET type='truck', make='Volvo', model='FH', payload_capacity=20000
Subclass-Only Columns
Columns that only apply to one subclass are declared on that subclass and left null for other types. This is a deliberate trade-off of STI — the table will have nullable columns that only some rows use:
// payload_capacity is only meaningful for trucks — it will be NULL for motorcycles
// sidecar is only meaningful for motorcycles — it will be NULL for trucks
$motorcycle = new MotorcycleEntity();
$motorcycle->setMake('BMW');
$motorcycle->setModel('R18');
$motorcycle->setSidecar(true);
$entityManager->persist($motorcycle);
$entityManager->flush();
// Results in: INSERT INTO vehicles SET type='motorcycle', make='BMW', model='R18', sidecar=1
Querying Across the Hierarchy
You can query any level of the hierarchy. Subclass queries are automatically scoped to their type, while base class queries return everything:
// Only trucks, sorted by payload capacity
$trucks = $entityManager->executeQuery("
range of t is App\\Entity\\TruckEntity
retrieve (t)
sort by t.payloadCapacity desc
");
// Only motorcycles with a sidecar
$sidecars = $entityManager->executeQuery("
range of m is App\\Entity\\MotorcycleEntity
retrieve (m) where m.sidecar = true
");
// All vehicles from a specific manufacturer, regardless of type
$volvos = $entityManager->executeQuery("
range of v is App\\Entity\\VehicleEntity
retrieve (v) where v.make = :make
", ['make' => 'Volvo']);
Single-Table Inheritance Best Practices
- Keep the hierarchy shallow — STI works well for one level of subclassing; deeper hierarchies accumulate nullable columns quickly and become hard to manage
- Make subclass-only columns nullable in the database, since rows of other types will not populate them
- Add an index on the discriminator column — every subclass query filters by it
- Prefer STI when subclasses share the majority of their columns; if they diverge significantly, a separate table per class is a better fit
- The base class query returns all rows without a type filter, so avoid using it when you only need a specific subtype