PHP 8.4 gave each driver a subclass (Pdo\Mysql, Pdo\Pgsql, Pdo\Sqlite...), returned by PDO::connect() or built with new Pdo\Mysql(...). It holds the MySQL-only constants as Pdo\Mysql::ATTR_* and adds getWarningCount(), and PHP 8.5 deprecates the old PDO::MYSQL_ATTR_* spellings. The option that matters most for large results is ATTR_USE_BUFFERED_QUERY: by default PDO copies the whole result into PHP memory before the first fetch(), while unbuffered mode streams rows from the server as you read them.
<?php
$pdo = require 'db.php'; // PDO::connect() returned a Pdo\Mysql
set_error_handler(fn($no, $msg) => print strstr($msg, ',', true) . "\n", E_DEPRECATED);
$old = PDO::MYSQL_ATTR_FOUND_ROWS; // the pre-8.4 spelling
$pdo->exec('SET SESSION cte_max_recursion_depth = 300000');
$big = "WITH RECURSIVE n (i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 300000)
SELECT i, REPEAT('x', 100) AS pad FROM n";
foreach (['buffered' => true, 'unbuffered' => false] as $mode => $flag) {
$pdo->setAttribute(Pdo\Mysql::ATTR_USE_BUFFERED_QUERY, $flag);
memory_reset_peak_usage();
$rows = 0;
foreach ($pdo->query($big) as $row) $rows++;
printf("%-10s %d rows, peak %4.1f MB\n", $mode, $rows, memory_get_peak_usage() / 2**20);
}Constant PDO::MYSQL_ATTR_FOUND_ROWS is deprecated since 8.5 buffered 300000 rows, peak 38.1 MB unbuffered 300000 rows, peak 0.5 MB
Streaming used half a megabyte instead of 38, the difference between an export that finishes and one that hits memory_limit. The price: until an unbuffered result is fully read or closed with closeCursor(), the connection runs nothing else (error 2014, "Cannot execute queries while other unbuffered queries are active"), so use a second connection for lookups during an export. Among the other options, ATTR_MULTI_STATEMENTS is on by default, so exec('DO 1; DO 2') runs both; pass false so an injected ; DROP TABLE cannot run.