Commit b114db0ddc2 for php.net

commit b114db0ddc29d799918f05fad73daf79863d3011
Author: Jakub Zelenka <bukka@php.net>
Date:   Mon May 11 20:12:08 2026 +0200

    streams: split filter seekability into read and write fields (#21878)

    The single seekable field caused write-chain seeks to reset filter
    state after the bug #49874 fix, breaking dechunk on php://temp (used
    by Symfony HttpClient).

    Split into read_seekable and write_seekable. Write defaults to ALWAYS
    for stateless and non-buffering filters. Buffer-holding filters
    (zlib, bz2, convert.*) accept a write_seek_mode parameter:
    "preserve" (default), "reset", or "strict". Invalid values throw
    ValueError.

    php_user_filter::seek gains a third int $chain argument; the ops
    seek signature is unchanged.

diff --git a/ext/bz2/bz2_filter.c b/ext/bz2/bz2_filter.c
index e1b24f6319f..09c49fa7668 100644
--- a/ext/bz2/bz2_filter.c
+++ b/ext/bz2/bz2_filter.c
@@ -381,9 +381,14 @@ static const php_stream_filter_ops php_bz2_compress_ops = {
 static php_stream_filter *php_bz2_filter_create(const char *filtername, zval *filterparams, bool persistent)
 {
 	const php_stream_filter_ops *fops = NULL;
+	php_stream_filter_seekable_t write_seekable;
 	php_bz2_filter_data *data;
 	int status = BZ_OK;

+	if (php_stream_filter_parse_write_seek_mode(filterparams, &write_seekable) == FAILURE) {
+		return NULL;
+	}
+
 	/* Create this filter */
 	data = pecalloc(1, sizeof(php_bz2_filter_data), persistent);

@@ -476,7 +481,7 @@ static php_stream_filter *php_bz2_filter_create(const char *filtername, zval *fi
 		return NULL;
 	}

-	return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START);
+	return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START, write_seekable);
 }

 const php_stream_filter_factory php_bz2_filter_factory = {
diff --git a/ext/bz2/tests/bz2_filter_seek_compress.phpt b/ext/bz2/tests/bz2_filter_seek_compress.phpt
index 0656b244484..557021c9fd0 100644
--- a/ext/bz2/tests/bz2_filter_seek_compress.phpt
+++ b/ext/bz2/tests/bz2_filter_seek_compress.phpt
@@ -1,55 +1,51 @@
 --TEST--
-bzip2.compress filter with seek to start
+bzip2.compress write filter is not reset on seek
 --EXTENSIONS--
 bz2
 --FILE--
 <?php
+/* Write filters are not reset on stream seek; seeking only affects the
+ * stream's read/write position, not the filter pipeline state. */
+
 $file = __DIR__ . '/bz2_filter_seek_compress.bz2';

-$text1 = 'Short text.';
-$text2 = 'This is a much longer text that will completely overwrite the previous compressed data in the file.';
+$text = 'Hello, World!';

 $fp = fopen($file, 'w+');
-stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE);
+$filter = stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE);
+
+fwrite($fp, $text);

-fwrite($fp, $text1);
-fflush($fp);
+/* Remove the filter to finalize compression cleanly before seeking */
+stream_filter_remove($filter);

-$size1 = ftell($fp);
-echo "Size after first write: $size1\n";
+$size = ftell($fp);
+echo "Size after write: $size\n";

+/* Seek to start succeeds; write filters no longer block seeking */
 $result = fseek($fp, 0, SEEK_SET);
 echo "Seek to start: " . ($result === 0 ? "SUCCESS" : "FAILURE") . "\n";

-fwrite($fp, $text2);
-fflush($fp);
-
-$size2 = ftell($fp);
-echo "Size after second write: $size2\n";
-echo "Second write is larger: " . ($size2 > $size1 ? "YES" : "NO") . "\n";
-
+/* Seek to middle also succeeds */
 $result = fseek($fp, 50, SEEK_SET);
 echo "Seek to middle: " . ($result === 0 ? "SUCCESS" : "FAILURE") . "\n";

 fclose($fp);

+/* Verify the compressed output is still valid */
 $fp = fopen($file, 'r');
 stream_filter_append($fp, 'bzip2.decompress', STREAM_FILTER_READ);
 $content = stream_get_contents($fp);
 fclose($fp);

-echo "Decompressed content matches text2: " . ($content === $text2 ? "YES" : "NO") . "\n";
+echo "Decompressed content matches: " . ($content === $text ? "YES" : "NO") . "\n";
 ?>
 --CLEAN--
 <?php
 @unlink(__DIR__ . '/bz2_filter_seek_compress.bz2');
 ?>
 --EXPECTF--
-Size after first write: 40
+Size after write: %d
 Seek to start: SUCCESS
-Size after second write: 98
-Second write is larger: YES
-
-Warning: fseek(): Stream filter bzip2.compress is seekable only to start position in %s on line %d
-Seek to middle: FAILURE
-Decompressed content matches text2: YES
+Seek to middle: SUCCESS
+Decompressed content matches: YES
diff --git a/ext/bz2/tests/bz2_filter_write_seek_modes.phpt b/ext/bz2/tests/bz2_filter_write_seek_modes.phpt
new file mode 100644
index 00000000000..b3b4fa39eb1
--- /dev/null
+++ b/ext/bz2/tests/bz2_filter_write_seek_modes.phpt
@@ -0,0 +1,54 @@
+--TEST--
+bzip2.compress write filter: write_seek_mode parameter
+--EXTENSIONS--
+bz2
+--FILE--
+<?php
+$file = __DIR__ . '/bz2_filter_write_seek_modes.bz2';
+
+$text1 = 'First message that will be discarded.';
+$text2 = 'Second message that replaces the first.';
+
+/* "reset" */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE,
+    ['write_seek_mode' => 'reset']);
+fwrite($fp, $text1);
+ftruncate($fp, 0);
+var_dump(fseek($fp, 0, SEEK_SET) === 0);
+fwrite($fp, $text2);
+fclose($fp);
+
+$fp = fopen($file, 'r');
+stream_filter_append($fp, 'bzip2.decompress', STREAM_FILTER_READ);
+$decoded = stream_get_contents($fp);
+fclose($fp);
+var_dump($decoded === $text2);
+
+/* "strict" */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE,
+    ['write_seek_mode' => 'strict']);
+fwrite($fp, $text1);
+var_dump(@fseek($fp, 0, SEEK_SET) === -1);
+fclose($fp);
+
+/* Invalid mode: ValueError */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'bzip2.compress', STREAM_FILTER_WRITE,
+    ['write_seek_mode' => 'nope']);
+fclose($fp);
+
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/bz2_filter_write_seek_modes.bz2');
+?>
+--EXPECTF--
+bool(true)
+bool(true)
+bool(true)
+
+Warning: stream_filter_append(): "write_seek_mode" filter parameter must be one of "preserve", "reset", or "strict" in %s
+
+Warning: stream_filter_append(): Unable to create or locate filter "bzip2.compress" in %s
diff --git a/ext/iconv/iconv.c b/ext/iconv/iconv.c
index 5dfc5d9a190..d9b54388a8f 100644
--- a/ext/iconv/iconv.c
+++ b/ext/iconv/iconv.c
@@ -2633,9 +2633,14 @@ static const php_stream_filter_ops php_iconv_stream_filter_ops = {
 static php_stream_filter *php_iconv_stream_filter_factory_create(const char *name, zval *params, bool persistent)
 {
 	php_iconv_stream_filter *inst;
+	php_stream_filter_seekable_t write_seekable;
 	const char *from_charset = NULL, *to_charset = NULL;
 	size_t from_charset_len, to_charset_len;

+	if (php_stream_filter_parse_write_seek_mode(params, &write_seekable) == FAILURE) {
+		return NULL;
+	}
+
 	if ((from_charset = strchr(name, '.')) == NULL) {
 		return NULL;
 	}
@@ -2663,7 +2668,7 @@ static php_stream_filter *php_iconv_stream_filter_factory_create(const char *nam
 	}

 	return php_stream_filter_alloc(&php_iconv_stream_filter_ops, inst, persistent,
-			PSFS_SEEKABLE_START);
+			PSFS_SEEKABLE_START, write_seekable);
 }
 /* }}} */

diff --git a/ext/standard/filters.c b/ext/standard/filters.c
index 5ee7e93c40c..d26429704e2 100644
--- a/ext/standard/filters.c
+++ b/ext/standard/filters.c
@@ -63,7 +63,8 @@ static const php_stream_filter_ops strfilter_rot13_ops = {

 static php_stream_filter *strfilter_rot13_create(const char *filtername, zval *filterparams, bool persistent)
 {
-	return php_stream_filter_alloc(&strfilter_rot13_ops, NULL, persistent, PSFS_SEEKABLE_ALWAYS);
+	return php_stream_filter_alloc(&strfilter_rot13_ops, NULL, persistent,
+			PSFS_SEEKABLE_ALWAYS, PSFS_SEEKABLE_ALWAYS);
 }

 static const php_stream_filter_factory strfilter_rot13_factory = {
@@ -147,12 +148,14 @@ static const php_stream_filter_ops strfilter_tolower_ops = {

 static php_stream_filter *strfilter_toupper_create(const char *filtername, zval *filterparams, bool persistent)
 {
-	return php_stream_filter_alloc(&strfilter_toupper_ops, NULL, persistent, PSFS_SEEKABLE_ALWAYS);
+	return php_stream_filter_alloc(&strfilter_toupper_ops, NULL, persistent,
+			PSFS_SEEKABLE_ALWAYS, PSFS_SEEKABLE_ALWAYS);
 }

 static php_stream_filter *strfilter_tolower_create(const char *filtername, zval *filterparams, bool persistent)
 {
-	return php_stream_filter_alloc(&strfilter_tolower_ops, NULL, persistent, PSFS_SEEKABLE_ALWAYS);
+	return php_stream_filter_alloc(&strfilter_tolower_ops, NULL, persistent,
+			PSFS_SEEKABLE_ALWAYS, PSFS_SEEKABLE_ALWAYS);
 }

 static const php_stream_filter_factory strfilter_toupper_factory = {
@@ -1634,7 +1637,7 @@ static const php_stream_filter_ops strfilter_convert_ops = {
 static php_stream_filter *strfilter_convert_create(const char *filtername, zval *filterparams, bool persistent)
 {
 	php_convert_filter *inst;
-
+	php_stream_filter_seekable_t write_seekable;
 	const char *dot;
 	int conv_mode = 0;

@@ -1648,6 +1651,10 @@ static php_stream_filter *strfilter_convert_create(const char *filtername, zval
 	}
 	++dot;

+	if (php_stream_filter_parse_write_seek_mode(filterparams, &write_seekable) == FAILURE) {
+		return NULL;
+	}
+
 	inst = pemalloc(sizeof(php_convert_filter), persistent);

 	if (strcasecmp(dot, "base64-encode") == 0) {
@@ -1667,7 +1674,7 @@ static php_stream_filter *strfilter_convert_create(const char *filtername, zval
 		return NULL;
 	}

-	return php_stream_filter_alloc(&strfilter_convert_ops, inst, persistent, PSFS_SEEKABLE_START);
+	return php_stream_filter_alloc(&strfilter_convert_ops, inst, persistent, PSFS_SEEKABLE_START, write_seekable);
 }

 static const php_stream_filter_factory strfilter_convert_factory = {
@@ -1761,7 +1768,7 @@ static php_stream_filter *consumed_filter_create(const char *filtername, zval *f
 	data->offset = ~0;
 	fops = &consumed_filter_ops;

-	return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START);
+	return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START, PSFS_SEEKABLE_ALWAYS);
 }

 static const php_stream_filter_factory consumed_filter_factory = {
@@ -1992,7 +1999,7 @@ static php_stream_filter *chunked_filter_create(const char *filtername, zval *fi
 	data->persistent = persistent;
 	fops = &chunked_filter_ops;

-	return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START);
+	return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START, PSFS_SEEKABLE_ALWAYS);
 }

 static const php_stream_filter_factory chunked_filter_factory = {
diff --git a/ext/standard/tests/filters/chunked_002.phpt b/ext/standard/tests/filters/chunked_002.phpt
new file mode 100644
index 00000000000..7114e278e3b
--- /dev/null
+++ b/ext/standard/tests/filters/chunked_002.phpt
@@ -0,0 +1,58 @@
+--TEST--
+Dechunk write filter state must survive stream seek
+--FILE--
+<?php
+/* The dechunk filter is commonly used as a write filter on php://temp buffers.
+ * The buffer is written to (through the filter) and then seeked to re-read
+ * the already-decoded output. Seeking the stream must NOT reset the write
+ * filter state, otherwise multi-chunk transfers break. */
+
+$buffer = fopen('php://temp', 'w+');
+stream_filter_append($buffer, 'dechunk', STREAM_FILTER_WRITE);
+
+/* Write first chunk */
+fwrite($buffer, "5\r\nHello\r\n");
+
+/* Read back decoded data; this seeks to offset 0 internally */
+$data = stream_get_contents($buffer, -1, 0);
+var_dump($data);
+
+/* Write second chunk; filter must still be in the correct state */
+fwrite($buffer, "7\r\n, World\r\n");
+
+/* Read all decoded data from the beginning */
+$data = stream_get_contents($buffer, -1, 0);
+var_dump($data);
+
+/* Write final (terminating) chunk */
+fwrite($buffer, "0\r\n\r\n");
+
+/* Read complete decoded output */
+$data = stream_get_contents($buffer, -1, 0);
+var_dump($data);
+
+fclose($buffer);
+
+/* Also verify that incomplete chunked transfer is still detected:
+ * writing a non-chunk byte after the filter has been reset by a
+ * seek should not produce output. */
+$buffer = fopen('php://temp', 'w+');
+stream_filter_append($buffer, 'dechunk', STREAM_FILTER_WRITE);
+
+fwrite($buffer, "5\r\nHello\r\n");
+$data = stream_get_contents($buffer, -1, 0);
+var_dump($data);
+
+/* The transfer is still in progress (no terminating 0-chunk seen).
+ * Verify incomplete state is preserved by checking ftell: the decoded
+ * write position should reflect only the 5 bytes written so far. */
+var_dump(ftell($buffer));
+
+fclose($buffer);
+?>
+--EXPECT--
+string(5) "Hello"
+string(12) "Hello, World"
+string(12) "Hello, World"
+string(5) "Hello"
+int(5)
diff --git a/ext/standard/tests/filters/convert_filter_write_seek_modes.phpt b/ext/standard/tests/filters/convert_filter_write_seek_modes.phpt
new file mode 100644
index 00000000000..0a2d4114bf3
--- /dev/null
+++ b/ext/standard/tests/filters/convert_filter_write_seek_modes.phpt
@@ -0,0 +1,61 @@
+--TEST--
+convert.* write filter: write_seek_mode parameter
+--FILE--
+<?php
+/* Smoke test: the write_seek_mode parameter is accepted on convert.* filters
+ * and behaves correctly per mode. The deeper reset semantics are exercised
+ * via the read path for convert.* (read_seekable = START always resets) and
+ * via the dedicated zlib/bz2 mode tests. */
+
+foreach (['convert.base64-encode', 'convert.quoted-printable-encode'] as $name) {
+    /* preserve: seeks succeed (default) */
+    $fp = fopen('php://memory', 'w+');
+    stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
+        ['write_seek_mode' => 'preserve']);
+    fwrite($fp, 'Hello');
+    var_dump(fseek($fp, 0, SEEK_SET) === 0);
+    var_dump(fseek($fp, 100, SEEK_SET) === 0);
+    fclose($fp);
+
+    /* reset: seeks succeed, callback dispatched */
+    $fp = fopen('php://memory', 'w+');
+    stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
+        ['write_seek_mode' => 'reset']);
+    fwrite($fp, 'Hello');
+    var_dump(fseek($fp, 0, SEEK_SET) === 0);
+    fclose($fp);
+
+    /* strict: seek fails */
+    $fp = fopen('php://memory', 'w+');
+    stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
+        ['write_seek_mode' => 'strict']);
+    fwrite($fp, 'Hello');
+    var_dump(@fseek($fp, 0, SEEK_SET) === -1);
+    fclose($fp);
+
+    /* invalid: ValueError */
+    $fp = fopen('php://memory', 'w+');
+        stream_filter_append($fp, $name, STREAM_FILTER_WRITE,
+            ['write_seek_mode' => 42]);
+    if ($fp) {
+        fclose($fp);
+    }
+}
+?>
+--EXPECTF--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+
+Warning: stream_filter_append(): "write_seek_mode" filter parameter must be one of "preserve", "reset", or "strict" in %s
+
+Warning: stream_filter_append(): Unable to create or locate filter "convert.base64-encode" in %s
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+
+Warning: stream_filter_append(): "write_seek_mode" filter parameter must be one of "preserve", "reset", or "strict" in %s
+
+Warning: stream_filter_append(): Unable to create or locate filter "convert.quoted-printable-encode" in %s
diff --git a/ext/standard/tests/filters/php_user_filter_04.phpt b/ext/standard/tests/filters/php_user_filter_04.phpt
index 72f874f21ad..6dc62779aa9 100644
--- a/ext/standard/tests/filters/php_user_filter_04.phpt
+++ b/ext/standard/tests/filters/php_user_filter_04.phpt
@@ -25,4 +25,4 @@ public function seek($offset): bool

 ?>
 --EXPECTF--
-Fatal error: Declaration of InvalidSeekFilter::seek($offset): bool must be compatible with php_user_filter::seek(int $offset, int $whence): bool in %s on line %d
+Fatal error: Declaration of InvalidSeekFilter::seek($offset): bool must be compatible with php_user_filter::seek(int $offset, int $whence, int $chain): bool in %s on line %d
diff --git a/ext/standard/tests/filters/user_filter_seek_01.phpt b/ext/standard/tests/filters/user_filter_seek_01.phpt
index 31ec95ca6aa..6023ced1d0e 100644
--- a/ext/standard/tests/filters/user_filter_seek_01.phpt
+++ b/ext/standard/tests/filters/user_filter_seek_01.phpt
@@ -39,7 +39,7 @@ public function onCreate(): bool

     public function onClose(): void {}

-    public function seek(int $offset, int $whence): bool
+    public function seek(int $offset, int $whence, int $chain): bool
     {
         // Stateless filter - always seekable to any position
         return true;
diff --git a/ext/standard/tests/filters/user_filter_seek_02.phpt b/ext/standard/tests/filters/user_filter_seek_02.phpt
index 39f4c3c6624..f728e75cdca 100644
--- a/ext/standard/tests/filters/user_filter_seek_02.phpt
+++ b/ext/standard/tests/filters/user_filter_seek_02.phpt
@@ -28,7 +28,7 @@ public function onCreate(): bool

     public function onClose(): void {}

-    public function seek(int $offset, int $whence): bool
+    public function seek(int $offset, int $whence, int $chain): bool
     {
         if ($offset === 0 && $whence === SEEK_SET) {
             $this->count = 0;
diff --git a/ext/standard/tests/filters/user_filter_seek_03.phpt b/ext/standard/tests/filters/user_filter_seek_03.phpt
new file mode 100644
index 00000000000..bc814e8ed16
--- /dev/null
+++ b/ext/standard/tests/filters/user_filter_seek_03.phpt
@@ -0,0 +1,67 @@
+--TEST--
+php_user_filter::seek receives chain identifier (read vs write)
+--FILE--
+<?php
+
+class TrackingFilter extends php_user_filter
+{
+    public static array $log = [];
+
+    public function filter($in, $out, &$consumed, bool $closing): int
+    {
+        while ($bucket = stream_bucket_make_writeable($in)) {
+            $consumed += $bucket->datalen;
+            stream_bucket_append($out, $bucket);
+        }
+        return PSFS_PASS_ON;
+    }
+
+    public function onCreate(): bool
+    {
+        return true;
+    }
+
+    public function onClose(): void {}
+
+    public function seek(int $offset, int $whence, int $chain): bool
+    {
+        $name = match ($chain) {
+            STREAM_FILTER_READ  => 'read',
+            STREAM_FILTER_WRITE => 'write',
+            default             => 'other',
+        };
+        self::$log[] = "$name:$offset:$whence";
+        return true;
+    }
+}
+
+stream_filter_register('test.tracking', 'TrackingFilter');
+
+$file = __DIR__ . '/user_filter_seek_03.txt';
+file_put_contents($file, "abcdefghij");
+
+/* Read chain: seek-to-start should dispatch with STREAM_FILTER_READ */
+TrackingFilter::$log = [];
+$fp = fopen($file, 'r');
+stream_filter_append($fp, 'test.tracking', STREAM_FILTER_READ);
+fread($fp, 4);
+fseek($fp, 0, SEEK_SET);
+fclose($fp);
+echo "Read chain log: " . implode(',', TrackingFilter::$log) . "\n";
+
+/* Write chain: any seek should dispatch with STREAM_FILTER_WRITE */
+TrackingFilter::$log = [];
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'test.tracking', STREAM_FILTER_WRITE);
+fwrite($fp, "xyz");
+fseek($fp, 0, SEEK_SET);
+fseek($fp, 5, SEEK_SET);
+fclose($fp);
+echo "Write chain log: " . implode(',', TrackingFilter::$log) . "\n";
+
+unlink($file);
+
+?>
+--EXPECT--
+Read chain log: read:0:0
+Write chain log: write:0:0,write:5:0
diff --git a/ext/standard/user_filters.c b/ext/standard/user_filters.c
index 816a798c4b0..ae4132733be 100644
--- a/ext/standard/user_filters.c
+++ b/ext/standard/user_filters.c
@@ -48,8 +48,9 @@ PHP_METHOD(php_user_filter, filter)

 PHP_METHOD(php_user_filter, seek)
 {
-	zend_long offset, whence;
-	if (zend_parse_parameters(ZEND_NUM_ARGS(), "ll", &offset, &whence) == FAILURE) {
+	zend_long offset, whence, chain;
+
+	if (zend_parse_parameters(ZEND_NUM_ARGS(), "lll", &offset, &whence, &chain) == FAILURE) {
 		RETURN_THROWS();
 	}

@@ -263,7 +264,7 @@ static zend_result userfilter_seek(
 {
 	zval *obj = &thisfilter->abstract;
 	zval retval;
-	zval args[2];
+	zval args[3];

 	/* the userfilter object probably doesn't exist anymore */
 	if (CG(unclean_shutdown)) {
@@ -289,8 +290,9 @@ static zend_result userfilter_seek(
 	/* Setup calling arguments */
 	ZVAL_LONG(&args[0], offset);
 	ZVAL_LONG(&args[1], whence);
+	ZVAL_LONG(&args[2], php_stream_filter_get_chain_type(stream, thisfilter));

-	zend_call_known_function(seek_method, Z_OBJ_P(obj), Z_OBJCE_P(obj), &retval, 2, args, NULL);
+	zend_call_known_function(seek_method, Z_OBJ_P(obj), Z_OBJCE_P(obj), &retval, 3, args, NULL);

 	zend_result ret = FAILURE;
 	if (Z_TYPE(retval) != IS_UNDEF) {
@@ -383,7 +385,8 @@ static php_stream_filter *user_filter_factory_create(const char *filtername,
 		return NULL;
 	}

-	filter = php_stream_filter_alloc(&userfilter_ops, NULL, false, PSFS_SEEKABLE_CHECK);
+	filter = php_stream_filter_alloc(&userfilter_ops, NULL, false,
+			PSFS_SEEKABLE_CHECK, PSFS_SEEKABLE_CHECK);

 	/* filtername */
 	add_property_string(&obj, "filtername", filtername);
diff --git a/ext/standard/user_filters.stub.php b/ext/standard/user_filters.stub.php
index 475ec58e79e..8696845b78c 100644
--- a/ext/standard/user_filters.stub.php
+++ b/ext/standard/user_filters.stub.php
@@ -50,7 +50,7 @@ class php_user_filter
     public function filter($in, $out, &$consumed, bool $closing): int {}

     /** @tentative-return-type */
-    public function seek(int $offset, int $whence): bool {}
+    public function seek(int $offset, int $whence, int $chain): bool {}

     /** @tentative-return-type */
     public function onCreate(): bool {}
diff --git a/ext/standard/user_filters_arginfo.h b/ext/standard/user_filters_arginfo.h
index d530a5c0006..f5b8a7dfa47 100644
Binary files a/ext/standard/user_filters_arginfo.h and b/ext/standard/user_filters_arginfo.h differ
diff --git a/ext/zlib/tests/zlib_filter_seek_deflate.phpt b/ext/zlib/tests/zlib_filter_seek_deflate.phpt
index 6acee8e4e8c..743f1be566f 100644
--- a/ext/zlib/tests/zlib_filter_seek_deflate.phpt
+++ b/ext/zlib/tests/zlib_filter_seek_deflate.phpt
@@ -1,55 +1,52 @@
 --TEST--
-zlib.deflate filter with seek to start
+zlib.deflate write filter is not reset on seek
 --EXTENSIONS--
 zlib
 --FILE--
 <?php
+/* Write filters are not reset on stream seek; seeking only affects the
+ * stream's read/write position, not the filter pipeline state. This ensures
+ * seeking a stream with write filters does not disrupt the filter state. */
+
 $file = __DIR__ . '/zlib_filter_seek_deflate.zlib';

-$text1 = 'Short text.';
-$text2 = 'This is a much longer text that will completely overwrite the previous compressed data in the file.';
+$text = 'Hello, World!';

 $fp = fopen($file, 'w+');
-stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE);
+$filter = stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE);
+
+fwrite($fp, $text);

-fwrite($fp, $text1);
-fflush($fp);
+/* Remove the filter to finalize compression cleanly before seeking */
+stream_filter_remove($filter);

-$size1 = ftell($fp);
-echo "Size after first write: $size1\n";
+$size = ftell($fp);
+echo "Size after write: $size\n";

+/* Seek to start succeeds; write filters no longer block seeking */
 $result = fseek($fp, 0, SEEK_SET);
 echo "Seek to start: " . ($result === 0 ? "SUCCESS" : "FAILURE") . "\n";

-fwrite($fp, $text2);
-fflush($fp);
-
-$size2 = ftell($fp);
-echo "Size after second write: $size2\n";
-echo "Second write is larger: " . ($size2 > $size1 ? "YES" : "NO") . "\n";
-
+/* Seek to middle also succeeds */
 $result = fseek($fp, 50, SEEK_SET);
 echo "Seek to middle: " . ($result === 0 ? "SUCCESS" : "FAILURE") . "\n";

 fclose($fp);

+/* Verify the compressed output is still valid */
 $fp = fopen($file, 'r');
 stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_READ);
 $content = stream_get_contents($fp);
 fclose($fp);

-echo "Decompressed content matches text2: " . ($content === $text2 ? "YES" : "NO") . "\n";
+echo "Decompressed content matches: " . ($content === $text ? "YES" : "NO") . "\n";
 ?>
 --CLEAN--
 <?php
 @unlink(__DIR__ . '/zlib_filter_seek_deflate.zlib');
 ?>
 --EXPECTF--
-Size after first write: %d
+Size after write: %d
 Seek to start: SUCCESS
-Size after second write: %d
-Second write is larger: YES
-
-Warning: fseek(): Stream filter zlib.deflate is seekable only to start position in %s on line %d
-Seek to middle: FAILURE
-Decompressed content matches text2: YES
+Seek to middle: SUCCESS
+Decompressed content matches: YES
diff --git a/ext/zlib/tests/zlib_filter_write_seek_modes.phpt b/ext/zlib/tests/zlib_filter_write_seek_modes.phpt
new file mode 100644
index 00000000000..39824a7283c
--- /dev/null
+++ b/ext/zlib/tests/zlib_filter_write_seek_modes.phpt
@@ -0,0 +1,78 @@
+--TEST--
+zlib.deflate write filter: write_seek_mode parameter
+--EXTENSIONS--
+zlib
+--FILE--
+<?php
+$file = __DIR__ . '/zlib_filter_write_seek_modes.zlib';
+
+$text1 = 'First message that will be discarded.';
+$text2 = 'Second message that replaces the first.';
+
+/* "reset": ftruncate(0) + fseek(0) starts a fresh deflate stream */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE,
+    ['write_seek_mode' => 'reset']);
+fwrite($fp, $text1);
+ftruncate($fp, 0);
+var_dump(fseek($fp, 0, SEEK_SET) === 0);
+fwrite($fp, $text2);
+fclose($fp);
+
+$fp = fopen($file, 'r');
+stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_READ);
+$decoded = stream_get_contents($fp);
+fclose($fp);
+var_dump($decoded === $text2);
+
+/* "reset": only seekable to start */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE,
+    ['write_seek_mode' => 'reset']);
+fwrite($fp, $text1);
+var_dump(fseek($fp, 5, SEEK_SET) === 0);
+fclose($fp);
+
+/* "preserve": same as default; seeks accepted, state preserved */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE,
+    ['write_seek_mode' => 'preserve']);
+fwrite($fp, $text1);
+var_dump(fseek($fp, 0, SEEK_SET) === 0);
+var_dump(fseek($fp, 50, SEEK_SET) === 0);
+fclose($fp);
+
+/* "strict": every write-chain seek fails */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE,
+    ['write_seek_mode' => 'strict']);
+fwrite($fp, $text1);
+var_dump(@fseek($fp, 0, SEEK_SET) === -1);
+var_dump(@fseek($fp, 50, SEEK_SET) === -1);
+fclose($fp);
+
+/* Invalid mode: ValueError */
+$fp = fopen($file, 'w+');
+stream_filter_append($fp, 'zlib.deflate', STREAM_FILTER_WRITE,
+    ['write_seek_mode' => 'rewind']);
+fclose($fp);
+
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . '/zlib_filter_write_seek_modes.zlib');
+?>
+--EXPECTF--
+bool(true)
+bool(true)
+
+Warning: fseek(): Stream filter zlib.deflate is seekable only to start position in %s
+bool(false)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+
+Warning: stream_filter_append(): "write_seek_mode" filter parameter must be one of "preserve", "reset", or "strict" in %s
+
+Warning: stream_filter_append(): Unable to create or locate filter "zlib.deflate" in %s
diff --git a/ext/zlib/zlib_filter.c b/ext/zlib/zlib_filter.c
index b6393feb908..2d0e4fbb7fa 100644
--- a/ext/zlib/zlib_filter.c
+++ b/ext/zlib/zlib_filter.c
@@ -357,9 +357,14 @@ static const php_stream_filter_ops php_zlib_deflate_ops = {
 static php_stream_filter *php_zlib_filter_create(const char *filtername, zval *filterparams, bool persistent)
 {
 	const php_stream_filter_ops *fops = NULL;
+	php_stream_filter_seekable_t write_seekable;
 	php_zlib_filter_data *data;
 	int status;

+	if (php_stream_filter_parse_write_seek_mode(filterparams, &write_seekable) == FAILURE) {
+		return NULL;
+	}
+
 	/* Create this filter */
 	data = pecalloc(1, sizeof(php_zlib_filter_data), persistent);
 	if (!data) {
@@ -498,7 +503,7 @@ static php_stream_filter *php_zlib_filter_create(const char *filtername, zval *f
 		return NULL;
 	}

-	return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START);
+	return php_stream_filter_alloc(fops, data, persistent, PSFS_SEEKABLE_START, write_seekable);
 }

 const php_stream_filter_factory php_zlib_filter_factory = {
diff --git a/main/streams/filter.c b/main/streams/filter.c
index 9ec14419569..3a19f5ce918 100644
--- a/main/streams/filter.c
+++ b/main/streams/filter.c
@@ -259,7 +259,8 @@ PHPAPI php_stream_filter *php_stream_filter_create(const char *filtername, zval
 }

 PHPAPI php_stream_filter *_php_stream_filter_alloc(const php_stream_filter_ops *fops,
-		void *abstract, bool persistent, php_stream_filter_seekable_t seekable STREAMS_DC)
+		void *abstract, bool persistent, php_stream_filter_seekable_t read_seekable,
+		php_stream_filter_seekable_t write_seekable STREAMS_DC)
 {
 	php_stream_filter *filter;

@@ -267,13 +268,60 @@ PHPAPI php_stream_filter *_php_stream_filter_alloc(const php_stream_filter_ops *
 	memset(filter, 0, sizeof(php_stream_filter));

 	filter->fops = fops;
-	filter->seekable = seekable;
+	filter->read_seekable = read_seekable;
+	filter->write_seekable = write_seekable;
 	Z_PTR(filter->abstract) = abstract;
 	filter->is_persistent = persistent;

 	return filter;
 }

+PHPAPI zend_result php_stream_filter_parse_write_seek_mode(
+		zval *filterparams,
+		php_stream_filter_seekable_t *write_seekable)
+{
+	*write_seekable = PSFS_SEEKABLE_ALWAYS;
+
+	if (filterparams == NULL) {
+		return SUCCESS;
+	}
+	if (Z_TYPE_P(filterparams) != IS_ARRAY && Z_TYPE_P(filterparams) != IS_OBJECT) {
+		return SUCCESS;
+	}
+
+	zval *tmp = zend_hash_str_find_ind(HASH_OF(filterparams),
+			"write_seek_mode", sizeof("write_seek_mode") - 1);
+	if (tmp == NULL) {
+		return SUCCESS;
+	}
+
+	zend_string *tmp_str;
+	zend_string *str = zval_get_tmp_string(tmp, &tmp_str);
+	zend_result result = SUCCESS;
+
+	if (zend_string_equals_literal(str, "preserve")) {
+		*write_seekable = PSFS_SEEKABLE_ALWAYS;
+	} else if (zend_string_equals_literal(str, "reset")) {
+		*write_seekable = PSFS_SEEKABLE_START;
+	} else if (zend_string_equals_literal(str, "strict")) {
+		*write_seekable = PSFS_SEEKABLE_NEVER;
+	} else {
+		php_error_docref(NULL, E_WARNING,
+			"\"write_seek_mode\" filter parameter must be one of "
+			"\"preserve\", \"reset\", or \"strict\"");
+		result = FAILURE;
+	}
+
+	zend_tmp_string_release(tmp_str);
+	return result;
+}
+
+PHPAPI int php_stream_filter_get_chain_type(php_stream *stream, php_stream_filter *filter)
+{
+	return filter->chain == &stream->readfilters ?
+			PHP_STREAM_FILTER_READ : PHP_STREAM_FILTER_WRITE;
+}
+
 PHPAPI void php_stream_filter_free(php_stream_filter *filter)
 {
 	if (filter->fops->dtor)
diff --git a/main/streams/php_stream_filter_api.h b/main/streams/php_stream_filter_api.h
index a61bc48815f..111127a8ad1 100644
--- a/main/streams/php_stream_filter_api.h
+++ b/main/streams/php_stream_filter_api.h
@@ -118,7 +118,8 @@ struct _php_stream_filter {
 	zval abstract; /* for use by filter implementation */
 	php_stream_filter *next;
 	php_stream_filter *prev;
-	php_stream_filter_seekable_t seekable;
+	php_stream_filter_seekable_t read_seekable;
+	php_stream_filter_seekable_t write_seekable;
 	bool is_persistent;

 	/* link into stream and chain */
@@ -141,13 +142,17 @@ PHPAPI zend_result _php_stream_filter_flush(php_stream_filter *filter, bool fini
 PHPAPI php_stream_filter *php_stream_filter_remove(php_stream_filter *filter, bool call_dtor);
 PHPAPI void php_stream_filter_free(php_stream_filter *filter);
 PHPAPI php_stream_filter *_php_stream_filter_alloc(const php_stream_filter_ops *fops,
-		void *abstract, bool persistent, php_stream_filter_seekable_t seekable STREAMS_DC);
+		void *abstract, bool persistent, php_stream_filter_seekable_t read_seekable,
+		php_stream_filter_seekable_t write_seekable STREAMS_DC);
+PHPAPI zend_result php_stream_filter_parse_write_seek_mode(zval *filterparams,
+		php_stream_filter_seekable_t *write_seekable);
+PHPAPI int php_stream_filter_get_chain_type(php_stream *stream, php_stream_filter *filter);

 END_EXTERN_C()
-#define php_stream_filter_alloc(fops, thisptr, persistent, seekable) \
-		_php_stream_filter_alloc((fops), (thisptr), (persistent), (seekable) STREAMS_CC)
-#define php_stream_filter_alloc_rel(fops, thisptr, persistent, seekable) \
-	_php_stream_filter_alloc((fops), (thisptr), (persistent), (seekable) STREAMS_REL_CC)
+#define php_stream_filter_alloc(fops, thisptr, persistent, rseek, wseek) \
+		_php_stream_filter_alloc((fops), (thisptr), (persistent), (rseek), (wseek) STREAMS_CC)
+#define php_stream_filter_alloc_rel(fops, thisptr, persistent, rseek, wseek) \
+	_php_stream_filter_alloc((fops), (thisptr), (persistent), (rseek), (wseek) STREAMS_REL_CC)
 #define php_stream_filter_prepend(chain, filter) _php_stream_filter_prepend((chain), (filter))
 #define php_stream_filter_append(chain, filter) _php_stream_filter_append((chain), (filter))
 #define php_stream_filter_flush(filter, finish) _php_stream_filter_flush((filter), (finish))
diff --git a/main/streams/streams.c b/main/streams/streams.c
index d7cff6cf8de..e638c52159a 100644
--- a/main/streams/streams.c
+++ b/main/streams/streams.c
@@ -1360,14 +1360,16 @@ PHPAPI zend_off_t _php_stream_tell(const php_stream *stream)
 	return stream->position;
 }

-static bool php_stream_are_filters_seekable(php_stream_filter *filter, bool is_start_seeking)
+static bool php_stream_are_filters_seekable(php_stream_filter *filter, bool is_start_seeking, int chain_type)
 {
 	while (filter) {
-		if (filter->seekable == PSFS_SEEKABLE_NEVER) {
+		php_stream_filter_seekable_t seekable = (chain_type == PHP_STREAM_FILTER_READ) ?
+				filter->read_seekable : filter->write_seekable;
+		if (seekable == PSFS_SEEKABLE_NEVER) {
 			php_error_docref(NULL, E_WARNING, "Stream filter %s is never seekable", filter->fops->label);
 			return false;
 		}
-		if (!is_start_seeking && filter->seekable == PSFS_SEEKABLE_START) {
+		if (!is_start_seeking && seekable == PSFS_SEEKABLE_START) {
 			php_error_docref(NULL, E_WARNING, "Stream filter %s is seekable only to start position", filter->fops->label);
 			return false;
 		}
@@ -1377,11 +1379,13 @@ static bool php_stream_are_filters_seekable(php_stream_filter *filter, bool is_s
 }

 static zend_result php_stream_filters_seek(php_stream *stream, php_stream_filter *filter,
-		bool is_start_seeking, zend_off_t offset, int whence)
+		bool is_start_seeking, zend_off_t offset, int whence, int chain_type)
 {
 	while (filter) {
-		if (((filter->seekable == PSFS_SEEKABLE_START && is_start_seeking) ||
-				filter->seekable == PSFS_SEEKABLE_CHECK) &&
+		php_stream_filter_seekable_t seekable = (chain_type == PHP_STREAM_FILTER_READ) ?
+				filter->read_seekable : filter->write_seekable;
+		if (((seekable == PSFS_SEEKABLE_START && is_start_seeking) ||
+				seekable == PSFS_SEEKABLE_CHECK) &&
 				filter->fops->seek(stream, filter, offset, whence) == FAILURE) {
 			php_error_docref(NULL, E_WARNING, "Stream filter seeking for %s failed", filter->fops->label);
 			return FAILURE;
@@ -1394,10 +1398,12 @@ static zend_result php_stream_filters_seek(php_stream *stream, php_stream_filter
 static zend_result php_stream_filters_seek_all(php_stream *stream, bool is_start_seeking,
 		zend_off_t offset, int whence)
 {
-	if (php_stream_filters_seek(stream, stream->writefilters.head, is_start_seeking, offset, whence) == FAILURE) {
+	if (php_stream_filters_seek(stream, stream->writefilters.head, is_start_seeking,
+			offset, whence, PHP_STREAM_FILTER_WRITE) == FAILURE) {
 		return FAILURE;
 	}
-	if (php_stream_filters_seek(stream, stream->readfilters.head, is_start_seeking, offset, whence) == FAILURE) {
+	if (php_stream_filters_seek(stream, stream->readfilters.head, is_start_seeking,
+			offset, whence, PHP_STREAM_FILTER_READ) == FAILURE) {
 		return FAILURE;
 	}

@@ -1422,11 +1428,13 @@ PHPAPI int _php_stream_seek(php_stream *stream, zend_off_t offset, int whence)

 	if (stream->writefilters.head) {
 		_php_stream_flush(stream, 0);
-		if (!php_stream_are_filters_seekable(stream->writefilters.head, is_start_seeking)) {
+		if (!php_stream_are_filters_seekable(stream->writefilters.head, is_start_seeking,
+				PHP_STREAM_FILTER_WRITE)) {
 			return -1;
 		}
 	}
-	if (stream->readfilters.head && !php_stream_are_filters_seekable(stream->readfilters.head, is_start_seeking)) {
+	if (stream->readfilters.head && !php_stream_are_filters_seekable(
+			stream->readfilters.head, is_start_seeking, PHP_STREAM_FILTER_READ)) {
 		return -1;
 	}