The RefersTo relation is similar to BelongsTo but designed for specific use cases:
createdBy and modifiedBy both pointing to User)Category → parentCategory)The entity is persisted first, then the related entity is saved, and finally the entity is updated with the relation reference.
To define a RefersTo relation using the annotated entities extension:
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Relation\RefersTo;
use Cycle\Annotated\Annotation\Relation\HasMany;
#[Entity]
class User
{
#[Column(type: 'primary')]
private int $id;
#[Column(type: 'string')]
private string $username;
#[RefersTo(target: Comment::class)]
private ?Comment $lastComment = null;
#[HasMany(target: Comment::class)]
private array $comments = [];
public function addComment(Comment $comment): void
{
$this->lastComment = $comment;
$this->comments[] = $comment;
}
public function getLastComment(): ?Comment
{
return $this->lastComment;
}
public function removeLastComment(): void
{
$this->lastComment = null;
}
}
| Parameter | Type | Default | Description |
|---|---|---|---|
| target | string | - | Required. Target entity role or class name |
| load | string | 'lazy' | Loading strategy: 'lazy' or 'eager' |
| cascade | bool | true | Automatically save related entity with source entity |
| nullable | bool | false | Whether relation can be null |
| innerKey | string|array | null | Key column(s) in source entity (parent). Defaults to source's primary key |
| outerKey | string|array | null | Foreign key column(s) in target entity. Defaults to {relationName}_{innerKey} |
| fkCreate | bool | true | Automatically create foreign key constraint |
| fkAction | string | 'CASCADE' | Foreign key action for both DELETE and UPDATE: 'CASCADE', 'NO ACTION', 'SET NULL' |
| fkOnDelete | string | null | Foreign key DELETE action (overrides fkAction if set): 'CASCADE', 'NO ACTION', 'SET NULL' |
| indexCreate | bool | true | Automatically create index on foreign key column(s) |
| inverse | Inverse | null | Configure inverse relation on target entity. See Inverse Relations |
Since Cycle ORM v2.x,
innerKeyandouterKeycan be arrays for composite keys.
RefersTo stores the foreign key in the target entity (not the source), using the pattern
{relationPropertyName}_{sourcePrimaryKey}:
#[Entity]
class User
{
// Creates column in Comment: last_comment_id (references comment.id)
#[RefersTo(target: Comment::class)]
private ?Comment $lastComment;
}
This is different from BelongsTo, which stores the FK in the source entity.
RefersTo is ideal when you need multiple relations pointing to the same entity:
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Relation\RefersTo;
#[Entity]
class Document
{
#[Column(type: 'primary')]
private int $id;
#[Column(type: 'string')]
private string $title;
#[RefersTo(target: User::class)]
private User $createdBy;
#[RefersTo(target: User::class)]
private ?User $modifiedBy = null;
#[RefersTo(target: User::class)]
private ?User $approvedBy = null;
public function __construct(string $title, User $createdBy)
{
$this->title = $title;
$this->createdBy = $createdBy;
}
public function modify(User $user): void
{
$this->modifiedBy = $user;
}
public function approve(User $user): void
{
$this->approvedBy = $user;
}
}
This creates three separate FK columns in the target User table:
created_by_id
modified_by_id
approved_by_id
RefersTo is perfect for hierarchical or tree structures:
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Relation\RefersTo;
#[Entity]
class Category
{
#[Column(type: 'primary')]
private int $id;
#[Column(type: 'string')]
private string $name;
#[RefersTo(target: Category::class, nullable: true)]
private ?Category $parent = null;
public function __construct(string $name, ?Category $parent = null)
{
$this->name = $name;
$this->parent = $parent;
}
public function getParent(): ?Category
{
return $this->parent;
}
public function setParent(?Category $parent): void
{
$this->parent = $parent;
}
}
Build hierarchies:
$electronics = new Category("Electronics");
$computers = new Category("Computers", $electronics);
$laptops = new Category("Laptops", $computers);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($electronics);
$manager->persist($computers);
$manager->persist($laptops);
$manager->run();
The ORM will automatically save the related entity and link to it (unless cascade: false):
$user = new User();
$user->setUsername("johndoe");
$comment = new Comment("Great article!");
$user->addComment($comment);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run();
Persist Order:
User) is persisted first (without FK reference)Comment) is persisted with generated IDChange the relation reference:
$user = $orm->getRepository(User::class)->findByPK(1);
$newComment = new Comment("Updated comment");
$user->addComment($newComment);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run();
Set the reference to null:
$user = $orm->getRepository(User::class)->findByPK(1);
$user->removeLastComment();
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run();
Load the related entity explicitly:
$user = $orm->getRepository(User::class)
->select()
->load('lastComment')
->wherePK(1)
->fetchOne();
print_r($user->getLastComment());
For self-referencing relations, load multiple levels:
$category = $orm->getRepository(Category::class)
->select()
->load('parent.parent.parent.parent') // Load 4 parent levels
->wherePK(1)
->fetchOne();
// Navigate hierarchy
$current = $category;
while ($current->getParent() !== null) {
echo $current->getParent()->getName() . " > ";
$current = $current->getParent();
}
Configure automatic loading:
#[RefersTo(target: Comment::class, load: 'eager')]
private ?Comment $lastComment;
$user = $orm->getRepository(User::class)->findByPK(1);
// lastComment is already loaded
print_r($user->getLastComment());
with() for FilteringFilter source entities based on target entity criteria:
$users = $orm->getRepository(User::class)
->select()
->with('lastComment')->where('lastComment.approved', true)
->fetchAll();
The Select query automatically joins when you reference the relation:
// Automatically joins the comments table
$users = $orm->getRepository(User::class)
->select()
->where('lastComment.approved', true)
->where('lastComment.rating', '>', 4)
->fetchAll();
Find categories by parent criteria:
$categories = $orm->getRepository(Category::class)
->select()
->where('parent.name', 'Electronics')
->fetchAll();
Find top-level categories (no parent):
$topLevelCategories = $orm->getRepository(Category::class)
->select()
->where('parent.id', null)
->fetchAll();
Define bidirectional relationships using the inverse parameter:
use Cycle\Annotated\Annotation\Relation\Inverse;
#[Entity]
class User
{
#[RefersTo(
target: Comment::class,
inverse: new Inverse(as: 'user', type: 'belongsTo')
)]
private ?Comment $lastComment;
}
This creates the inverse relation on Comment:
// Equivalent to defining on Comment:
#[Entity]
class Comment
{
#[BelongsTo(target: User::class)]
private User $user;
}
By default, RefersTo creates a foreign key with CASCADE:
#[RefersTo(target: Comment::class)]
private ?Comment $lastComment;
// Creates: FOREIGN KEY (last_comment_id) REFERENCES comments(id)
// ON DELETE CASCADE ON UPDATE CASCADE
#[RefersTo(
target: Comment::class,
fkAction: 'SET NULL',
nullable: true
)]
private ?Comment $lastComment;
#[RefersTo(
target: Comment::class,
fkAction: 'CASCADE',
fkOnDelete: 'SET NULL',
nullable: true
)]
private ?Comment $lastComment;
Available Actions:
| Action | Description |
|---|---|
| CASCADE | Delete/update source when target is deleted/updated |
| SET NULL | Set foreign key to NULL (requires nullable: true) |
| NO ACTION | Prevent target deletion/update if source exists (database enforced) |
#[RefersTo(
target: Comment::class,
fkCreate: false,
indexCreate: false
)]
private ?Comment $lastComment;
#[Entity]
class Category
{
#[RefersTo(target: Category::class, nullable: true)]
private ?Category $parent = null;
public function getAncestors(): array
{
$ancestors = [];
$current = $this->parent;
while ($current !== null) {
$ancestors[] = $current;
$current = $current->getParent();
}
return $ancestors;
}
public function getPath(): array
{
return array_reverse($this->getAncestors());
}
public function isChildOf(Category $category): bool
{
return in_array($category, $this->getAncestors(), true);
}
}
#[Entity]
class Document
{
#[RefersTo(target: User::class)]
private User $createdBy;
#[RefersTo(target: User::class, nullable: true)]
private ?User $modifiedBy = null;
#[Column(type: 'datetime')]
private \DateTimeInterface $createdAt;
#[Column(type: 'datetime', nullable: true)]
private ?\DateTimeInterface $modifiedAt = null;
public function __construct(User $creator)
{
$this->createdBy = $creator;
$this->createdAt = new \DateTimeImmutable();
}
public function modify(User $user): void
{
$this->modifiedBy = $user;
$this->modifiedAt = new \DateTimeImmutable();
}
}
#[Entity]
class Category
{
#[RefersTo(target: Category::class, nullable: true)]
private ?Category $parent = null;
public function setParent(?Category $parent): void
{
if ($parent !== null && $parent->isChildOf($this)) {
throw new \InvalidArgumentException(
'Cannot set parent: would create circular reference'
);
}
$this->parent = $parent;
}
private function isChildOf(Category $category): bool
{
$current = $this->parent;
while ($current !== null) {
if ($current === $category) {
return true;
}
$current = $current->getParent();
}
return false;
}
}
| Aspect | RefersTo | BelongsTo |
|---|---|---|
| Foreign Key | Stored in target entity | Stored in source entity |
| Save Order | Source → Target → Update Source | Parent → Child |
| Primary Use Case | Multiple refs to same entity, cycles | Single parent-child relationship |
| Update Queries | Requires extra update | Single insert |
| Self-Reference | Fully supported | Not recommended |
RefersTo requires an additional UPDATE query:
-- 1. Insert source
INSERT INTO users (username)
VALUES ('john');
-- 2. Insert target
INSERT INTO comments (text)
VALUES ('Great!');
-- 3. Update source with reference
UPDATE users
SET last_comment_id = 1
WHERE id = 1;
For high-volume scenarios, consider if BelongsTo would work instead.
Loading deep self-referencing structures can cause performance issues:
// Bad: Loads one level at a time
$category = $orm->getRepository(Category::class)->findByPK(1);
while ($category->getParent() !== null) {
$category = $category->getParent(); // N queries!
}
// Good: Load all at once
$category = $orm->getRepository(Category::class)
->select()
->load('parent.parent.parent') // Single query
->wherePK(1)
->fetchOne();