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 four relationship types:
ManyToOne - Many entities reference one entity. Example: Many products belong to one category.
class ProductEntity {
/**
* @Orm\ManyToOne(targetEntity="CategoryEntity")
*/
private CategoryEntity $category;
}
OneToMany - One entity has many related entities. Example: One category has many products.
class CategoryEntity {
/**
* @Orm\OneToMany(targetEntity="ProductEntity", mappedBy="categoryId")
*/
public EntityCollection $products;
}
OneToOne - One entity relates to exactly one other entity. Example: One user has one profile.
// Owning side
class UserEntity {
/**
* @Orm\Column(name="profile_id", type="integer", nullable=true)
*/
private ?int $profileId = null;
/**
* @Orm\OneToOne(targetEntity="ProfileEntity", inversedBy="user")
*/
private ?ProfileEntity $profile = null;
public function setProfile(?ProfileEntity $profile): void {
$this->profile = $profile;
$this->profileId = $profile?->getProfileId();
}
}
// Inverse side
class ProfileEntity {
/**
* @Orm\OneToOne(targetEntity="UserEntity", mappedBy="profileId")
*/
private ?UserEntity $user = null;
}
ManyToMany - Many entities relate to many entities. ObjectQuel uses explicit bridge entities for this (see Entity Bridge guide for details).
Owning vs Inverse Side
Every relationship has two sides. Understanding the difference is critical:
/**
* @Orm\Table(name="products")
*/
class ProductEntity {
/**
* @Orm\Column(name="category_id", type="integer")
*/
private int $categoryId;
/**
* @Orm\ManyToOne(targetEntity="CategoryEntity")
*/
private CategoryEntity $category; // OWNING SIDE - has the foreign key
}
/**
* @Orm\Table(name="categories")
*/
class CategoryEntity {
/**
* @Orm\OneToMany(targetEntity="ProductEntity", mappedBy="categoryId")
*/
public EntityCollection $products; // INVERSE SIDE - referenced by foreign key
}
| Side | Description | Impact |
|---|---|---|
| Owning Side | The entity with the foreign key column | Changes here affect the database |
| Inverse Side | The entity referenced by the foreign key | Changes here are ignored by ObjectQuel |
Critical rule: Only changes to the owning side establish or break relationships. The inverse side collection is purely for navigation.
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
- Collection relationships (OneToMany)
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.
Working with Collections
OneToMany relationships use EntityCollection to hold related entities:
use Quellabs\ObjectQuel\Collections\EntityCollection;
class CustomerEntity {
/**
* @Orm\OneToMany(targetEntity="OrderEntity", mappedBy="customerId")
*/
public EntityCollection $orders;
public function __construct() {
$this->orders = new EntityCollection();
}
}
// 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);
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
");
// Query using explicit join condition
$results = $entityManager->executeQuery("
range of p is App\\Entity\\ProductEntity
range of c is App\\Entity\\CategoryEntity via p.categoryId = c.categoryId
retrieve (p, c.name) where c.active = true
");
// Multi-level traversal
$results = $entityManager->executeQuery("
range of c is App\\Entity\\CustomerEntity
range of o is App\\Entity\\OrderEntity via c.orders
range of i is App\\Entity\\OrderItemEntity via o.items
retrieve (c.email, o.orderId, i.quantity)
where o.orderDate >= :date
", ['date' => '2024-01-01']);
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 Update Owning Side
// WRONG - Only updates inverse side
$category->products->add($product); // This does nothing!
// CORRECT - Update owning side
$product->setCategory($category); // This persists
$category->products->add($product); // This is just for navigation
3. Not Initializing Collections
// WRONG
class CategoryEntity {
public EntityCollection $products; // Uninitialized!
}
// CORRECT
class CategoryEntity {
public EntityCollection $products;
public function __construct() {
$this->products = new EntityCollection();
}
}
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
- Add database indexes on foreign key columns
- Consider denormalization for frequently accessed data