Commit f465d35334b for php.net

commit f465d35334bbf61e709e67a346f371fd5c6f2de5
Author: Jakub Zelenka <bukka@php.net>
Date:   Sat Jul 25 23:56:15 2026 +0200

    Add OpenSSL TLS 1.3 early data (0-RTT) support (#22802)

    This builds on the session resumption support to let clients send and
    servers accept TLS 1.3 early data through the stream SSL context.

    Clients set the early_data context option (a string) to send 0-RTT data,
    which is only sent when the resumed session allows it. Servers enable
    acceptance with max_early_data and receive the data through the
    early_data_cb callback, so nothing is buffered on the stream. The outcome
    is reported as 'accepted', 'rejected' or 'not_sent' in the early_data key
    of the crypto stream_get_meta_data() array.

    Enabling early data also shares the server SSL_CTX across accepted
    connections and keeps the session cache on, both of which 0-RTT
    resumption requires.

diff --git a/NEWS b/NEWS
index 484e462698d..dfcbac91519 100644
--- a/NEWS
+++ b/NEWS
@@ -366,6 +366,8 @@ PHP                                                                        NEWS
     and Openssl\Session class. (Jakub Zelenka)
   . Added TLS external PSK support for streams with new context options and
     Openssl\Psk class. (Jakub Zelenka)
+  . Added TLS 1.3 early data (0-RTT) support for streams with new context
+    options early_data, max_early_data and early_data_cb. (Jakub Zelenka)
   . Added stream crypto status for exposing OpenSSL WANT_READ / WANT_WRITE.
     (Jakub Zelenka)

diff --git a/UPGRADING b/UPGRADING
index 93245f87f42..34817c10974 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -299,6 +299,11 @@ PHP 8.6 UPGRADE NOTES
     RFC: https://wiki.php.net/rfc/tls_session_resumption
   . Added TLS external PSK support for streams with new stream context options:
     psk_client_cb and psk_server_cb. This allows setting and receiving PSK.
+  . Added TLS 1.3 early data (0-RTT) support for streams. Clients send early
+    data with the early_data context option; servers accept it with
+    max_early_data and receive it through the early_data_cb callback. The
+    outcome is reported as 'accepted', 'rejected' or 'not_sent' in the
+    early_data key of the crypto stream_get_meta_data() array.

 - Phar:
   . Overriding the getMTime() and getPathname() methods of SplFileInfo now
diff --git a/ext/openssl/tests/tls_early_data_basic.phpt b/ext/openssl/tests/tls_early_data_basic.phpt
new file mode 100644
index 00000000000..9e469119270
--- /dev/null
+++ b/ext/openssl/tests/tls_early_data_basic.phpt
@@ -0,0 +1,89 @@
+--TEST--
+TLS 1.3 early data (0-RTT) basic client/server round-trip
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+if (!defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) die("skip TLS 1.3 not available");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_basic.pem.tmp';
+
+$serverCode = <<<'CODE'
+    $early = '';
+    $ctx = stream_context_create(['ssl' => [
+        'local_cert' => '%s',
+        'session_id_context' => 'early-data-test',
+        'max_early_data' => 16384,
+        'early_data_cb' => function ($stream, string $data) use (&$early) {
+            $early .= $data;
+        },
+    ]]);
+
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx);
+    phpt_notify_server_start($server);
+
+    for ($i = 0; $i < 2; $i++) {
+        $early = '';
+        $client = @stream_socket_accept($server, 30);
+        if ($client) {
+            $meta = stream_get_meta_data($client);
+            $status = $meta['crypto']['early_data'] ?? 'none';
+            fwrite($client, "early=[$early] status=$status\n");
+            fclose($client);
+        }
+    }
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+    $sessionData = null;
+
+    $ctx = stream_context_create(['ssl' => [
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'session_new_cb' => function ($stream, $session) use (&$sessionData) {
+            $sessionData = $session;
+        },
+    ]]);
+
+    /* First connection: full handshake, capture the resumable session */
+    $client1 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+        30, STREAM_CLIENT_CONNECT, $ctx);
+    echo trim(fgets($client1)) . "\n";
+    fclose($client1);
+
+    /* Second connection: resume and send 0-RTT early data */
+    $ctx2 = stream_context_create(['ssl' => [
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'session_data' => $sessionData,
+        'early_data' => 'ping-0rtt',
+    ]]);
+
+    $client2 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+        30, STREAM_CLIENT_CONNECT, $ctx2);
+    echo trim(fgets($client2)) . "\n";
+    $meta2 = stream_get_meta_data($client2);
+    echo "client early_data: " . ($meta2['crypto']['early_data'] ?? 'none') . "\n";
+    fclose($client2);
+CODE;
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey('tls_early_data_basic', $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_basic.pem.tmp');
+?>
+--EXPECT--
+early=[] status=not_sent
+early=[ping-0rtt] status=accepted
+client early_data: accepted
diff --git a/ext/openssl/tests/tls_early_data_invalid.phpt b/ext/openssl/tests/tls_early_data_invalid.phpt
new file mode 100644
index 00000000000..2989f5481ce
--- /dev/null
+++ b/ext/openssl/tests/tls_early_data_invalid.phpt
@@ -0,0 +1,50 @@
+--TEST--
+TLS 1.3 early data with an invalid early_data option raises TypeError
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+if (!defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) die("skip TLS 1.3 not available");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_invalid.pem.tmp';
+
+$serverCode = <<<'CODE'
+    $ctx = stream_context_create(['ssl' => ['local_cert' => '%s']]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx);
+    phpt_notify_server_start($server);
+    @stream_socket_accept($server, 3);
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+    $ctx = stream_context_create(['ssl' => [
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'early_data' => 123,
+    ]]);
+    try {
+        @stream_socket_client('tls://{{ ADDR }}', $errno, $errstr,
+            5, STREAM_CLIENT_CONNECT, $ctx);
+        echo "no exception\n";
+    } catch (TypeError $e) {
+        echo "caught: ", $e->getMessage(), "\n";
+    }
+CODE;
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey('tls_early_data_invalid', $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_invalid.pem.tmp');
+?>
+--EXPECT--
+caught: early_data must be a string
diff --git a/ext/openssl/tests/tls_early_data_large.phpt b/ext/openssl/tests/tls_early_data_large.phpt
new file mode 100644
index 00000000000..15940f866d7
--- /dev/null
+++ b/ext/openssl/tests/tls_early_data_large.phpt
@@ -0,0 +1,80 @@
+--TEST--
+TLS 1.3 early data (0-RTT) larger than one read chunk
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+if (!defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) die("skip TLS 1.3 not available");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_large.pem.tmp';
+
+$serverCode = <<<'CODE'
+    $early = '';
+    $ctx = stream_context_create(['ssl' => [
+        'local_cert' => '%s',
+        'session_id_context' => 'early-data-large',
+        'max_early_data' => 65536,
+        'early_data_cb' => function ($stream, string $data) use (&$early) {
+            $early .= $data;
+        },
+    ]]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx);
+    phpt_notify_server_start($server);
+    for ($i = 0; $i < 2; $i++) {
+        $early = '';
+        $client = @stream_socket_accept($server, 30);
+        if ($client) {
+            $meta = stream_get_meta_data($client);
+            $status = $meta['crypto']['early_data'] ?? 'none';
+            fwrite($client, "len=" . strlen($early) . " ok=" .
+                (int) ($early === str_repeat('A', 40000)) . " status=$status\n");
+            fclose($client);
+        }
+    }
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+    $sessionData = null;
+    $ctx = stream_context_create(['ssl' => [
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'session_new_cb' => function ($stream, $session) use (&$sessionData) {
+            $sessionData = $session;
+        },
+    ]]);
+    $client1 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+        30, STREAM_CLIENT_CONNECT, $ctx);
+    echo trim(fgets($client1)) . "\n";
+    fclose($client1);
+
+    $ctx2 = stream_context_create(['ssl' => [
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'session_data' => $sessionData,
+        'early_data' => str_repeat('A', 40000),
+    ]]);
+    $client2 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+        30, STREAM_CLIENT_CONNECT, $ctx2);
+    echo trim(fgets($client2)) . "\n";
+    fclose($client2);
+CODE;
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey('tls_early_data_large', $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_large.pem.tmp');
+?>
+--EXPECT--
+len=0 ok=0 status=not_sent
+len=40000 ok=1 status=accepted
diff --git a/ext/openssl/tests/tls_early_data_unsupported.phpt b/ext/openssl/tests/tls_early_data_unsupported.phpt
new file mode 100644
index 00000000000..8d121dc9f48
--- /dev/null
+++ b/ext/openssl/tests/tls_early_data_unsupported.phpt
@@ -0,0 +1,79 @@
+--TEST--
+TLS 1.3 early data option is ignored when the session does not allow 0-RTT
+--EXTENSIONS--
+openssl
+--SKIPIF--
+<?php
+if (!function_exists("proc_open")) die("skip no proc_open");
+if (!defined('STREAM_CRYPTO_METHOD_TLSv1_3_SERVER')) die("skip TLS 1.3 not available");
+?>
+--FILE--
+<?php
+$certFile = __DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_unsupported.pem.tmp';
+
+/* Server does NOT enable early data, so a resumed session must not carry a
+ * 0-RTT limit and the client must fall back to a normal handshake. */
+$serverCode = <<<'CODE'
+    $ctx = stream_context_create(['ssl' => [
+        'local_cert' => '%s',
+        'session_cache' => true,
+        'session_id_context' => 'early-data-off',
+    ]]);
+    $server = stream_socket_server('tls://127.0.0.1:0', $errno, $errstr,
+        STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx);
+    phpt_notify_server_start($server);
+    for ($i = 0; $i < 2; $i++) {
+        $client = @stream_socket_accept($server, 30);
+        if ($client) {
+            fwrite($client, "hello\n");
+            fclose($client);
+        }
+    }
+CODE;
+$serverCode = sprintf($serverCode, $certFile);
+
+$clientCode = <<<'CODE'
+    $sessionData = null;
+    $ctx = stream_context_create(['ssl' => [
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'session_new_cb' => function ($stream, $session) use (&$sessionData) {
+            $sessionData = $session;
+        },
+    ]]);
+    $client1 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+        30, STREAM_CLIENT_CONNECT, $ctx);
+    echo trim(fgets($client1)) . "\n";
+    fclose($client1);
+
+    $ctx2 = stream_context_create(['ssl' => [
+        'verify_peer' => false,
+        'verify_peer_name' => false,
+        'session_data' => $sessionData,
+        'early_data' => 'ping-0rtt',
+    ]]);
+    $client2 = stream_socket_client("tls://{{ ADDR }}", $errno, $errstr,
+        30, STREAM_CLIENT_CONNECT, $ctx2);
+    echo trim(fgets($client2)) . "\n";
+    $meta2 = stream_get_meta_data($client2);
+    echo "reused: " . ($meta2['crypto']['session_reused'] ? 'yes' : 'no') . "\n";
+    echo "early_data key present: " . (array_key_exists('early_data', $meta2['crypto']) ? 'yes' : 'no') . "\n";
+    fclose($client2);
+CODE;
+
+include 'CertificateGenerator.inc';
+$certificateGenerator = new CertificateGenerator();
+$certificateGenerator->saveNewCertAsFileWithKey('tls_early_data_unsupported', $certFile);
+
+include 'ServerClientTestCase.inc';
+ServerClientTestCase::getInstance()->run($clientCode, $serverCode);
+?>
+--CLEAN--
+<?php
+@unlink(__DIR__ . DIRECTORY_SEPARATOR . 'tls_early_data_unsupported.pem.tmp');
+?>
+--EXPECT--
+hello
+hello
+reused: yes
+early_data key present: no
diff --git a/ext/openssl/xp_ssl.c b/ext/openssl/xp_ssl.c
index f2adfc49075..a8105a15c43 100644
--- a/ext/openssl/xp_ssl.c
+++ b/ext/openssl/xp_ssl.c
@@ -188,6 +188,24 @@ typedef struct _php_openssl_psk_callbacks_t {
 	zend_fcall_info_cache server_cb;
 } php_openssl_psk_callbacks_t;

+#ifdef HAVE_TLS13
+/* TLS 1.3 early data (0-RTT) handshake phase */
+typedef enum {
+	PHP_OPENSSL_EARLY_DATA_NONE = 0,
+	PHP_OPENSSL_EARLY_DATA_ACTIVE,
+	PHP_OPENSSL_EARLY_DATA_DONE,
+} php_openssl_early_data_state_t;
+
+/* Size of the buffer used to drain server-side early data chunk by chunk */
+#define PHP_OPENSSL_EARLY_DATA_CHUNK 16384
+
+/* Holds the server early data callback */
+typedef struct _php_openssl_early_data_callbacks_t {
+	int refcount;
+	zend_fcall_info_cache read_cb;
+} php_openssl_early_data_callbacks_t;
+#endif
+
 /* Holds session callback */
 typedef struct _php_openssl_session_callbacks_t {
 	int refcount;
@@ -222,6 +240,14 @@ typedef struct _php_openssl_netstream_data_t {
 	 * psk_use_session_cb call but OpenSSL doesn't free it, so we own it. */
 	unsigned char *psk_identity_buf;
 	size_t psk_identity_len;
+#ifdef HAVE_TLS13
+	/* TLS 1.3 early data (0-RTT) */
+	php_openssl_early_data_callbacks_t *early_data_callbacks;
+	/* Client payload to send as early data, borrowed for the handshake */
+	zend_string *early_data_send;
+	size_t early_data_offset;
+	php_openssl_early_data_state_t early_data_state;
+#endif
 	char *url_name;
 	unsigned state_set:1;
 	unsigned _spare:31;
@@ -1657,6 +1683,14 @@ static SSL_SESSION *php_openssl_psk_build_session(SSL *ssl,
 		return NULL;
 	}

+#ifdef HAVE_TLS13
+	/* Allow 0-RTT for PSK sessions (0 on the client, so a no-op there) */
+	uint32_t max_early_data = SSL_get_max_early_data(ssl);
+	if (max_early_data > 0) {
+		SSL_SESSION_set_max_early_data(sess, max_early_data);
+	}
+#endif
+
 	return sess;
 }

@@ -2029,6 +2063,100 @@ static zend_result php_openssl_setup_server_psk(php_stream *stream,
 	return SUCCESS;
 }

+#ifdef HAVE_TLS13
+/* Stash the client early data (0-RTT) payload to be sent during the handshake. */
+static zend_result php_openssl_setup_client_early_data(php_stream *stream,
+		php_openssl_netstream_data_t *sslsock)
+{
+	zval *val;
+
+	if (!GET_VER_OPT("early_data")) {
+		return SUCCESS;
+	}
+
+	if (Z_TYPE_P(val) != IS_STRING) {
+		zend_type_error("early_data must be a string");
+		return FAILURE;
+	}
+
+	if (Z_STRLEN_P(val) > 0) {
+		sslsock->early_data_send = zend_string_dup(Z_STR_P(val), php_stream_is_persistent(stream));
+	}
+
+	return SUCCESS;
+}
+
+/* Configure server acceptance of early data (0-RTT) and the receive callback. */
+static zend_result php_openssl_setup_server_early_data(php_stream *stream,
+		php_openssl_netstream_data_t *sslsock)
+{
+	zval *val;
+	zend_long max_early_data = 0;
+
+	if (GET_VER_OPT("max_early_data")) {
+		max_early_data = zval_get_long(val);
+		if (max_early_data < 0 || (uint64_t) max_early_data > UINT32_MAX) {
+			zend_value_error("max_early_data must be between 0 and %u", UINT32_MAX);
+			return FAILURE;
+		}
+	}
+
+	if (GET_VER_OPT("early_data_cb")) {
+		if (php_stream_is_persistent(stream)) {
+			php_stream_warn(stream, PersistentNotSupported,
+					"early_data_cb is not supported for persistent streams");
+			return FAILURE;
+		}
+
+		char *is_callable_error = NULL;
+		zend_fcall_info_cache fcc = {0};
+		if (!zend_is_callable_ex(val, NULL, 0, NULL, &fcc, &is_callable_error)) {
+			if (is_callable_error) {
+				zend_type_error("early_data_cb must be a valid callback, %s", is_callable_error);
+				efree(is_callable_error);
+			} else {
+				zend_type_error("early_data_cb must be a valid callback");
+			}
+			return FAILURE;
+		}
+
+		sslsock->early_data_callbacks = pecalloc(1, sizeof(php_openssl_early_data_callbacks_t), 0);
+		sslsock->early_data_callbacks->refcount = 1;
+		zend_fcc_addref(&fcc);
+		sslsock->early_data_callbacks->read_cb = fcc;
+
+		/* A callback without an explicit limit still enables 0-RTT */
+		if (max_early_data == 0) {
+			max_early_data = PHP_OPENSSL_EARLY_DATA_CHUNK;
+		}
+	}
+
+	if (max_early_data > 0) {
+		SSL_CTX_set_max_early_data(sslsock->ctx, (uint32_t) max_early_data);
+		/* Accept as much as we advertise (recv limit defaults to 16 KB otherwise) */
+		SSL_CTX_set_recv_max_early_data(sslsock->ctx, (uint32_t) max_early_data);
+		/* 0-RTT requires resumption, so ensure the server cache is not left off */
+		if (SSL_CTX_get_session_cache_mode(sslsock->ctx) == SSL_SESS_CACHE_OFF) {
+			SSL_CTX_set_session_cache_mode(sslsock->ctx, SSL_SESS_CACHE_SERVER);
+		}
+	}
+
+	return SUCCESS;
+}
+
+/* Whether the server context enables early data (0-RTT). */
+static bool php_openssl_is_server_early_data_enabled(php_stream *stream)
+{
+	zval *val;
+
+	if (GET_VER_OPT("early_data_cb")) {
+		return true;
+	}
+
+	return GET_VER_OPT("max_early_data") && zval_get_long(val) > 0;
+}
+#endif
+
 /**
  * OpenSSL new session callback - called when a new session is established
  */
@@ -2558,6 +2686,11 @@ static zend_result php_openssl_create_server_ctx(php_stream *stream,
 		if (FAILURE == php_openssl_setup_client_psk(stream, sslsock)) {
 			return FAILURE;
 		}
+#ifdef HAVE_TLS13
+		if (FAILURE == php_openssl_setup_client_early_data(stream, sslsock)) {
+			return FAILURE;
+		}
+#endif
 	} else if (PHP_STREAM_CONTEXT(stream)) {
 		if (FAILURE == php_openssl_setup_server_session(stream, sslsock)) {
 			return FAILURE;
@@ -2565,6 +2698,11 @@ static zend_result php_openssl_create_server_ctx(php_stream *stream,
 		if (FAILURE == php_openssl_setup_server_psk(stream, sslsock)) {
 			return FAILURE;
 		}
+#ifdef HAVE_TLS13
+		if (FAILURE == php_openssl_setup_server_early_data(stream, sslsock)) {
+			return FAILURE;
+		}
+#endif
 		if (FAILURE == php_openssl_set_server_specific_opts(stream, sslsock->ctx)) {
 			return FAILURE;
 		}
@@ -2621,6 +2759,12 @@ static zend_result php_openssl_setup_crypto(php_stream *stream,
 				parent_sslsock->psk_callbacks->refcount++;
 				sslsock->psk_callbacks = parent_sslsock->psk_callbacks;
 			}
+#ifdef HAVE_TLS13
+			if (parent_sslsock->early_data_callbacks) {
+				parent_sslsock->early_data_callbacks->refcount++;
+				sslsock->early_data_callbacks = parent_sslsock->early_data_callbacks;
+			}
+#endif

 			sslsock->ssl_handle = SSL_new(sslsock->ctx);
 			if (!sslsock->ssl_handle) {
@@ -2739,6 +2883,80 @@ static zend_result php_openssl_set_blocking(php_openssl_netstream_data_t *sslsoc
 	return result;
 }

+#ifdef HAVE_TLS13
+/* Send pending client early data (0-RTT), then connect. Returns like SSL_connect(). */
+static int php_openssl_handshake_client_early_data(php_openssl_netstream_data_t *sslsock)
+{
+	while (sslsock->early_data_state == PHP_OPENSSL_EARLY_DATA_ACTIVE) {
+		size_t written = 0;
+		int ret = SSL_write_early_data(sslsock->ssl_handle,
+				ZSTR_VAL(sslsock->early_data_send) + sslsock->early_data_offset,
+				ZSTR_LEN(sslsock->early_data_send) - sslsock->early_data_offset,
+				&written);
+		if (ret <= 0) {
+			return ret;
+		}
+		sslsock->early_data_offset += written;
+		if (sslsock->early_data_offset >= ZSTR_LEN(sslsock->early_data_send)) {
+			sslsock->early_data_state = PHP_OPENSSL_EARLY_DATA_DONE;
+		}
+	}
+
+	return SSL_connect(sslsock->ssl_handle);
+}
+
+/* Receive server early data (0-RTT) chunk by chunk, then accept. Returns like SSL_accept(). */
+static int php_openssl_handshake_server_early_data(php_stream *stream,
+		php_openssl_netstream_data_t *sslsock)
+{
+	unsigned char buf[PHP_OPENSSL_EARLY_DATA_CHUNK];
+
+	while (sslsock->early_data_state == PHP_OPENSSL_EARLY_DATA_ACTIVE) {
+		size_t readbytes = 0;
+		int ret = SSL_read_early_data(sslsock->ssl_handle, buf, sizeof(buf), &readbytes);
+
+		if (ret == SSL_READ_EARLY_DATA_ERROR) {
+			/* Caller inspects SSL_get_error() for WANT_READ/WRITE or error */
+			return 0;
+		}
+
+		if (ret == SSL_READ_EARLY_DATA_SUCCESS) {
+			if (readbytes > 0 && sslsock->early_data_callbacks
+					&& ZEND_FCC_INITIALIZED(sslsock->early_data_callbacks->read_cb)) {
+				zval args[2];
+				ZVAL_RES(&args[0], stream->res);
+				ZVAL_STRINGL(&args[1], (char *) buf, readbytes);
+				zend_call_known_fcc(&sslsock->early_data_callbacks->read_cb,
+						NULL, 2, args, NULL);
+				zval_ptr_dtor(&args[1]);
+			}
+			continue;
+		}
+
+		/* SSL_READ_EARLY_DATA_FINISH */
+		sslsock->early_data_state = PHP_OPENSSL_EARLY_DATA_DONE;
+	}
+
+	return SSL_accept(sslsock->ssl_handle);
+}
+#endif
+
+/* Perform one handshake step, processing early data (0-RTT) first when active. */
+static int php_openssl_do_handshake(php_stream *stream, php_openssl_netstream_data_t *sslsock)
+{
+#ifdef HAVE_TLS13
+	if (sslsock->early_data_state == PHP_OPENSSL_EARLY_DATA_ACTIVE) {
+		return sslsock->is_client
+			? php_openssl_handshake_client_early_data(sslsock)
+			: php_openssl_handshake_server_early_data(stream, sslsock);
+	}
+#endif
+
+	return sslsock->is_client
+		? SSL_connect(sslsock->ssl_handle)
+		: SSL_accept(sslsock->ssl_handle);
+}
+
 static int php_openssl_enable_crypto(php_stream *stream,
 		php_openssl_netstream_data_t *sslsock,
 		php_stream_xport_crypto_param *cparam) /* {{{ */
@@ -2769,9 +2987,23 @@ static int php_openssl_enable_crypto(php_stream *stream,
 				php_openssl_enable_client_sni(stream, sslsock);
 #endif
 				SSL_set_connect_state(sslsock->ssl_handle);
+#ifdef HAVE_TLS13
+				/* Only send early data if the resumed session allows it */
+				if (sslsock->early_data_send != NULL) {
+					SSL_SESSION *sess = SSL_get_session(sslsock->ssl_handle);
+					if (sess != NULL && SSL_SESSION_get_max_early_data(sess) > 0) {
+						sslsock->early_data_state = PHP_OPENSSL_EARLY_DATA_ACTIVE;
+					}
+				}
+#endif
 			} else {
 				php_openssl_init_server_reneg_limit(stream, sslsock);
 				SSL_set_accept_state(sslsock->ssl_handle);
+#ifdef HAVE_TLS13
+				if (SSL_get_max_early_data(sslsock->ssl_handle) > 0) {
+					sslsock->early_data_state = PHP_OPENSSL_EARLY_DATA_ACTIVE;
+				}
+#endif
 			}
 			sslsock->state_set = 1;
 		}
@@ -2795,11 +3027,7 @@ static int php_openssl_enable_crypto(php_stream *stream,
 			struct timeval cur_time, elapsed_time;

 			ERR_clear_error();
-			if (sslsock->is_client) {
-				n = SSL_connect(sslsock->ssl_handle);
-			} else {
-				n = SSL_accept(sslsock->ssl_handle);
-			}
+			n = php_openssl_do_handshake(stream, sslsock);

 			if (has_timeout) {
 				gettimeofday(&cur_time, NULL);
@@ -3198,6 +3426,19 @@ static int php_openssl_sockop_close(php_stream *stream, int close_handle) /* {{{
 		efree(sslsock->psk_identity_buf);
 	}

+#ifdef HAVE_TLS13
+	if (sslsock->early_data_callbacks && --sslsock->early_data_callbacks->refcount == 0) {
+		if (ZEND_FCC_INITIALIZED(sslsock->early_data_callbacks->read_cb)) {
+			zend_fcc_dtor(&sslsock->early_data_callbacks->read_cb);
+		}
+		pefree(sslsock->early_data_callbacks, php_stream_is_persistent(stream));
+	}
+
+	if (sslsock->early_data_send) {
+		zend_string_release(sslsock->early_data_send);
+	}
+#endif
+
 	pefree(sslsock, php_stream_is_persistent(stream));

 	return 0;
@@ -3335,6 +3576,25 @@ static int php_openssl_sockop_set_option(php_stream *stream, int option, int val
 				add_assoc_string(&tmp, "cipher_version", SSL_CIPHER_get_version(cipher));
 				add_assoc_bool(&tmp, "session_reused", SSL_session_reused(sslsock->ssl_handle));

+#ifdef HAVE_TLS13
+				/* Report early data (0-RTT) outcome only when it was attempted */
+				if (sslsock->early_data_state == PHP_OPENSSL_EARLY_DATA_DONE) {
+					const char *early_data_status;
+					switch (SSL_get_early_data_status(sslsock->ssl_handle)) {
+						case SSL_EARLY_DATA_ACCEPTED:
+							early_data_status = "accepted";
+							break;
+						case SSL_EARLY_DATA_REJECTED:
+							early_data_status = "rejected";
+							break;
+						default:
+							early_data_status = "not_sent";
+							break;
+					}
+					add_assoc_string(&tmp, "early_data", early_data_status);
+				}
+#endif
+
 #ifdef HAVE_TLS_ALPN
 				{
 					const unsigned char *alpn_proto = NULL;
@@ -3558,8 +3818,12 @@ static int php_openssl_sockop_set_option(php_stream *stream, int option, int val
 						stream, option, value, ptrparam);

 					if (xparam->outputs.returncode == 0 && sslsock->enable_on_connect) {
-						/* Check if we should create SSL_CTX early for session resumption */
-						if (php_openssl_is_session_cache_enabled(stream, false)) {
+						/* Create SSL_CTX early to share it for session resumption and early data */
+						if (php_openssl_is_session_cache_enabled(stream, false)
+#ifdef HAVE_TLS13
+								|| php_openssl_is_server_early_data_enabled(stream)
+#endif
+						) {
 							if (FAILURE == php_openssl_create_server_ctx(stream, sslsock, sslsock->method)) {
 								xparam->outputs.returncode = -1;
 							}