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.

explanation

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 referencedColumn is omitted, it defaults to the target entity's (ProfileEntity) primary key column.
  • If localColumn is omitted, it defaults to the annotated property's name with Id appended — so $profile defaults its local column to profileId, matching the @Orm\Column declared 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 (CollectionInterface or array) — ObjectQuel loads all matching entities into a collection. This is the natural shape when the related annotation is a ManyToOne (many products → one category, so the category's @InverseOf is 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 a OneToOne:
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
  • @InverseOf properties, 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 @RequiredRelation for 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 @InverseOf properties
  • Add database indexes on foreign key columns
  • Consider denormalization for frequently accessed data