The Has Many relation defines that an entity exclusively owns multiple other entities in a parent-children relationship. This is the most common one-to-many relationship pattern.
The parent entity is persisted first, then all child entities with references to the parent.
To define a HasMany relation using the annotated entities extension:
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Relation\HasMany;
#[Entity]
class User
{
#[Column(type: 'primary')]
private int $id;
#[Column(type: 'string')]
private string $username;
#[HasMany(target: Post::class)]
private array $posts = [];
public function getPosts(): array
{
return $this->posts;
}
public function addPost(Post $post): void
{
$this->posts[] = $post;
}
public function removePost(Post $post): void
{
$this->posts = array_filter(
$this->posts,
static fn(Post $p) => $p !== $post
);
}
}
#[Entity]
class Post
{
#[Column(type: 'primary')]
private int $id;
#[Column(type: 'string')]
private string $title;
}
| 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 child entities with parent entity |
| nullable | bool | false | Whether children can exist without parent (affects foreign key NULL constraint) |
| innerKey | string|array | null | Key column(s) in parent entity. Defaults to parent's primary key |
| outerKey | string|array | null | Foreign key column(s) in child entities. Defaults to {parentRole}_{innerKey} |
| where | array | [] | Additional WHERE conditions applied when loading relation |
| orderBy | array | [] | Default sorting for loaded collection |
| 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) |
| collection | string | null | Collection class for loaded entities. See Collections |
| inverse | Inverse | null | Configure inverse relation on child entities. See Inverse Relations |
Since Cycle ORM v2.x,
innerKeyandouterKeycan be arrays for composite keys.
By default, the ORM generates a foreign key column in child entities using the pattern
{parentRole}_{parentPrimaryKey}:
#[Entity]
class User
{
// Creates column in Post: user_id (references user.id)
#[HasMany(target: Post::class)]
private array $posts = [];
}
Customize the foreign key column:
#[Entity]
class User
{
// Uses custom column in Post: author_id
#[HasMany(target: Post::class, outerKey: 'author_id')]
private array $posts = [];
}
Child entities are automatically saved with the parent when persisted (unless cascade: false):
$user = new User();
$user->setUsername("johndoe");
$post1 = new Post();
$post1->setTitle("First Post");
$post2 = new Post();
$post2->setTitle("Second Post");
$user->addPost($post1);
$user->addPost($post2);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run();
The save order ensures:
User) is saved first, generating its IDPost entities) are saved, with parent's ID as the foreign key$user = $orm->getRepository(User::class)->findByPK(1);
$newPost = new Post();
$newPost->setTitle("Another Post");
$user->addPost($newPost);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run();
$user = $orm->getRepository(User::class)
->select()
->load('posts')
->wherePK(1)
->fetchOne();
// Create new collection
$newPosts = [
new Post("Post 1"),
new Post("Post 2"),
];
// This will delete old posts and create new ones
$user->setPosts($newPosts);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run();
To delete a child entity, remove it from the collection:
$user = $orm->getRepository(User::class)
->select()
->load('posts')
->wherePK(1)
->fetchOne();
$postToRemove = $user->getPosts()[0];
$user->removePost($postToRemove);
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run(); // Post is deleted
If you want to detach children without deleting them, use nullable: true:
#[HasMany(target: Post::class, nullable: true)]
private array $posts = [];
$user->removePost($post);
// Post is detached (user_id set to NULL) but not deleted
Load child entities explicitly using the load() method:
$user = $orm->getRepository(User::class)
->select()
->load('posts')
->wherePK(1)
->fetchOne();
foreach ($user->getPosts() as $post) {
echo $post->getTitle() . "\n";
}
Configure the relation to always load with the parent:
#[HasMany(target: Post::class, load: 'eager')]
private array $posts = [];
With eager loading:
$user = $orm->getRepository(User::class)->findByPK(1);
// Posts are already loaded
print_r($user->getPosts());
Note:
By default, HasMany loads children using a separateWHERE INquery, not a JOIN. This is more efficient for one-to-many relationships.
Pre-filter child entities when loading:
$users = $orm->getRepository(User::class)
->select()
->load('posts', ['where' => ['published' => true]])
->fetchAll();
You can also set default filters in the relation definition:
#[HasMany(
target: Post::class,
where: ['published' => true, 'deleted_at' => null]
)]
private array $posts = [];
Specify sorting when loading:
$users = $orm->getRepository(User::class)
->select()
->load('posts', [
'where' => ['published' => true],
'orderBy' => ['published_at' => 'DESC']
])
->fetchAll();
Set default sorting in the relation definition:
#[HasMany(
target: Post::class,
orderBy: ['published_at' => 'DESC', 'title' => 'ASC']
)]
private array $posts = [];
with for PreloadingCombine with() and load() for single-query loading:
$users = $orm->getRepository(User::class)
->select()
->distinct()
->with('posts', ['as' => 'published_posts'])
->where('posts.published', true)
->load('posts', ['using' => 'published_posts'])
->fetchAll();
This produces a single SQL query instead of separate queries.
with() for FilteringFilter parent entities based on child entity criteria:
$users = $orm->getRepository(User::class)
->select()
->distinct()
->with('posts')->where('posts.published', true)
->fetchAll();
Important: Always use
distinct()with HasMany filtering to avoid duplicate parent rows.
The Select query automatically joins related tables when referenced:
// Automatically joins the posts table
$users = $orm->getRepository(User::class)
->select()
->distinct()
->where('posts.published', true)
->where('posts.views', '>', 1000)
->fetchAll();
Find users with at least 5 published posts:
$users = $orm->getRepository(User::class)
->select()
->distinct()
->with('posts')
->where('posts.published', true)
->having('COUNT(posts.id)', '>=', 5)
->fetchAll();
Find users without posts:
$usersWithoutPosts = $orm->getRepository(User::class)
->select()
->where('posts.id', null)
->fetchAll();
HasMany relations support custom collection classes for managing child entities. By default, relations use a simple array, but you can use more sophisticated collection types.
#[HasMany(target: Post::class)]
private array $posts = [];
Use Doctrine Collections for richer functionality:
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[Entity]
class User
{
#[HasMany(target: Post::class, collection: 'doctrine')]
private Collection $posts;
public function __construct()
{
$this->posts = new ArrayCollection();
}
public function getPosts(): Collection
{
return $this->posts;
}
public function addPost(Post $post): void
{
if (!$this->posts->contains($post)) {
$this->posts->add($post);
}
}
}
#[HasMany(target: Post::class, collection: 'my_custom_collection')]
private CustomCollection $posts;
Read more about relation collections.
Define bidirectional relationships using the inverse parameter:
use Cycle\Annotated\Annotation\Relation\Inverse;
#[Entity]
class User
{
#[HasMany(
target: Post::class,
inverse: new Inverse(as: 'author', type: 'belongsTo')
)]
private array $posts = [];
}
This automatically creates the inverse BelongsTo relation on Post:
// Equivalent to defining on Post:
#[Entity]
class Post
{
#[BelongsTo(target: User::class)]
private User $author;
}
Inverse Attribute Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
| as | string | - | Required. Property name for the inverse relation |
| type | string | - | Required. Relation type: 'belongsTo', 'hasOne', 'hasMany', etc. |
| load | string | null | Loading strategy for inverse: 'eager', 'lazy', 'promise' |
By default, HasMany creates a foreign key with CASCADE on both DELETE and UPDATE:
#[HasMany(target: Post::class)]
private array $posts = [];
// Creates: FOREIGN KEY (user_id) REFERENCES users(id)
// ON DELETE CASCADE ON UPDATE CASCADE
When a user is deleted, all their posts are automatically deleted.
Control what happens when the parent is deleted or updated:
#[HasMany(
target: Post::class,
fkAction: 'SET NULL', // Both DELETE and UPDATE
nullable: true // Required for SET NULL
)]
private array $posts = [];
#[HasMany(
target: Post::class,
fkAction: 'CASCADE', // Default for UPDATE
fkOnDelete: 'NO ACTION', // Prevent deletion if posts exist
)]
private array $posts = [];
Available Actions:
| Action | Description |
|---|---|
| CASCADE | Delete/update children when parent is deleted/updated |
| SET NULL | Set foreign key to NULL (requires nullable: true) |
| NO ACTION | Prevent parent deletion/update if children exist (database enforced) |
#[HasMany(
target: Post::class,
fkCreate: false,
indexCreate: false
)]
private array $posts = [];
$users = $orm->getRepository(User::class)
->select()
->load('posts', [
'where' => [
'published' => true,
'published_at' => ['>' => new DateTime('-30 days')]
]
])
->fetchAll();
$users = $orm->getRepository(User::class)
->select()
->distinct()
->with('posts')
->having('COUNT(posts.id)', '>', 10)
->fetchAll();
Use different collection strategies:
#[Entity]
class User
{
#[HasMany(
target: Post::class,
where: ['published' => true],
orderBy: ['published_at' => 'DESC']
)]
private array $publishedPosts = [];
#[HasMany(
target: Post::class,
where: ['published' => false]
)]
private array $draftPosts = [];
}
$user = $orm->getRepository(User::class)->findByPK(1);
// Add multiple posts efficiently
$newPosts = [
new Post("Post 1"),
new Post("Post 2"),
new Post("Post 3"),
];
foreach ($newPosts as $post) {
$user->addPost($post);
}
$manager = new \Cycle\ORM\EntityManager($orm);
$manager->persist($user);
$manager->run(); // Single transaction
// Separate query (default) - better for many children
$user = $orm->getRepository(User::class)
->select()
->load('posts')
->wherePK(1)
->fetchOne();
// Single query with JOIN - better for few children or filtering
$user = $orm->getRepository(User::class)
->select()
->with('posts')
->load('posts', ['using' => 'posts'])
->wherePK(1)
->fetchOne();
When dealing with large collections, use pagination on the child query:
$user = $orm->getRepository(User::class)->findByPK(1);
$posts = $orm->getRepository(Post::class)
->select()
->where('user_id', $user->getId())
->orderBy('created_at', 'DESC')
->limit(20)
->offset(0)
->fetchAll();