From c6d19577eba066fedaf846e26f45483e0ec2fbec Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Tue, 11 Aug 2026 22:01:11 +0500 Subject: [PATCH 1/8] Add a stack limit check in php_count_recursive() (#23197) --- NEWS | 2 ++ ext/spl/spl_observer.c | 6 +++- ext/standard/array.c | 20 ++++++++++-- ext/standard/php_array.h | 1 + .../array/count_recursive_stack_limit.phpt | 32 +++++++++++++++++++ 5 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 ext/standard/tests/array/count_recursive_stack_limit.phpt diff --git a/NEWS b/NEWS index 6447fa7bc881..68eb850d62c5 100644 --- a/NEWS +++ b/NEWS @@ -78,6 +78,8 @@ PHP NEWS nested arrays). (Lazizbek Ergashev) . Fixed bug GH-23115 (Stack overflow in compact() with deeply nested arrays). (Lazizbek Ergashev) + . Fixed stack overflow in count() with COUNT_RECURSIVE and deeply nested + arrays. (Lazizbek Ergashev) - Streams: . Fixed bug GH-15836 (Use-after-free when a user stream filter accesses diff --git a/ext/spl/spl_observer.c b/ext/spl/spl_observer.c index 56cdbdd4b5f3..9a9710e069f2 100644 --- a/ext/spl/spl_observer.c +++ b/ext/spl/spl_observer.c @@ -679,7 +679,11 @@ PHP_METHOD(SplObjectStorage, count) } if (mode == PHP_COUNT_RECURSIVE) { - RETURN_LONG(php_count_recursive(&intern->storage)); + zend_long count = php_count_recursive(&intern->storage); + if (UNEXPECTED(count < 0)) { + RETURN_THROWS(); + } + RETURN_LONG(count); } RETURN_LONG(zend_hash_num_elements(&intern->storage)); diff --git a/ext/standard/array.c b/ext/standard/array.c index bb23c99c5709..85a017eff7f9 100644 --- a/ext/standard/array.c +++ b/ext/standard/array.c @@ -611,10 +611,18 @@ PHPAPI zend_long php_count_recursive(HashTable *ht) /* {{{ */ zend_long cnt = 0; zval *element; +#ifdef ZEND_CHECK_STACK_LIMIT + if (UNEXPECTED(zend_call_stack_overflowed(EG(stack_limit)))) { + zend_call_stack_size_error(); + return -1; + } +#endif + if (!(GC_FLAGS(ht) & GC_IMMUTABLE)) { if (GC_IS_RECURSIVE(ht)) { php_error_docref(NULL, E_WARNING, "Recursion detected"); - return 0; + /* A user error handler may have thrown. */ + return EG(exception) ? -1 : 0; } GC_PROTECT_RECURSION(ht); } @@ -623,7 +631,12 @@ PHPAPI zend_long php_count_recursive(HashTable *ht) /* {{{ */ ZEND_HASH_FOREACH_VAL(ht, element) { ZVAL_DEREF(element); if (Z_TYPE_P(element) == IS_ARRAY) { - cnt += php_count_recursive(Z_ARRVAL_P(element)); + zend_long sub_cnt = php_count_recursive(Z_ARRVAL_P(element)); + if (UNEXPECTED(sub_cnt < 0)) { + cnt = -1; + break; + } + cnt += sub_cnt; } } ZEND_HASH_FOREACH_END(); @@ -656,6 +669,9 @@ PHP_FUNCTION(count) cnt = zend_hash_num_elements(Z_ARRVAL_P(array)); } else { cnt = php_count_recursive(Z_ARRVAL_P(array)); + if (UNEXPECTED(cnt < 0)) { + RETURN_THROWS(); + } } RETURN_LONG(cnt); break; diff --git a/ext/standard/php_array.h b/ext/standard/php_array.h index 2a35af603808..24e320a5313c 100644 --- a/ext/standard/php_array.h +++ b/ext/standard/php_array.h @@ -29,6 +29,7 @@ PHPAPI int php_array_merge(HashTable *dest, HashTable *src); PHPAPI int php_array_merge_recursive(HashTable *dest, HashTable *src); PHPAPI int php_array_replace_recursive(HashTable *dest, HashTable *src); PHPAPI int php_multisort_compare(const void *a, const void *b); +/* Returns -1 and throws if the array is nested too deeply. */ PHPAPI zend_long php_count_recursive(HashTable *ht); PHPAPI bool php_array_data_shuffle(php_random_algo_with_state engine, zval *array); diff --git a/ext/standard/tests/array/count_recursive_stack_limit.phpt b/ext/standard/tests/array/count_recursive_stack_limit.phpt new file mode 100644 index 000000000000..a7f3918ba350 --- /dev/null +++ b/ext/standard/tests/array/count_recursive_stack_limit.phpt @@ -0,0 +1,32 @@ +--TEST-- +Stack overflow in count() with COUNT_RECURSIVE and deeply nested arrays +--SKIPIF-- + +--INI-- +zend.max_allowed_stack_size=256K +--FILE-- +getMessage(), "\n"; + var_dump($e->getPrevious()); +} +?> +--EXPECTF-- +Error: Maximum call stack size of %d bytes (zend.max_allowed_stack_size - zend.reserved_stack_size) reached. Infinite recursion? +NULL From 8ce7f7f5e93e7e8c527f55e3d98df976a1d0f03c Mon Sep 17 00:00:00 2001 From: Lazizbek Ergashev Date: Tue, 11 Aug 2026 18:01:42 +0100 Subject: [PATCH 2/8] Fix GH-23204: use-after-free when __toString() destroys an array argument implode() walks the array with ZEND_HASH_FOREACH_VAL while holding no reference on it. Converting a Stringable element runs user code, and if that code drops the last remaining reference to the array (`$a = null;` from __toString()), arData is freed and the next iteration reads freed memory. strtr() and str_replace() read their array arguments the same way and crash the same way, so they are fixed here too. Taking a reference on the table for the duration of the read keeps it alive and turns an in-place mutation into a separation instead, same as zend_compare_symbol_tables() does around zend_hash_compare(). In implode() the reference is released after the pieces have been concatenated, since the collected zend_strings are still owned by the array until then. Close GH-23207 --- ext/standard/string.c | 36 +++++++- ext/standard/tests/strings/gh23204.phpt | 109 ++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 ext/standard/tests/strings/gh23204.phpt diff --git a/ext/standard/string.c b/ext/standard/string.c index 0c7a7453eaab..34444d80d185 100644 --- a/ext/standard/string.c +++ b/ext/standard/string.c @@ -983,6 +983,9 @@ PHPAPI void php_implode(const zend_string *glue, HashTable *pieces, zval *return uint32_t flags = ZSTR_GET_COPYABLE_CONCAT_PROPERTIES(glue); + /* Converting an element may call __toString(), which can destroy pieces. */ + GC_TRY_ADDREF(pieces); + ZEND_HASH_FOREACH_VAL(pieces, tmp) { if (EXPECTED(Z_TYPE_P(tmp) == IS_STRING)) { ptr->str = Z_STR_P(tmp); @@ -1042,6 +1045,7 @@ PHPAPI void php_implode(const zend_string *glue, HashTable *pieces, zval *return } free_alloca(strings, use_heap); + GC_TRY_DTOR_NO_REF(pieces); RETURN_NEW_STR(str); } /* }}} */ @@ -3392,7 +3396,12 @@ static void php_strtr_array(zval *return_value, zend_string *str, HashTable *fro { if (zend_hash_num_elements(from_ht) < 1) { RETURN_STR_COPY(str); - } else if (zend_hash_num_elements(from_ht) == 1) { + } + + /* Converting a replacement may call __toString(), which can destroy from_ht. */ + GC_TRY_ADDREF(from_ht); + + if (zend_hash_num_elements(from_ht) == 1) { zend_long num_key; zend_string *str_key, *tmp_str, *replace, *tmp_replace; zval *entry; @@ -3421,11 +3430,13 @@ static void php_strtr_array(zval *return_value, zend_string *str, HashTable *fro } zend_tmp_string_release(tmp_str); zend_tmp_string_release(tmp_replace); - return; + break; } ZEND_HASH_FOREACH_END(); } else { php_strtr_array_ex(return_value, str, from_ht); } + + GC_TRY_DTOR_NO_REF(from_ht); } /* {{{ Translates characters in str using given translation tables */ @@ -4485,6 +4496,17 @@ static void _php_str_replace_common( RETURN_THROWS(); } + /* Converting an element may call __toString(), which can destroy the arrays. */ + if (search_ht) { + GC_TRY_ADDREF(search_ht); + } + if (replace_ht) { + GC_TRY_ADDREF(replace_ht); + } + if (subject_ht) { + GC_TRY_ADDREF(subject_ht); + } + /* if subject is an array */ if (subject_ht) { array_init(return_value); @@ -4511,6 +4533,16 @@ static void _php_str_replace_common( if (zcount) { ZEND_TRY_ASSIGN_REF_LONG(zcount, count); } + + if (search_ht) { + GC_TRY_DTOR_NO_REF(search_ht); + } + if (replace_ht) { + GC_TRY_DTOR_NO_REF(replace_ht); + } + if (subject_ht) { + GC_TRY_DTOR_NO_REF(subject_ht); + } } /* {{{ php_str_replace_common */ diff --git a/ext/standard/tests/strings/gh23204.phpt b/ext/standard/tests/strings/gh23204.phpt new file mode 100644 index 000000000000..e2ae20592c5a --- /dev/null +++ b/ext/standard/tests/strings/gh23204.phpt @@ -0,0 +1,109 @@ +--TEST-- +GH-23204 (Use-after-free when __toString() destroys the array being read) +--CREDITS-- +e1abrador +--FILE-- +getMessage(), "\n"; +} + +class UnsetPats implements Stringable { + public function __toString(): string { + global $d; + $d = null; + return "X"; + } +} + +$d = ["aa" => new UnsetPats, "bb" => "2", "cc" => "3", "dd" => "4"]; +echo "strtr: ", strtr("aabbccdd", $d), "\n"; + +$e = ["aa" => new UnsetPats]; +$d = &$e; +echo "strtr single: ", strtr("aabb", $e), "\n"; + +class UnsetSearch implements Stringable { + public function __toString(): string { + global $f; + $f = null; + return "a"; + } +} + +$f = [new UnsetSearch, "b", "c", "d"]; +echo "str_replace search: ", str_replace($f, "z", "abcd"), "\n"; + +class UnsetReplace implements Stringable { + public function __toString(): string { + global $g; + $g = null; + return "z"; + } +} + +$g = [new UnsetReplace, "y", "y", "y"]; +echo "str_replace replace: ", str_replace(["a", "b", "c", "d"], $g, "abcd"), "\n"; + +class UnsetSubject implements Stringable { + public function __toString(): string { + global $h; + $h = null; + return "abcd"; + } +} + +$h = [new UnsetSubject, "abcd"]; +var_dump(str_replace("a", "z", $h)); +?> +--EXPECT-- +destroyed: X,2,3,4 +NULL +appended: X,2,3,4 +count: 5 +Exception: boom +strtr: X234 +strtr single: Xbb +str_replace search: zzzz +str_replace replace: zyyy +array(2) { + [0]=> + string(4) "zbcd" + [1]=> + string(4) "zbcd" +} From 195b7b556e19a3dda95fbae418bcf34a0e10c3ae Mon Sep 17 00:00:00 2001 From: Kamil Tekiela Date: Tue, 11 Aug 2026 18:40:57 +0100 Subject: [PATCH 3/8] Collapse if statement in php_stat (#23219) --- ext/standard/filestat.c | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ext/standard/filestat.c b/ext/standard/filestat.c index 96669a809346..13478b6f5c31 100644 --- a/ext/standard/filestat.c +++ b/ext/standard/filestat.c @@ -736,12 +736,12 @@ PHPAPI void php_stat(zend_string *filename, int type, zval *return_value) RETURN_FALSE; } if (IS_ACCESS_CHECK(type)) { - if ((wrapper = php_stream_locate_url_wrapper(ZSTR_VAL(filename), &local, 0)) == &php_plain_files_wrapper - && php_check_open_basedir(local)) { - RETURN_FALSE; - } - + wrapper = php_stream_locate_url_wrapper(ZSTR_VAL(filename), &local, 0); if (wrapper == &php_plain_files_wrapper) { + if (php_check_open_basedir(local)) { + RETURN_FALSE; + } + char realpath[MAXPATHLEN]; const char *file_path_to_check; /* if the wrapper is not found, we need to expand path to match open behavior */ From f5b0147625f696ad4a4c6b4d46780890b5e92505 Mon Sep 17 00:00:00 2001 From: Joe Ferguson Date: Tue, 11 Aug 2026 17:35:30 +0000 Subject: [PATCH 4/8] [ci skip] Update NEWS for 8.6.0beta2 --- NEWS | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NEWS b/NEWS index 1ed1362f3557..2326aa8951fd 100644 --- a/NEWS +++ b/NEWS @@ -1,6 +1,9 @@ PHP NEWS ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| -?? ??? ????, PHP 8.6.0beta1 +?? ??? ????, PHP 8.6.0beta2 + + +13 Aug 2026, PHP 8.6.0beta1 - Core: . Deprecated "namespace" as a class constant name. (NickSdot) From 0b91828b01e61eef064df7ba4238a469029f39ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E7=94=B0=20=E6=86=B2=E5=A4=AA=E9=83=8E?= Date: Tue, 11 Aug 2026 19:44:17 +0100 Subject: [PATCH 5/8] ext/pdo_pgsql: Fix several lazy fetch defects With PDO::ATTR_PREFETCH => 0 a statement streams its result set, and the cleanup reads the rest of it by calling PQgetResult() until it returns NULL. That never happens while the connection is copying: PQgetResult() hands out a fresh COPY result every time. A COPY run through a lazy fetch has therefore spun at 100% CPU since 8.5.0, as soon as another lazy fetch takes the connection over. The copy has to be ended first: a copy in with PQputCopyEnd(), a copy out by draining PQgetCopyData(). The drain was skipped as well, because is_running_unbuffered was cleared first, both in the cleanup's own abort path and in pgsql_stmt_fetch() before it calls the cleanup. With PDO::ATTR_EMULATE_PREPARES or Pdo\Pgsql::ATTR_DISABLE_PREPARES the connection then stayed busy and the next lazy fetch failed with "another command is already in progress". The connection's pointer to the statement streaming on it was only cleared while closing a server-side prepared statement, which those two modes do not create, so destroying one left the pointer dangling for the next lazy fetch to read. And a statement whose stream was taken over kept its row counters after its result had been freed, so fetch() returned a row of NULLs rather than false. Close GH-23065 --- NEWS | 7 +++ ext/pdo_pgsql/pgsql_statement.c | 46 +++++++++++++++---- ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt | 36 +++++++++++++++ ext/pdo_pgsql/tests/lazy_fetch_copy.phpt | 35 ++++++++++++++ ext/pdo_pgsql/tests/lazy_fetch_drain.phpt | 36 +++++++++++++++ ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt | 28 +++++++++++ .../tests/lazy_fetch_takeover_buffered.phpt | 38 +++++++++++++++ 7 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_copy.phpt create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_drain.phpt create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt create mode 100644 ext/pdo_pgsql/tests/lazy_fetch_takeover_buffered.phpt diff --git a/NEWS b/NEWS index ffc76ae4a64b..ac441555e783 100644 --- a/NEWS +++ b/NEWS @@ -55,6 +55,13 @@ PHP NEWS . Fixed bug GH-23016 (NULL values in long columns come back as garbage binary strings). (Calvin Buckley, iliaal) +- PDO_PGSQL: + . Fixed several lazy fetch (PDO::ATTR_PREFETCH => 0) defects: an infinite + loop when cleaning up a fetch left in a COPY, a use-after-free when a + statement with emulated or disabled prepares is destroyed, a connection + left busy for the next fetch, and rows delivered from a result another + statement took over. (KentarouTakeda) + - Reflection: . Fixed bug GH-22905 (Reflection exception messages truncate on null bytes). (DanielEScherzer) diff --git a/ext/pdo_pgsql/pgsql_statement.c b/ext/pdo_pgsql/pgsql_statement.c index 89f713ffcbff..be3f31f62a37 100644 --- a/ext/pdo_pgsql/pgsql_statement.c +++ b/ext/pdo_pgsql/pgsql_statement.c @@ -66,12 +66,12 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) { pdo_pgsql_db_handle *H = S->H; - if (S->is_running_unbuffered && S->result && (fin_mode & FIN_ABORT)) { + /* a buffered query may have already drained this statement's stream */ + if (S->is_running_unbuffered && H->running_stmt == S && S->result && (fin_mode & FIN_ABORT)) { PGcancel *cancel = PQgetCancel(H->server); char errbuf[256]; PQcancel(cancel, errbuf, 256); PQfreeCancel(cancel); - S->is_running_unbuffered = false; } if (S->result) { @@ -80,7 +80,7 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) S->result = NULL; } - if (S->is_running_unbuffered) { + if (S->is_running_unbuffered && H->running_stmt == S) { /* https://postgresql.org/docs/current/libpq-async.html: * "PQsendQuery cannot be called again until PQgetResult has returned NULL" * And as all single-row functions are connection-wise instead of statement-wise, @@ -90,8 +90,35 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) // instead of discarding results we could store them to their statement // so that their fetch() will get them (albeit not in lazy mode anymore). while ((S->result = PQgetResult(H->server))) { + ExecStatusType status = PQresultStatus(S->result); + PQclear(S->result); S->result = NULL; + + /* PQgetResult() keeps handing out the same result while the + * connection is copying: only these calls can end it */ + if (status == PGRES_COPY_IN || status == PGRES_COPY_BOTH) { + /* fail a copy in, so that abandoning a statement cannot + * commit it; a replication stream only accepts a clean end */ + const char *error = status == PGRES_COPY_IN + ? "COPY terminated by PDO" + : NULL; + + if (PQputCopyEnd(H->server, error) < 0) { + break; + } + } + if (status == PGRES_COPY_OUT || status == PGRES_COPY_BOTH) { + char *buf; + int nbytes; + + while ((nbytes = PQgetCopyData(H->server, &buf, 0)) > 0) { + PQfreemem(buf); + } + if (nbytes < -1) { + break; + } + } } S->is_running_unbuffered = false; } @@ -113,9 +140,6 @@ static void pgsql_stmt_finish(pdo_pgsql_stmt *S, int fin_mode) } S->is_prepared = false; - if (H->running_stmt == S) { - H->running_stmt = NULL; - } } } @@ -126,6 +150,10 @@ static int pgsql_stmt_dtor(pdo_stmt_t *stmt) pgsql_stmt_finish(S, FIN_DISCARD|(server_obj_usable ? FIN_CLOSE|FIN_ABORT : 0)); + if (server_obj_usable && S->H->running_stmt == S) { + S->H->running_stmt = NULL; + } + if (S->stmt_name) { efree(S->stmt_name); S->stmt_name = NULL; @@ -561,7 +589,7 @@ static int pgsql_stmt_fetch(pdo_stmt_t *stmt, return 0; } } else { - if (S->is_running_unbuffered && S->current_row >= stmt->row_count) { + if (S->is_running_unbuffered && S->H->running_stmt == S && S->current_row >= stmt->row_count) { ExecStatusType status; /* @todo in unbuffered mode, PQ allows multiple queries to be passed: @@ -590,12 +618,12 @@ static int pgsql_stmt_fetch(pdo_stmt_t *stmt, S->current_row = 0; if (!stmt->row_count) { - S->is_running_unbuffered = false; /* libpq requires looping until getResult returns null */ pgsql_stmt_finish(S, 0); } } - if (S->current_row < stmt->row_count) { + /* another statement may have taken over and freed the result */ + if (S->result && S->current_row < stmt->row_count) { S->current_row++; return 1; } else { diff --git a/ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt b/ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt new file mode 100644 index 000000000000..7968c2653206 --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_cancel.phpt @@ -0,0 +1,36 @@ +--TEST-- +PDO PgSQL an abandoned lazy fetch frees the connection without a prepared statement +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +foreach ([ + 'PDO::ATTR_EMULATE_PREPARES' => [PDO::ATTR_EMULATE_PREPARES => true], + 'Pdo\Pgsql::ATTR_DISABLE_PREPARES' => [Pdo\Pgsql::ATTR_DISABLE_PREPARES => true], +] as $label => $options) { + $options[PDO::ATTR_PREFETCH] = 0; + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + $stmt = null; + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + echo "$label: "; + var_dump((bool) $stmt->fetchAll()); +} +?> +--EXPECT-- +PDO::ATTR_EMULATE_PREPARES: bool(true) +Pdo\Pgsql::ATTR_DISABLE_PREPARES: bool(true) diff --git a/ext/pdo_pgsql/tests/lazy_fetch_copy.phpt b/ext/pdo_pgsql/tests/lazy_fetch_copy.phpt new file mode 100644 index 000000000000..91321e2bcde2 --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_copy.phpt @@ -0,0 +1,35 @@ +--TEST-- +PDO PgSQL a lazy fetch left in a COPY does not hang the connection cleanup +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT); +$pdo->setAttribute(PDO::ATTR_PREFETCH, 0); +$pdo->exec("CREATE TEMPORARY TABLE lazy_fetch_copy (i int)"); + +foreach ([ + 'COPY OUT' => "COPY (SELECT 1) TO STDOUT", + 'COPY IN' => "COPY lazy_fetch_copy FROM STDIN", +] as $label => $sql) { + $copy = $pdo->prepare($sql); + $copy->execute(); + + $stmt = $pdo->prepare("VALUES (1), (2)"); + $stmt->execute(); + echo "$label: "; + var_dump((bool) $stmt->fetchAll()); +} +?> +--EXPECT-- +COPY OUT: bool(true) +COPY IN: bool(true) diff --git a/ext/pdo_pgsql/tests/lazy_fetch_drain.phpt b/ext/pdo_pgsql/tests/lazy_fetch_drain.phpt new file mode 100644 index 000000000000..a650628d3cce --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_drain.phpt @@ -0,0 +1,36 @@ +--TEST-- +PDO PgSQL a drained lazy fetch frees the connection without a prepared statement +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +foreach ([ + 'PDO::ATTR_EMULATE_PREPARES' => [PDO::ATTR_EMULATE_PREPARES => true], + 'Pdo\Pgsql::ATTR_DISABLE_PREPARES' => [Pdo\Pgsql::ATTR_DISABLE_PREPARES => true], +] as $label => $options) { + $options[PDO::ATTR_PREFETCH] = 0; + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + $stmt->fetchAll(); + + $stmt = $pdo->prepare("VALUES (1), (2)", $options); + $stmt->execute(); + echo "$label: "; + var_dump((bool) $stmt->fetchAll()); +} +?> +--EXPECT-- +PDO::ATTR_EMULATE_PREPARES: bool(true) +Pdo\Pgsql::ATTR_DISABLE_PREPARES: bool(true) diff --git a/ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt b/ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt new file mode 100644 index 000000000000..eace678310de --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_takeover.phpt @@ -0,0 +1,28 @@ +--TEST-- +PDO PgSQL a lazy fetch whose stream was taken over reports no leftover rows +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$pdo->setAttribute(PDO::ATTR_PREFETCH, 0); + +$first = $pdo->prepare("VALUES (1), (2)"); +$first->execute(); + +$pdo->prepare("VALUES (1), (2)")->execute(); + +var_dump($first->fetchAll(PDO::FETCH_NUM)); +?> +--EXPECT-- +array(0) { +} diff --git a/ext/pdo_pgsql/tests/lazy_fetch_takeover_buffered.phpt b/ext/pdo_pgsql/tests/lazy_fetch_takeover_buffered.phpt new file mode 100644 index 000000000000..aa8c2aa86c4a --- /dev/null +++ b/ext/pdo_pgsql/tests/lazy_fetch_takeover_buffered.phpt @@ -0,0 +1,38 @@ +--TEST-- +PDO PgSQL a lazy fetch stale after a buffered query does not read the next statement's rows +--EXTENSIONS-- +pdo_pgsql +--SKIPIF-- + +--FILE-- +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + +$first = $pdo->prepare("VALUES (1), (2), (3)", [PDO::ATTR_PREFETCH => 0]); +$first->execute(); +$first->fetch(); + +// a buffered query drains the stream but does not end $first's lazy fetch +$pdo->prepare("VALUES (99)")->execute(); + +$third = $pdo->prepare("VALUES (777), (888)", [PDO::ATTR_PREFETCH => 0]); +$third->execute(); + +var_dump($first->fetch(PDO::FETCH_NUM)); +var_dump($third->fetchAll(PDO::FETCH_COLUMN)); +?> +--EXPECT-- +bool(false) +array(2) { + [0]=> + string(3) "777" + [1]=> + string(3) "888" +} From 472af11e01b7898f9dd21c28d0a94a8d5a1e5c8d Mon Sep 17 00:00:00 2001 From: David Carlier Date: Tue, 11 Aug 2026 19:49:45 +0100 Subject: [PATCH 6/8] [ci skip] Add NEWS entry --- NEWS | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/NEWS b/NEWS index 2326aa8951fd..a2122027513f 100644 --- a/NEWS +++ b/NEWS @@ -2,6 +2,12 @@ PHP NEWS ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| ?? ??? ????, PHP 8.6.0beta2 +- PDO_PGSQL: + . Fixed several lazy fetch (PDO::ATTR_PREFETCH => 0) defects: an infinite + loop when cleaning up a fetch left in a COPY, a use-after-free when a + statement with emulated or disabled prepares is destroyed, a connection + left busy for the next fetch, and rows delivered from a result another + statement took over. (KentarouTakeda) 13 Aug 2026, PHP 8.6.0beta1 From 4596a6db20eca47f6a440bddda67d07df444e18b Mon Sep 17 00:00:00 2001 From: Calvin Buckley Date: Tue, 11 Aug 2026 15:50:03 -0300 Subject: [PATCH 7/8] PHP 8.4 is now for PHP 8.4.26-dev --- NEWS | 5 ++++- Zend/zend.h | 2 +- configure.ac | 2 +- main/php_version.h | 6 +++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/NEWS b/NEWS index 68eb850d62c5..14149cee9c60 100644 --- a/NEWS +++ b/NEWS @@ -1,6 +1,9 @@ PHP NEWS ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| -?? ??? ????, PHP 8.4.25 +?? ??? ????, PHP 8.4.26 + + +27 Aug 2026, PHP 8.4.25 - Core: . Fixed bug GH-23088 (Stack overflow when comparing deeply nested arrays). diff --git a/Zend/zend.h b/Zend/zend.h index d0e46881d989..2d5b1539f8cf 100644 --- a/Zend/zend.h +++ b/Zend/zend.h @@ -20,7 +20,7 @@ #ifndef ZEND_H #define ZEND_H -#define ZEND_VERSION "4.4.25-dev" +#define ZEND_VERSION "4.4.26-dev" #define ZEND_ENGINE_3 diff --git a/configure.ac b/configure.ac index 33608b4ae94e..29afea98f53e 100644 --- a/configure.ac +++ b/configure.ac @@ -17,7 +17,7 @@ dnl Basic autoconf initialization, generation of config.nice. dnl ---------------------------------------------------------------------------- AC_PREREQ([2.68]) -AC_INIT([PHP],[8.4.25-dev],[https://github.com/php/php-src/issues],[php],[https://www.php.net]) +AC_INIT([PHP],[8.4.26-dev],[https://github.com/php/php-src/issues],[php],[https://www.php.net]) AC_CONFIG_SRCDIR([main/php_version.h]) AC_CONFIG_AUX_DIR([build]) AC_PRESERVE_HELP_ORDER diff --git a/main/php_version.h b/main/php_version.h index f6516cc08b0b..e068320d766c 100644 --- a/main/php_version.h +++ b/main/php_version.h @@ -2,7 +2,7 @@ /* edit configure.ac to change version number */ #define PHP_MAJOR_VERSION 8 #define PHP_MINOR_VERSION 4 -#define PHP_RELEASE_VERSION 25 +#define PHP_RELEASE_VERSION 26 #define PHP_EXTRA_VERSION "-dev" -#define PHP_VERSION "8.4.25-dev" -#define PHP_VERSION_ID 80425 +#define PHP_VERSION "8.4.26-dev" +#define PHP_VERSION_ID 80426 From a2640aebc914b3c6fc997c379b1f91a2c4db8c55 Mon Sep 17 00:00:00 2001 From: NickSdot <32384907+NickSdot@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:01:17 +0700 Subject: [PATCH 8/8] [RFC] Allow Readonly Property Defaults (GH-22588) Co-authored-by: DanielEScherzer --- NEWS | 1 + UPGRADING | 2 + .../readonly_with_property_default.phpt | 36 +++++++++++ .../readonly_with_property_default_trait.phpt | 25 ++++++++ .../readonly_clone_success1.phpt | 59 +++++++++++++++++++ .../readonly_props/readonly_trait_match.phpt | 13 ++++ .../readonly_props/readonly_with_default.phpt | 32 +++++++++- ...default_abstract_get_set_implicit_set.phpt | 18 ++++++ ...ly_with_default_asymmetric_visibility.phpt | 34 +++++++++++ .../readonly_with_default_inheritance.phpt | 40 +++++++++++++ ...donly_with_default_interface_get_only.phpt | 17 ++++++ ...adonly_with_default_interface_get_set.phpt | 16 +++++ ...efault_interface_get_set_implicit_set.phpt | 17 ++++++ .../readonly_with_default_trait_mismatch.phpt | 20 +++++++ Zend/tests/readonly_props/serialization.phpt | 57 ++++++++++++++++++ Zend/tests/readonly_props/unset.phpt | 29 +++++++++ Zend/zend_compile.c | 5 -- .../tests/ReflectionClass_toString_009.phpt | 32 ++++++++++ ...lectionProperty_readonly_with_default.phpt | 35 +++++++++++ .../ReflectionProperty_toString_002.phpt | 14 +++++ ext/standard/var_unserializer.re | 3 + 21 files changed, 497 insertions(+), 8 deletions(-) create mode 100644 Zend/tests/readonly_classes/readonly_with_property_default.phpt create mode 100644 Zend/tests/readonly_classes/readonly_with_property_default_trait.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_abstract_get_set_implicit_set.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_inheritance.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_interface_get_only.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_interface_get_set.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_interface_get_set_implicit_set.phpt create mode 100644 Zend/tests/readonly_props/readonly_with_default_trait_mismatch.phpt create mode 100644 ext/reflection/tests/ReflectionClass_toString_009.phpt create mode 100644 ext/reflection/tests/ReflectionProperty_readonly_with_default.phpt create mode 100644 ext/reflection/tests/ReflectionProperty_toString_002.phpt diff --git a/NEWS b/NEWS index a2122027513f..d9f1281d594f 100644 --- a/NEWS +++ b/NEWS @@ -16,6 +16,7 @@ PHP NEWS . Changed run-tests.php to run in parallel by default, using up to 10 automatically detected workers. Pass -j1 for sequential execution. (NickSdot) + . Allowed readonly properties to declare default values. (NickSdot) . Changed run-tests.php to run test subprocesses without a shell where possible. (NickSdot) . Fixed GH-23083 (SEGV build_trace_args in zend_exceptions.c with diff --git a/UPGRADING b/UPGRADING index 779c95ae9530..f82aad3f8b3c 100644 --- a/UPGRADING +++ b/UPGRADING @@ -299,6 +299,8 @@ PHP 8.6 UPGRADE NOTES ======================================== - Core: + . Readonly properties may now declare default values. + RFC: https://wiki.php.net/rfc/readonly_property_defaults . It is now possible to use reference assign on WeakMap without the key needing to be present beforehand. . It is now possible to define the __debugInfo() magic method on enums. diff --git a/Zend/tests/readonly_classes/readonly_with_property_default.phpt b/Zend/tests/readonly_classes/readonly_with_property_default.phpt new file mode 100644 index 000000000000..49d2bdf03a1e --- /dev/null +++ b/Zend/tests/readonly_classes/readonly_with_property_default.phpt @@ -0,0 +1,36 @@ +--TEST-- +Properties of a readonly class may have default values +--FILE-- +bar = 2; + } catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; + } + } +} + +$foo = new Foo(); +var_dump($foo->bar); +var_dump($foo->nullable); + +try { + $foo->bar = 3; +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +Error: Cannot modify readonly property Foo::$bar +int(1) +NULL +Error: Cannot modify readonly property Foo::$bar diff --git a/Zend/tests/readonly_classes/readonly_with_property_default_trait.phpt b/Zend/tests/readonly_classes/readonly_with_property_default_trait.phpt new file mode 100644 index 000000000000..9decd22a8baf --- /dev/null +++ b/Zend/tests/readonly_classes/readonly_with_property_default_trait.phpt @@ -0,0 +1,25 @@ +--TEST-- +Readonly class may use readonly trait property with default value +--FILE-- +prop); + +class B { + use TDefault; +} + +var_dump(new B()->prop); + +?> +--EXPECT-- +int(2) +int(2) diff --git a/Zend/tests/readonly_props/readonly_clone_success1.phpt b/Zend/tests/readonly_props/readonly_clone_success1.phpt index 72cd9e9622b3..50e5e6df81e2 100644 --- a/Zend/tests/readonly_props/readonly_clone_success1.phpt +++ b/Zend/tests/readonly_props/readonly_clone_success1.phpt @@ -23,6 +23,41 @@ var_dump($foo2); var_dump(clone $foo2); +class FooWithDefault { + public readonly int $bar = 1; + + public function __clone() + { + $this->bar++; + } +} + +$fooWithDefault = new FooWithDefault(); + +var_dump(clone $fooWithDefault); + +$fooWithDefault2 = clone $fooWithDefault; +var_dump($fooWithDefault2); + +var_dump(clone $fooWithDefault2); + +class FooWithDefaultCloneWith { + public readonly int $bar = 1; + + public function withBar(int $bar) + { + return clone($this, ['bar' => $bar]); + } +} + +$clone = new FooWithDefaultCloneWith(); +var_dump($clone); + +$clone2 = $clone->withBar(2); +var_dump($clone2); + +var_dump($clone2->withBar(0)); + ?> --EXPECTF-- object(Foo)#%d (%d) { @@ -37,3 +72,27 @@ object(Foo)#%d (%d) { ["bar"]=> int(3) } +object(FooWithDefault)#%d (%d) { + ["bar"]=> + int(2) +} +object(FooWithDefault)#%d (%d) { + ["bar"]=> + int(2) +} +object(FooWithDefault)#%d (%d) { + ["bar"]=> + int(3) +} +object(FooWithDefaultCloneWith)#%d (%d) { + ["bar"]=> + int(1) +} +object(FooWithDefaultCloneWith)#%d (%d) { + ["bar"]=> + int(2) +} +object(FooWithDefaultCloneWith)#%d (%d) { + ["bar"]=> + int(0) +} diff --git a/Zend/tests/readonly_props/readonly_trait_match.phpt b/Zend/tests/readonly_props/readonly_trait_match.phpt index 00aa6349aa14..36f2e11ec2bc 100644 --- a/Zend/tests/readonly_props/readonly_trait_match.phpt +++ b/Zend/tests/readonly_props/readonly_trait_match.phpt @@ -13,7 +13,20 @@ class C { use T1, T2; } +trait TDefault1 { + public readonly int $prop = 1; +} +trait TDefault2 { + public readonly int $prop = 1; +} +class CDefault { + use TDefault1, TDefault2; +} + +var_dump(new CDefault()->prop); + ?> ===DONE=== --EXPECT-- +int(1) ===DONE=== diff --git a/Zend/tests/readonly_props/readonly_with_default.phpt b/Zend/tests/readonly_props/readonly_with_default.phpt index 12afe5cde153..affe62f8d1ff 100644 --- a/Zend/tests/readonly_props/readonly_with_default.phpt +++ b/Zend/tests/readonly_props/readonly_with_default.phpt @@ -3,17 +3,43 @@ Readonly property with default value --FILE-- 2]; + public readonly E $enum = E::Case; + public readonly string $enumString = E::Case->name; } $test = new Test; +var_dump($test->prop); +var_dump($test->className); +var_dump($test->nullable); +var_dump($test->array); +var_dump($test->enum); +var_dump($test->enumString); try { $test->prop = 2; } catch (Error $e) { - echo $e->getMessage(), "\n"; + echo $e::class, ': ', $e->getMessage(), PHP_EOL; } ?> ---EXPECTF-- -Fatal error: Readonly property Test::$prop cannot have default value in %s on line %d +--EXPECT-- +int(1) +string(4) "Test" +NULL +array(2) { + [0]=> + int(1) + ["two"]=> + int(2) +} +enum(E::Case) +string(4) "Case" +Error: Cannot modify readonly property Test::$prop diff --git a/Zend/tests/readonly_props/readonly_with_default_abstract_get_set_implicit_set.phpt b/Zend/tests/readonly_props/readonly_with_default_abstract_get_set_implicit_set.phpt new file mode 100644 index 000000000000..e61574d07aaf --- /dev/null +++ b/Zend/tests/readonly_props/readonly_with_default_abstract_get_set_implicit_set.phpt @@ -0,0 +1,18 @@ +--TEST-- +Readonly property with default value has restricted set visibility for get/set abstract property +--DESCRIPTION-- +The error message should be improved, the set access level comes from readonly. +--FILE-- + +--EXPECTF-- +Fatal error: Set access level of C::$prop must be omitted (as in class P) in %s on line %d diff --git a/Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt b/Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt new file mode 100644 index 000000000000..11b5a741ca17 --- /dev/null +++ b/Zend/tests/readonly_props/readonly_with_default_asymmetric_visibility.phpt @@ -0,0 +1,34 @@ +--TEST-- +Readonly property with default value and asymmetric visibility +--FILE-- +$prop; + try { + $test->$prop = 42; + } catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; + } + echo "$$prop before $before, after {$test->$prop}", PHP_EOL; +} + +?> +--EXPECT-- +Error: Cannot modify readonly property Test::$default +$default before 1, after 1 +Error: Cannot modify readonly property Test::$private +$private before 2, after 2 +Error: Cannot modify readonly property Test::$protected +$protected before 3, after 3 +Error: Cannot modify readonly property Test::$public +$public before 4, after 4 diff --git a/Zend/tests/readonly_props/readonly_with_default_inheritance.phpt b/Zend/tests/readonly_props/readonly_with_default_inheritance.phpt new file mode 100644 index 000000000000..8b285dcdd769 --- /dev/null +++ b/Zend/tests/readonly_props/readonly_with_default_inheritance.phpt @@ -0,0 +1,40 @@ +--TEST-- +Readonly property with default value and inheritance +--FILE-- +prop; + } +} + +class PrivateChild extends PrivateParent { + public readonly int $prop = 4; +} + +var_dump(new ChildInherits()->prop); +var_dump(new ChildOverrides()->prop); + +$privateChild = new PrivateChild(); +var_dump($privateChild->getParentProp()); +var_dump($privateChild->prop); + +?> +--EXPECT-- +int(1) +int(2) +int(3) +int(4) diff --git a/Zend/tests/readonly_props/readonly_with_default_interface_get_only.phpt b/Zend/tests/readonly_props/readonly_with_default_interface_get_only.phpt new file mode 100644 index 000000000000..1e64e26b4517 --- /dev/null +++ b/Zend/tests/readonly_props/readonly_with_default_interface_get_only.phpt @@ -0,0 +1,17 @@ +--TEST-- +Readonly property with default value satisfies get-only interface property +--FILE-- +prop); +?> +--EXPECT-- +int(42) diff --git a/Zend/tests/readonly_props/readonly_with_default_interface_get_set.phpt b/Zend/tests/readonly_props/readonly_with_default_interface_get_set.phpt new file mode 100644 index 000000000000..102444cc044f --- /dev/null +++ b/Zend/tests/readonly_props/readonly_with_default_interface_get_set.phpt @@ -0,0 +1,16 @@ +--TEST-- +Readonly public(set) property with default value does not satisfy get/set interface property +--FILE-- + +--EXPECTF-- +Fatal error: Class C contains 1 abstract method and must therefore be declared abstract or implement the remaining method (I::$prop::set) in %s on line %d diff --git a/Zend/tests/readonly_props/readonly_with_default_interface_get_set_implicit_set.phpt b/Zend/tests/readonly_props/readonly_with_default_interface_get_set_implicit_set.phpt new file mode 100644 index 000000000000..106bf2aeb0cf --- /dev/null +++ b/Zend/tests/readonly_props/readonly_with_default_interface_get_set_implicit_set.phpt @@ -0,0 +1,17 @@ +--TEST-- +Readonly property with default value has restricted set visibility for get/set interface property +--DESCRIPTION-- +The error message should be improved, the set access level comes from readonly. Ref: Zend/tests/property_hooks/interface_get_set_readonly.phpt +--FILE-- + +--EXPECTF-- +Fatal error: Set access level of C::$prop must be omitted (as in class I) in %s on line %d diff --git a/Zend/tests/readonly_props/readonly_with_default_trait_mismatch.phpt b/Zend/tests/readonly_props/readonly_with_default_trait_mismatch.phpt new file mode 100644 index 000000000000..6668b1d860bb --- /dev/null +++ b/Zend/tests/readonly_props/readonly_with_default_trait_mismatch.phpt @@ -0,0 +1,20 @@ +--TEST-- +Readonly trait property default value mismatch +--FILE-- + +--EXPECTF-- +Fatal error: T1 and T2 define the same property ($prop) in the composition of C. However, the definition differs and is considered incompatible. Class was composed in %s on line %d diff --git a/Zend/tests/readonly_props/serialization.phpt b/Zend/tests/readonly_props/serialization.phpt index f9e1f364673f..0d56067760d5 100644 --- a/Zend/tests/readonly_props/serialization.phpt +++ b/Zend/tests/readonly_props/serialization.phpt @@ -17,6 +17,42 @@ var_dump(unserialize($s)); var_dump(unserialize("O:4:\"Test\":1:{s:4:\"prop\";i:2;}")); var_dump(unserialize("O:4:\"Test\":2:{s:4:\"prop\";i:2;s:4:\"prop\";i:3;}")); +class TestDefault { + public readonly int $prop = 1; +} + +var_dump($s = serialize(new TestDefault)); +var_dump(unserialize($s)); + +var_dump(unserialize("O:11:\"TestDefault\":0:{}")); +var_dump(unserialize("O:11:\"TestDefault\":1:{s:4:\"prop\";i:2;}")); + +class TestDefaultWithUnserialize { + public readonly int $prop = 1; + public public(set) readonly int $lock = 1; + + public function __unserialize(array $data): void { + foreach ($data as $key => $value) { + $this->{$key} = $value; + } + } +} + +$testDefaultWithUnserialize = unserialize("O:26:\"TestDefaultWithUnserialize\":1:{s:4:\"prop\";i:2;}"); +var_dump($testDefaultWithUnserialize); + +try { + $testDefaultWithUnserialize->prop = 3; +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +try { + $testDefaultWithUnserialize->lock = 3; +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + ?> --EXPECT-- string(30) "O:4:"Test":1:{s:4:"prop";i:1;}" @@ -32,3 +68,24 @@ object(Test)#1 (1) { ["prop"]=> int(3) } +string(38) "O:11:"TestDefault":1:{s:4:"prop";i:1;}" +object(TestDefault)#1 (1) { + ["prop"]=> + int(1) +} +object(TestDefault)#1 (1) { + ["prop"]=> + int(1) +} +object(TestDefault)#1 (1) { + ["prop"]=> + int(2) +} +object(TestDefaultWithUnserialize)#1 (2) { + ["prop"]=> + int(2) + ["lock"]=> + int(1) +} +Error: Cannot modify readonly property TestDefaultWithUnserialize::$prop +Error: Cannot modify readonly property TestDefaultWithUnserialize::$lock diff --git a/Zend/tests/readonly_props/unset.phpt b/Zend/tests/readonly_props/unset.phpt index b8bd4218fa0c..823c021f6122 100644 --- a/Zend/tests/readonly_props/unset.phpt +++ b/Zend/tests/readonly_props/unset.phpt @@ -54,6 +54,31 @@ try { echo $e->getMessage(), "\n"; } +class Test4 { + public readonly int $prop = 1; + + public function __construct() { + try { + unset($this->prop); + } catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; + } + } + + public function __get($name) { + throw new Exception('Unreachable'); + } +} + +$test = new Test4; +var_dump($test->prop); // Don't call __get. +try { + unset($test->prop); +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} +var_dump($test->prop); // Still don't call __get. + ?> --EXPECT-- Cannot unset readonly property Test::$prop @@ -62,3 +87,7 @@ int(1) int(1) Cannot unset readonly property Test2::$prop Cannot unset protected(set) readonly property Test3::$prop from global scope +Error: Cannot unset readonly property Test4::$prop +int(1) +Error: Cannot unset readonly property Test4::$prop +int(1) diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index 6fd4df023520..1d837c59832b 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -9497,11 +9497,6 @@ static void zend_compile_prop_decl(zend_ast *ast, zend_ast *type_ast, uint32_t f zend_error_noreturn(E_COMPILE_ERROR, "Readonly property %s::$%s must have type", ZSTR_VAL(ce->name), ZSTR_VAL(name)); } - if (!Z_ISUNDEF(value_zv)) { - zend_error_noreturn(E_COMPILE_ERROR, - "Readonly property %s::$%s cannot have default value", - ZSTR_VAL(ce->name), ZSTR_VAL(name)); - } if (flags & ZEND_ACC_STATIC) { zend_error_noreturn(E_COMPILE_ERROR, "Static property %s::$%s cannot be readonly", diff --git a/ext/reflection/tests/ReflectionClass_toString_009.phpt b/ext/reflection/tests/ReflectionClass_toString_009.phpt new file mode 100644 index 000000000000..b2d118c459b8 --- /dev/null +++ b/ext/reflection/tests/ReflectionClass_toString_009.phpt @@ -0,0 +1,32 @@ +--TEST-- +ReflectionClass::__toString() - readonly property with default +--FILE-- + +--EXPECTF-- +Class [ class Test ] { + @@ %s 3-5 + + - Constants [0] { + } + + - Static properties [0] { + } + + - Static methods [0] { + } + + - Properties [1] { + Property [ public protected(set) readonly int $property = 42 ] + } + + - Methods [0] { + } +} diff --git a/ext/reflection/tests/ReflectionProperty_readonly_with_default.phpt b/ext/reflection/tests/ReflectionProperty_readonly_with_default.phpt new file mode 100644 index 000000000000..d3c3e8a084e4 --- /dev/null +++ b/ext/reflection/tests/ReflectionProperty_readonly_with_default.phpt @@ -0,0 +1,35 @@ +--TEST-- +Reflection for readonly property with default value +--FILE-- +isReadOnly()); +var_dump($rp->hasDefaultValue()); +var_dump($rp->getDefaultValue()); +var_dump(new ReflectionClass(Foo::class)->getDefaultProperties()); + +$test = new Foo(); +try { + $rp->setValue($test, 2); +} catch (Error $e) { + echo $e::class, ': ', $e->getMessage(), PHP_EOL; +} + +?> +--EXPECT-- +bool(true) +bool(true) +int(1) +array(2) { + ["prop"]=> + int(1) + ["nullable"]=> + NULL +} +Error: Cannot modify readonly property Foo::$prop diff --git a/ext/reflection/tests/ReflectionProperty_toString_002.phpt b/ext/reflection/tests/ReflectionProperty_toString_002.phpt new file mode 100644 index 000000000000..5b93d2149b9a --- /dev/null +++ b/ext/reflection/tests/ReflectionProperty_toString_002.phpt @@ -0,0 +1,14 @@ +--TEST-- +ReflectionProperty::__toString() - readonly with default +--FILE-- + +--EXPECT-- +Property [ public protected(set) readonly int $nick = 42 ] diff --git a/ext/standard/var_unserializer.re b/ext/standard/var_unserializer.re index eca9660c5605..1fb8793c2bbc 100644 --- a/ext/standard/var_unserializer.re +++ b/ext/standard/var_unserializer.re @@ -17,6 +17,7 @@ #include "php_incomplete_class.h" #include "zend_portability.h" #include "zend_exceptions.h" +#include "zend_objects.h" /* {{{ reference-handling for unserializer: var_* */ #define VAR_ENTRIES_MAX 1018 /* 1024 - offsetof(php_unserialize_data, entries) / sizeof(void*) */ @@ -300,6 +301,7 @@ PHPAPI void var_destroy(php_unserialize_data_t *var_hashx) zval param; ZVAL_COPY(¶m, &var_dtor_hash->data[i + 1]); + zend_object_set_properties_reinitable(Z_OBJ_P(zv), /* reinitable */ true); BG(serialize_lock)++; zend_call_known_instance_method_with_1_params( Z_OBJCE_P(zv)->__unserialize, Z_OBJ_P(zv), NULL, ¶m); @@ -308,6 +310,7 @@ PHPAPI void var_destroy(php_unserialize_data_t *var_hashx) GC_ADD_FLAGS(Z_OBJ_P(zv), IS_OBJ_DESTRUCTOR_CALLED); } BG(serialize_lock)--; + zend_object_set_properties_reinitable(Z_OBJ_P(zv), /* reinitable */ false); zval_ptr_dtor(¶m); } else { GC_ADD_FLAGS(Z_OBJ_P(zv), IS_OBJ_DESTRUCTOR_CALLED);