From d3d2f9868e869db650f6e78a423a839215c1f54b Mon Sep 17 00:00:00 2001 From: David Carlier Date: Sun, 10 May 2026 13:04:51 +0100 Subject: [PATCH 1/2] ext/soap: GH-21981 auto-encode DateTimeInterface from SoapServer. When a SoapServer handler returns a bare DateTime/DateTimeImmutable without wrapping it in SoapVar, the encoder dispatched on IS_OBJECT and emitted xsi:type="SOAP-ENC:Struct" (a serialized object graph) instead of an xsd:dateTime instant. Hook guess_xml_convert to detect DateTimeInterface and route to XSD_DATETIME. This only fires on the unknown-type fallback path; an explicit WSDL-bound encoder still wins. --- ext/soap/php_encoding.c | 6 +++- ext/soap/tests/gh21981.phpt | 58 +++++++++++++++++++++++++++++++++++++ ext/soap/tests/gh21981.wsdl | 42 +++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 ext/soap/tests/gh21981.phpt create mode 100644 ext/soap/tests/gh21981.wsdl diff --git a/ext/soap/php_encoding.c b/ext/soap/php_encoding.c index fece87e9797e..da35e9ac19e4 100644 --- a/ext/soap/php_encoding.c +++ b/ext/soap/php_encoding.c @@ -2816,7 +2816,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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0bd4a3e71b2f3dd0a70f62699a20ac7e68b935b2 Mon Sep 17 00:00:00 2001 From: David Carlier Date: Sun, 10 May 2026 13:05:14 +0100 Subject: [PATCH 2/2] Add SOAP_USE_DATETIME_OBJECT feature flag. Opt-in, decodes xsd:dateTime/date/time to DateTimeImmutable on both SoapClient and SoapServer. Typemap entries still take precedence. --- ext/soap/php_encoding.c | 28 ++++++++- ext/soap/php_soap.h | 1 + ext/soap/soap.stub.php | 5 ++ ext/soap/soap_arginfo.h | 3 +- ext/soap/tests/gh21981_client.phpt | 91 +++++++++++++++++++++++++++++ ext/soap/tests/gh21981_server.phpt | 47 +++++++++++++++ ext/soap/tests/gh21981_typemap.phpt | 49 ++++++++++++++++ 7 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 ext/soap/tests/gh21981_client.phpt create mode 100644 ext/soap/tests/gh21981_server.phpt create mode 100644 ext/soap/tests/gh21981_typemap.phpt diff --git a/ext/soap/php_encoding.c b/ext/soap/php_encoding.c index da35e9ac19e4..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) { 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_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"