Relationship Mapping
Relationship mapping links entities together to reflect how data relates in your domain. Instead of manually writing JOIN queries, you define relationships once using annotations, then ObjectQuel handles the database operations automatically.
Relationship Types
ObjectQuel supports three relationship annotations. Each one is placed on the entity that holds the foreign key column — that's the owning side, and it's the only place a relationship is actually defined. Changes to an owning-side property are what get written to the database.
ManyToOne - Many entities reference one entity. Example: Many products belong to one category.
class ProductEntity {
/**
* @Orm\Column(name="category_id", type="integer", nullable=true)
*/
private ?int $categoryId = null;
/**
* @Orm\ManyToOne(targetEntity="CategoryEntity")
*/
public ?CategoryEntity $category = null;
}
OneToOne - One entity relates to exactly one other entity. Example: One user has one profile.
class UserEntity {
/**
* @Orm\Column(name="profile_id", type="integer", nullable=true)
*/
private ?int $profileId = null;
/**
* @Orm\OneToOne(targetEntity="ProfileEntity", referencedColumn="user_id")
*/
private ?ProfileEntity $profile = null; // owning side
}
class ProfileEntity {
/**
* @Orm\Column(name="user_id", type="integer")
*/
private int $userId;
/**
* @Orm\InverseOf(targetEntity="UserEntity", relation="profile")
*/
private ?UserEntity $user = null; // hydration marker - for navigation back to the user
}
ManyToMany - Many entities relate to many entities. ObjectQuel uses explicit bridge entities for this (see Entity Bridge guide for details).
Both referencedColumn and localColumn are optional and have sensible defaults, so the example above can be written more tersely:
class UserEntity {
/**
* @Orm\Column(name="profile_id", type="integer", nullable=true)
*/
private ?int $profileId = null;
/**
* @Orm\OneToOne(targetEntity="ProfileEntity")
*/
private ?ProfileEntity $profile = null; // owning side
}
- If
referencedColumnis omitted, it defaults to the target entity's (ProfileEntity) primary key column. - If
localColumnis omitted, it defaults to the annotated property's name withIdappended — so$profiledefaults its local column toprofileId, matching the@Orm\Columndeclared above.
Each of these annotations is what actually defines the relationship and owns the foreign key. There's no second entity-level concept they pair with — the related entity just doesn't have a relationship annotation at all. If you want a convenient property on that entity to navigate back to its related rows, you add a hydration marker: @InverseOf, described below.
@InverseOf: A Hydration Marker
@InverseOf tells ObjectQuel which owning-side property, on which entity, to use as the lookup when filling this property in, and how to shape the result.
class CategoryEntity {
/**
* @Orm\InverseOf(targetEntity="ProductEntity", relation="category")
*/
public CollectionInterface $products;
}
Here, relation="category" points at the $category property on ProductEntity — the actual @ManyToOne that defines the relationship. @InverseOf just says "fill this property with the products whose category points to me."
Critical rule: Only the owning-side property establishes or breaks a relationship in the database. @InverseOf properties are never written; their job is to tell the hydrator where to load the related entities into, so your code can navigate from CategoryEntity back to its products without writing a separate query.
The shape of the property determines how ObjectQuel hydrates it:
- Collection-typed property (
CollectionInterfaceor array) — ObjectQuel loads all matching entities into a collection. This is the natural shape when the related annotation is aManyToOne(many products → one category, so the category's@InverseOfis a collection of products). - Scalar-typed property (a single nullable object reference) — ObjectQuel performs a single lookup and assigns one entity or
null. This is the shape used to hydrate the reverse of aOneToOne:
class ProfileEntity {
/**
* @Orm\InverseOf(targetEntity="UserEntity", relation="profile")
*/
private ?UserEntity $user = null; // scalar - one match expected, not a collection
}
@InverseOf always points at a @ManyToOne or @OneToOne declared on another entity. Removing the @InverseOf property leaves the relationship itself fully intact and persisting correctly; you simply lose the convenient reverse-navigation property.
Fetch Strategies
Control when related entities are loaded using the fetch parameter:
class ProductEntity {
/**
* EAGER - Load immediately when product is loaded
* @Orm\ManyToOne(targetEntity="CategoryEntity", fetch="EAGER")
*/
private CategoryEntity $category;
/**
* LAZY - Load only when accessed
* @Orm\ManyToOne(targetEntity="BrandEntity", fetch="LAZY")
*/
private ?BrandEntity $brand = null;
}
When to use EAGER:
- Relationship is almost always needed
- Related entity is small
- Using
@RequiredRelation(mandatory relationships)
When to use LAZY:
- Relationship is rarely accessed
- Related entity is large or has many properties
@InverseOfproperties, especially collection-typed ones
Required vs Optional Relationships
Use @RequiredRelation for mandatory relationships:
class ProductEntity {
/**
* Required - every product must have a category
* @Orm\ManyToOne(targetEntity="CategoryEntity", fetch="EAGER")
* @Orm\RequiredRelation
*/
private CategoryEntity $category;
/**
* @Orm\Column(name="category_id", type="integer")
*/
private int $categoryId; // NOT NULL in database
/**
* Optional - product may have a brand
* @Orm\ManyToOne(targetEntity="BrandEntity", fetch="LAZY")
*/
private ?BrandEntity $brand = null;
/**
* @Orm\Column(name="brand_id", type="integer", nullable=true)
*/
private ?int $brandId = null; // NULL allowed
}
@RequiredRelation uses INNER JOIN instead of LEFT JOIN, improving query performance. It only applies to @ManyToOne / @OneToOne — @InverseOf has no equivalent, since it doesn't control a join's type, only how the hydrated result is shaped.
Working with Collections
Collection-typed @InverseOf properties use EntityCollection to hold related entities:
use Quellabs\ObjectQuel\Collections\Collection;
use Quellabs\ObjectQuel\Collections\CollectionInterface;
class CustomerEntity {
/**
* @Orm\InverseOf(targetEntity="OrderEntity", relation="customer")
*/
public CollectionInterface $orders;
public function __construct() {
$this->orders = new Collection();
}
}
// Adding entities
$customer->orders->add($order);
// Removing entities
$customer->orders->removeElement($order);
// Checking if collection contains an entity
if ($customer->orders->contains($order)) {
// ...
}
// Counting items
$count = count($customer->orders);
Remember: calling add() or removeElement() on this collection is purely a local, in-memory convenience — it does not persist anything. See "Forgetting to Set the Owning-Side Property" below.
Querying Across Relationships
Use the via keyword to traverse relationships in ObjectQuel queries:
// Query using relationship property
$results = $entityManager->executeQuery("
range of p is App\\Entity\\ProductEntity
range of c is App\\Entity\\CategoryEntity via p.category
retrieve (p, c.name) where c.active = true
");
// Multi-level traversal — via only accepts ManyToOne/OneToOne properties on the dependent entity
$results = $entityManager->executeQuery("
range of c is App\\Entity\\CustomerEntity
range of o is App\\Entity\\OrderEntity via o.customer
range of i is App\\Entity\\OrderItemEntity via i.order
retrieve (c.email, o.orderId, i.quantity)
where o.orderDate >= :date
", ['date' => '2024-01-01']);
Note that via traverses @ManyToOne/@OneToOne properties (p.category, o.customer, i.order) — it walks from the foreign key holder toward the entity it references. There is no equivalent for traversing an @InverseOf property directly in a query, since it isn't a real join path; it's a hydration result on an already-loaded entity.
Common Pitfalls
1. N+1 Query Problem
// BAD - Triggers one query per product
$products = $entityManager->executeQuery("
range of p is App\\Entity\\ProductEntity
retrieve (p)
");
foreach($products as $row) {
$product = $row['p'];
echo $product->getCategory()->getName(); // Triggers a query!
}
// GOOD - One query loads everything
$products = $entityManager->executeQuery("
range of p is App\\Entity\\ProductEntity
range of c is App\\Entity\\CategoryEntity via p.category
retrieve (p, c)
");
foreach($products as $row) {
$product = $row['p'];
echo $product->getCategory()->getName(); // No query!
}
2. Forgetting to Set the Owning-Side Property
// WRONG - Only updates the InverseOf-hydrated property
$category->products->add($product); // This does nothing!
// CORRECT - Set the actual relationship
$product->setCategory($category); // This persists
$category->products->add($product); // This is just for navigation
3. Not Initializing Collections
// WRONG
class CategoryEntity {
public CollectionInterface $products; // Uninitialized!
}
// CORRECT
class CategoryEntity {
public CollectionInterface $products;
public function __construct() {
$this->products = new Collection();
}
}
4. @InverseOf Needs a Real Owning-Side Annotation to Point At
// WRONG - @InverseOf alone defines nothing; there is no foreign key here
class CategoryEntity {
/**
* @Orm\InverseOf(targetEntity="ProductEntity", relation="category")
*/
public CollectionInterface $products;
}
// If ProductEntity::$category isn't a real @ManyToOne, this annotation
// has nothing to hydrate from and will not work.
// CORRECT - the owning side must declare the relationship
class ProductEntity {
/**
* @Orm\Column(name="category_id", type="integer", nullable=true)
*/
private ?int $categoryId = null;
/**
* @Orm\ManyToOne(targetEntity="CategoryEntity")
*/
public ?CategoryEntity $category = null;
}
Performance Tips
- Use
@RequiredRelationfor mandatory relationships (INNER JOIN vs LEFT JOIN) - Preload relationships in queries to avoid N+1 problems
- Use LAZY loading for rarely accessed relationships, especially collection-typed
@InverseOfproperties - Add database indexes on foreign key columns
- Consider denormalization for frequently accessed data