A security update inside one PHP branch, for example 8.3.x to 8.3.36, usually keeps the same extension ABI. A move between branches, such as 7.4 to 8.3, does not. Plan those as different projects. PHP2Shell (CVE-2026-109618 and CVE-2026-109619, disclosed 21 October 2026) pushes many teams toward an update, and the extension check is where most “simple” updates fail.
This guide shows how to list what is loaded, separate packaged modules from custom ones, rebuild what must be rebuilt, and fix the common load errors.
Prerequisites and applicable environments {#prerequisites}
- Root or
sudoon the host, and a staging copy if you can build one. - The target version: a fixed upstream release (8.2.35, 8.3.36, 8.4.27, or 8.5.12) or a matched vendor package.
- A list of third-party extensions and loaders your application needs.
- A rollback plan. See the rollback plan guide.
PHP 8.1 and older get no upstream updates. Vendor backports are possible, and minimal source backports exist for 7.4, 8.0, and 8.1 only. There is no upstream patch for 5.6. If your extension set forces you to stay on an old branch, treat it as a migration problem with temporary mitigations. WAF and ini mitigations are not complete fixes.
What breaks and why {#what-breaks}
PHP extensions compiled as shared objects (.so) are tied to an API and ABI. Two numbers matter.
| Term | What it is | Changes when |
|---|---|---|
| PHP API number | Version of the internal extension API | A new minor branch (for example 8.2 to 8.3) |
| Zend Module API | Version of the Zend engine module interface | A new minor branch |
| Thread safety (NTS or ZTS) | How the interpreter was built | A different build flavor or package |
| Debug flag | Whether the build includes debug code | A debug build is installed |
An extension built for one combination refuses to load on another. Within a branch, a patch update normally keeps all four stable. Across branches, assume every custom .so must be rebuilt.
Step-by-step procedure {#procedure}
Step 1. List modules per SAPI {#step-list-modules}
READ-ONLY
php -m | sort > /tmp/modules-cli.txt
wc -l /tmp/modules-cli.txt
php -m on the CLI is a hint. It can miss a module that is enabled only in the FPM or Apache ini. To see the web SAPI list, call the FPM binary directly with its own config.
Debian and Ubuntu:
sudo php-fpm8.3 -m | sort > /tmp/modules-fpm.txt
diff /tmp/modules-cli.txt /tmp/modules-fpm.txt
RHEL, AlmaLinux, Rocky Linux:
sudo php-fpm -m | sort > /tmp/modules-fpm.txt
diff /tmp/modules-cli.txt /tmp/modules-fpm.txt
> redis
27a29
> imagick
Lines starting with > exist only in the FPM list. Those are the modules the CLI check would have missed. Save both files with the ticket.
Step 2. Find where each module comes from {#step-find-origin}
READ-ONLY
php -i | grep -E '^(extension_dir|Loaded Configuration File|Scan this dir)'
ls -l "$(php -r 'echo ini_get("extension_dir");')"
Then ask the package manager who owns each file.
Debian and Ubuntu:
for f in "$(php -r 'echo ini_get("extension_dir");')"/*.so; do
if pkg=$(dpkg -S "$f" 2>/dev/null); then printf '%s\t%s\n' "$f" "${pkg%%:*}"; else printf '%s\tNOT PACKAGED\n' "$f"; fi
done
RHEL, AlmaLinux, Rocky Linux:
for f in "$(php -r 'echo ini_get("extension_dir");')"/*.so; do
if pkg=$(rpm -qf "$f" 2>/dev/null); then printf '%s\t%s\n' "$f" "$pkg"; else printf '%s\tNOT PACKAGED\n' "$f"; fi
done
Anything marked NOT PACKAGED is a custom build and needs a rebuild plan. Check any extension directory used by an extra ini file as well, such as /opt/ or /usr/local/lib/php/.
Step 3. Classify each extension {#step-classify}
Use a sample table like this one. Names are illustrative, and the status column is yours to fill from evidence.
| Extension | Origin | Same-branch update | Branch change | Notes |
|---|---|---|---|---|
mysqli, pdo_mysql |
Distro package | Updates with package | Updates with package | Install the matching php8.x-mysql set. |
gd, curl, mbstring |
Distro package | Updates with package | Updates with package | Check the same package set is installed. |
redis (PECL) |
Package or PECL build | Check origin | Rebuild or reinstall for the target | Use the packaged module where available. |
imagick |
Package or PECL build | Check origin | Rebuild or reinstall for the target | Depends on system ImageMagick libraries. |
In-house .so |
Custom build | Re-test | Rebuild required | Rebuild against the target headers. |
| ionCube, SourceGuardian loaders | Third-party | Vendor check | Vendor build required | Not assessed in this review. |
ionCube, SourceGuardian, and similar loaders are third-party and were not assessed in this review. If the site depends on one, check that vendor's build for the target PHP before the window.
Step 4. Install the target packages {#step-install}
SERVICE
Package installs change the live interpreter. Do this on staging first, or in an approved window.
Debian and Ubuntu (example, replace with the branch you target):
sudo apt-get update
sudo apt-get install --only-upgrade php8.3-cli php8.3-fpm php8.3-mysql php8.3-curl
RHEL, AlmaLinux, Rocky Linux:
sudo dnf upgrade 'php*'
On Ubuntu 24.04 noble, php8.3-* is fixed at 8.3.6-0ubuntu0.24.04.13 (USN-8948-1). Do not replace those packages with an upstream tarball to chase a version number.
Step 5. Rebuild custom extensions {#step-phpize}
Only needed for custom .so files that you must keep. Build on a staging host with the target PHP development package installed.
CONFIG
Debian and Ubuntu (development headers, example for 8.3):
sudo apt-get install php8.3-dev build-essential autoconf
RHEL, AlmaLinux, Rocky Linux:
sudo dnf install php-devel gcc make autoconf
CONFIG
Then, inside your extension source directory:
phpize --clean
phpize
./configure --with-php-config="$(command -v php-config)"
make -j"$(nproc)"
make test
Confirm that php-config belongs to the target branch before you run ./configure. A wrong php-config is the most common cause of an extension that builds fine and fails to load.
READ-ONLY
php-config --version
php-config --phpapi
php-config --extension-dir
20230831
/usr/lib/php/20230831
CAUTION
Installing the built file replaces what is in the live extension directory. Keep a copy of the old .so first:
sudo cp -a /usr/lib/php/20230831/myext.so /root/myext.so.bak
sudo make install
Adjust the directory to the one printed by php-config --extension-dir. The numbers above are an example of the layout, not a value to copy.
Step 6. Enable and test the load {#step-enable}
CONFIG
Debian and Ubuntu:
echo 'extension=myext.so' | sudo tee /etc/php/8.3/mods-available/myext.ini
sudo phpenmod -v 8.3 myext
RHEL, AlmaLinux, Rocky Linux:
echo 'extension=myext.so' | sudo tee /etc/php.d/50-myext.ini
READ-ONLY
Check the load before you touch production workers:
php -m | grep -i myext
php -d display_errors=1 -r 'echo "load ok\n";'
Startup warnings appear on the console. Read them.
SERVICE
Restart the web SAPI so workers load the new extension and the new interpreter. A package install without a restart is incomplete.
Debian and Ubuntu:
sudo systemctl restart php8.3-fpm
RHEL, AlmaLinux, Rocky Linux:
sudo systemctl restart php-fpm
Troubleshooting: Unable to load dynamic library {#troubleshooting-load}
The most frequent failure reads like this on startup.
| Message fragment | Likely cause | Fix |
|---|---|---|
cannot open shared object file: No such file |
Wrong extension_dir or file not installed |
Check php-config --extension-dir and where make install put the file. |
undefined symbol |
Built against a different branch’s headers | Rebuild with the correct php-config. |
Module compiled with module API=... |
API mismatch between build and interpreter | Clean with phpize --clean, rebuild for the target. |
Module compiled with build ID=API...,NTS vs ZTS |
Thread safety flavor mismatch | Use a build matching the interpreter flavor. |
Unable to load dynamic library for a packaged module |
Package not installed for the new branch | Install the matching php8.x-<name> package. |
| Loader error from a third-party extension | Vendor build does not support the target PHP | Get the vendor build, or keep the old branch temporarily and mitigate. |
If the web SAPI fails to start after an update, run php-fpm8.3 -t (Debian family) or php-fpm -t (RHEL family) and read the first error. Disable only the failing extension ini, test again, and restart. Do not delete the whole conf.d directory.
A loader that fails closed takes the site down. A loader that is simply absent is also a failure, because the application cannot decode its files. Have the rollback ready before the window, and test the loader on staging with the real application code.
Cross-branch moves {#cross-branch}
If the target is a different branch, widen the checks.
- Read each extension’s own release notes for the target PHP branch.
- Rebuild every custom
.so. Do not copy files from the old branch. - Check application code for removed functions and changed behavior in the target branch, using staging and your test suite.
- Test with the same
php.inisettings as production, or the differences will hide failures. - Plan the cutover with a rollback. See minimal-downtime upgrades.
Staying on PHP 8.1 or older because an extension will not build is a temporary position. Ask the vendor for a backport, check whether a minimal source backport exists (7.4, 8.0, and 8.1 only), and set a migration date. Do not assume a 5.6 patch exists.
Completion checklist {#completion-checklist}
- Module lists saved for CLI and for the web SAPI, and the differences understood.
- Every extension classified as packaged, custom, or third-party loader.
- Packaged modules updated together with the interpreter, with matching branch names.
- Custom
.sofiles rebuilt withphpizeagainst the targetphp-config. -
php-config --versionand--phpapiconfirmed to match the target beforemake install. - Old
.sofiles backed up before replacement. - Third-party loaders checked against the vendor’s build for the target PHP.
-
php-fpm -tpasses and the startup log has no “Unable to load dynamic library” warnings. - Workers restarted, and the running version verified.
- Rollback tested or at least written down with a named owner.
Related guides {#related-guides}
- Minimal-downtime PHP upgrade
- PHP upgrade rollback plan
- Check the PHP-FPM version
- Verify the patch after upgrade
- How to fix PHP2Shell
- How to check for PHP2Shell
Questions
Do I need to rebuild extensions for a security update inside the same branch?
Usually not for packaged modules, because they update together with the interpreter. Custom .so files built outside the package manager should be rebuilt or checked against the new headers.
Why does php -m on the CLI not match what the website loads?
The CLI and the web SAPI read different php.ini and conf.d directories. An extension enabled only for FPM or Apache will not show in CLI output.