When you fetch multiple entities and need to load their relations, doing it one by one causes the N+1 query problem. BulkLoader solves this by loading relations for all entities at once. Only unloaded relations are resolved - already loaded relations won't be overwritten.
To load relations, use the \Cycle\ORM\Relation\BulkLoaderInterface.
If you're using a Cycle ORM bridge, get the BulkLoader instance from the DI container:
use Cycle\ORM\Relation\BulkLoaderInterface;
final class Service
{
public function __construct(
private BulkLoaderInterface $bulkLoader
) {}
}
If you're not using a bridge, configure the binding in your application container:
// Container configuration
$container->bindSingleton(
\Cycle\ORM\Relation\BulkLoaderInterface::class,
\Cycle\ORM\Relation\BulkLoader::class
);
Then inject it into your services as shown above.
The BulkLoaderInterface::collect() method is immutable and returns a new instance
of Cycle\ORM\Relation\RelationLoaderInterface that you can use to chain load() calls and finally execute run().
$users = $userRepository->findAll();
$bulkLoader
->collect(...$users)
->load('profile')
->run();
foreach ($users as $user) {
echo $user->profile->imageUrl; // No extra queries
}
This executes just 2 queries instead of 1 + N: one to fetch users, one to fetch all their profiles.
// Multiple relations
$bulkLoader
->collect(...$users)
->load('profile')
->load('comments')
->load('posts')
->run();
// Nested relations
$bulkLoader
->collect(...$users)
->load('posts.comments')
->run();
// With options
$bulkLoader
->collect(...$users)
->load('comments', [
'orderBy' => ['created_at' => 'DESC'],
'where' => ['approved' => true]
])
->run();
// Sort by pivot columns (many-to-many)
$bulkLoader
->collect(...$users)
->load('tags', ['orderBy' => ['@.@.created_at' => 'DESC']])
->run();
All entities must be of the same role.
Entities must be loaded from the database (not new).
$user = new User(); // not persisted or fetched
$bulkLoader
->collect($user)
->load('profile')
->run(); // LogicException
Relations use database state, not runtime changes. It's required because to ensure consistency.
$profile = $profileRepository->findByPK(1); // user_id = 5 in DB
$profile->user_id = 10; // changed at runtime
$bulkLoader->collect($profile)->load('user')->run();
// $profile->user still points to User(5), not User(10)
Already loaded relations are not overwritten:
$user = $userRepository->findByPK(1);
$user->profile; // lazy loading triggered
$bulkLoader->collect($user)->load('profile')->run();
// $user->profile is the same instance, not reloaded