In a December blog post Juliette Reinders Folmer described the upgrade path to allow projects for supporting PHP 8 alongside older versions of PHP as a nightmare.
Seeing it come up in my timeline again recently, I started thinking about the affects of type declarations on projects, especially those with a plugin architecture.
In WordPress, most of the plugin architecture is handled via hooks: actions and filters. In particular, I was thinking about the affects of type declarations in a plugin’s filters.
Consider a plugin to prevent a particular wp-cron hook from been scheduled:
<?php
declare(strict_types = 1);
namespace Plugin1;
use stdClass;
/**
* Prevent scheduling of 'plugin1.no_run' event.
*
* @param bool|null $pre The preflight value.
* @param stdClass $event The cron event being scheduled.
* @return bool|null False if 'plugin1.no_run' event, otherwise unmodified $pre.
*/
function no_run( ?bool $pre, stdClass $event ) : ?bool {
if ( $event->hook === 'plugin1.no_run' ) {
return false;
}
return $pre;
}
add_filter( 'pre_schedule_event', 'Plugin1\\no_run', 10, 2 );
For the purposes of this article, the strict type declarations of the first parameter and the return type are the most important. In both cases the allowed values are null
, true
or false
.
Type declarations and trust
The problem with this code is that it introduces a high level of implicit trust: trust in both WordPress Core and trust in other plugins you have installed on your site.
In code you are saying “I trust WordPress and other plugins to always pass null
, true
or false
to my function”.
Now consider another plugin, a plugin that uses “clever” code and decides that returning a truthy or falsy value is close enough to returning true
or false
.
<?php
function plugin2_no_run( $pre, $event ) {
// Do not schedule plugin2_no_run.
if ( $event->hook === 'plugin2_no_run' ) {
return 0;
}
return 1;
}
add_filter( 'pre_schedule_event', 'plugin2_no_run', 5, 2 );
This plugin’s code runs at priority five, before the type declared first plugin’s code. In effect, WordPress calls the functions like this:
$value = null; //default value
$value = plugin2_no_run( $value, /* event object */ );
// $value equals either 1 or 0.
$value = \Plugin1\no_run( $value, /* event object */ );
Due to the strict type declared in the first plugin, the result will be a fatal error in the first plugin: Uncaught TypeError: Plugin1\no_run(): Argument #1 ($pre) must be of type ?bool, int given
.
The fatal error is reported against the type declared plugin, even though the incorrect value was the result of the other plugin’s clever code. Not only has a fatal error been triggered, debugging the actual cause has become a painful process.
WordPress and type declarations
One of the things WordPress is known for is its strong commitment to backward compatibility. However, this doesn’t necessarily extend to typing: a stdClass
in one release may become a WordPress defined class in another, for example WP_Post_Type
.
In WordPress 5.7, the cron functions will be changing to allow for a WP_Error
object to be returned if the calling function requests it.
An additional parameter has been added to wp_schedule_single_event()
and wp_schedule_event()
to allow plugin authors to request further details.
The WordPress code has been written in such a way as to allow plugin authors to return a WP_Error
object from their filters, and WordPress will convert it to a boolean if needs be.
Consider a plugin that has been updated to return a WP_Error
object.
namespace Plugin3;
use stdClass;
use WP_Error;
/**
* Prevent scheduling of 'plugin3.no_run' event.
*
* @param bool|null|WP_Error $pre The preflight value.
* @param stdClass $event The cron event being scheduled.
* @return bool|null|WP_Error WP_Error if blocking event, otherwise unmodified $pre.
*/
function no_run_57( $pre, stdClass $event ) {
if ( $event->hook === 'plugin3.no_run' ) {
return new WP_Error(
'plugin3_no_run_denied',
"The 'plugin3.no_run' event can not be scheduled."
);
}
return $pre;
}
add_filter( 'pre_schedule_event', 'Plugin3\\no_run_57', 5, 2 );
The plugin author is doing everything correct, everything is in accordance with the WordPress way. Neither the first parameter or return type is declared allowing other plugins to return unexpected values.
The plugin has been updated to support WordPress 5.7 and above, and returns a WP_Error
object in the knowledge that WordPress will convert it to false
if needs be.
Again, assuming the plugin3.no_run
hook is been scheduled, let’s consider the code WordPress effectively runs:
$value = null; //default value
$value = Plugin3\no_run_57( $value, /* event object */ );
// $value is a WP_Error object for the hook plugin3.no_run.
$value = \Plugin1\no_run( $value, /* event object */ );
Once again, the typing enforced on the first plugin’s first parameter and return type will cause a fatal due to an Uncaught TypeError
.
Without the type declaration, both plugins would have been able to interact together error free.
For the boolean/truthy example above, the fatal error is only triggered in the first plugin due to the strict_types=1
declaration.
In this second example, in which the type passed is an object rather than a boolean, the fatal error occurs both with and without strict_types
declared.
When to use type declarations in WordPress
For code running on WordPress filters, for the first parameter and return type I’d argue almost never. If you’re writing and thoroughly reviewing all the plugins used on a site yourself, you can probably get away with it but it introduces risk. To do so also requires a review of each version of WordPress before you upgrade.
For second and subsequent parameters running on WordPress filters, and any parameter of code running on WordPress actions, there is less risk with declaring types and you’re more likely to get away with it without errors. Again, it still requires a review of each version of WordPress before you upgrade.
For functions you are calling directly from other functions within your plugin, it is almost certainly safe to use type declarations.
Strict typing does have its benefits and can help ensure code quality and compatibility. To help address the issues with WordPress hooks, Juliette has opened a ticket proposing type aware actions and filters.
Strong typing in PHP
To return to Juliette’s article about the pain of upgrading projects to PHP 8, I think the problem comes from increasingly strict type rules in a runtime compiled language. The only way to detect type issues is to run the code. In projects with less than 100% test coverage (ie, most of them) problems are easily missed and may only become apparent in production.
The JavaScript world provides a good example for introducing a typed language: TypeScript. As a separate and compiled language, TypeScript allows the discovery of type errors to occur prior to runtime. The project specifically states that adding runtime errors is not one its goals.
I think compilation vs runtime design of TypeScript is something PHP could learn from.
I use type declarations for the parameters of filter callbacks in my User Switching plugin, including the first parameter.
I’ve had a few bug reports over the years about fatal errors but not too many. Fewer than I expected considering, as you note, the error actually blames the innocent plugin that comes after the real culprit.
The two that occur most frequently are `map_meta_cap` and `user_has_cap`, both of which expect an array as its first parameter, and it seems that returning `null` or `false` is somewhat common in plugins that want to prevent a capability from being applied.
Not typing the first parameter of a filter callback is probably a good piece of advice in general, but also might not be as big a problem as we would expect.
“The only way to detect type issues is to run the code.”
PHP has great static analysis tools in the form of PHPStan and Psalm.
I really strongly recommend people use them.
“I think compilation vs runtime design of TypeScript is something PHP could learn from.”
The ‘discovery of type errors to occur prior to runtime’ is accomplished by those static analysis tools. Although there are other nice things in TypeScript (e.g. lightweight type declarations and quite nice imports of functions from other files), there are also huge downsides to preprocessing code, particularly how slow to start typescript is, and how complicated a trivial JS app is.
Previous efforts to get people to use PHP preprocessing have failed due to not providing enough benefit, against the worse developer experience.
PHP internals is really short of resources, even now there is some funding in place. There’s really very little chance of a pre-process tool being worked on as part of core PHP, until someone stumps up something like $1million for people to work exclusively on that. And even then, there might be pushback.
“I think the problem comes from increasingly strict type rules in a runtime compiled language.”
I think the tradeoff is between people who have existing codebases they need to maintain, vs people who are either writing new code, or have a codebase that is already tightly analyzed by PHPStan/Psalm and are using strict mode.
For people having to upgrade code, the current situation is obviously not optimal. People writing new code that is type correct all the way through, PHP is in a great place.
Other than inventing a timemachine, and making these subtle changes a decade earlier, I can’t see a better way of making the BC breaking changes that have (in most people’s estimates) moved PHP to be a better language.
p.s. yes, I’m slightly behind on my todo list.