Skip to content

ext/pdo: Add PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS attribute#21257

Closed
Crovitche-1623 wants to merge 1 commit intophp:masterfrom
Crovitche-1623:pdo-autocommit-aware-transactions
Closed

ext/pdo: Add PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS attribute#21257
Crovitche-1623 wants to merge 1 commit intophp:masterfrom
Crovitche-1623:pdo-autocommit-aware-transactions

Conversation

@Crovitche-1623
Copy link

@Crovitche-1623 Crovitche-1623 commented Feb 19, 2026

Short description

Proposal for a new opt-in PDO attribute that makes commit() and rollBack() aware of the logical transaction state when autocommit is disabled, eliminating the need for a redundant BEGIN after every COMMIT.

Problem statement

When PDO::ATTR_AUTOCOMMIT is set to false, MySQL sends SET autocommit = 0 at connection time. In this mode, every SQL statement is implicitly part of a transaction — there is no "auto-commit after each statement" behavior. A COMMIT ends the current transaction, and the next statement implicitly starts a new one.

However, MySQL's SERVER_STATUS_IN_TRANS flag — which PHP 8+'s PDO::inTransaction() and PDO::commit() rely on — is cleared by COMMIT. It is only set again when the next SQL statement executes (any statement, including SELECT). This creates a gap:

1. SET autocommit = 0          → flag NOT set (no transaction yet)
2. INSERT INTO ...              → flag SET (implicit transaction started)
3. COMMIT                       → flag CLEARED
   ← GAP: inTransaction()=false, commit() throws →
4. INSERT INTO ...              → flag SET again

During this gap (between steps 3 and 4), calling PDO::commit() throws:

PDOException: There is no active transaction

This forces every framework and ORM to implement the same workaround: immediately send BEGIN after every COMMIT to re-set the server flag. This BEGIN is functionally redundant — MySQL is already ready for an implicit transaction — but without it, the next commit() call fails.

Concrete impact

Each level-1 commit produces two SQL statements instead of one:

COMMIT;              -- actual commit
BEGIN;               -- redundant re-sync (one extra network round-trip)

In a typical web request with 1–3 explicit commits, this means 1–3 wasted round-trips to MySQL per request.

Benchmark results

A benchmark comparing COMMIT + BEGIN (current workaround) vs COMMIT only (proposed behavior) on MySQL 8.4 (Docker, local network). Results averaged over 2 runs of 50,000 commit cycles each (100,000 total cycles):

COMMIT + BEGIN (current)             35.006314 s
COMMIT only (proposed)               31.380110 s
--------------------------------------------------------
Difference                           3.626204 s
Overhead                             11.6%
Per-commit overhead                  73 µs
Total commit cycles                  50000 (per run, averaged over 2 runs)

The redundant BEGIN adds ~12% overhead and ~73 µs per commit. This is on a local Docker network with near-zero latency — in production environments with network latency between application and database (cloud services, separate hosts), the per-commit cost would be higher.

Benchmark script
<?php

/**
 * Benchmark: measures the overhead of a redundant BEGIN after every COMMIT
 * when autocommit is disabled.
 *
 * Compares two approaches:
 *   A) INSERT → COMMIT → BEGIN  (current workaround)
 *   B) INSERT → COMMIT          (proposed: no redundant BEGIN)
 *
 * Usage:
 *   php benchmark_begin_overhead.php [iterations] [runs]
 *
 * Examples:
 *   php benchmark_begin_overhead.php              # 1000 iterations, 5 runs
 *   php benchmark_begin_overhead.php 5000 10       # 5000 iterations, 10 runs
 *
 * Environment variables:
 *   MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE
 */

$iterations = (int) ($argv[1] ?? 1000);
$runs       = (int) ($argv[2] ?? 5);
$host       = $_SERVER['MYSQL_HOST'] ?? 'database';
$port       = (int) ($_SERVER['MYSQL_PORT'] ?? 3306);
$user       = $_SERVER['MYSQL_USER'] ?? 'root';
$pass       = $_SERVER['MYSQL_PASSWORD'] ?? ($_SERVER['MYSQL_ROOT_PASSWORD'] ?? 'root');
$db         = $_SERVER['MYSQL_DATABASE'] ?? 'database';

$dsn = \sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $db);

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE          => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

$pdo->exec('SET autocommit = 0');

$pdo->exec('DROP TABLE IF EXISTS _bench_begin_overhead');
$pdo->exec(
    'CREATE TABLE _bench_begin_overhead ('
    . 'id INT AUTO_INCREMENT PRIMARY KEY,'
    . 'val INT NOT NULL'
    . ') ENGINE=InnoDB',
);
$pdo->exec('COMMIT');

$stmt = $pdo->prepare('INSERT INTO _bench_begin_overhead (val) VALUES (?)');

$version = $pdo->query('SELECT VERSION()')->fetchColumn();

\printf("Benchmark: redundant BEGIN overhead after COMMIT\n");
\printf("%-24s %s\n", 'MySQL version', $version);
\printf("%-24s %s:%d\n", 'Server', $host, $port);
\printf("%-24s %d\n", 'Iterations per run', $iterations);
\printf("%-24s %d\n", 'Runs', $runs);
\printf("%s\n\n", \str_repeat('-', 56));

$timeWithBegin    = 0;
$timeWithoutBegin = 0;

for ($run = 0; $run < $runs; ++$run) {
    $pdo->exec('TRUNCATE TABLE _bench_begin_overhead');
    $pdo->exec('COMMIT');

    // Alternate execution order to reduce systematic bias
    if ($run % 2 === 0) {
        $pdo->exec('BEGIN');
        $t = \hrtime(true);
        for ($i = 0; $i < $iterations; ++$i) {
            $stmt->execute([$i]);
            $pdo->exec('COMMIT');
            $pdo->exec('BEGIN');
        }
        $timeWithBegin += \hrtime(true) - $t;
        $pdo->exec('COMMIT');

        $pdo->exec('TRUNCATE TABLE _bench_begin_overhead');
        $pdo->exec('COMMIT');

        $pdo->exec('BEGIN');
        $t = \hrtime(true);
        for ($i = 0; $i < $iterations; ++$i) {
            $stmt->execute([$i]);
            $pdo->exec('COMMIT');
        }
        $timeWithoutBegin += \hrtime(true) - $t;
        $pdo->exec('COMMIT');
    } else {
        $pdo->exec('BEGIN');
        $t = \hrtime(true);
        for ($i = 0; $i < $iterations; ++$i) {
            $stmt->execute([$i]);
            $pdo->exec('COMMIT');
        }
        $timeWithoutBegin += \hrtime(true) - $t;
        $pdo->exec('COMMIT');

        $pdo->exec('TRUNCATE TABLE _bench_begin_overhead');
        $pdo->exec('COMMIT');

        $pdo->exec('BEGIN');
        $t = \hrtime(true);
        for ($i = 0; $i < $iterations; ++$i) {
            $stmt->execute([$i]);
            $pdo->exec('COMMIT');
            $pdo->exec('BEGIN');
        }
        $timeWithBegin += \hrtime(true) - $t;
        $pdo->exec('COMMIT');
    }

    \printf("  run %d/%d done\n", $run + 1, $runs);
}

$totalCycles   = $iterations * $runs;
$secWithBegin  = $timeWithBegin / 1_000_000_000;
$secWithout    = $timeWithoutBegin / 1_000_000_000;
$diff          = $secWithBegin - $secWithout;
$overheadPct   = ($diff / $secWithout) * 100;
$perCommitUs   = ($diff / $totalCycles) * 1_000_000;

\printf("\n%s\n", \str_repeat('-', 56));
\printf("%-36s %02.6f s\n", 'COMMIT + BEGIN (current)', $secWithBegin);
\printf("%-36s %02.6f s\n", 'COMMIT only (proposed)', $secWithout);
\printf("%s\n", \str_repeat('-', 56));
\printf("%-36s %02.6f s\n", 'Difference', $diff);
\printf("%-36s %.1f%%\n", 'Overhead', $overheadPct);
\printf("%-36s %.0f µs\n", 'Per-commit overhead', $perCommitUs);
\printf("%-36s %d\n", 'Total commit cycles', $totalCycles);
\printf("%s\n", \str_repeat('-', 56));

$pdo->exec('DROP TABLE IF EXISTS _bench_begin_overhead');
$pdo->exec('COMMIT');

Context: PHP 7 → PHP 8 behavioral change

PHP 7

PDO::inTransaction() and PDO::commit() checked an internal in_txn boolean, managed entirely client-side:

  • beginTransaction()in_txn = true
  • commit() / rollBack()in_txn = false

This flag was purely based on PDO API calls and did not reflect the actual server state. For example, a DDL statement (CREATE TABLE) triggers an implicit commit in MySQL, but PHP 7's inTransaction() still returned true.

PHP 8+

The MySQL PDO driver now provides pdo_mysql_in_transaction() in ext/pdo_mysql/mysql_driver.c, which queries the actual server status:

/* {{{ pdo_mysql_in_transaction */
static bool pdo_mysql_in_transaction(pdo_dbh_t *dbh)
{
    pdo_mysql_db_handle *H = (pdo_mysql_db_handle *)dbh->driver_data;
    PDO_DBG_ENTER("pdo_mysql_in_transaction");
    PDO_DBG_RETURN((pdo_mysql_get_server_status(H->server) & SERVER_STATUS_IN_TRANS) != 0);
}
/* }}} */

Both PDO::inTransaction() and PDO::commit() delegate to the same internal helper pdo_is_in_transaction():

static bool pdo_is_in_transaction(pdo_dbh_t *dbh) {
    if (dbh->methods->in_transaction) {
        return dbh->methods->in_transaction(dbh);
    }
    return dbh->in_txn;
}

This change was a net improvement — it correctly detects implicit commits from DDL statements. But it introduced the gap problem described above for users with autocommit = 0, because SERVER_STATUS_IN_TRANS is cleared after COMMIT even when autocommit is off.

Affected projects

Multiple major PHP frameworks encountered the broader "no active transaction" problem on PHP 8+ and implemented workarounds. The root trigger varies:

Project Issue Trigger Workaround
Doctrine DBAL Custom Connection subclasses autocommit = 0 gap Send BEGIN after every COMMIT (in custom wrapper)
Drupal #2736777 DDL implicit commit Check inTransaction() before commit()/rollBack()
Laravel #35380 DDL implicit commit Check inTransaction() before rollBack()
Yii2 #18406 DDL implicit commit Guard commit()/rollBack() calls
Doctrine Migrations #1104 DDL implicit commit Disable transactional migrations on MySQL

Important distinction: the Drupal, Laravel, Yii2, and Doctrine Migrations issues are triggered by DDL statements (CREATE TABLE, ALTER TABLE) that cause MySQL to implicitly commit the current transaction — this happens regardless of the autocommit setting. The Doctrine DBAL workaround (custom Connection subclass sending BEGIN after every COMMIT) is the one directly caused by the autocommit = 0 gap.

This proposal specifically targets the autocommit = 0 gap. However, it also helps the DDL case when autocommit is off: after a DDL implicit commit, SERVER_STATUS_IN_TRANS is cleared, and commit()/rollBack() would throw — the attribute prevents this throw.

For the DDL case with autocommit on, a different solution would be needed (e.g., checking inTransaction() before calling commit(), as Drupal does).

Proposed solution

New attribute: PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS

A new opt-in PDO attribute that modifies the behavior of commit() and rollBack() when autocommit is disabled and no server-level transaction is active.

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_AUTOCOMMIT => false,
    PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS => true,
]);

Behavioral change (only when both conditions are met)

When ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS is true and ATTR_AUTOCOMMIT is false:

Method Current behavior (gap) Proposed behavior (gap)
commit() Throws "no active transaction" Returns true silently (no-op)
rollBack() Throws "no active transaction" Returns true silently (no-op)
inTransaction() Returns false Unchanged — still returns false
beginTransaction() Works normally Unchanged — still works normally

Key design decisions:

  1. inTransaction() is unchanged — it continues to reflect the actual SERVER_STATUS_IN_TRANS flag. This preserves backward compatibility and keeps inTransaction() accurate for code that relies on the server-level transaction state.

  2. beginTransaction() is unchanged — it still works in the gap, which is correct because MySQL can accept an explicit BEGIN at any time when autocommit is off.

  3. Only commit() and rollBack() are affected — they silently succeed instead of throwing when there is nothing to commit/rollback. This is safe because a COMMIT or ROLLBACK with no pending changes is a no-op from MySQL's perspective.

Scope

  • No driver-specific changes — the change is in core PDO. While it technically applies to all drivers, the primary beneficiary is the MySQL driver where the autocommit = 0 + SERVER_STATUS_IN_TRANS gap is the most common pain point
  • Opt-in only — default behavior is unchanged
  • No new SQL emitted — this removes unnecessary SQL, it does not add any

Why this matters

Before (current state)

Every framework with autocommit = 0 must send a redundant BEGIN after every COMMIT:

→ COMMIT          ← OK    (1 round-trip)
→ BEGIN            ← OK    (1 round-trip, redundant)
... next operations ...
→ COMMIT          ← OK    (1 round-trip)
→ BEGIN            ← OK    (1 round-trip, redundant)

After (with the attribute)

→ COMMIT          ← OK    (1 round-trip)
... next operations ...
→ COMMIT          ← OK    (1 round-trip)

The redundant BEGIN is eliminated — each commit cycle costs one round-trip instead of two. For applications with frequent commits, this is a measurable improvement in latency and MySQL thread utilization.

Cross-driver compatibility

The proposed change is in core PDO (pdo_dbh.c), not in a specific driver. The guard condition !dbh->auto_commit ensures that the new behavior only activates when a driver actually supports disabling autocommit.

Driver ATTR_AUTOCOMMIT support Driver-level in_transaction() Change activates? Impact
pdo_mysql Yes Yes (SERVER_STATUS_IN_TRANS) Yes — primary target Eliminates redundant BEGIN
pdo_pgsql No Yes (PQtransactionStatus) No — auto_commit stays true None
pdo_sqlite No Yes (sqlite3_get_autocommit) No — auto_commit stays true None
pdo_sqlsrv No (throws error) No (uses in_txn) No — auto_commit stays true None
pdo_oci Yes No (uses in_txn) If user sets autocommit=false Safe — no gap exists with in_txn
pdo_firebird Yes Yes (in_manually_txn) If user sets autocommit=false Safe — no-op is benign
pdo_odbc Yes No (uses in_txn) If user sets autocommit=false Safe — no gap exists with in_txn

Key observations:

  • PostgreSQL, SQLite, SQL Server do not support ATTR_AUTOCOMMIT = false. The auto_commit field stays at its default true value — the proposed guard condition is never satisfied. Zero impact.

  • Oracle, ODBC support ATTR_AUTOCOMMIT but rely on PDO's internal in_txn flag (no driver-level in_transaction()). There is no gap: after commit(), in_txn is false and stays false until beginTransaction() is called. The no-op only fires on a double-commit, which is benign.

  • Firebird has its own in_manually_txn flag. After commit(), the flag is cleared and the driver restarts an autocommit transaction internally. The no-op is harmless.

  • MySQL/MariaDB is the only driver where the gap actually exists (between COMMIT and the next SQL statement, SERVER_STATUS_IN_TRANS is cleared). This is the primary use case the attribute is designed for.

Backward compatibility

Zero BC break. The attribute is opt-in and defaults to false. Existing code behaves identically. Only code that explicitly enables the attribute sees the new behavior.

Examples

Current workaround (typical Doctrine DBAL Connection subclass)

When using Doctrine DBAL with auto_commit: false, applications must subclass Connection and override commit() to immediately re-open a PDO-level transaction after every commit:

public function commit(): void
{
    // ... nesting level management ...

    if (1 === $this->getTransactionNestingLevel()) {
        $driverConnection->commit();

        // Without this BEGIN, the next commit() throws
        // "There is no active transaction"
        if (!$this->isAutoCommit()) {
            $driverConnection->beginTransaction(); // ← redundant BEGIN
        }
    }

    // ...
}

This pattern is used by any project combining Doctrine DBAL with autocommit = 0.

With the proposed attribute

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_AUTOCOMMIT => false,
    PDO::ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS => true,
]);

// No workaround needed — commit() in the gap silently returns true
$pdo->exec('INSERT INTO t VALUES (1)');
$pdo->commit();

$pdo->exec('INSERT INTO t VALUES (2)');
$pdo->commit(); // ← works without a preceding BEGIN

Alternatives considered

1. Modify pdo_mysql_in_transaction() to return true when autocommit is off

Rejected. This would make beginTransaction() throw "There is already an active transaction" whenever autocommit is off, breaking existing code. It also changes the semantics of inTransaction() from "does the server have an active transaction" to "are we logically in a transaction", which is a different question.

2. Move the BEGIN workaround into the MySQL driver's commit handler

Rejected. This just moves the redundant BEGIN from userland to C. The same SQL is emitted, the same round-trip is wasted. It solves nothing.

3. Change the default behavior (no opt-in attribute)

Rejected. Existing code may rely on the exception to detect programming errors (e.g., double-commit). An opt-in attribute avoids any BC break.

Test coverage

6 .phpt tests in ext/pdo_mysql/tests/, all passing against MySQL 8.4:

  • pdo_mysql_autocommit_aware_commit.phptcommit() in the gap returns true
  • pdo_mysql_autocommit_aware_rollback.phptrollBack() in the gap returns true
  • pdo_mysql_autocommit_aware_intransaction.phptinTransaction() unchanged
  • pdo_mysql_autocommit_aware_begintransaction.phptbeginTransaction() unchanged
  • pdo_mysql_autocommit_aware_default_off.phpt — attribute defaults to off (BC preserved)
  • pdo_mysql_autocommit_aware_with_autocommit_on.phpt — no effect when autocommit on

No regressions on existing PDO / PDO MySQL tests.

References

PHP source code

Existing bug reports and issues

MySQL documentation

When autocommit is off, MySQL's SERVER_STATUS_IN_TRANS flag is cleared
after COMMIT/ROLLBACK even though the session is still logically in a
transaction. This causes commit() and rollBack() to throw "There is no
active transaction" in the gap before the next SQL statement.

This new opt-in attribute makes commit() and rollBack() return true
(no-op) instead of throwing when autocommit is off and no server-level
transaction is active. This eliminates the need for the redundant BEGIN
that frameworks send after every COMMIT to work around this gap.
@kamil-tekiela
Copy link
Member

Thank you for the very detailed description of the PR and the problem, however I don't think I fully understand the reasoning behind it.

To summarise, it seems to me like you would like to introduce a flag in PDO to bring back the silent behaviour that was present in PHP 7 when PDO didn't report the error "There is no active transaction". But I am not sure why?

You gave 5 examples of frameworks where the new behaviour was noticed, but then you said that the last 4 are irrelevant to what you are talking about. The one that is relevant doesn't have a linked PR or bug report. This makes it more difficult to buy into the problem. You claim that people now have to add BEGIN (I am not sure if you mean by calling beginTransaction() or if you mean sending the bare COMMIT directly), but that just seems like an individual application decision and not something that is dictated by the new PHP behaviour.

However, MySQL's SERVER_STATUS_IN_TRANS flag — which PHP 8+'s PDO::inTransaction() and PDO::commit() rely on — is cleared by COMMIT. It is only set again when the next SQL statement executes (any statement, including SELECT). This creates a gap:

What gap? Why would you want to call commit() twice in a row? Surely, the second one should fail with an exception. If it doesn't, then at best it's just dead code, and at worst it could mean data loss.

$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '', [
    PDO::ATTR_AUTOCOMMIT => false,
]);

$pdo->commit();

This code throws an exception and so does this one:

$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '', [
    PDO::ATTR_AUTOCOMMIT => false,
]);

$pdo->query('CREATE TEMPORARY TABLE IF NOT EXISTS test (id INT)');
$pdo->commit();
$pdo->commit();

What is the difference? Where is the gap? If there is no transaction, then there is nothing to commit.

@Crovitche-1623
Copy link
Author

Crovitche-1623 commented Feb 19, 2026

@kamil-tekiela

The issue is that with SET autocommit = 0, MySQL considers you to be always in a transaction. After COMMIT, a new implicit transaction starts immediately — that's what autocommit = 0 means. There is no moment where you are "outside" a transaction.

However, SERVER_STATUS_IN_TRANS is not always set in this mode. After COMMIT, the flag is cleared and only set again when the next statement executes — even though MySQL is already in a new implicit transaction. So between COMMIT and the next statement, PDO's view (inTransaction() = false) does not match MySQL's actual state (always in a transaction).

Your second example calls commit() right after commit() — yes, that throws, but with autocommit = 0 it shouldn't, because MySQL is already in a new implicit transaction. That's exactly the desynchronization this PR addresses.

The real scenario is:

$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '', [
      PDO::ATTR_AUTOCOMMIT => false,
]);

$pdo->exec('INSERT INTO t VALUES (1)');
$pdo->commit();   // OK — flag was set by the INSERT

$pdo->exec('INSERT INTO t VALUES (2)');
$pdo->commit();   // OK — flag was set by the INSERT

$pdo->commit();   // Throws — but MySQL is still logically in a transaction

The third commit() throws even though MySQL is ready to accept a COMMIT (it would be a no-op). The flag is simply lagging behind the actual server state.

Frameworks like Doctrine DBAL that use autocommit = 0 call commit() at the end of each unit of work. Between the previous commit() and the next batch of queries, they need to maintain a valid transaction state. Since PDO reports inTransaction() = false during this window, they are forced to send a redundant BEGIN after every COMMIT just to re-synchronize the flag — not because MySQL needs it, but because PDO does.

@Crovitche-1623
Copy link
Author

What gap?

The "gap" is the window between COMMIT (which clears SERVER_STATUS_IN_TRANS) and the next SQL statement (which sets it again). During this window, inTransaction() returns false even though MySQL with autocommit = 0 is already in a new implicit transaction.

@Crovitche-1623
Copy link
Author

Regarding the issues I mentioned, you're right. I searched for the error message There is no action transaction. Some of them may be out of scope here because they may be triggered by DDL implicit commits rather than the autocommit = 0 gap.

@kamil-tekiela
Copy link
Member

The issue is that with SET autocommit = 0, MySQL considers you to be always in a transaction. After COMMIT, a new implicit transaction starts immediately — that's what autocommit = 0 means. There is no moment where you are "outside" a transaction.

That's not true. It's only true from the perspective of a developer, but not from the perspective of the server. The transaction is started only when needed, i.e. when a non-DDL query is sent. The flag represents the true state of the transaction. That's why the PHP 8 behaviour is the correct one now.

Your second example calls commit() right after commit() — yes, that throws, but with autocommit = 0 it shouldn't, because MySQL is already in a new implicit transaction

But it's not in a new implicit transaction yet. Your example is exactly the same as mine. My point still stands: calling commit() after another commit() is a programmer's error and needs to be announced via an error.

$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '', [
    PDO::ATTR_AUTOCOMMIT => false,
]);

var_dump($pdo->inTransaction()); // false
$pdo->query('CREATE TEMPORARY TABLE IF NOT EXISTS test (id INT)');
var_dump($pdo->inTransaction()); // true
$pdo->commit();
var_dump($pdo->inTransaction()); // false
$pdo->commit();

In my example above, I was trying to show you that after opening a connection with autocommit off there is no transaction yet. A commit() will fail just like it would fail after the earlier transaction was committed. Both, after opening a connection and after commit(), inTransaction reports false. And it reports the truth.

The third commit() throws even though MySQL is ready to accept a COMMIT (it would be a no-op). The flag is simply lagging behind the actual server state.

Yeah, but when is MySQL not ready to accept COMMIT?

$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '', [
    PDO::ATTR_AUTOCOMMIT => true,
]);

$pdo->query('COMMIT');
$pdo->setAttribute(PDO::ATTR_AUTOCOMMIT, false);
$pdo->query('COMMIT');
$pdo->query('COMMIT');

None of these will report any error from MySQL because MySQL doesn't care about noop COMMIT. It doesn't mean that MySQL is always in a transaction. In that example, MySQL never opens a transaction.

Frameworks like Doctrine DBAL that use autocommit = 0 call commit() at the end of each unit of work. Between the previous commit() and the next batch of queries, they need to maintain a valid transaction state. Since PDO reports inTransaction() = false during this window, they are forced to send a redundant BEGIN after every COMMIT just to re-synchronize the flag — not because MySQL needs it, but because PDO does.

Ahh, I see. So it's just a logical problem. But that's exactly how it should be. It's the framework's choice to issue a manual BEGIN. They don't have to do it.

Think of transactions in a less opaque way:

  • In autocommit=On mode, MySQL adds COMMIT after every query. Every query is a transaction on its own.
    • If you open a transaction explicitly via BEGIN, then MySQL will not call COMMIT unless the query is a DDL. This state is tracked with SERVER_STATUS_IN_TRANS and it is set to false when the transaction ends with commit or rollback.
  • In autocommit=Off mode, MySQL WILL NOT call COMMIT after every query. The first query issued starts a de facto transaction.
    • This state is tracked with SERVER_STATUS_IN_TRANS and it is set to false when the transaction ends with commit or rollback.
    • A DDL query still triggers a commit and ends the transaction. No difference here.

So the only difference between autocommit off and on is that when it's off, you must trigger commit manually.

@Crovitche-1623
Copy link
Author

Crovitche-1623 commented Feb 19, 2026

That's not true. It's only true from the perspective of a developer, but not from the perspective of the server.

You're right. The point I was trying to make is that with autocommit = 0, the server will accept a COMMIT at any time, but PDO rejects it based on the flag (which does not represent if MySQL CAN receive a commit or not) before it reaches the server.

commit() after another commit() is a programmer's error [...]

That's true, but from my POV only if autocommit = ON. If the code was written knowing autocommit = OFF, why can't we assume the code is correct ? Specially if the code was written using the attribute I proposed ?


Moreover, I think maybe we're conflating two separate issues. My PR is not about DDL implicit commits — it's specifically about the gap after an explicit commit() when autocommit = 0. No DDL involved, just simple DML → COMMIT → DML → COMMIT cycles.


[...] They don't have to do it [...]

They do have to. The only alternative is to defer the BEGIN until the next actual query is about to be sent — which means the framework must intercept every query to inject a lazy BEGIN before it. That's significantly more complex than just sending BEGIN immediately after COMMIT, which is why frameworks take the simpler approach — at the cost of a redundant round-trip.


In my opinion, the BEGIN just solves the problem by toggling manually the flag on the server side but it add a redundant round-trip for nothing.

@Crovitche-1623
Copy link
Author

In other words, you prefer having visibility on the implicit commit problem through a flag that doesn't fully reflect MySQL's actual state, rather than relying on whether MySQL can accept the COMMIT or not. Is that correct?

@kamil-tekiela
Copy link
Member

The point I was trying to make is that with autocommit = 0, the server will accept a COMMIT at any time

It will also accept it at any time with autocommit = 1, as I have shown in my previous reply.

which does not represent if MySQL CAN receive a commit or not

It's not supposed to. The flag is only there to indicate whether the transaction is pending. If no changes were made to the data, then there is no transaction. You can call COMMIT at any time, but if you do it when not in a transaction, then it's pointless and signifies that you have an error in your application logic.

If the code was written knowing autocommit = OFF, why can't we assume the code is correct ?

try { $pdo->commit(); } catch (PDOException) {}

Moreover, I think maybe we're conflating two separate issues. My PR is not about DDL implicit commits — it's specifically about the gap after an explicit commit() when autocommit = 0.

Yes, it was a bit confusing at first, but I think I understand what you are talking about. I was just disputing that there is a gap. In my opinion, there is no gap.

They do have to. The only alternative is to defer the BEGIN until the next actual query is about to be sent — which means the framework must intercept every query to inject a lazy BEGIN before it. That's significantly more complex than just sending BEGIN immediately after COMMIT, which is why frameworks take the simpler approach — at the cost of a redundant round-trip.

Then I probably don't understand the full picture here. When you are in autocommit=off mode, there is no need to call BEGIN, neither manually nor via beginTransaction(). What I am suggesting is that it's a self-imposed requirement by the framework developers and not a real need.


What I imagine the problem is, is that the framework is trying to indicate to its user that they don't need to start a transaction because autocommit is off. However, until the transaction is started, the inTransaction() function will return false, so they manually start the transaction anyway so that the users calling inTransaction will see true. But that is just my guess based on what you are describing, as I have never seen it.

@kamil-tekiela
Copy link
Member

In other words, you prefer having visibility on the implicit commit problem through a flag that doesn't fully reflect MySQL's actual state, rather than relying on whether MySQL can accept the COMMIT or not. Is that correct?

The flag reflects MySQL's true state. After all, it comes from the MySQL server. It's got nothing to do with whether MySQL can accept COMMIT or not, because it can always accept COMMIT.

@Crovitche-1623
Copy link
Author

Crovitche-1623 commented Feb 19, 2026

What I imagine the problem is, is that the framework is trying to indicate to its user that they don't need to start a transaction because autocommit is off. However, until the transaction is started, the inTransaction() function will return false, so they manually start the transaction anyway so that the users calling inTransaction will see true. But that is just my guess based on what you are describing, as I have never seen it.

Exactly ! My problem was that after a commit() using autocommit=0, if I don't explicitly call beginTransaction, we get a There is no transaction exception on the next commit if no DML was sent in the interval. This is a frequent case when we want to conditionnaly create data and commit at the end. Sometimes, no data need to be created at all and I would like to expect no exception as MySQL is capable of receiving the COMMIT that is a no-op.

Frameworks cannot guarantee that a statement will always be executed before the next commit(), so it has two options: either track whether any statement was executed since the last commit (complex), or simply send BEGIN immediately after every COMMIT (simple but redundant and causes an unnecessary round-trip).

@kamil-tekiela
Copy link
Member

Can't you just do:

if ($pdo->inTransaction()) {
    $pdo->commit();
}

@Crovitche-1623
Copy link
Author

if ($pdo->inTransaction()) {
    $pdo->commit();
}

It would solve the use case of the conditional data because inTransaction() relies on the underlying flag that returns false even if MySQL is semantically in a transaction when autocommit mode is disabled.

Unfortunately, I cannot use this in my situation since I'm using Doctrine DBAL. They tried to add inTransaction() to their DriverConnection API (doctrine/dbal#4616), which required adding inTransaction() to non-PDO PHP drivers (mysqli, sqlsrv, etc.) first. A PoC for mysqli_in_transaction() was created, and the proposal was sent to internals@, but it didn't get traction at the time. If inTransaction() were added to the other drivers, this could also solve the problem and my PR would no longer be needed since I could then try to handle it on the application side.

@Crovitche-1623
Copy link
Author

Crovitche-1623 commented Feb 19, 2026

However, even if inTransaction() were added to all drivers, I think it would only enable the workaround at each call site. The attribute I proposed has the advantage of handling it at the connection level.

@kamil-tekiela
Copy link
Member

This discussion has been very productive, but unfortunately, I am going to decline the addition of ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS. I just don't see a need for this in PDO.

The issues you are describing require different solutions. And it's going to be mostly on the framework developers for not being able to provide you with the full capabilities of PDO. The proposal you linked to, about adding similar functions to the legacy drivers, could be good but there doesn't seem to be much interest in this. Such an RFC would probably pass if someone were interested in doing it. But after that it would also require DBAL to implement it too.

If I recall correctly, in DBAL you can still get the pure PDO object, and so you can call inTransaction() if your project only uses PDO.

As you said, using inTransaction before commit() is just a workaround, same as replacing $pdo->commit() with $pdo->query('COMMIT'). Both are good workarounds, but the real solution is to simply fix the application logic and never call commit() when there is nothing to commit.

For example, you could do something similar to what they do in EasyDB: https://github.com/paragonie/easydb/blob/39ba24863954af16351c4ae2150d2fb7c66c23f6/src/EasyDB.php#L1315
Wrap the transaction try-catch in a function which accepts a callable. You don't have to worry about autocommit or commit() at all. The function guarantees that a transaction is started upon entry and it's either committed or rolled back upon exit. You don't have to worry about spaghetti code and accidentally calling commit() twice.

Either way, adding ATTR_AUTOCOMMIT_AWARE_TRANSACTIONS and bringing back the old broken behaviour to PDO is a step backwards. Since it isn't absolutely necessary, we should not add such features to the language.

See also my answer on Stack Overflow from 2 years ago https://stackoverflow.com/a/79090764/1839439

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments