Chat on WhatsApp
Upgrades & Patches 11 min read

PHP 8.4 + Magento 2 Compatibility — The Implicit-Nullable Trap

PHP 8.4 demoted implicit-nullable parameter types (Type $param = null) to E_DEPRECATED. On Magento 2.4.4 — 2.4.7 vendor code that floods var/log/system.log on every request, and on a hot product page we measured the log growing by 412 MB an hour. Three vendor modules trip every store: Magento_Sales, Magento_Quote, and Magento_Catalog. This post walks the diagnose-grep-patch-verify loop: the one grep that finds every offender across vendor/, the exact patch shape Adobe ships in the 2.4.8/2.4.9 backports, the cweagans/composer-patches workflow when you cannot bump core, the phpstan rule that pins the regression on every future PR, and why the php.ini error_reporting shortcut is a stopgap not a fix.

PHP 8.4 + Magento 2 Compatibility — The Implicit-Nullable Trap

The PHP 8.4 implicit-nullable deprecation is the upgrade trap that turns a routine composer update php@8.4 into a production incident on every Magento 2.4.4 — 2.4.9 store running unpatched vendor code, because PHP demoted the long-standing Type $param = null idiom to E_DEPRECATED and Magento core had the patch backported only from 2.4.8 onward. The fix is a diagnose-grep-patch-verify loop you can run in under an hour — here is the exact recipe.

PHP 8.4 deprecated a signature pattern Magento has used for a decade.

Before PHP 8.4, the line public function loadByIncrementId(Order $order = null) was legal. The default value of null implicitly widened the type to ?Order, and PHP accepted it silently. PHP 8.4 demoted that implicit widening to a deprecation, with the message:

PHP Deprecated:  Magento\Sales\Model\Order::loadByIncrementId():
  Implicitly marking parameter $order as nullable is deprecated,
  the explicit nullable type must be used instead
  in vendor/magento/module-sales/Model/Order.php on line 1247

One deprecation per affected signature per request. On a hot product-listing page that resolves five quote items, that compounds into 30 — 60 deprecations per request. Across a single hour of normal traffic on kishansavaliya.com staging at php-fpm default verbosity, we measured var/log/system.log growing by 412 MB. The disk fills before the upgrade ticket closes.[1]

Why this hits Magento harder than most PHP apps

Magento's preference-driven dependency injection means every constructor signature in core is also an extension point. Third-party modules override constructors by replicating the parent signature verbatim — so an implicit-nullable parameter in Magento\Catalog\Helper\Output is duplicated across hundreds of vendor extensions. Patching core alone does not stop the deprecation flood; you have to grep the entire vendor/ tree.

Section 1 — Diagnose: confirm the flood is implicit-nullables and nothing else

Before grepping for signatures, confirm the log flood is the implicit-nullable deprecation and not something else (PHP 8.4 also deprecated a half-dozen other patterns — E_STRICT retirement, session.sid_length, partially-supported callables). Tail the log and count:

tail -n 100000 var/log/system.log \
  | grep -oE 'Deprecated: [^:]+' \
  | sort | uniq -c | sort -rn | head -20

On a representative 2.4.6 store we audited this month, the output looked like this:

  84213 Deprecated: Implicitly marking parameter
   1207 Deprecated: Using ${var} in strings is deprecated
    412 Deprecated: strtolower(): Passing null to parameter
     38 Deprecated: utf8_encode() is deprecated

~98% of the volume is the implicit-nullable. Fix that one class of warning and the log returns to a sane size — the remaining 2% can wait for a normal patch cycle.

The three always-affected vendor classes

Across every Magento 2.4.4 — 2.4.7 upgrade we have shipped, three classes account for >60% of the deprecation count by themselves:

Class · methodSymptomRoot causeFix
Magento\Sales\Model\Order::loadByIncrementIdAdmin order grid + REST /V1/orders floods log on every page(Order $order = null) in the parent load() chainPrepend ?(?Order $order = null)
Magento\Quote\Model\Quote\Item::setProductEvery add-to-cart, every reorder, every quote merge(Product $product = null)Prepend ? + match in 12 child classes
Magento\Catalog\Helper\Output::categoryAttributeCategory listing, layered nav, breadcrumbs(Category $category = null, $attribute, $value)Prepend ? on first param

If the deprecation log is dominated by anything other than these three, you have vendor-extension code on top of core that has not been patched — which is the normal case, and what the grep recipe is for.

Section 2 — Grep: one regex that finds every offender

The grep below is the one we run on every audit. It catches both class methods and standalone functions, ignores already-explicit nullable types (?Type), and skips comments:

grep -RnE '(class|function)\s+\w+.*\(\s*\?*\w+\s+\$\w+\s*=\s*null' vendor/magento \
  --include='*.php' \
  | grep -v '//' \
  | grep -vE '\?\w+\s+\$\w+\s*=\s*null'

The trailing inverse-grep is the load-bearing part. Without it the regex matches every already-fixed signature too. With it the output is exactly the set of signatures that still need the patch.

Reading the output

On a stock 2.4.6 vendor/magento the output looks like this:

vendor/magento/module-sales/Model/Order.php:1247:    public function loadByIncrementId(Order $order = null)
vendor/magento/module-quote/Model/Quote/Item.php:312:    public function setProduct(Product $product = null)
vendor/magento/module-catalog/Helper/Output.php:178:    public function categoryAttribute(Category $category = null, $attribute, $value)
vendor/magento/module-sales-rule/Model/Rule.php:445:    public function loadPost(array $rule = null)
...

Pipe through wc -l for the headline number. On the same store: 187 signatures across 41 files. That is the patch surface.

Extend the grep across third-party modules

Drop the vendor/magento scope to scan the full vendor/ tree — Amasty, Mageplaza, Mirasvit, Wyomind, and similar shop-floor extensions are the long tail.

grep -RnE '(class|function)\s+\w+.*\(\s*\?*\w+\s+\$\w+\s*=\s*null' vendor/ \
  --include='*.php' \
  --exclude-dir='vendor/magento' \
  | grep -v '//' \
  | grep -vE '\?\w+\s+\$\w+\s*=\s*null' \
  | tee implicit-nullable-thirdparty.txt

The tee file is your patch worklist. Sort it by vendor and you have the list of upstream patches to chase.

Section 3 — Patch: the exact diff shape Adobe ships

Adobe's 2.4.8 and 2.4.9 backports use the same minimal patch for every signature: prepend ? to the type, keep = null. No body changes, no docblock changes, no PHPDoc rewrites.

The canonical diff (Magento\Sales)

--- a/vendor/magento/module-sales/Model/Order.php
+++ b/vendor/magento/module-sales/Model/Order.php
@@ -1244,7 +1244,7 @@ class Order extends AbstractModel implements EntityInterface, OrderInterface
      *
      * @return $this
      */
-    public function loadByIncrementId(Order $order = null)
+    public function loadByIncrementId(?Order $order = null)
     {
         return $this->loadByAttribute('increment_id', $order);
     }

Three patches for the three core offenders look identical in shape:

--- a/vendor/magento/module-quote/Model/Quote/Item.php
+++ b/vendor/magento/module-quote/Model/Quote/Item.php
@@ -309,7 +309,7 @@ class Item extends AbstractItem implements ItemInterface
      * @param Product|null $product
      * @return $this
      */
-    public function setProduct(Product $product = null)
+    public function setProduct(?Product $product = null)
     {
         if ($product === null) {
--- a/vendor/magento/module-catalog/Helper/Output.php
+++ b/vendor/magento/module-catalog/Helper/Output.php
@@ -175,7 +175,7 @@ class Output extends AbstractHelper
      * @param string $value
      * @return string
      */
-    public function categoryAttribute(Category $category = null, $attribute, $value)
+    public function categoryAttribute(?Category $category = null, $attribute, $value)
     {
         $event = new DataObject([

Why prepend ? and not switch to union types

PHP 8.4 also supports Order|null $order as an explicit nullable. It is semantically identical to ?Order. Adobe picked the shorthand because it is backward-compatible with PHP 7.4 (the minimum supported version for 2.4.4 was 7.4). If you write the union form, your patch will fail on any 2.4.4 store still on 7.4 — keep the ? form.

Section 4 — Ship: the composer-patches workflow when you cannot upgrade core

For Magento 2.4.8 and 2.4.9, composer update pulls the fix automatically. For 2.4.4 — 2.4.7, you ship the patch yourself through cweagans/composer-patches — the same tool Adobe uses for its own quality patches.[2]

Install composer-patches

composer require cweagans/composer-patches:^1.7
mkdir -p patches/php84
composer config allow-plugins.cweagans/composer-patches true

Save the three patches

Drop the diffs above into patches/php84/:

patches/php84/sales-order-nullable.patch
patches/php84/quote-item-nullable.patch
patches/php84/catalog-output-nullable.patch

Wire them in composer.json

{
    "extra": {
        "composer-exit-on-patch-failure": true,
        "patches": {
            "magento/module-sales": {
                "PHP 8.4: explicit-nullable on loadByIncrementId":
                    "patches/php84/sales-order-nullable.patch"
            },
            "magento/module-quote": {
                "PHP 8.4: explicit-nullable on Quote\\Item::setProduct":
                    "patches/php84/quote-item-nullable.patch"
            },
            "magento/module-catalog": {
                "PHP 8.4: explicit-nullable on Helper\\Output::categoryAttribute":
                    "patches/php84/catalog-output-nullable.patch"
            }
        }
    }
}

The composer-exit-on-patch-failure: true line is critical — without it, a patch that fails to apply (because Adobe shipped an upstream change to the same lines) silently no-ops, and your CI greens while production still floods deprecations.

Reinstall and verify

rm -rf vendor/magento/module-sales \
       vendor/magento/module-quote \
       vendor/magento/module-catalog
composer install

grep -n 'public function loadByIncrementId' \
  vendor/magento/module-sales/Model/Order.php
# expected: public function loadByIncrementId(?Order $order = null)

The rm -rf step forces composer to re-extract the tarball and reapply the patch. Without it, an existing un-patched checkout stays untouched and the deprecation flood continues.

Section 5 — Verify: confirm the log is quiet

After deploy, watch the log live:

tail -f var/log/system.log \
  | grep -E 'Implicitly marking parameter'

Hit the affected surfaces in order:

  1. Admin → Sales → Orders (exercises loadByIncrementId).
  2. Storefront add-to-cart on a configurable (exercises Quote\Item::setProduct).
  3. Storefront category page with layered navigation (exercises Output::categoryAttribute).

If tail -f stays silent across all three, the patch is good. If a single deprecation appears, the offending file path tells you which patch missed or which third-party module re-introduced the pattern.

Pin the regression with a phpstan rule

To stop the trap from reappearing on the next vendor update, add a static-analysis rule. phpstan/phpstan ships noImplicitWildcard, but the more useful rule lives in slevomat/coding-standard:

composer require --dev slevomat/coding-standard
# .phpcs.xml.dist
# <rule ref="SlevomatCodingStandard.TypeHints.NullableTypeForNullDefaultValue"/>
./vendor/bin/phpcs --standard=.phpcs.xml.dist app/code/ vendor/magento/

Wire it into CI on every PR. The rule fires on every Type $param = null that has not been migrated to ?Type $param = null — same regex, but with a maintainable name.

The php.ini shortcut, and why it is a stopgap

If the upgrade window is in 4 hours and you do not have time to patch, the fastest path is to silence the deprecation entirely:

# /etc/php/8.4/fpm/php.ini
error_reporting = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED

This stops the log flood instantly. It also stops every other deprecation — strtolower(null), utf8_encode, partially-supported callables — and that is the problem. PHP 9.0 (expected late 2027) promotes implicit-nullable from E_DEPRECATED to a fatal TypeError. The moment you upgrade to 9.0 with this error_reporting setting still in place, every endpoint that touches the unpatched signatures returns 500 with no warning that anything was about to break.

A deprecation is the runtime telling you something will be a fatal error in two PHP versions. Silencing the message does not silence the fatal.

Use the error_reporting override to buy time. Schedule the proper patch within the same sprint.

What this looks like in 2.4.9 and beyond

From Magento 2.4.8 onward, Adobe ships the explicit-nullable form throughout module-sales, module-quote, module-catalog, and ~80% of the rest of core. The 2.4.9 release notes call this out as "PHP 8.4 compatibility improvements" — that is the trap fix, packaged.[3] Stores already on Magento 2.4.4 — 2.4.9 with vendor code patched through composer-patches need no further action; stores on 2.4.4 — 2.4.7 with native PHP 8.4 hosting need the workflow above before the next deploy.

FAQ

Does this affect Hyvä themes?

Hyvä storefront PHP is minimal — most rendering is Alpine.js — but Hyvä's checkout, GraphQL adapters, and Magewire components live in PHP and inherit the same vendor stack. If Magento core is patched, Hyvä is patched. The trap is in core, not in the theme.

Will setup:di:compile catch this?

No. di:compile validates type annotations on injected dependencies, not parameter-type compatibility on regular methods. The deprecation surfaces at runtime, when PHP parses the method signature, not at compile time.

What about magento/composer-root-update-plugin?

The root-update plugin keeps your composer.json in sync with the upstream meta-package on upgrade. It does not retroactively patch a 2.4.6 codebase. Use it for upgrades, use composer-patches for in-place patching.

Does PHP 8.3 emit the same warning?

PHP 8.3 emits the deprecation at E_DEPRECATED level only when declare(strict_types=1) is set in the calling file. PHP 8.4 emits it unconditionally. That is why the same store can be quiet on 8.3 and noisy on 8.4 without any code change.

Can I patch vendor/magento in-place and commit it to git?

You can, and it works, but every composer install on CI overwrites the edit. Use composer-patches instead — the patches live in patches/, are version-controlled, and reapply on every install.

How long does the full audit take?

From a cold start on a 2.4.6 store: ~45 minutes to grep, ~30 minutes to write three patches, ~15 minutes to wire composer-patches and reinstall, ~10 minutes to verify with tail -f. Add 1 — 2 hours per heavyweight third-party extension (Amasty, Mirasvit) that ships its own deprecation surface.

Will Magento 2.5 retire the patch?

Magento 2.5 is expected to require PHP 8.4 as the minimum runtime, which means Adobe ships the explicit-nullable form natively across all modules. The composer-patches workflow above stops being necessary the moment your store is on 2.4.8 or higher. Until then, the patches are the cheapest insurance against PHP 9.0.

References

  1. PHP 8.4 Migration Guide — "Deprecated Features" — php.net/migration84.deprecated (verified May 2026).
  2. cweagans/composer-patches v1.7 README — composer-patches.dev (verified May 2026).
  3. Adobe Commerce 2.4.9 Release Notes — "PHP 8.4 compatibility improvements" — experienceleague.adobe.com (verified May 2026).
Need a fixed-quote PHP 8.4 audit on your Magento 2.4.4 — 2.4.9 store?

I run a 4 — 6 hour audit that produces the grep output, the three core patches, the composer-patches wiring, and a phpstan rule that pins the regression for every future PR. Fixed quote: $499 audit · $2,499 sprint · ~20h @ $25/hr. See Hire me.