cleverage/doctrine-process-bundle
Integrates Doctrine with the CleverAge ProcessBundle, letting you use Doctrine entities and repositories within your process workflows. Part of the CleverAge ProcessBundle project for Symfony.
Reality check before you type composer require. This is a Symfony bundle, not a Laravel package. It extends cleverage/process-bundle, boots through Symfony\Component\HttpKernel\Bundle\Bundle, and its tasks are wired as Symfony DI services consuming Doctrine\ORM\EntityManagerInterface. In a Laravel app you will not get a service provider, a facade, or a config file — you'll get either a Composer conflict on symfony/framework-bundle or a package that installs and does absolutely nothing. Plan accordingly: either you own a Symfony service alongside your Laravel app, or you read this bundle as a reference design and port the task pattern to Eloquent (see the mapping table in the next section).
composer require cleverage/process-bundle cleverage/doctrine-process-bundle
// config/bundles.php
return [
// ...
CleverAge\ProcessBundle\CleverAgeProcessBundle::class => ['all' => true],
CleverAge\DoctrineProcessBundle\CleverAgeDoctrineProcessBundle::class => ['all' => true],
];
No bundle-level configuration is required for the common case — it piggybacks on your existing doctrine.orm.entity_manager configuration.
Where to look first, in this order:
docs/index.md — the only real usage documentation; the root README just points at it.config/services.yaml in the bundle — this is where the real task inventory lives. v2.0 explicitly dropped autowiring/autoconfiguration, so every task is an explicit service ID prefixed cleverage_doctrine_process. Grep it for the task list.DatabaseUpdaterTask options/params/connection, AbstractDoctrineQueryTask (criteria, order, limit, offset), reader next()-before-execution, and the DI extension. Those tests are the most accurate documentation of option names and defaults that exists.changelog/BC.md — the fastest way to avoid writing code against an option that was removed (see Gotchas).First use case — a batched entity export/importer. The canonical pipeline is DoctrineReaderTask → one or more transform tasks → DoctrineWriterTask:
# config/packages/cleverage_process.yaml
cleverage_process:
processes:
product_reindex:
tasks:
read:
service: cleverage_doctrine_process.task.doctrine_reader
options:
class: App\Entity\Product
criteria: { status: 'active' }
order: { id: 'ASC' }
limit: 500
transform:
service: app.task.product_transform
next: write
write:
service: cleverage_doctrine_process.task.doctrine_writer
Run it with the process bundle's console command, then watch the logs. If it works once, you've validated the whole stack — everything else in this bundle is a variation on that shape.
Don't install this. Instead, replicate the four task archetypes with things you already have:
| Bundle concept | Laravel equivalent |
|---|---|
DoctrineReaderTask (criteria/order/limit/offset) |
Model::query()->orderBy(...)->chunkById() / lazyById() |
DoctrineWriterTask (persist + flush) |
->save(), Model::upsert(), inside DB::transaction() |
DoctrineUpdaterTask (bulk criteria update) |
Model::where(...)->update([...]) |
DoctrineDetacherTask / ClearEntityManagerTask |
Model::clearBootedModels() / DB::purge() between chunks |
DatabaseReaderTask (raw sql + params) |
DB::select($sql, $params) / DB::cursor() |
entity_manager option (multi-EM) |
->on('mysql_read') / ->useWritePdo() connection hints |
| Process definition YAML | Bus::chain([...]), Pipeline, or a queued Job sequence |
The architectural idea worth stealing is tasks as small, composable, DI-registered services with an options array, orchestrated by a declarative graph rather than nested closures. That maps cleanly onto Laravel's job chaining and middleware, and it's the part of this package with real value.
This is the 90% case. The critical detail is that doctrine/orm 3 removed the ability to partially flush the EntityManager, so you cannot flush inside the writer's iteration loop anymore — global_flush is gone (BC break in v2.0). Bounded memory is therefore on you:
read:
service: cleverage_doctrine_process.task.doctrine_reader
options:
class: App\Entity\Order
criteria: { processedAt: null }
order: { id: 'ASC' }
next: transform
Two things changed in v3.2 that you should lean on:
Pair the reader with a DoctrineDetacherTask after the writer so entities don't accumulate in the identity map:
write:
service: cleverage_doctrine_process.task.doctrine_writer
next: detach
detach:
service: cleverage_doctrine_process.task.doctrine_detacher
AbstractDoctrineQueryTask supports criteria, order, limit, and offset. Use it to shard a large job across workers — one process run per offset window, dispatched from a cron or a queue:
options:
class: App\Entity\Invoice
criteria: { tenant: '%tenant_id%' }
order: { id: 'ASC' }
limit: 1000
offset: 5000
Because criteria is an array of equality/IN style filters, anything more complex belongs in a custom task that wraps your own QueryBuilder — subclass AbstractDoctrineQueryTask and override the builder hook rather than trying to encode a JOIN in YAML.
DatabaseReaderTask runs native SQL with bound params on a named connection. Note the v3.2 fix: table is only required when sql is not set. So for a reporting extract:
options:
connection: default
sql: "SELECT id, total FROM orders WHERE created_at > :since ORDER BY id"
params: { since: '2026-01-01 00:00:00' }
This returns scalar rows, not entities — route them into a plain PHP transform task, not the Doctrine writer.
If you have a read replica, a legacy DB, or a separate reporting EM, use the entity_manager option on every Doctrine task. Before v3.2 this option was ignored by everything except ClearEntityManagerTask — it silently fell back to the EM managing the entity class. If you're on <2.x/3.2, that was a live bug that would have written to your read replica. Pin ^3.2.
options:
entity_manager: reporting
class: App\Entity\LegacyOrder
Since the bundle explicitly disables autowiring/autoconfiguration (BC break in v2.0), register your own tasks the same way for consistency and predictable container debugging:
# config/services.yaml
app.task.product_transform:
class: App\Process\ProductTransformTask
shared: false # required — tasks are stateful per run
arguments: ['@logger', '@app.product_normalizer']
tags: [{ name: 'cleverage_process.task' }]
The shared: false is not optional. v2.0.1 shipped a fix specifically to add missing shared: false to tasks; stateful services reused across process runs leak options and per-input state. Set it on every custom task.
Use DoctrineUpdaterTask for bulk criteria-based updates and DoctrineWriterTask when you need entity lifecycle events, validation, or relations. The distinction matters because the updater bypasses the unit of work — no preUpdate listeners, no timestampable behaviors, no domain events. If your entity has a HasLifecycleCallbacks mapping, you want the writer.
v1.0.3 fixed input params being passed properly to both reader and updater — so passing a map of substitution values from an upstream task into the updater's params option is a supported flow, not a hack.
Version pinning is not optional here. The BC path runs v1 → v2 → v3 with real removals:
DoctrineWriterTask's global_flush option was removed in v2.0 (ORM 3 dropped partial flush). Any tutorial or StackOverflow answer mentioning it is dead.PurgeDoctrineCacheTask was removed entirely in v2.0. If you're migrating from v1, you need a replacement task.cleverage/process-bundle: ^5.0 alongside ^3.0 of this bundle — mismatched major versions of the pair is the single most common install failure.doctrine/dbal 4 is explicitly forbidden (^2.9 || ^3.0). If another package in your tree pulls DBAL 4, composer update will fail with a resolution error that looks unrelated to this bundle. The symfony/orm-pack does the same, for the same reason.EntityManager identity map is your memory leak. Nothing in this bundle clears the EM for you. In any long-running process (a daemon, a worker, a multi-thousand-row import), chain DoctrineDetacherTask or a ClearEntityManagerTask after every N items. Symptom: PHP memory climbs steadily across iterations and the process dies at 512M with no exception in your task's code. If you've seen that, you've already hit it.
Null inputs now throw, deliberately. v3.2 fixed DoctrineDetacherTask to report the right task name on null input (it used to blame DoctrineWriterTask, sending you debugging in the wrong direction) and made DoctrineRemoverTask throw an explicit \RuntimeException instead of a \TypeError. If your process graph has optional branches that can emit null, add a filtering transform before the detach/remove task — don't rely on the task to no-op.
Reader re-execution semantics changed in v3.2. The query now runs again for each input. If you had a process where a reader task had multiple inputs and you "worked around" skipped rows with duplicate task definitions, that workaround is now wrong — you'll process rows twice. Audit any process definitions with a reader that has more than one incoming next.
Debugging the container. Because services are explicit rather than autowired, bin/console debug:container cleverage_doctrine_process is the fastest way to enumerate what's actually registered, and bin/console debug:container --tag=cleverage_process.task lists what the process bundle can find. A task that "isn't running" is almost always an unresolvable service ID in YAML — the process bundle validates lazily, so a typo in service: surfaces as a runtime error deep in a run rather than at container compile time.
Extension point for anything missing. The bundle is intentionally thin — no plugin system beyond DI. For custom behavior (retry semantics, per-chunk transactions, custom hydration), write a task service and compose it. Extending the shipped tasks is fragile: v3.2 changed reader internals (next() before execution, one-at-a-time hydration) and v2.0 changed service naming from FQCN to cleverage_doctrine_process.*. Subclass AbstractDoctrineQueryTask if you must; otherwise compose.
Maturity signal worth weighing. Zero dependents, zero stars, one maintainer organization, and a version history where 3.2 is described almost entirely as internal correctness fixes. That's not a knock on quality — the test coverage added in 3.2 is genuinely good — it's a signal about bus factor. Budget for the possibility that you own the fork. Keep the bundle behind your own task interfaces so a swap (or the Laravel port) is a config change rather than a rewrite.
If you're the Laravel developer evaluating this for a port: the one behavior you must replicate is the reader fix from v3.2. Streaming hydration one row at a time is equivalent to lazyById()/cursor(), and re-executing the query per input is equivalent to how a Laravel job chain re-resolves its query per chunk. Getting lazy iteration wrong is exactly the OOM failure that motivated their fix, and ->get() in a loop is the same mistake in Eloquent clothing.
How can I help you explore Laravel packages today?