aeatech/transaction-manager-doctrine-adapter
Doctrine DBAL adapter for AEATech Transaction Manager with best‑effort prepared‑statement reuse and explicit parameter binding compatible with both DBAL 3 and DBAL 4.
None, PerTransaction, PerConnection.PDO::PARAM_* integer constants.AEATech\TransactionManager\DoctrineAdapter\DbalMysqlStatementCachingConnectionAdapterAEATech\TransactionManager\DoctrineAdapter\DbalPostgresStatementCachingConnectionAdapterAEATech\TransactionManager\DoctrineAdapter\DbalMysqlConnectionAdapterAEATech\TransactionManager\DoctrineAdapter\DbalPostgresConnectionAdaptercomposer require aeatech/transaction-manager-doctrine-adapter
use AEATech\TransactionManager\DoctrineAdapter\DbalMysqlStatementCachingConnectionAdapter;
use AEATech\TransactionManager\DoctrineAdapter\StatementCache\LruStatementCache;
use AEATech\TransactionManager\DoctrineAdapter\StatementCache\SqlAndParamCountCacheKeyBuilder;
use AEATech\TransactionManager\DoctrineAdapter\StatementExecutor\BindingInfoResolver;
use AEATech\TransactionManager\DoctrineAdapter\StatementExecutor\StatementExecutor;
use AEATech\TransactionManager\Query;
use AEATech\TransactionManager\StatementReusePolicy;
use Doctrine\DBAL\DriverManager;
$conn = DriverManager::getConnection([
'driver' => 'pdo_mysql',
'host' => '127.0.0.1',
'dbname' => 'app',
'user' => 'app',
'password' => 'secret',
]);
$executor = new StatementExecutor(new BindingInfoResolver());
$perTxCache = new LruStatementCache(100);
$perConnCache = new LruStatementCache(500);
$keyBuilder = new SqlAndParamCountCacheKeyBuilder();
$adapter = new DbalMysqlStatementCachingConnectionAdapter(
$conn,
$executor,
$keyBuilder,
$perTxCache,
$perConnCache,
);
// Execute parametrized query with statement reuse
$q = new Query(
'UPDATE accounts SET balance = balance - ? WHERE id = ?',
[100, 42]
);
$q->statementReusePolicy = StatementReusePolicy::PerTransaction;
$affected = $adapter->executeQuery($q);
None: No caching — each call prepares a fresh statement (may result in prepare-per-call behavior for server-side prepared statements).PerTransaction: Prepared statements are cached for the current transaction and dropped on commit()/rollBack().PerConnection: Prepared statements are cached for the lifetime of the connection and dropped on close().LruStatementCache provides O(1) get/set and evicts the least‑recently used entry when capacity is exceeded.
Implementation notes:
Doctrine\DBAL\Statement objects.set() pushes the size above capacity.clear() drops the entire cache (used on transaction/connection boundaries).SqlAndParamCountCacheKeyBuilder builds keys as sha256(sql) | 'p:' . count(params).
Implications:
StatementExecutor executes an already prepared DBAL Statement and performs explicit parameter binding using the wrapped driver statement. This bypasses DBAL’s internal binding logic and supports a broader set of type descriptors for compatibility across DBAL versions.
Supported type descriptors for each parameter:
Doctrine\DBAL\ParameterType (DBAL 4 enum)Doctrine\DBAL\Types\Type instance'integer', 'string')PDO::PARAM_* ints (PDO::PARAM_INT, PDO::PARAM_BOOL, PDO::PARAM_NULL, PDO::PARAM_LOB)Binding rules:
?). Original array keys may have gaps — binding order follows array iteration order.':id' vs 'id'). The executor does not normalize names.ParameterType::STRING.IN (...) expansion) and are rejected.Compatibility notes:
Type::getType($name), then convertToDatabaseValue() is applied using the active platform, and getBindingType() is used for the driver bind.PDO::PARAM_* integers are mapped to the corresponding ParameterType where applicable.DbalMysqlStatementCachingConnectionAdapter::beginTransactionWithOptions() sets the isolation level for the next transaction (if provided in $options) and then begins the transaction:
$adapter->beginTransactionWithOptions($options);
// executes: SET TRANSACTION ISOLATION LEVEL ... (only if isolationLevel is set), then BEGIN
DbalPostgresStatementCachingConnectionAdapter::beginTransactionWithOptions() begins a transaction first and then sets isolation for the current transaction only (if provided in $options):
$adapter->beginTransactionWithOptions($options);
// executes: BEGIN; then SET TRANSACTION ISOLATION LEVEL ... (only if isolationLevel is set)
Both adapters:
isolationLevel. If null, no isolation level command is issued, and the database/session default is used.begin with options, commit, rollBack).rowCount() is returned from the driver result and may be 0 for unsupported operations depending on the driver.PerTransaction and prevented by clearing caches.use Doctrine\DBAL\ParameterType;
$q = new Query('UPDATE t SET a = ? WHERE id = ?', ['10', 5]);
$q->types = [0 => 'integer', 1 => ParameterType::INTEGER];
$q->statementReusePolicy = StatementReusePolicy::PerTransaction;
$adapter->executeQuery($q);
use Doctrine\DBAL\ParameterType;
$q = new Query('UPDATE t SET a = :a WHERE id = :id', [':a' => 'x', ':id' => 10]);
$q->types = [':a' => 'string', ':id' => ParameterType::INTEGER];
$q->statementReusePolicy = StatementReusePolicy::PerConnection;
$adapter->executeQuery($q);
use PDO;
$q = new Query('INSERT INTO files(data) VALUES(?)', [$blob]);
$q->types = [PDO::PARAM_LOB];
$adapter->executeQuery($q);
Selecting the appropriate adapter depends on your database, prepared statement mode, and performance characteristics of your workload.
| Database | Mode | Recommended Adapter | Why? |
|---|---|---|---|
| MySQL | Server-side Prepares | DbalMysqlStatementCachingConnectionAdapter |
Significant performance gain. avoids repeated COM_STMT_PREPARE round-trips and server-side parsing. |
| MySQL | Emulated Prepares | DbalMysqlConnectionAdapter |
Simpler and sufficient. PDO client-side emulation is already efficient; statement caching provides no measurable benefit. |
| PostgreSQL | Native | DbalPostgresConnectionAdapter |
Recommended default. pdo_pgsql preparation overhead is low; client-side caching yields only marginal gains in typical workloads. |
| PostgreSQL | Complex/Heavy load | DbalPostgresStatementCachingConnectionAdapter |
Optional optimization. May reduce allocation and preparation overhead under extreme load or highly repetitive statement execution. |
PDO::ATTR_EMULATE_PREPARES => false, prefer DbalMysqlStatementCachingConnectionAdapter. Reusing prepared statements avoids repeated server-side prepares and can result in 2× or greater throughput improvements for statement-heavy workloads.For detailed performance measurements and the rationale behind these recommendations, see the Benchmarking section.
The package includes a benchmark suite to measure the effectiveness of prepared statement reuse across different databases and configurations.
A dedicated script bench.sh is provided to run benchmarks in a controlled environment with CPU pinning to reduce noise.
# Run all benchmarks (MySQL and PostgreSQL)
./bench.sh all
# Run only MySQL benchmarks
./bench.sh mysql
# Run only PostgreSQL benchmarks
./bench.sh pgsql
The script performs the following actions:
phpbench within the PHP container.Typical results (measured on PHP 8.4, Opcache enabled, Xdebug disabled):
| Database | Mode | Subject | No Cache | With Cache | Improvement |
|---|---|---|---|---|---|
| MySQL | Server-side Prepares | Simple Query | ~52μs | ~22μs | ~57% |
| MySQL | Server-side Prepares | Complex Query | ~59μs | ~24μs | ~59% |
| MySQL | Emulated Prepares | Simple Query | ~31μs | ~31μs | ~0% |
| MySQL | Emulated Prepares | Complex Query | ~38μs | ~38μs | ~0% |
| PostgreSQL | Native | Simple Query | ~38μs | ~37μs | ~2% |
| PostgreSQL | Native | Complex Query | ~41μs | ~40μs | ~2% |
Note:
In theNo Cachescenario with MySQL server-side prepared statements, the benchmark intentionally performs a fullPREPAREon every execution.
This represents a worst-case usage pattern where statements are not reused at all.
The observed speedup therefore reflects the cost of repeated server-side prepares rather than the overhead of the cache itself.
These benchmarks are designed to measure adapter-level behavior in a controlled environment. Absolute numbers should not be treated as universal performance characteristics of MySQL or PostgreSQL.
Make sure the Docker containers are up and running. From the project root:
docker-compose -p aeatech-transaction-manager-doctrine-adapter -f docker/docker-compose.yml up -d --build
docker-compose -p aeatech-transaction-manager-doctrine-adapter -f docker/docker-compose.yml exec php-cli-8.2 composer install
PHP 8.2
docker-compose -p aeatech-transaction-manager-doctrine-adapter -f docker/docker-compose.yml exec php-cli-8.2 vendor/bin/phpunit
PHP 8.3
docker-compose -p aeatech-transaction-manager-doctrine-adapter -f docker/docker-compose.yml exec php-cli-8.3 vendor/bin/phpunit
PHP 8.4
docker-compose -p aeatech-transaction-manager-doctrine-adapter -f docker/docker-compose.yml exec php-cli-8.4 vendor/bin/phpunit
for v in 8.2 8.3 8.4; do \
echo "Testing PHP $v..."; \
docker-compose -p aeatech-transaction-manager-doctrine-adapter -f docker/docker-compose.yml exec -T php-cli-$v vendor/bin/phpunit || break; \
done
docker-compose -p aeatech-transaction-manager-doctrine-adapter -f docker/docker-compose.yml exec php-cli-8.4 vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G
This project is licensed under the MIT License. See the LICENSE file for details.
How can I help you explore Laravel packages today?