PHP 8.6 for WordPress Plugin Authors: What Changes and When to Update CI

PHP 8.6 is in RC5 with GA planned for 19 November 2026. What changes for plugin code, which deprecations to fix now, and when to add 8.6 to CI.

Code editor card showing a CI matrix for PHP 8.1 to 8.5 passing and PHP 8.6 allowed to fail

PHP 8.6 is in release candidate, and the planned release date on php.net is 19 November 2026. If you ship a WordPress plugin, this is the week to decide what goes into your CI matrix and what goes onto your to-do list. This guide covers what PHP 8.6 changes for plugin code, what is only deprecated, what can break, and a plan for when to add 8.6 to CI.

Everything here comes from php.net, the php-src UPGRADING notes for the PHP-8.6 branch, and the RFC pages on wiki.php.net. I did not install PHP 8.6 and I did not run any plugin on it. Code samples are copied from RFCs or written from the UPGRADING text, and each one is marked as untested. Where I could not confirm something, I say so.

Where PHP 8.6 stands today

The php.net 2026 news archive lists the pre-release trail. Alpha 1 came on 2 July 2026 and Beta 1 on 13 August. There was no RC1 announcement: the build published on 24 September was called RC2 because of a packaging mistake made while preparing RC1. RC3 followed on 8 October, RC4 on 21 October and RC5 on 5 November.

The RC5 announcement names the next step as GA, PHP 8.6.0, on 19 November 2026. The release schedule on wiki.php.net gives the same date and also shows that the hard feature freeze was 22 September. So the feature list is closed. What you read in UPGRADING today is what ships, unless a release manager approves a late change.

Two more facts matter for planning:

  • Every pre-release announcement says not to use these builds in production. Treat 8.6 as a test target until 19 November, and then wait for the first patch releases before you tell customers it is safe.
  • The RC4 announcement says it includes fixes for two critical unauthenticated remote code execution bugs (CVE-2026-109618 and CVE-2026-109619) and urges users of earlier 8.6.0 builds to update. If your CI image was built from Alpha, Beta or RC2 or RC3, rebuild it on RC5.

Since the date is still “planned”, check php.net on the day before you act on it. Dates in release schedules can slip, and this post was written before the release.

What WordPress says about PHP 8.6

The WordPress core handbook page on PHP compatibility lists PHP 8.5 as supported by WordPress 7.1, 7.0 and 6.9. It does not mention any version newer than 8.5. So there is no official WordPress statement on PHP 8.6 that I could find, and I would not write one into your plugin readme.

The same page says the old “beta support” label for new PHP versions has been retired. It was used until at least 10% of WordPress sites ran that PHP version or later. The page gives May 2026 as the date the label went away. The practical effect for you: nobody will tell you when 8.6 is “officially” fine. Your own test matrix is the only signal you get.

The page also says WordPress requires PHP 7.4 or higher as much as possible, and that WordPress 7.0 dropped support for PHP 7.2 and 7.3. Your plugin may set a higher minimum than core. That is your choice. It does mean your CI matrix is a range, not a single version, and 8.6 is the new top of the range.

The breaking changes that can hit plugin code

The UPGRADING file has a long “Backward Incompatible Changes” section. Most of it covers rare extensions, such as COM, SNMP and Sysvshm. I picked the items that a typical plugin could meet. For each one, the question to ask is: does my code, or a library I bundle, do this?

Session defaults are now safer

This is the change most likely to cause a visible bug. Three session settings change their defaults:

  • session.use_strict_mode goes from 0 to 1.
  • session.cookie_httponly goes from 0 to 1.
  • session.cookie_samesite goes from unset to “Lax”.

The RFC (“session security defaults”) passed each vote with no “No” votes: 27 to 0 for strict mode, 26 to 0 with one abstention for HttpOnly, and 26 to 0 for SameSite. The RFC lists what can break:

  • Strict mode rejects session IDs that the server has no stored session for. An app that hands its own ID to another subdomain, without writing the session first, will see that ID replaced.
  • HttpOnly hides the session cookie from document.cookie. Any script that reads the cookie to put it in a header will stop working.
  • SameSite=Lax stops the cookie on cross-site POST requests. The RFC names SAML single sign-on that starts at the service provider, and old cross-origin form posts.

WordPress core logs users in with its own authentication cookies, not PHP sessions, as far as I know. The RFC does not mention WordPress at all. But some plugins call session_start() for carts, multi-step forms or payment returns. If yours does, search for it now. A payment gateway that posts back to your site from another domain is the classic case for the SameSite change.

If you need the old behavior for one flow, set the value yourself. This is untested, and the setting names come from the UPGRADING text:

<?php
// Only if a flow truly needs it. Prefer fixing the flow.
// SameSite=None needs the Secure flag, per UPGRADING.
session_set_cookie_params( array(
    'secure'   => true,
    'samesite' => 'None',
) );
ini_set( 'session.use_strict_mode', '0' );

One more session item: session_encode() now returns an empty string for an empty session, where it used to return false. It returns false only when encoding fails. If you wrote if ( false === session_encode() ) to detect “nothing to save”, that check changes meaning. Custom session handlers get a related note: with lazy_write on, sessions that were read empty and stay empty now call updateTimestamp() instead of write().

trim() now removes form feed characters

The form feed character (\f) is added to the default characters stripped by trim(), rtrim() and ltrim(). For almost all plugin input this is a harmless fix. It matters if you compare a trimmed string to a stored hash or a fixture, or if you parse a file format where form feed has meaning. Add a test with a string that has a trailing \f if you handle imported text.

preg_grep() returns false on regex errors

preg_grep() now returns false, not a partial array, when PCRE hits an error. The example given is malformed UTF-8 with the /u modifier. That matches other preg_* functions. If you pass user text to preg_grep() and then foreach over the result, a false value now triggers a warning in the loop. Check the return value.

unpack() reads “<” and “>” as endianness

In unpack(), a “<” or “>” right after a format code is now an endianness modifier. A format such as s<value now gives the key value, not <value. And C>name raises a ValueError. The RFC for this also adds endianness modifiers to pack(). Binary-format code in image or font handling is where this shows up, and it is rare in plugins.

Stricter argument checks that now throw

PHP 8.6 turns several silent behaviors into a ValueError. Here are the ones that look most likely to appear in a plugin:

  • array_filter() throws on an invalid $mode, and array_change_key_case() throws on an invalid $case.
  • pathinfo() throws on an invalid $flags, and scandir() throws on an invalid $sorting_order.
  • file_exists(), is_file(), is_dir(), filesize(), filemtime() and a dozen related file functions throw when the path contains a NUL byte.
  • getenv(), putenv(), parse_str() and proc_open() (for the $cwd argument) throw on NUL bytes.
  • number_format() throws when $decimals is outside the integer range, and sleep() and usleep() throw on values that would overflow.

The NUL byte change is the one to think about. If a plugin passes a request value to file_exists() after weak sanitizing, the old behavior was a quiet false. The new behavior is an uncaught ValueError, which means a fatal error and a white screen. The fix is the same as it always was: validate and sanitize the path before it reaches a file function. But on PHP 8.6 a missed case now becomes a crash instead of a silent miss.

Smaller changes worth a search

  • By-reference foreach loops can now visit elements that were skipped earlier after array compaction. If you unset array items inside a by-reference loop, add a test.
  • array_intersect() now converts values to strings while it scans its inputs, not during sort comparisons. That can change the number and order of conversion warnings and __toString() calls.
  • Reading a magic property with ?? or empty() no longer calls __get() when __isset() has already written the value into the property table.
  • DOM properties that used to be documented read-only now use asymmetric visibility (public private(set)). Writing to them still fails, but the error message changes to “Cannot modify private(set) property”.
  • Files are treated as Phar archives on include only if “.phar” appears as an extension in the file name part of the path, not in a directory name.
  • The built-in CLI web server no longer reflects the Host header, and it accepts the HTTP QUERY method instead of answering 501.

Deprecations: warnings today, removals later

A deprecation in 8.6 does not break your plugin on 8.6. It writes an E_DEPRECATED message. That still matters for two reasons. Plugin Check and many hosts treat new notices as noise or as a quality signal, and a deprecated feature is a candidate for removal in a later major version. UPGRADING lists the full set. These are the ones I would grep for.

Language-level deprecations

  • A return statement inside a finally block.
  • Returning a value from __construct() or __destruct(), and making either of them a generator.
  • A third argument to define(). That argument was for case-insensitive constants, which have been ignored since PHP 8.0.
  • Using “namespace” as a class constant name.
  • Naming a function readonly.
  • Using let or is as the name of a class, interface, trait, enum, function or constant, or as an alias in a use declaration or class_alias().
  • Using “_” as a constant name, or as an alias in use. This extends the PHP 8.4 deprecation of “_” as a class name.
  • Calling is_a() or is_subclass_of() with a string as the first argument when $allow_string is false.
  • A return type of array|null or ?array on __debugInfo(). Use array.

The finally case is the one I would expect to find in older code, so here is what it looks like. This is a sketch based on the UPGRADING text and is untested:

<?php
function save_row( $db, $row ) {
    try {
        return $db->insert( $row );
    } finally {
        // Deprecated in PHP 8.6: return inside finally.
        // It also hides the return value and any exception above.
        return false;
    }
}

The reason for the deprecation is in that comment: a return in finally overrides the earlier return and also swallows a thrown exception. If you have this pattern, move the cleanup into the finally block and put the return outside it.

Function deprecations with direct replacements

Most of the deprecated functions have a one-line fix, and the replacement is named in UPGRADING:

  • is_double() and doubleval(): use is_float() and floatval().
  • is_long() and is_integer(): use is_int().
  • spl_object_hash(): use spl_object_id().
  • spl_classes(): use ReflectionExtension::getClassNames().
  • strcoll() and the SORT_LOCALE_STRING flag: use the Collator class from the intl extension.
  • metaphone(): use a userland phonetic matching library. There is no built-in replacement.
  • Passing an object to array_walk(), array_walk_recursive() or http_build_query(): call get_object_vars() first.

The spl_object_hash() to spl_object_id() change is a one-word swap, but check what you do with the value. The two functions return different types: spl_object_hash() gives a string and spl_object_id() gives an int. If you use the result as an array key, both work. If you store it in a database column or compare it to a string, test it.

Extension deprecations

  • MySQLi. mysqli_get_charset() and mysqli::get_charset() are deprecated, and so are mysqli_stmt_init(), mysqli::stmt_init() and calling the mysqli_stmt constructor without a query. WordPress talks to MySQL through wpdb, which I understand uses mysqli. I did not check whether core calls any of these functions, so grep your own code and let core handle its own.
  • Mbstring. The Mbregex functions are deprecated because the Oniguruma library they rely on is no longer maintained. If you call mb_ereg(), mb_split() or the other mb_ereg* functions, plan a move to preg_* with the /u modifier. Passing objects to mb_convert_variables() is deprecated too.
  • SPL. Ten ArrayIterator methods are deprecated, including getFlags(), asort() and serialize(). So are the CSV methods on SplFileObject: fgetcsv(), fputcsv(), setCsvControl() and getCsvControl().
  • Reflection. Calling ReflectionProperty::setValue() or setRawValue() with an object of the wrong class is deprecated. So is calling ReflectionMethod::invoke() with an object on a static method. Test helpers that poke private properties are the likely place for this.
  • Zlib and Bzip2. Passing an object as a stream filter parameter, or as the options for deflate_init() and inflate_init(), is deprecated.
  • Streams. More than 16 filters in one php://filter URL, without setting the new filter.max_filter_count option, now raises a deprecation.
  • Session. Passing a handler object that lacks create_sid() and validateId() to session_set_save_handler() is deprecated. A class that implements SessionHandlerInterface without those methods also gets a notice.

New features you can use, and when

New syntax cannot be used while your plugin still supports older PHP. If your plugin supports PHP 8.1, you cannot write a readonly property default in code that loads on 8.1, because it is a parse or compile error there. So the useful question for each feature is: is it a runtime function you can feature-detect, or new syntax that waits until you raise your minimum?

Syntax changes (wait for your minimum)

Readonly properties can have default values. The RFC passed 24 to 0 with five abstentions. Today a readonly property cannot declare a default. Here is the shape from the RFC:

<?php
interface Rule
{
    public string $className { get; }
}

final class RuleA implements Rule
{
    public readonly string $className = SomeParser::class;
}

Partial function application. This one has the most reach. The RFC passed 33 to 0 and was merged for PHP 8.6 alpha 3. You call a function with a ? placeholder for one missing argument, or ... for all the rest, and you get a closure back. These lines come from the RFC:

<?php
// ? marks one argument to be supplied later
$result = array_map( str_replace( 'hello', 'hi', ? ), $arr );

// ... stands for all remaining arguments
$c = stuff( 1, 'hi', ... );

UPGRADING also lists a speed gain: an array_map() call whose callback is a first-class callable or a partial application is compiled into the equivalent foreach loop. Neither feature can be used in a plugin that still loads on PHP 8.5 or older.

The other syntax additions are smaller. Enums can define __debugInfo(). The #[\Override] attribute can be applied to class constants, including enum cases. And you can now assign to a property of an object stored in a constant with OBJ->prop = $val, which the RFC calls direct mutation of objects stored in constants.

New functions and classes (feature-detect them)

These are runtime additions, so you can guard them with function_exists() or class_exists() and keep supporting older PHP:

  • mysqli::quote_string() and mysqli_quote_string().
  • A Time\Duration class in the Date extension (RFC: duration class).
  • Uri\Rfc3986\UriBuilder and Uri\WhatWg\UrlBuilder, plus getUriType(), getHostType() and a percent-encoding helper. These extend the URI extension from PHP 8.5.
  • A new polling API in the IO area (RFC: poll API) and a new stream errors API controlled by the error_mode, error_store and error_handler context options.
  • ReflectionProperty::isReadable() and isWritable(), and ReflectionParameter::getDocComment().
  • TLS session resumption, external PSK and TLS 1.3 early data options for OpenSSL streams.
  • JSON error messages now carry the location of the error, so json_last_error_msg() and JsonException are easier to read in logs.

For most plugins, the JSON message change is the one you will feel first. A malformed API response will now tell you where it broke. That is a small gain, and it needs no code change.

One INI setting for debugging

A new setting called error_include_args makes errors show the function arguments consistently. It is off by default. The UPGRADING text says it uses the same machinery as stack traces, so sensitive parameters are not printed and long strings are cut at zend.exception_string_param_max_len. It may help in a CI log. I would not turn it on in production without reading that behavior closely.

Performance changes

UPGRADING lists several speed changes for PHP 8.6, and none of them needs code changes on your side:

  • printf() calls that use only %s and %d are compiled into string interpolation.
  • Arguments are passed more efficiently to known constructors, such as new self().
  • A stateless closure cache is added, so a closure that captures nothing is not rebuilt on each call.
  • The TAILCALL virtual machine and ZTS builds are faster.

The notes give no numbers, and I have not measured anything. Do not put a speed claim in your changelog because of this list. Measure your own code if you want to say something.

What I would change in plugin code now

Everything below works on the PHP versions you support today, so none of it has to wait for 8.6:

  1. Replace is_double(), is_long(), is_integer() and doubleval() with is_float(), is_int() and floatval(). These exist on every version.
  2. Replace spl_object_hash() with spl_object_id(), after you check where the value is stored.
  3. Remove the third argument from any define() call.
  4. Move return statements out of finally blocks.
  5. Remove return values from constructors and destructors.
  6. Rename any class, function or constant called let, is or _. This is rare. A namespaced name is likely fine, but I would read the RFC text before assuming that.
  7. Check every session_start() call and the flows around it.
  8. Validate paths before they reach file_exists() and its relatives.
  9. Check the return value of preg_grep().

Do not forget bundled code. A Composer package in your vendor folder is your responsibility once you ship it. Run your tests with the bundled libraries loaded, not only with your own code. And if you use PHPUnit, check that the PHPUnit version you run supports PHP 8.6. I did not confirm which PHPUnit release does.

When to update CI

The honest answer for PHP 8.6 is in two steps, because the facts changed on 5 November and will change again on 19 November.

Step 1: add 8.6 as a non-blocking job now

RC5 is out and the feature list is frozen. A failing test today tells you something real about your code, so there is little reason to wait. Run an 8.6 job that is allowed to fail, with all errors on, and read its output after each push. Here is a sketch for GitHub Actions. It is untested, and I could not confirm that the shivammathur/setup-php action offers an 8.6 release candidate by the “8.6” version string, so check its documentation first:

jobs:
  test:
    runs-on: ubuntu-latest
    continue-on-error: ${{ matrix.experimental }}
    strategy:
      fail-fast: false
      matrix:
        php: [ '8.1', '8.2', '8.3', '8.4', '8.5' ]
        experimental: [ false ]
        include:
          - php: '8.6'
            experimental: true
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          ini-values: error_reporting=-1, display_errors=On
      - run: composer install --no-interaction
      - run: composer test

Three details in that file are the point. continue-on-error keeps a red 8.6 job from blocking merges. fail-fast: false lets the other versions finish, so one red job does not hide the rest. And error_reporting=-1 makes deprecations show up in the log, which is how you will find most 8.6 issues.

If you also run static analysis, such as PHPStan or the PHPCompatibility sniffs for PHP_CodeSniffer, check whether your version knows about 8.6 rules. I could not confirm that either tool has 8.6 support. For a broader view of why green checks do not prove a plugin works, see what PHPCS, PHPStan and Plugin Check cannot see.

Step 2: make it blocking after GA, when three things are true

Do not flip the job to blocking on 19 November. Wait until these hold:

  1. PHP 8.6.0 is actually released on php.net, and your CI image or setup action installs the final build, not an RC.
  2. The dev tools in your pipeline run on 8.6 without errors from their own code: PHPUnit, PHPCS, PHPStan and any Composer package you only use in tests. A red job caused by a tool is not a finding about your plugin.
  3. Your own test suite passes on 8.6 with deprecations on, and you have read each notice. Some come from vendor code that you cannot fix. For those, decide whether to update the package, patch it, or note it as known.

Then promote 8.6 to a blocking job. The same step is also when you can reasonably say “tested on PHP 8.6” in your own docs. I would still word it as “tested on” and not “supports”, because the matrix proves only what your tests cover.

What to do with plugin headers and readme claims

Two declarations matter. The first is the Requires PHP header, which is a minimum and does not need to change for 8.6. The second is any text in your readme that claims compatibility. Because WordPress core does not publish a PHP 8.6 statement yet, keep that text factual: which versions your matrix ran and when.

If you use Composer, check your composer.json php constraint. A constraint like >=8.1 already allows 8.6, so a Composer install on a site with 8.6 will not be blocked by it. A caret constraint such as ^8.1 also allows 8.6, because it covers all 8.x releases. A fixed list such as 8.1.* || 8.2.* will block newer versions. If your constraint lists exact versions, add 8.6 only after your matrix passes.

A realistic schedule

  • This week (RC5): add the non-blocking 8.6 job. Fix the cheap items from the list above. Rebuild any image that was made from an earlier pre-release, because of the two RCE fixes in RC4.
  • 19 November (planned GA): confirm the release on php.net. Switch the job to the final build.
  • The weeks after GA: wait for the first patch releases and for your tools to catch up. Promote to blocking. Update your docs.
  • Next release of your plugin: fix remaining deprecations, so you are not carrying notices when hosts start moving customers to 8.6.

For the WordPress side of your matrix, the 7.2 beta cycle has its own checklist. See WordPress 7.2 for plugin authors for the database mode change and the schedule. Run both in the same CI so you can tell which side broke a test.

What I could not confirm

  • Whether WordPress core, or any popular plugin, calls the deprecated functions listed here. I did not read core for this.
  • Which versions of PHPUnit, PHPStan and PHPCompatibility support PHP 8.6.
  • Whether the common CI setup actions and Docker images publish PHP 8.6 builds under the version string I used.
  • How any of the performance changes behave in a real WordPress request. UPGRADING gives no numbers.
  • That 19 November will hold. It is the planned date on php.net as of the RC5 announcement.

Sources

The short version

PHP 8.6 is a normal yearly release for most plugins: a handful of deprecations, a few behavior changes, and nothing that removes a feature plugin authors rely on. The two changes that can break something are the session defaults and the stricter ValueError checks, so search for those first. Add an allowed-failure 8.6 job today, fix the cheap deprecations on every PHP version you support, and make the job blocking only after the final release and your tools catch up.

Varun Dubey

Written by

Varun Dubey

Varun Dubey runs Wbcom Designs, the WordPress studio he founded in India in 2009. He has spent sixteen years building on WordPress and BuddyPress, shipping client work and products such as Reign, BuddyX, Jetonomy and MediaVerse, and has been putting Claude and OpenAI workflows into production since 2023. He writes up what the studio learns along the way.

More about Varun

No comments yet