Commit 7bd27e73e9d for php.net

commit 7bd27e73e9df4dbd6af8ea79125dd7e08ba414ec
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Fri Mar 13 18:17:40 2026 -0400

    Fix GH-20214: PDO::FETCH_DEFAULT unexpected behavior with PDOStatement::setFetchMode (#21434)

    When setFetchMode(PDO::FETCH_DEFAULT) is called, mode=0
    (PDO_FETCH_USE_DEFAULT) gets stored as the statement's default fetch
    type. Later, do_fetch() tries to resolve PDO_FETCH_USE_DEFAULT by
    reading stmt->default_fetch_type, which is also 0 — circular
    reference that on 8.4 silently fell through to FETCH_BOTH and on
    master throws a ValueError.

    Resolve PDO_FETCH_USE_DEFAULT to the connection-level default early in
    pdo_stmt_setup_fetch_mode(), before flags extraction and the mode
    switch, so the rest of the function processes the actual fetch mode.

    fixes #20214
    closes #21434

diff --git a/NEWS b/NEWS
index b110b17bef2..b9576a45608 100644
--- a/NEWS
+++ b/NEWS
@@ -2,6 +2,9 @@ PHP                                                                        NEWS
 |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
 ?? ??? ????, PHP 8.4.22

+- PDO:
+  . Fixed bug GH-20214 (PDO::FETCH_DEFAULT unexpected behavior with
+    setFetchMode). (iliaal)

 07 May 2026, PHP 8.4.21

diff --git a/ext/pdo/pdo_stmt.c b/ext/pdo/pdo_stmt.c
index 9ba82e822b6..2ae3b1dd9e8 100644
--- a/ext/pdo/pdo_stmt.c
+++ b/ext/pdo/pdo_stmt.c
@@ -1733,6 +1733,10 @@ bool pdo_stmt_setup_fetch_mode(pdo_stmt_t *stmt, zend_long mode, uint32_t mode_a

 	flags = mode & PDO_FETCH_FLAGS;

+	if ((mode & ~PDO_FETCH_FLAGS) == PDO_FETCH_USE_DEFAULT) {
+		mode = stmt->dbh->default_fetch_type | flags;
+	}
+
 	if (!pdo_stmt_verify_mode(stmt, mode, mode_arg_num, false)) {
 		return false;
 	}
diff --git a/ext/pdo/tests/gh20214.phpt b/ext/pdo/tests/gh20214.phpt
new file mode 100644
index 00000000000..8afd667558c
--- /dev/null
+++ b/ext/pdo/tests/gh20214.phpt
@@ -0,0 +1,64 @@
+--TEST--
+GH-20214 (PDO::FETCH_DEFAULT unexpected behavior with PDOStatement::setFetchMode)
+--EXTENSIONS--
+pdo
+--SKIPIF--
+<?php
+$dir = getenv('REDIR_TEST_DIR');
+if (false == $dir) die('skip no driver');
+require_once $dir . 'pdo_test.inc';
+PDOTest::skip();
+?>
+--FILE--
+<?php
+if (getenv('REDIR_TEST_DIR') === false) putenv('REDIR_TEST_DIR='.__DIR__ . '/../../pdo/tests/');
+require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
+$db = PDOTest::factory();
+
+$db->exec('CREATE TABLE gh20214 (c1 VARCHAR(10), c2 VARCHAR(10))');
+$db->exec("INSERT INTO gh20214 VALUES ('v1', 'v2')");
+
+$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_OBJ);
+
+// setFetchMode with FETCH_DEFAULT should use connection default (FETCH_OBJ)
+$stmt = $db->query('SELECT c1, c2 FROM gh20214');
+$stmt->setFetchMode(PDO::FETCH_DEFAULT);
+$row = $stmt->fetch();
+var_dump($row instanceof stdClass);
+var_dump($row->c1);
+
+// fetch with FETCH_DEFAULT should also use connection default
+$stmt = $db->query('SELECT c1, c2 FROM gh20214');
+$row = $stmt->fetch(PDO::FETCH_DEFAULT);
+var_dump($row instanceof stdClass);
+
+// fetchAll with FETCH_DEFAULT should also use connection default
+$stmt = $db->query('SELECT c1, c2 FROM gh20214');
+$rows = $stmt->fetchAll(PDO::FETCH_DEFAULT);
+var_dump($rows[0] instanceof stdClass);
+
+// setFetchMode then fetch without argument
+$stmt = $db->query('SELECT c1, c2 FROM gh20214');
+$stmt->setFetchMode(PDO::FETCH_DEFAULT);
+$row = $stmt->fetch();
+var_dump($row instanceof stdClass);
+
+// query() with FETCH_DEFAULT as second argument
+$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_NUM);
+$stmt = $db->query('SELECT c1, c2 FROM gh20214', PDO::FETCH_DEFAULT);
+$row = $stmt->fetch();
+var_dump(is_array($row) && isset($row[0]));
+?>
+--CLEAN--
+<?php
+require_once getenv('REDIR_TEST_DIR') . 'pdo_test.inc';
+$db = PDOTest::factory();
+PDOTest::dropTableIfExists($db, "gh20214");
+?>
+--EXPECT--
+bool(true)
+string(2) "v1"
+bool(true)
+bool(true)
+bool(true)
+bool(true)