endroid/flusher
Endroid Flusher auto-tunes database batch sizes when importing or modifying large data sets. It incrementally tries new sizes, keeps the fastest, and switches if conditions change. Create with Flusher($manager), call flush() while persisting, and finish() to flush remaining data.
Install
composer require endroid/flusher
The package is a thin layer over Doctrine ORM, so you need a working EntityManagerInterface first. There is no bundle, no service provider, and no auto-discovery — it is a plain library you instantiate yourself.
Where to look first
src/ — the entire library is a handful of classes; read the Flusher class signature before wiring it anywhere. The public API is small enough to read in a few minutes and this is the fastest way to confirm the exact method names (persist() / flush() / constructor arguments) for the version you pulled in.tests/ — endroid packages are usually well tested; the test cases are the best documentation of the intended loop shape.README.md — explains the why (adaptive batch size) but not much of the how-to-integrate.First use case
Replace the hand-rolled counter idiom in an import command:
// Before: a magic number you guessed, and it's wrong on someone else's hardware
foreach ($rows as $i => $row) {
$em->persist($this->hydrate($row));
if ($i % 250 === 0) {
$em->flush();
$em->clear();
}
}
$em->flush();
// After: let the flusher decide
$flusher = new Flusher($em /*, step size */);
foreach ($rows as $row) {
$flusher->persist($this->hydrate($row));
}
$flusher->flush(); // final drain
The value proposition is real but narrow: you get it only on bulk write workloads with unpredictable optimal batch sizes — CSV/API imports, backfills, migrations that hydrate entities, data anonymisation jobs. For a normal request that persists three entities, this is dead weight.
Wrap it, don't scatter it. Put the flusher behind a small application-level service (BulkWriter, ImportPersister) so your import code deals in domain objects and the tuning strategy stays swappable:
final class BulkWriter
{
public function __construct(private EntityManagerInterface $em) {}
public function write(iterable $entities): void
{
$flusher = new Flusher($this->em);
foreach ($entities as $entity) {
$flusher->persist($entity);
}
$flusher->flush();
}
}
Keep the flusher alive for the duration of the job, not the request. Its whole mechanism is incremental — it starts small and ratchets the batch size up or down based on measured flush cost. Create one per import run, not per chunk, otherwise it never gets past its first probe.
Symfony console command pattern. This is the natural home for the package:
#[AsCommand(name: 'app:import-products')]
class ImportProductsCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$flusher = new Flusher($this->em);
foreach ($this->reader->rows() as $row) {
$flusher->persist($this->hydrator->hydrate($row));
}
$flusher->flush();
return Command::SUCCESS;
}
}
Register the EntityManagerInterface injection normally; instantiate the flusher inline or as a factory service.
Do not clear the EntityManager underneath it. The tempting copy-paste from old import scripts is $em->clear() after each flush to control memory. That detaches every managed entity and will break any entity you still hold a reference to (and any association you touch afterwards). If memory is genuinely the constraint, let the flusher own the flush cadence and handle detaching deliberately — or better, process in resumable pages keyed by ID so you can drop the whole EM per page.
Transaction discipline. Wrap the entire run in an explicit transaction only if the dataset fits comfortably in one — which for the workloads this package targets it usually does not. Prefer per-batch transactions (Doctrine's implicit behaviour) so a failure mid-import leaves you with a known-good prefix rather than a rollback of two hours of work.
Pair with an ID cursor, not an offset. If the flusher clears or you restart the job, OFFSET pagination will silently skip or duplicate rows as the table grows. Iterate WHERE id > :lastId ORDER BY id LIMIT :n.
Log the chosen batch size. You want observability into whether the adaptation is doing anything useful. If the class exposes the current batch size (check the source), log it periodically from your command; otherwise wrap the flusher and time each flush yourself. A run that settles on batch size 1 means the workload is not batch-friendly and the package is just adding overhead.
Use it as a template, not a dependency, if the API is too thin. At this size, it is entirely reasonable to vendor the tuning loop into your own AdaptiveFlusher and keep control of the interface. The algorithm — try a size, measure, step, keep the winner — is a dozen lines.
src/ fully before production use, pin the version in composer.json, and be prepared to fork. Do not let it become load-bearing in a critical path without a fallback.persist() is not flush(). Make sure your loop actually drains at the end. A silent off-by-one that leaves the tail of the dataset unflushed is the classic failure mode here, and it only shows up on datasets that aren't a clean multiple of the final batch size.APP_DEBUG=1, every flush is far more expensive and the tuning is meaningless. Always benchmark and import with debug off and doctrine.sql_logger / profiler disabled.$em->getReference() for foreign keys you need to set on new rows.gc_collect_cycles() and memory. If you are fighting memory, note that PHP's cycle collector is not run per flush — an occasional gc_collect_cycles() in your loop is often the fix that makes a larger batch size viable.How can I help you explore Laravel packages today?