libxml2 2.14 Broke PHP Windows Builds - And Nobody Noticed
Why a static PHP build on Windows dies with hundreds of LNK2001 errors against modern libxml2, and why Linux never sees it.
Back in April I was getting the embed SAPI building on Windows for ePHPm. I was
using my fork of static-php-cli (SPC). The link step for
php8ts.dll fell over with a wall of errors that all looked like this:
php8ts.dll.def : error LNK2001: unresolved external symbol UTF8ToHtml
php8ts.dll.def : error LNK2001: unresolved external symbol UTF8Toisolat1
php8ts.dll.def : error LNK2001: unresolved external symbol __htmlDefaultSAXHandler
...
php8ts.dll.def : error LNK2001: unresolved external symbol attributeDecl
...
(That excerpt is from php/php-src#22752. It’s the same failure someone else hit three months later. My log is long gone, and theirs is public.)
Nothing about it says “libxml2”. The error is blamed on a .def file I never wrote. The same build on Linux and macOS
is perfectly happy. This one cost me an evening, and the upstream fix landed last week. So here is the whole story.
What a .def file actually does
PHP’s Windows build is driven by configure.js and a pile of config.w32 files. The libxml extension can be built
statically into php8ts.dll. When it is, ext/libxml/config.w32 adds a module-definition file to the link:
if (!PHP_LIBXML_SHARED) {
ADD_DEF_FILE("ext\\libxml\\php_libxml2.def");
}
That file is a list of libxml2 function names under an EXPORTS header. The point is to re-export libxml2’s API out of
php8ts.dll. Then shared extensions like dom, xsl or simplexml can call into the copy of libxml2 that lives
inside the PHP DLL.
The catch is how MSVC treats it. Every name listed under EXPORTS is a hard requirement. If link.exe can’t find a
definition for it in some object or library on the command line, you get LNK2001 and no DLL. It doesn’t matter that
nothing in PHP calls the function. The .def file asked for it, so it must exist.
Linux and macOS don’t use .def files at all. Symbols are exported by visibility, and a symbol nobody references simply
isn’t there. That is the entire reason this is a Windows-only failure.
What libxml2 took away
The list in php_libxml2.def was written a long time ago. It had been edited a handful of times since
(2022 for 2.10,
2024 for 2.13). At that point it was 1,514 lines long. Meanwhile
libxml2 had been deprecating chunks of its API for years. In 2.14.0 (March 27, 2025) it started deleting them.
From the 2.14.0 NEWS:
The FTP module and related functions were removed.
Several legacy symbols and the functions in xmlunicode.h were removed.
The shell was moved from libxml2 to xmllint. Several related functions are no longer available.
xmlunicode.h is the big one for PHP. In 2.13.0 it was a 366-line header declaring 166 xmlUCSIs* functions. There
was one per Unicode block and category, like xmlUCSIsArabic and xmlUCSIsAegeanNumbers. In 2.14.0 the whole header,
minus a comment block, is this:
#ifndef __XML_UNICODE_H__
#define __XML_UNICODE_H__
#ifdef __GNUC__
#warning "libxml/xmlunicode.h is deprecated"
#endif
#endif /* __XML_UNICODE_H__ */
The implementations are still in xmlunicode.c because the regex engine uses them. But 128 of them are now static int, and the rest are XML_HIDDEN in a private header. They’re internal now. PHP’s .def file still listed all 166
of them. It also listed:
- the
xmlNanoFTP*client - the
xmlShell*functions - a pile of unprefixed SAX1 names like
characters,startElementandattributeDeclthat libxml2 used to export straight into the global namespace
2.15.0 (September 2025) kept going. It removed the HTTP client and the old win32 build system. The xmlNanoHTTP*
declarations are now behind a stubs flag. But the break for PHP starts at 2.14.
Measuring the gap
My fix (below) works by scanning libxml2’s installed headers for what they declare. I reused the same idea to answer
“which libxml2 releases can link the old .def at all”. Take every symbol in the pre-fix php_libxml2.def. Then
check whether any public header in a given libxml2 tag declares it with XMLPUBFUN/XMLPUBVAR, or defines it through
the XML_OP globals macro:
Zero are missing through 2.13.0. Then 294 are missing at 2.14.0, and the same 294 at 2.15.0. Of those, 166 are
xmlUCSIs* and 26 are the FTP client (xmlNanoFTP* plus xmlIOFTP*). It’s a cliff, and it’s at 2.14.
Why nobody noticed
Because almost nobody builds PHP on Windows against a current libxml2.
The official Windows builds use the prebuilt dependency series from windows.php.net. Here is the libxml2 line out of packages-8.4-vs17-x64-stable.txt:
libxml2-2.11.9-8-vs17-x64.zip
2.11.9. Every vs17 libxml2 package in that directory is a 2.11.9 build, an unsuffixed one plus -1 through -8. So
the official pipeline never sees a libxml2 that’s missing anything. Linux distros moved to 2.14 and 2.15 and didn’t
care, because no .def. That leaves the people who build PHP on Windows and bring their own libxml2. That’s a
pretty short list. SPC is on it, because SPC builds every library from source. Its libxml2 entry tracks the newest
v2.x.y tag on GNOME/libxml2, which by this spring was 2.15.x.
So the .def file sat there broken against current libxml2 for over a year. The only people who’d hit it were static
builders on Windows. Which is to say me, in April.
The fix I carried in SPC
My first instinct was a blocklist: strip lines starting with xmlUCSIs, xmlNanoFTP, xmlShell and so on. That
works for exactly one libxml2 version. It breaks the next time they delete something. The better source of truth is
sitting right there in the build root: libxml2’s own installed headers. libxml2 marks its entire public API with
XMLPUBFUN (functions) and XMLPUBVAR (variables). So a .def entry without a matching declaration can be dropped.
This went into SourcePatcher::patchBeforeMake() for Windows builds as part of
crazywhalecc/static-php-cli#1079. Trimmed:
public static function patchLibxml2DefForWindows(): void
{
$def_file = SOURCE_PATH . '/php-src/ext/libxml/php_libxml2.def';
$include_dir = BUILD_ROOT_PATH . '/include/libxml2/libxml';
// features compiled out of this libxml2 build: #undef LIBXML_<FEATURE>_ENABLED
$disabled_features = [];
$version_h = file_get_contents("{$include_dir}/xmlversion.h");
if (preg_match_all('/#undef\s+(LIBXML_\w+_ENABLED)/', $version_h, $m)) {
foreach ($m[1] as $feat) {
$disabled_features[$feat] = true;
}
}
$header_symbols = [];
foreach (glob("{$include_dir}/*.h") as $header) {
$ifdef_depth = 0;
$disabled_depth = 0;
foreach (explode("\n", file_get_contents($header)) as $hline) {
$trimmed = ltrim($hline);
if (preg_match('/^#\s*if(?:def|ndef)?\s+.*?(LIBXML_\w+_ENABLED)/', $trimmed, $im)) {
++$ifdef_depth;
if (isset($disabled_features[$im[1]])) {
$disabled_depth = $ifdef_depth;
}
} elseif (preg_match('/^#\s*if\b/', $trimmed)) {
++$ifdef_depth;
} elseif (preg_match('/^#\s*endif/', $trimmed)) {
if ($ifdef_depth === $disabled_depth) {
$disabled_depth = 0;
}
$ifdef_depth = max(0, $ifdef_depth - 1);
}
if ($disabled_depth > 0) {
continue; // declared, but not compiled into this build
}
if (preg_match('/XMLPUBFUN\s+\S+\s+(?:XMLCALL\s+|XMLCDECL\s+)?(\w+)\s*\(/', $trimmed, $fm)) {
$header_symbols[$fm[1]] = true;
}
if (preg_match('/XMLPUBVAR\s+\S+\s+(\w+)/', $trimmed, $vm)) {
$header_symbols[$vm[1]] = true;
}
}
}
$filtered = [];
$removed = 0;
foreach (file($def_file, FILE_IGNORE_NEW_LINES) as $line) {
$sym = trim($line);
if ($sym === '' || $sym === 'EXPORTS' || str_starts_with($sym, ';') || isset($header_symbols[$sym])) {
$filtered[] = $line;
} else {
++$removed;
}
}
file_put_contents($def_file, implode("\n", $filtered) . "\n");
logger()->info("Stripped {$removed} missing symbols from php_libxml2.def (libxml2 compat)");
}
The #ifdef tracking was the part I didn’t have on the first pass. A header can declare a function inside #ifdef LIBXML_HTTP_ENABLED while the library you built has HTTP turned off. So “it’s in the header” isn’t the same as “it’s
in libxml2_a.lib”. xmlversion.h tells you which features were compiled out. Declarations inside one of those
blocks get skipped. It also doesn’t need dumpbin on the PATH. That matters because this runs in plain PHP before
nmake is ever invoked.
That PR got closed for a different reason. SPC was in the middle of a v3 rewrite, and the maintainer had already reworked the Windows embed build there. So the patch lived in my fork.
The upstream fix, and the 2.14 hole in it
In July someone else hit it building plain php.exe against libxml2 2.15.3 and filed
GH-22752. On August 8th the
fix landed:
- The old list was preserved as
php_libxml2_legacy.def. - A new
php_libxml2.defdropped 314 entries and added a few newer APIs. config.w32now picks one based on the libxml2 version:
if (libxml_ver >= 21500) {
/* The HTTP and FTP modules, SAX1, the shell, xmlunicode.h
and various other legacy symbols were removed in 2.15. */
ADD_DEF_FILE("ext\\libxml\\php_libxml2.def");
} else {
ADD_DEF_FILE("ext\\libxml\\php_libxml2_legacy.def");
}
That’s a reasonable fix, and I’m glad it’s in. But read the comment against the libxml2 NEWS above. The FTP module,
xmlunicode.h and the shell went away in 2.14, not 2.15. The reporter tested 2.13.9 (works) and 2.15.3 (broken)
and never tried 2.14. The libxml2 maintainer even pointed at 2.14 in the thread. The fix that landed still gates
at 21500. As far as I can tell from reading it, a build against any libxml2 2.14.x still gets the legacy list. It still
asks for 166 xmlUCSIs* exports and still dies the same way. The chart above says the gate should be at 21400.
2.14 is an odd version to be stuck on, since 2.15 has been out for nearly a year. In practice this probably bites very few people. It’s the same shape as the original bug though: a version boundary nobody exercised on Windows.
Takeaways
- A
.deffile is a contract with every library you link. Each line is a promise that some object file provides that name. When a dependency deletes API, Linux shrugs and Windows refuses to link. The error points at the.definstead of the library that changed. - Pinned dependencies hide breakage. The official PHP Windows builds pinning libxml2 2.11.9 is a perfectly sane choice. It just means the people who’d notice first are the ones building from source.
- Derive, don’t enumerate. A blocklist of removed prefixes would have needed updating for 2.14, again for 2.15 and again for whatever comes next. Asking the headers what exists keeps working.
How I measured
The chart is a static scan, no compiler involved. There were two inputs:
ext/libxml/php_libxml2.deffrom php-src at b08def51, the last version before the August fix (1,514 lines)include/libxml/*.hfrom the GNOME/libxml2 tagsv2.11.9,v2.13.0,v2.14.0andv2.15.0
For each tag I collected every name declared with XMLPUBFUN/XMLPUBVAR. Comments were stripped, and declarations
were allowed to span lines. I added the globals generated by XML_OP(name, ...), which also produce a __name
accessor. Then I counted .def symbols with no match. It ignores #ifdef feature guards. So it’s a floor rather than
an exact list of linker errors for any one build configuration. The 166 xmlUCSIs* count is a straight count of
.def lines and of XMLPUBFUN declarations in the 2.13.0 xmlunicode.h.
Links
- php/php-src#22752 - “Build failure with libxml 2.15”
- php/php-src@bc7537db - the upstream fix
- crazywhalecc/static-php-cli#1079 - my SPC PR with the header-scan patch
- libxml2 NEWS
- Module-definition (.def) files on Microsoft Learn
- PHP Windows dependency series
