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_modegoes from 0 to 1.session.cookie_httponlygoes from 0 to 1.session.cookie_samesitegoes 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, andarray_change_key_case()throws on an invalid$case.pathinfo()throws on an invalid$flags, andscandir()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()andproc_open()(for the$cwdargument) throw on NUL bytes.number_format()throws when$decimalsis outside the integer range, andsleep()andusleep()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
foreachloops 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
??orempty()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
returnstatement inside afinallyblock. - 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
letorisas the name of a class, interface, trait, enum, function or constant, or as an alias in ausedeclaration orclass_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()oris_subclass_of()with a string as the first argument when$allow_stringis false. - A return type of
array|nullor?arrayon__debugInfo(). Usearray.
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()anddoubleval(): useis_float()andfloatval().is_long()andis_integer(): useis_int().spl_object_hash(): usespl_object_id().spl_classes(): useReflectionExtension::getClassNames().strcoll()and theSORT_LOCALE_STRINGflag: use theCollatorclass 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()orhttp_build_query(): callget_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()andmysqli::get_charset()are deprecated, and so aremysqli_stmt_init(),mysqli::stmt_init()and calling themysqli_stmtconstructor without a query. WordPress talks to MySQL throughwpdb, 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 othermb_ereg*functions, plan a move topreg_*with the/umodifier. Passing objects tomb_convert_variables()is deprecated too. - SPL. Ten
ArrayIteratormethods are deprecated, includinggetFlags(),asort()andserialize(). So are the CSV methods onSplFileObject:fgetcsv(),fputcsv(),setCsvControl()andgetCsvControl(). - Reflection. Calling
ReflectionProperty::setValue()orsetRawValue()with an object of the wrong class is deprecated. So is callingReflectionMethod::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()andinflate_init(), is deprecated. - Streams. More than 16 filters in one
php://filterURL, without setting the newfilter.max_filter_countoption, now raises a deprecation. - Session. Passing a handler object that lacks
create_sid()andvalidateId()tosession_set_save_handler()is deprecated. A class that implementsSessionHandlerInterfacewithout 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()andmysqli_quote_string().- A
Time\Durationclass in the Date extension (RFC: duration class). Uri\Rfc3986\UriBuilderandUri\WhatWg\UrlBuilder, plusgetUriType(),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_storeanderror_handlercontext options. ReflectionProperty::isReadable()andisWritable(), andReflectionParameter::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()andJsonExceptionare 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%sand%dare 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:
- Replace
is_double(),is_long(),is_integer()anddoubleval()withis_float(),is_int()andfloatval(). These exist on every version. - Replace
spl_object_hash()withspl_object_id(), after you check where the value is stored. - Remove the third argument from any
define()call. - Move
returnstatements out offinallyblocks. - Remove return values from constructors and destructors.
- Rename any class, function or constant called
let,isor_. This is rare. A namespaced name is likely fine, but I would read the RFC text before assuming that. - Check every
session_start()call and the flows around it. - Validate paths before they reach
file_exists()and its relatives. - 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:
- PHP 8.6.0 is actually released on php.net, and your CI image or setup action installs the final build, not an RC.
- 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.
- 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
- php.net news archive for 2026, with the Alpha, Beta and RC announcements and the planned GA date.
- PHP 8.6 release schedule on wiki.php.net.
- UPGRADING file, php-src PHP-8.6 branch.
- RFC pages: session security defaults, readonly property defaults and partial function application v2.
- PHP compatibility and WordPress versions, from the core handbook.
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.





No comments yet