dbal-bundle — DBAL manager for Symfony highload projects
A Symfony bundle for highload systems where Doctrine ORM is a bottleneck: bulk insert/update/upsert, cursor-based iterators, direct DTO mapping, advisory locks, PSR-3 logging, and verified cross-vendor performance.
Overview
Dbal Bundle is a Symfony bundle for high-load systems where the standard capabilities of Doctrine ORM become a bottleneck. The bundle provides abstractions and interfaces for direct, efficient, and scalable database operations at the Doctrine DBAL level — bulk insert/update/upsert, cursor-based streaming iterators, DTO mapping, advisory locks, and verified cross-vendor performance against MySQL 8, MariaDB 10.5+, and PostgreSQL 12+.
The bundle does not aim to replace Doctrine ORM. It lives next to it: use ORM for transactional single-row reads/writes, use the bundle for bulk operations, streaming reads, and read-modify-write sequences that need explicit concurrency control.
The architectural reasoning behind this layer — what tasks ORM does not carry, where its zone of responsibility ends, and why a dedicated DBAL layer pays off in high-load projects — is in Why high-load Symfony needs a dedicated DBAL layer alongside ORM.
Quick start
Minimum scenario: install the bundle, register it, and run a bulk insert through the factory.
composer require elriseio/dbal-bundle
// config/bundles.php
return [
Elrise\Bundle\DbalBundle\ElriseDbalBundle::class => ['all' => true],
];
class MyService
{
public function __construct(private DbalManagerFactory $factory) {}
public function insertUsers(array $emails): void
{
$inserter = $this->factory->createBulkInserter();
$now = date('Y-m-d H:i:s');
$inserter->insert('users', array_map(
static fn (string $email) => ['email' => $email, 'created_at' => $now],
$emails,
));
}
}
Full configuration, all bulk operations, concurrency helpers, and the verified performance tables are below.
What it is and what it is not
The bundle does:
- provide bulk
BulkInserter,BulkUpdater,BulkUpserter,BulkDeleterwith explicit type per field and DB-native upsert (ON DUPLICATE KEY UPDATEon MySQL/MariaDB,ON CONFLICT ... DO UPDATEon PostgreSQL); - provide
DbalFinderandDbalMutatorfor typed reads and writes with optional DTO mapping (no ORM hydration); - provide
CursorIteratorInterfaceandOffsetIteratorInterfacefor streaming large result sets with bounded memory; - support multiple Doctrine
Connectioninstances side-by-side (multi-database / sharded consumers); - expose
TransactionServicewith PostgreSQL advisory locks and row-levelFOR UPDATE/FOR NO KEY UPDATE/FOR SHARE/FOR KEY SHARElocks (with MySQL 8+ semantics); - register seven Doctrine Types on
build()so consumers can declare columns via#[Column(type: '...')]without hand-rolledTyperegistration; - emit PSR-3 events from every bulk operation (start, end, constraint_violation, connection_error) when a logger is injected;
- emit a JSON caller-trace comment prepended to every
executeQuery/executeStatement/preparecall whensql_comment_enabledis on, so MySQL slow log andpg_stat_statementscarry the originating controller; - ship an
itests/integration layer with verified numbers on MySQL 8.4, MariaDB 11, and PostgreSQL 16 (see §Verified Load-Test Performance).
The bundle does not:
- replace Doctrine ORM — it runs alongside it. Single-row reads and transactional writes should stay on ORM unless there is a measured reason to move them;
- ship a query DSL or a query builder beyond the per-vendor SQL helpers in
Sql\Builder\SqlBuilderInterface; - manage schema or migrations — use Doctrine Migrations or any other tool you already have;
- ship an ORM-style unit-of-work or identity map. The bundle operates row-by-row at the DBAL level;
- support Doctrine ORM 3.x + DBAL 3.x as a runtime combo. The bundle targets DBAL 4.2+; using DBAL 3.x will throw at boot.
Compatibility
| Component | Version |
|---|---|
| PHP | 8.3+ (strict types) |
| Symfony | 7.2+ |
| Doctrine DBAL | 4.2+ (verified on 4.4.x) |
| MySQL | 8.0+ (verified on 8.0, 8.4 LTS) |
| MariaDB | 10.5+ (verified on 10.6, 10.10, 10.11, 11.0.7) |
| PostgreSQL | 12+ (test fixtures target 16; 17 expected compatible) |
| PSR-3 | 1.x or 3.x (declared psr/log: ^3.0 in composer.json) |
CI gate: scripts/check_interface_namespace_imports.sh rejects legacy Enum\*Interface imports.
Architecture
The bundle is built on interfaces and abstractions that are easy to extend and adapt to any needs.
At the core of select operations are generators (yield), which allows:
- processing large volumes of data with minimal memory consumption;
- starting data processing before the entire query completes (lazy loading);
- implementing streaming and data transfer — useful when integrating with queues, APIs, synchronisation logic, and exports.
Core interfaces
Finder / Mutator
DbalFinderInterface: reading data, with optional mapping of the result to a DTO.DbalMutatorInterface: updates, deletes, inserts, plus a plainexecutemethod.
Bulk operations
BulkInserterInterface: inserts one or several rows into the database.BulkUpdaterInterface: updates one or several rows in the database.BulkUpserterInterface: a combined operation that updates or inserts rows (upsert) in the database.BulkDeleterInterface: deletes rows from the database, including soft-delete support.
Iterators
CursorIteratorInterface: cursor-based reading, suitable for streaming processing.OffsetIteratorInterface: standard paginated iteration.
Helpers
DtoFieldExtractor: extracts and normalises fields from a DTO.DbalTypeGuesser: maps PHP types to SQL types.MysqlSqlBuilder: SQL query generator for MySQL.
Installation
composer require elriseio/dbal-bundle
Register the bundle in config/bundles.php:
return [
Elrise\Bundle\DbalBundle\ElriseDbalBundle::class => ['all' => true],
];
Working with DbalManagerFactory
The DbalManagerFactory class lets you conveniently create DBAL infrastructure components with the ability to override the database connection (Connection) and the configuration (DbalBundleConfig) per service.
Quick creation of DbalManager
If you want to use all DBAL components at once, just call createManager():
$dbalManager = $factory->createManager();
You can pass custom Connection and DbalBundleConfig:
$dbalManager = $factory->createManager($customConnection, $customConfig);
Creating individual components
If you need one of the components separately, use the matching method:
$finder = $factory->createFinder(...);
$mutator = $factory->createMutator(...);
$cursorIterator = $factory->createCursorIterator(...);
$offsetIterator = $factory->createOffsetIterator(...);
$bulkInserter = $factory->createBulkInserter(...);
$bulkUpdater = $factory->createBulkUpdater(...);
$bulkUpserter = $factory->createBulkUpserter(...);
$bulkDeleter = $factory->createBulkDeleter(...);
For every method you can specify a custom Connection and (optionally) DbalBundleConfig. This is especially useful when working with multiple databases or with different configuration strategies.
Service example
class MyService
{
public function __construct(private DbalManagerFactory $factory) {}
public function updateBulkData(array $rows): void
{
$bulkUpdater = $this->factory->createBulkUpdater();
$bulkUpdater->update('my_table', $rows);
}
}
Bulk Insert
The module supports bulk data insertion with the ability to specify:
- the table name;
- the array of rows to insert;
- automatic or manual ID generation;
- explicit type per field.
Usage example
/** @var BulkInserterInterface $inserter */
$inserter->insert('user_table', [
[
'id' => IdStrategy::AUTO_INCREMENT, // ID generated by the DB
'email' => ['user1@example.com', ParameterType::STRING],
'created_at' => (new \DateTime())->format('Y-m-d H:i:s'),
],
[
'id' => IdStrategy::UUID, // ID generated in code
'email' => ['user2@example.com', ParameterType::STRING],
'created_at' => (new \DateTime())->format('Y-m-d H:i:s'),
],
[
// ID generated in code (UUIDv7 default)
'email' => ['user3@example.com', ParameterType::STRING],
'created_at' => (new \DateTime())->format('Y-m-d H:i:s'),
],
]);
The
['value', ParameterType::TYPE]array lets you set the value type, compatible withDoctrine\DBAL\ParameterType. If the type is not specified, it is determined automatically.
ID generation strategies (IdStrategy)
The ID can be generated automatically or set manually, depending on the strategy:
| Strategy | Description |
|---|---|
IdStrategy::AUTO_INCREMENT | The value is not given — it is generated at the database level. |
IdStrategy::UUID | The value is generated in code (UUIDv7). Default for new code. |
IdStrategy::UID | Deprecated since 1.0.x for collision-safety; remains in 2.0 (per ADR-0002 § Decision 3). The 18-char id is collision-prone under concurrent writers. Prefer IdStrategy::UUID (UUIDv7) for new code; existing IdStrategy::UID users do not need to migrate to upgrade to 2.0. |
IdStrategy::INT | The value is generated as a random integer. |
IdStrategy::STRING | A string is generated (for example, based on uniqid()). |
IdStrategy::DEFAULT | The value must be used for working with Postgres and generating a DEFAULT ID as part of Insert / Upsert operations. |
IdStrategy::migrateFromV1() | Static helper. Maps v1 cases (INT, STRING) to their v2 replacements per ADR-0002 § Decision 3. Available in 1.x as a developer-experience aid for consumers planning their 2.0 upgrade. Cases surviving 2.0 (AUTO_INCREMENT, UUID, UID, DEFAULT) pass through unchanged. |
DbalBulkUpdater
DbalBulkUpdater lets you update from one to many rows in the database.
Example
$bulkUpdater
->updateMany('api_history', [
['id' => 1, 'status' => 'success'],
['id' => 2, 'status' => 'success'],
]);
By default the
idfield is used as the condition. Updates are issued viaCASE WHEN ... THEN ...without multiple queries. The number of affected rows is returned.
DbalBulkUpserter
DbalBulkUpserter lets you insert or update records by key fields. If a record with that id already exists, it is updated; otherwise a new record is inserted.
Example
$bulkUpserter
->upsertMany('api_history', [
[
'id' => 123,
'status' => 'success',
'updated_at' => date('Y-m-d H:i:s'),
'created_at' => date('Y-m-d H:i:s'),
],
[
'id' => IdStrategy::AUTO_INCREMENT,
'status' => 'success',
'updated_at' => date('Y-m-d H:i:s'),
'created_at' => date('Y-m-d H:i:s'),
],
], ['status', 'updated_at']);
Replaceable fields are passed as the third argument (
replaceFields).idcan be generated automatically viaIdStrategy::AUTO_INCREMENT.
PostgreSQL: RETURNING id in one round-trip
On PostgreSQL, upsertManyReturningIds appends RETURNING <column> to the upsert SQL and returns the generated/updated IDs in row order. MySQL / MariaDB do not support RETURNING and will throw LogicException on this call.
$ids = $bulkUpserter->upsertManyReturningIds(
'api_history',
[
['id' => IdStrategy::AUTO_INCREMENT, 'status' => 'success', 'updated_at' => date('Y-m-d H:i:s')],
['id' => IdStrategy::AUTO_INCREMENT, 'status' => 'pending', 'updated_at' => date('Y-m-d H:i:s')],
],
['status', 'updated_at'],
);
// $ids is [123, 124] in the order of the input rows.
DbalFinder
DbalFinder provides methods for typed retrieval of data from the database.
Usage examples
// Get one row by SQL (LIMIT 1 is appended automatically).
$result = $finder->fetchOneBySql(
'SELECT * FROM api_history WHERE id = :id',
['id' => $id],
ApiDto::class
);
// Get multiple rows with mapping to a DTO.
$results = $finder->fetchAllBySql(
'SELECT * FROM api_history ORDER BY id LIMIT 10',
[],
ApiDto::class
);
// Find a record by ID.
$result = $finder->findById($id, 'api_history', ApiDto::class);
// Find records by a list of IDs.
$result = $finder->findByIdList($idList, 'api_history', ApiDto::class);
If no DTO class is given, an array is returned.
DbalMutator
DbalMutator is intended for safe insertion and modification of data in database tables.
Usage examples
// Insert one row into a table.
$mutator->insert('api_history', [
'type' => ['callback', ParameterType::STRING],
'merchant_id' => '12345',
'provider' => 'example-provider',
'trace_id' => 'trace-001',
'our_id' => 'our-001',
'ext_id' => 'ext-001',
'data' => json_encode(['source' => 'test']),
'status' => 'success',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
Fields with types (for example,
['value', ParameterType::STRING]) are supported. If no type is specified, it is determined automatically.
⚠️ Important
Before using insert(), updateMany(), upsertMany(), you must always specify the actual service fields via setFieldNames() or the common fieldNames config:
->setFieldNames([
BundleConfigurationInterface::ID_NAME => 'id',
BundleConfigurationInterface::CREATED_AT_NAME => 'created_at',
BundleConfigurationInterface::UPDATED_AT_NAME => 'updated_at',
])
SQL caller-trace comment (opt-in)
The bundle exposes the existing DbalConnection::setAdditionalSqlCommentEnable toggle through the doctrine_dbal bundle configuration. Default: off (backward-compatible).
When enabled, every executeQuery, executeStatement, and prepare call prepends a JSON caller-trace comment to the SQL string. The comment carries applicationCaller (the first non-framework class in the backtrace) and entryPointController (the Symfony controller or console command name, if any). Operators can read the comment in their MySQL slow log, PostgreSQL pg_stat_statements, or the database’s general log to see which application code path produced each query.
Enable per environment in config/packages/doctrine_dbal.yaml:
doctrine_dbal:
sql_comment_enabled: true
The same flag is also exposed on DbalBundleConfig::$sqlCommentEnabled for programmatic control in tests or custom wiring.
PSR-3 Logger integration for bulk operations (opt-in)
AbstractDbalWriteExecutor and its descendants (BulkInserter, BulkUpdater, BulkUpserter, BulkDeleter) accept an optional Psr\Log\LoggerInterface. When no logger is injected, the executor uses a NullLogger and behaves exactly as before. When a logger is injected (e.g. Symfony\Bridge\Monolog\Logger), each bulk operation emits four event types:
| Event | Level | Payload |
|---|---|---|
dbal.bulk.{op}.start | INFO | operation, table, chunk_size, total_rows |
dbal.bulk.{op}.end | INFO | operation, table, rows_affected, duration_ms, peak_memory_mb |
dbal.bulk.constraint_violation | WARNING | original DBAL message, attempt count |
dbal.bulk.connection_error | ERROR | original DBAL message, attempt count |
{op} is one of insert, update, upsert, delete, soft_delete.
Wire a logger via services.yaml:
services:
Elrise\Bundle\DbalBundle\Manager\Bulk\BulkInserter:
parent: Elrise\Bundle\DbalBundle\Manager\Bulk\AbstractDbalWriteExecutor
arguments:
$logger: '@monolog.logger.dbal'
monolog.logger.dbal:
class: Symfony\Bridge\Monolog\Logger
arguments: ['dbal']
composer.json already declares psr/log: ^3.0; no additional dependency is required.
Concurrency helpers (advisory locks and row-level locks)
TransactionService exposes high-throughput concurrency helpers.
PostgreSQL advisory locks
PostgreSQL advisory locks are application-level mutexes not tied to any table — useful for leader election or single-writer sections across an entire app.
// PG advisory lock — leader election or single-writer section
$txService->transactional(function () use ($txService) {
$txService->acquireAdvisoryLock(42); // blocks until granted
try {
// ... critical section ...
} finally {
$txService->releaseAdvisoryLock(42);
}
});
tryAdvisoryLock($key) is the non-blocking variant; it returns false if another holder owns the lock. All three advisory-lock methods are PostgreSQL-only and throw LogicException on MySQL / MariaDB.
Row-level locks
SELECT ... WHERE ... FOR UPDATE locks specific rows before a read-modify-write sequence so other transactions cannot modify them until the current transaction commits or rolls back.
// Row-level lock before read-modify-write
$txService->transactional(function () use ($txService, $id) {
$txService->lockRows('orders', ['id' => $id]); // FOR UPDATE
$order = $finder->findById($id, 'orders');
// ... modify and save ...
});
The LockMode enum (FOR_UPDATE, FOR_NO_KEY_UPDATE, FOR_SHARE, FOR_KEY_SHARE) covers both PostgreSQL row-lock forms and MySQL 8+ semantics. On MySQL legacy, FOR_SHARE maps to LOCK IN SHARE MODE. FOR_NO_KEY_UPDATE and FOR_KEY_SHARE are PostgreSQL-only and throw LogicException on MySQL / MariaDB.
Registered Doctrine Types
The bundle registers seven Doctrine Types on build() so consumers can declare columns via #[Column(type: '...')] without hand-rolled Type registration:
float_array—FLOAT[]array literal (PostgreSQL).float4_array—FLOAT4[]array literal (PostgreSQL).int_array—INT[]array literal (PostgreSQL).text_array—TEXT[]array literal (PostgreSQL).numeric_array—NUMERIC[]array literal (PostgreSQL).jsonb— Doctrine’s built-inJsonbType; emitsJSONBon PostgreSQL 12+ and falls back toJSONon MySQL / MariaDB.jsonb_object— Doctrine’s built-inJsonbObjectType; same shape asjsonbbut normalises object shapes on read.
Verified Load-Test Performance
Measured by the itests/ integration load-testing pipeline (ADR-0004) on real MySQL 8.4, PostgreSQL 16, and MariaDB 11 in Docker. Full report with the per-vendor raw envelopes: itests/reports/WAVE_LT_CROSS_VENDOR_REPORT.md.
Single-vendor metrics (one --rows 1000 --chunk 100 invocation per scenario):
| Scenario | Vendor | duration | ops/sec | p50 latency | p95 / p99 latency | errors |
|---|---|---|---|---|---|---|
bulk_insert | MySQL 8.4 | 0.207s | 4,831 | 16.48 ms | 41.45 ms | 0 |
bulk_insert | PostgreSQL 16 | 0.130s | 7,692 | 10.31 ms | 20.79 ms | 0 |
bulk_insert | MariaDB 11 | 0.222s | 4,505 | 20.73 ms | 37.99 ms | 0 |
bulk_upsert | MySQL 8.4 | 0.027s | 37,037 | 11.29 ms | 104.90 ms | 0 |
bulk_upsert | PostgreSQL 16 | 0.108s | 9,259 | 10.17 ms | 805.01 ms | 0 |
bulk_upsert | MariaDB 11 | 0.028s | 35,714 | 12.12 ms | 47.19 ms | 0 |
bulk_update | MySQL 8.4 | 0.031s | 64,516 | 1.97 ms | 19.45 / 31.68 ms | 0 |
bulk_update | PostgreSQL 16 | 0.148s | 13,514 | 9.34 ms | 14.45 / 16.23 ms | 0 |
bulk_update | MariaDB 11 | 0.028s | 71,429 | 1.81 ms | 9.41 / 13.85 ms | 0 |
cursor_stream | MySQL 8.4 | 0.002s | 500,000 | 0.15 ms | 0.21 ms | 0 |
cursor_stream | PostgreSQL 16 | 0.003s | 333,333 | 0.23 ms | 0.30 ms | 0 |
cursor_stream | MariaDB 11 | 0.002s | 500,000 | 0.13 ms | 0.17 ms | 0 |
Bounded-concurrency parallel runner (--workers 2 --concurrency 2, cursor_stream, --rows 500 per worker):
| Vendor | total_rows | total_duration | throughput ops/sec | p50 / p95 / p99 latency | errors |
|---|---|---|---|---|---|
| MySQL 8.4 | 400 | 0.002s | 200,000 | 0.31 / 0.34 / 0.42 ms | 0 |
| PostgreSQL 16 | 1,000 | 0.002s | 500,000 | 0.23 / 0.24 / 0.24 ms | 0 |
| MariaDB 11 | 1,000 | 0.002s | 500,000 | 0.32 / 0.35 / 0.38 ms | 0 |
PG and MariaDB scale linearly with worker count. MySQL’s total_rows=400 on the parallel run is a known unique-email collision between workers, not a correctness regression.
Envelope contract (ADR-0004 Decision §5) is observed end-to-end on all three vendors; 306/306 PHPUnit tests pass; php-cs-fixer reports 0 violations. bulk_insert is bounded at 4-7k rows/sec on all three vendors — a known gap to native COPY / LOAD DATA captured in ADR-0005 and tracked under AR-036; not a correctness regression.
Testing
The bundle ships a BulkTest console-command suite under BulkTestCommands/ that exercises every bulk operation against a real database. The fixtures live in tests/_db/.
Wire the test commands
Add to services.yaml:
services:
Elrise\Bundle\DbalBundle\Manager\Bulk\BulkUpserter:
arguments:
$connection: '@Doctrine\DBAL\Connection'
$config: '@Elrise\Bundle\DbalBundle\Config\DbalBundleConfig'
$sqlBuilder: '@Elrise\Bundle\DbalBundle\Sql\Builder\SqlBuilderInterface'
Elrise\Bundle\DbalBundle\BulkTestCommands\BulkInsertManyCommand:
arguments:
$connection: '@Doctrine\DBAL\Connection'
$bulkInserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkInserterInterface'
tags: [ 'console.command' ]
Elrise\Bundle\DbalBundle\BulkTestCommands\BulkUpdateManyCommand:
arguments:
$connection: '@Doctrine\DBAL\Connection'
$bulkInserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkInserterInterface'
$bulkUpdater: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkUpdaterInterface'
tags: [ 'console.command' ]
Elrise\Bundle\DbalBundle\BulkTestCommands\BulkUpsertManyCommand:
arguments:
$connection: '@Doctrine\DBAL\Connection'
$bulkInserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkInserterInterface'
$bulkUpserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkUpserterInterface'
tags: [ 'console.command' ]
Elrise\Bundle\DbalBundle\BulkTestCommands\BulkDeleteManyCommand:
arguments:
$connection: '@Doctrine\DBAL\Connection'
$bulkDeleter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkDeleterInterface'
$bulkInserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkInserterInterface'
tags: [ 'console.command' ]
Elrise\Bundle\DbalBundle\BulkTestCommands\BulkSoftDeleteManyCommand:
arguments:
$connection: '@Doctrine\DBAL\Connection'
$bulkDeleter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkDeleterInterface'
$bulkInserter: '@Elrise\Bundle\DbalBundle\Manager\Contract\BulkInserterInterface'
tags: [ 'console.command' ]
Test table
Run the prepopulated SQL fixtures against your test database before executing the commands:
// for MySQL
tests/_db/init.sql
// for PostgreSQL
tests/_db/init_postgres.sql
Command usage
bin/console dbal:test:run-all
bin/console dbal:test:bulk-insert-many
bin/console dbal:test:bulk-update-many
bin/console dbal:test:bulk-upsert-many
bin/console dbal:test:bulk-delete-many
bin/console dbal:test:bulk-soft-delete-many
bin/console dbal:test:cursor-iterator
bin/console dbal:test:offset-iterator
bin/console dbal:test:finder
bin/console dbal:test:mutator
bin/console dbal:test:transaction-service
bin/console dbal:test:insert
Every command supports:
--chunk=<int>— chunk size for batch processing.--count=<int>— number of records (default 1000).--cycle=<int>— number of insert / update / delete cycles (for benchmarking).--track— enables logging of the results.
Example:
bin/console app:test:bulk-upsert-many --chunk=200 --count=5000 --cycle=5 --track
Result logging
When the --track flag is passed, the command saves performance logs to a CSV file:
var/log/<test_type>_<timestamp>.csv
Each row in the log contains:
- iteration number;
- execution time;
- memory used;
- memory delta;
- cumulative time.
Get hands-on
dummy-market-agent is a reference Symfony service with Application Layer, CQRS, API Platform, and DBAL-based persistence. Start it with Docker to inspect API operations, command/query handlers, persistence, container wiring, layer boundaries, and tests.
Known compatibility gaps
- PostgreSQL 17 / MariaDB 12 / MySQL 9 have not yet been added as dedicated test fixtures. SQL fragments in the bundle are expected to remain compatible because they target stable core features (
ON DUPLICATE KEY UPDATE,ON CONFLICT ... DO UPDATE), but please open an issue if you hit a regression on a newer server.