overblog/dataloader-bundle
Symfony bundle integrating DataLoader PHP for efficient batched data fetching. Register loaders as services, reduce N+1 queries, and add per-request caching to GraphQL or REST resolvers with simple tags and autowiring.
What actually changed in v1.1.0: a single PR adding Symfony 7.1 compatibility. No new API, no deprecations, no breaking changes. If you're already on the package, this is a routine constraint widening — bump and move on. If you were blocked on Symfony 7.1, this unblocks you.
What the package is: a Symfony bundle that wires overblog/dataloader-php (a PHP port of Facebook's DataLoader) into the DI container. It solves exactly one problem: N+1 lookups by key. You queue keys during a request, then one batch function resolves them all. It is not a query builder, cache layer, or GraphQL server — it's the batching primitive you put in front of your repository.
Laravel caveat: there is no Laravel service provider here. A Laravel developer has two realistic paths:
overblog/dataloader-php directly (recommended) and bind it yourself — you get the same batching with none of the Symfony config surface.composer require overblog/dataloader-php # Laravel
composer require overblog/dataloader-bundle # Symfony 6.4/7.x
Where to look first:
DataLoader::create() static factory and its options array (batchLoadFn, maxBatchSize, cacheMap, cacheKeyFn, batchScheduleFn).DataLoaderResolverInterface and the overblog_dataloader.data_loaders config block — that's the whole public surface.batch_load_fn: the contract is fn(array $keys): array — same length, same order, same keys.First use case: a GraphQL field resolver (or an API resource collection) that fetches User by id. Replace the per-item User::find($id) with $loader->load($id). That alone removes the classic N+1 in list endpoints.
$loader = DataLoader::create(function (array $ids): array {
$users = User::whereIn('id', $ids)->get()->keyBy('id');
// MUST preserve input order and length
return array_map(fn ($id) => $users->get($id), $ids);
});
Bind per-request, not as a singleton. In Laravel, use scoped() (Octane-safe) rather than singleton(); in Symfony, the default shared service is fine under PHP-FPM but needs a reset in RoadRunner/Swoole.
// AppServiceProvider
$this->app->scoped(UserLoader::class, fn () => DataLoader::create(...));
One loader per entity type, keyed by primary key. A loader is a cache + a queue; mixing types or key spaces into one loader is how you get silent cross-contamination. Name them (user, post, comment) and resolve by name — that's what the bundle's DataLoaderResolver does and it's a good pattern to copy.
The reorder pattern is the core idiom. Your batch function receives keys in arbitrary order; the DB returns rows in arbitrary order. Always build a key => row map and map back over the input keys. This is 90% of correct DataLoader usage.
Chunk large batches. maxBatchSize caps the batch; pair it with an IN (...) size limit (500–1000 rows is a sane ceiling) so a 5,000-key request becomes 5–10 queries instead of one query that blows the packet limit.
In GraphQL resolvers, return the promise from the resolver and let the executor resolve it. In synchronous contexts (controllers, console commands, Blade), you must force resolution:
use React\Promise\Promise;
$results = Promise\all($promises)->wait(); // triggers the batch
Cache maps are a real extension point, not decoration. The default is an in-memory map for the loader's lifetime. For long-running workers, swap in a per-request CacheMapInterface implementation, or clear it explicitly. For volatile data (stock levels, presence), disable caching entirely rather than fighting staleness.
Testing: inject a fake loader whose batch fn is synchronous and records the calls. Assert one call with N keys rather than asserting the results — that's the behaviour you actually care about.
Laravel-native alternative: if you're on Lighthouse or a similar GraphQL stack, check whether it already ships deferred/batching primitives before adding this dependency. The value here is in the pattern; the package is a thin, stable wrapper around it.
Order and length are a contract, not a suggestion. If batchLoadFn returns 3 items for 5 keys, or returns them in DB order, you get wrong data attached to wrong records with no error. Always array_map over the input keys.
Missing keys must yield null placeholders. array_filter or keyBy alone will shrink the array and shift every subsequent value. This is the single most common bug in DataLoader ports.
One exception rejects the whole batch. If a single row's hydration throws, every promise in that batch rejects — not just the one key. Defensive: catch per-row errors inside the batch fn and return null/an error sentinel for that slot.
Duplicates are deduplicated by the cache. If you load(5) three times, the batch fn receives [5] once. Don't assume the keys array reflects call count, and don't rely on ordering of calls.
Don't return a Collection or generator. The contract wants a plain array. Laravel's ->get() returns a Collection — call ->all() or ->keyBy() before mapping.
Long-running workers will serve stale rows. The loader cache outlives the request under Octane/RoadRunner. Either register as scoped, implement ResetInterface (Symfony), or rebuild the loader in a middleware. Symptoms: an updated record still showing old values until the worker restarts.
Promises need a scheduler. The default batch scheduling uses React's tick mechanism. In a plain synchronous request nothing dispatches until you wait() on a promise — if you return the promise object itself into a non-async consumer, you'll get an unresolved object, not data. Always wait() at the boundary.
Symfony config quirks (bundle users): loader names in overblog_dataloader.data_loaders become the keys passed to DataLoaderResolverInterface::resolve(). Custom cache_map services must implement the bundle's CacheMapInterface and be public or explicitly injected — a private service registered by id will fail to resolve.
Batching doesn't fix pagination. It only helps by-key lookups. For "top N per parent" you still need a windowed query (ROW_NUMBER() / whereIn + grouping in PHP). Reaching for a loader there gets you a slower N+1.
Version pinning: ^1.1 is safe if you're on Symfony 7.1+ and want the widened constraint. On Symfony ≤7.0, ^1.0 behaves identically — don't schedule an upgrade for this release alone.
Extension points worth knowing: cacheKeyFn (composite keys), batchScheduleFn (control when the batch flushes — useful in queue workers to batch across jobs), and CacheMapInterface (per-request or bounded cache). These three cover almost every "the default doesn't fit" case.
How can I help you explore Laravel packages today?