diff --git a/ext/soap/php_encoding.c b/ext/soap/php_encoding.c
index fece87e9797e..5ba55e74f020 100644
--- a/ext/soap/php_encoding.c
+++ b/ext/soap/php_encoding.c
@@ -70,6 +70,7 @@ static xmlNodePtr to_xml_duration(encodeTypePtr type, zval *data, int style, xml
static zval *to_zval_object(zval *ret, encodeTypePtr type, xmlNodePtr data);
static zval *to_zval_array(zval *ret, encodeTypePtr type, xmlNodePtr data);
+static zval *to_zval_datetime(zval *ret, encodeTypePtr type, xmlNodePtr data);
static xmlNodePtr to_xml_object(encodeTypePtr type, zval *data, int style, xmlNodePtr parent);
static xmlNodePtr to_xml_array(encodeTypePtr type, zval *data, int style, xmlNodePtr parent);
@@ -140,9 +141,9 @@ encode defaultEncoding[] = {
{{XSD_FLOAT, XSD_FLOAT_STRING, XSD_NAMESPACE, NULL, NULL, NULL}, to_zval_double, to_xml_double},
{{XSD_DOUBLE, XSD_DOUBLE_STRING, XSD_NAMESPACE, NULL, NULL, NULL}, to_zval_double, to_xml_double},
- {{XSD_DATETIME, XSD_DATETIME_STRING, XSD_NAMESPACE, NULL, NULL, NULL}, to_zval_stringc, to_xml_datetime},
- {{XSD_TIME, XSD_TIME_STRING, XSD_NAMESPACE, NULL, NULL, NULL}, to_zval_stringc, to_xml_time},
- {{XSD_DATE, XSD_DATE_STRING, XSD_NAMESPACE, NULL, NULL, NULL}, to_zval_stringc, to_xml_date},
+ {{XSD_DATETIME, XSD_DATETIME_STRING, XSD_NAMESPACE, NULL, NULL, NULL}, to_zval_datetime, to_xml_datetime},
+ {{XSD_TIME, XSD_TIME_STRING, XSD_NAMESPACE, NULL, NULL, NULL}, to_zval_datetime, to_xml_time},
+ {{XSD_DATE, XSD_DATE_STRING, XSD_NAMESPACE, NULL, NULL, NULL}, to_zval_datetime, to_xml_date},
{{XSD_GYEARMONTH, XSD_GYEARMONTH_STRING, XSD_NAMESPACE, NULL, NULL, NULL}, to_zval_stringc, to_xml_gyearmonth},
{{XSD_GYEAR, XSD_GYEAR_STRING, XSD_NAMESPACE, NULL, NULL, NULL}, to_zval_stringc, to_xml_gyear},
{{XSD_GMONTHDAY, XSD_GMONTHDAY_STRING, XSD_NAMESPACE, NULL, NULL, NULL}, to_zval_stringc, to_xml_gmonthday},
@@ -1616,6 +1617,27 @@ static zval *to_zval_object(zval *ret, encodeTypePtr type, xmlNodePtr data)
return to_zval_object_ex(ret, type, data, NULL);
}
+static zval *to_zval_datetime(zval *ret, encodeTypePtr type, xmlNodePtr data)
+{
+ to_zval_stringc(ret, type, data);
+
+ if (!(SOAP_GLOBAL(features) & SOAP_USE_DATETIME_OBJECT) || Z_TYPE_P(ret) != IS_STRING) {
+ return ret;
+ }
+
+ zend_string *str = zend_string_copy(Z_STR_P(ret));
+ zval_ptr_dtor_str(ret);
+ php_date_instantiate(php_date_get_immutable_ce(), ret);
+ if (!php_date_initialize(Z_PHPDATE_P(ret), ZSTR_VAL(str), ZSTR_LEN(str), NULL, NULL, 0)) {
+ zval_ptr_dtor(ret);
+ ZVAL_STR(ret, str);
+ } else {
+ zend_string_release(str);
+ }
+
+ return ret;
+}
+
static int model_to_xml_object(xmlNodePtr node, sdlContentModelPtr model, zval *object, int style, int strict)
{
@@ -2816,7 +2838,11 @@ static xmlNodePtr guess_xml_convert(encodeTypePtr type, zval *data, int style, x
xmlNodePtr ret;
if (data) {
- enc = get_conversion(Z_TYPE_P(data));
+ if (Z_TYPE_P(data) == IS_OBJECT && instanceof_function_slow(Z_OBJCE_P(data), php_date_get_interface_ce())) {
+ enc = get_conversion(XSD_DATETIME);
+ } else {
+ enc = get_conversion(Z_TYPE_P(data));
+ }
} else {
enc = get_conversion(IS_NULL);
}
diff --git a/ext/soap/php_soap.h b/ext/soap/php_soap.h
index a2363c7a2211..c018d4883a08 100644
--- a/ext/soap/php_soap.h
+++ b/ext/soap/php_soap.h
@@ -136,6 +136,7 @@ struct _soapService {
#define SOAP_SINGLE_ELEMENT_ARRAYS (1<<0)
#define SOAP_WAIT_ONE_WAY_CALLS (1<<1)
#define SOAP_USE_XSI_ARRAY_TYPE (1<<2)
+#define SOAP_USE_DATETIME_OBJECT (1<<3)
#define WSDL_CACHE_NONE 0x0
#define WSDL_CACHE_DISK 0x1
diff --git a/ext/soap/soap.stub.php b/ext/soap/soap.stub.php
index fdd4a46e109f..083d7382b059 100644
--- a/ext/soap/soap.stub.php
+++ b/ext/soap/soap.stub.php
@@ -400,6 +400,11 @@ final class Sdl
* @cvalue SOAP_USE_XSI_ARRAY_TYPE
*/
const SOAP_USE_XSI_ARRAY_TYPE = UNKNOWN;
+ /**
+ * @var int
+ * @cvalue SOAP_USE_DATETIME_OBJECT
+ */
+ const SOAP_USE_DATETIME_OBJECT = UNKNOWN;
/**
* @var int
diff --git a/ext/soap/soap_arginfo.h b/ext/soap/soap_arginfo.h
index 2f7d56ca4221..e61e651d5427 100644
--- a/ext/soap/soap_arginfo.h
+++ b/ext/soap/soap_arginfo.h
@@ -1,5 +1,5 @@
/* This is a generated file, edit soap.stub.php instead.
- * Stub hash: 14c74a5d6f547837f536920d5abb741e2b6e4373 */
+ * Stub hash: f69e60067ec95eb5c7b5c84def04ab0789df5139 */
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_use_soap_error_handler, 0, 0, _IS_BOOL, 0)
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, enable, _IS_BOOL, 0, "true")
@@ -308,6 +308,7 @@ static void register_soap_symbols(int module_number)
REGISTER_LONG_CONSTANT("SOAP_SINGLE_ELEMENT_ARRAYS", SOAP_SINGLE_ELEMENT_ARRAYS, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SOAP_WAIT_ONE_WAY_CALLS", SOAP_WAIT_ONE_WAY_CALLS, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("SOAP_USE_XSI_ARRAY_TYPE", SOAP_USE_XSI_ARRAY_TYPE, CONST_PERSISTENT);
+ REGISTER_LONG_CONSTANT("SOAP_USE_DATETIME_OBJECT", SOAP_USE_DATETIME_OBJECT, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("WSDL_CACHE_NONE", WSDL_CACHE_NONE, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("WSDL_CACHE_DISK", WSDL_CACHE_DISK, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("WSDL_CACHE_MEMORY", WSDL_CACHE_MEMORY, CONST_PERSISTENT);
diff --git a/ext/soap/tests/gh21981.phpt b/ext/soap/tests/gh21981.phpt
new file mode 100644
index 000000000000..97506f001306
--- /dev/null
+++ b/ext/soap/tests/gh21981.phpt
@@ -0,0 +1,58 @@
+--TEST--
+GH-21981 (Returning a bare DateTimeInterface from a SoapServer should not require SoapVar)
+--EXTENSIONS--
+soap
+--FILE--
+
+
+
+
+
+
+XML;
+
+echo "--- WSDL with unresolved part type (xsd:datetime typo) ---\n";
+$server = new SoapServer(__DIR__.'/gh21981.wsdl', ['cache_wsdl' => WSDL_CACHE_NONE]);
+$server->addFunction('getCurrentDate');
+$server->handle($request_wsdl);
+
+$request_nowsdl = <<
+
+
+
+
+
+XML;
+
+echo "--- Non-WSDL server, DateTimeImmutable return ---\n";
+$server = new SoapServer(null, ['uri' => 'http://testuri.org']);
+$server->addFunction('getCurrentDateImmutable');
+$server->handle($request_nowsdl);
+?>
+--EXPECT--
+--- WSDL with unresolved part type (xsd:datetime typo) ---
+
+2026-05-09T12:34:56.123456Z
+--- Non-WSDL server, DateTimeImmutable return ---
+
+2026-05-09T12:34:56.123456Z
diff --git a/ext/soap/tests/gh21981.wsdl b/ext/soap/tests/gh21981.wsdl
new file mode 100644
index 000000000000..4fafc9891457
--- /dev/null
+++ b/ext/soap/tests/gh21981.wsdl
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ext/soap/tests/gh21981_client.phpt b/ext/soap/tests/gh21981_client.phpt
new file mode 100644
index 000000000000..994b63e494da
--- /dev/null
+++ b/ext/soap/tests/gh21981_client.phpt
@@ -0,0 +1,91 @@
+--TEST--
+GH-21981 (SoapClient: SOAP_USE_DATETIME_OBJECT decodes xsd:dateTime/date/time into DateTimeImmutable)
+--EXTENSIONS--
+soap
+--INI--
+soap.wsdl_cache_enabled=0
+--FILE--
+canned;
+ }
+}
+
+function envelope(string $body): string {
+ return <<
+
+
+ {$body}
+
+
+XML;
+}
+
+function show($value): void {
+ if (is_object($value)) {
+ printf("%s(%s)" . PHP_EOL, get_class($value), $value->format('Y-m-d\\TH:i:s.uP'));
+ } else {
+ var_dump($value);
+ }
+}
+
+$opts_off = ['location' => 'test://', 'uri' => 'urn:test'];
+$opts_on = ['location' => 'test://', 'uri' => 'urn:test', 'features' => SOAP_USE_DATETIME_OBJECT];
+
+echo "--- flag OFF: xsd:dateTime stays a string ---" . PHP_EOL;
+$c = new StubClient(null, $opts_off);
+$c->canned = envelope('2026-05-09T12:34:56.123456Z');
+show($c->foo());
+
+echo "--- flag ON: xsd:dateTime -> DateTimeImmutable ---" . PHP_EOL;
+$c = new StubClient(null, $opts_on);
+$c->canned = envelope('2026-05-09T12:34:56.123456Z');
+show($c->foo());
+
+echo "--- flag ON: xsd:dateTime with offset ---" . PHP_EOL;
+$c = new StubClient(null, $opts_on);
+$c->canned = envelope('2026-05-09T12:34:56+02:00');
+show($c->foo());
+
+echo "--- flag ON: xsd:date -> DateTimeImmutable ---" . PHP_EOL;
+$c = new StubClient(null, $opts_on);
+$c->canned = envelope('2026-05-09');
+show($c->foo());
+
+echo "--- flag ON: xsd:time -> DateTimeImmutable ---" . PHP_EOL;
+$c = new StubClient(null, $opts_on);
+$c->canned = envelope('12:34:56Z');
+show($c->foo());
+
+echo "--- flag ON: malformed dateTime -> graceful string fallback ---" . PHP_EOL;
+$c = new StubClient(null, $opts_on);
+$c->canned = envelope('not-a-date');
+show($c->foo());
+
+echo "--- flag ON: xsi:nil -> NULL ---" . PHP_EOL;
+$c = new StubClient(null, $opts_on);
+$c->canned = envelope('');
+show($c->foo());
+?>
+--EXPECTF--
+--- flag OFF: xsd:dateTime stays a string ---
+string(27) "2026-05-09T12:34:56.123456Z"
+--- flag ON: xsd:dateTime -> DateTimeImmutable ---
+DateTimeImmutable(2026-05-09T12:34:56.123456+00:00)
+--- flag ON: xsd:dateTime with offset ---
+DateTimeImmutable(2026-05-09T12:34:56.000000+02:00)
+--- flag ON: xsd:date -> DateTimeImmutable ---
+DateTimeImmutable(2026-05-09T00:00:00.000000%s)
+--- flag ON: xsd:time -> DateTimeImmutable ---
+DateTimeImmutable(%s12:34:56.000000+00:00)
+--- flag ON: malformed dateTime -> graceful string fallback ---
+string(10) "not-a-date"
+--- flag ON: xsi:nil -> NULL ---
+NULL
diff --git a/ext/soap/tests/gh21981_server.phpt b/ext/soap/tests/gh21981_server.phpt
new file mode 100644
index 000000000000..d7bf767fb614
--- /dev/null
+++ b/ext/soap/tests/gh21981_server.phpt
@@ -0,0 +1,47 @@
+--TEST--
+GH-21981 (SoapServer: SOAP_USE_DATETIME_OBJECT decodes incoming xsd:dateTime arguments into DateTimeImmutable)
+--EXTENSIONS--
+soap
+--INI--
+soap.wsdl_cache_enabled=0
+--FILE--
+format('Y-m-d\\TH:i:s.uP') . ')';
+ }
+ return gettype($d) . '(' . var_export($d, true) . ')';
+}
+
+$request = <<
+
+
+
+ 2026-05-09T12:34:56.123456+02:00
+
+
+
+XML;
+
+echo "--- flag OFF: handler receives a string ---" . PHP_EOL;
+$server = new SoapServer(null, ['uri' => 'urn:test']);
+$server->addFunction('consume');
+$server->handle($request);
+
+echo "--- flag ON: handler receives a DateTimeImmutable ---" . PHP_EOL;
+$server = new SoapServer(null, ['uri' => 'urn:test', 'features' => SOAP_USE_DATETIME_OBJECT]);
+$server->addFunction('consume');
+$server->handle($request);
+?>
+--EXPECT--
+--- flag OFF: handler receives a string ---
+
+string('2026-05-09T12:34:56.123456+02:00')
+--- flag ON: handler receives a DateTimeImmutable ---
+
+DateTimeImmutable(2026-05-09T12:34:56.123456+02:00)
diff --git a/ext/soap/tests/gh21981_typemap.phpt b/ext/soap/tests/gh21981_typemap.phpt
new file mode 100644
index 000000000000..5e15bcd0be79
--- /dev/null
+++ b/ext/soap/tests/gh21981_typemap.phpt
@@ -0,0 +1,49 @@
+--TEST--
+GH-21981 (User typemap from_xml still wins over SOAP_USE_DATETIME_OBJECT)
+--EXTENSIONS--
+soap
+--INI--
+soap.wsdl_cache_enabled=0
+--FILE--
+canned;
+ }
+}
+
+function from_xml_dt(string $xml): string {
+ return 'typemap-handled:' . $xml;
+}
+
+$canned = <<
+
+
+ 2026-05-09T12:34:56.123456Z
+
+
+XML;
+
+$opts = [
+ 'location' => 'test://',
+ 'uri' => 'urn:test',
+ 'features' => SOAP_USE_DATETIME_OBJECT,
+ 'typemap' => [[
+ 'type_ns' => 'http://www.w3.org/2001/XMLSchema',
+ 'type_name' => 'dateTime',
+ 'from_xml' => 'from_xml_dt',
+ ]],
+];
+
+$c = new StubClient(null, $opts);
+$c->canned = $canned;
+var_dump($c->foo());
+?>
+--EXPECTF--
+string(%d) "typemap-handled:2026-05-09T12:34:56.123456Z%A"