Modern PHP is considerably more type-aware than the PHP of a decade ago. After PHP 8, passing the wrong type to many internal functions is no longer quietly tolerated. It can result in a Type Error and stop the request.
That evolution exposes an important engineering lesson:
If your code expects a specific type, make it clear. Don’t assume the runtime will always give you the right type.
A recent WP Rocket issue provides a good example.
The issue, WP Rocket #8596, reports a fatal error in Cloudflare::unregister_callback():
Fatal TypeError:
substr(): Argument #1 ($string) must be of type string, int given
The issue was reported against WP Rocket 3.22.1, WordPress 7.1 alpha, and PHP 8.3. GitHub currently marks the issue as closed and associates it with WP Rocket 3.23.3.
The interesting part isn’t merely the one-line fix.
The important part is understanding why the bug happened. And how type safety could have prevented it, and what developers can learn.
The Bug in Simple Terms
The problematic operation was:
substr($key, -strlen($method))
The developer expected $key to be a string.
But $key came from a WordPress callback array:
$wp_filter[$hook]->callbacks[$priority]
PHP can convert a numeric string array key into an integer. So $key could become 123456 instead of “123456”.
WP Rocket then passed that integer to substr().
PHP 8+ rejects that operation because substr() expects a string as its first argument. The result is a fatal TypeError.
The proposed fix was straightforward:
substr((string) $key, -strlen($method))
That converts the value into the type the operation requires.
At first glance, this looks like a trivial bug.
It isn’t.
It illustrates a much larger issue in PHP application development.
PHP Is More Strict Than It Used to Be
Older PHP code often relied heavily on implicit conversions.
For example, developers became accustomed to things such as:
$value = 123;
echo substr($value, 0, 2);
Historically, PHP was permissive about converting values between types.
Modern PHP has moved toward much stricter behavior.
That is generally a good thing.
If a function expects a string, passing an integer should not silently succeed in every situation. Strictness exposes programming errors closer to their source.
The downside is that old assumptions embedded in large codebases can suddenly become visible.
And WordPress is an especially interesting environment because it is a huge ecosystem consisting of:
WordPress core
themes
plugins
caching systems
CDN integrations
security plugins
ecommerce systems
custom code
third-party libraries
These components interact through APIs and data structures that have evolved over many years.
A plugin therefore cannot safely assume that every value it receives will have the exact type its author imagined.
Where the Value Comes From
To understand the bug, it helps to understand WordPress hooks.
WordPress allows plugins and themes to register callbacks:
add_action(
‘some_hook’,
‘my_callback’
);
WordPress internally stores callbacks in WP_Hook.
The current WordPress implementation documents $callbacks using a structure that includes callback identifiers as keys. The source describes the structure as:
array<int, array<string, Hook_Callback>>
and constructs callback entries using a generated unique ID.
G
GitHub
+1
This is important because PHP arrays have special behavior around numeric keys.
Consider:
$array[“123456”] = “hello”;
PHP can treat “123456” as the integer key:
$array[123456] = “hello”;
So code that retrieves the key later cannot necessarily assume that the original textual representation is still a string.
That is exactly the kind of situation described in WP Rocket #8596.
The Real Problem Wasn’t PHP
It would be easy to say:
“PHP converted my string into an integer. That’s PHP’s fault.”
But that isn’t a particularly useful conclusion.
PHP’s array-key behavior is established behavior.
The better engineering question is:
Did WP Rocket’s code correctly handle the type of data it was consuming?
In this case, the answer appears to be no.
The WP Rocket code was effectively saying:
I know $key is a string.
without actually enforcing or normalizing that assumption.
Then it performed a string operation on it.
That’s the fundamental weakness.
Should They Have Used Strong Typing?
Yes—but with an important qualification.
Strong typing would have made the codebase safer, but simply adding scalar type declarations everywhere would not magically solve this particular problem.
There are two different concepts:
1. Type declarations
For example:
function process_key(string $key): string
{
return $key;
}
This establishes a contract:
This function expects a string.
That’s good.
2. Type normalization
Sometimes you receive data from an external or loosely typed system.
Then you might deliberately normalize it:
$key = (string) $key;
That’s also good.
For this WP Rocket issue, converting the key to a string is the right approach because PHP can represent the array key as an integer.
The operation requires a string, so the code should explicitly convert the value before performing a string operation.
The Better Code
Instead of:
if (substr($key, -strlen($method)) !== $method) {
continue;
}
the defensive version is:
$key = (string) $key;
if (substr($key, -strlen($method)) !== $method) {
continue;
}
Or:
if (substr((string) $key, -strlen($method)) !== $method) {
continue;
}
The second version is concise.
The first can sometimes be clearer because the normalization happens once and the rest of the code can treat $key consistently.
But There Is an Even Bigger Lesson
The best code doesn’t merely ask:
“What type do I expect?”
It asks:
“What types can this value actually have at runtime?”
That distinction is fundamental.
Suppose you write:
function process(string $value): void
You have documented your expectation.
But if the value ultimately comes from:
a database
HTTP input
WordPress hooks
plugin APIs
JSON
serialized data
PHP arrays
third-party libraries
then your assumption may still be wrong.
Type declarations protect function boundaries.
They don’t cut the need for defensive programming at system boundaries.
WordPress Makes This Particularly Important
WordPress is highly extensible.
One plugin can register a callback.
Another plugin can inspect it.
A caching plugin can change it.
A security plugin can interact with the same hook.
A theme can introduce another callback.
A future WordPress version can alter execution order.
This means a plugin that interacts directly with WordPress internals needs to be particularly careful. WordPress uses type annotations for WP_Hook, but PHP’s runtime array-key behavior still matters. WordPress also allows many callbacks on the same hook, with execution controlled by priority order.
WordPress Developer Resources
That creates a huge interaction surface.
Why This Became a Fatal Error
There is a critical difference between these two situations.
A weakly typed operation might produce:
Warning
and allow the request to continue.
A PHP 8+ internal function receiving an invalid argument type can instead produce:
TypeError
which is an exception/error condition that can terminate the request if it isn’t handled.
In this case:
substr(123456, …)
is fundamentally invalid because substr() requires a string.
Therefore:
integer
↓
substr()
↓
TypeError
↓
request failure
That’s why the GitHub issue was classified as critical. The issue describes the plugin or website as potentially unusable when the condition occurs.
G
GitHub
Was WordPress at Fault?
This deserves a nuanced answer.
The bug appeared on WordPress 7.1 alpha, which may have triggered the issue more easily.
The core problem was in WP Rocket’s code and could also affect stable WordPress.
So I would distinguish between:
Trigger:
A particular WordPress/plugin/theme callback situation produces a numeric callback key.
Failure:
WP Rocket assumes that key is a string and passes it to substr().
Result:
PHP 8+ throws a TypeError.
That’s why blaming WordPress alone would miss the actual robustness problem.
Could Static Analysis Have Found This?
Potentially, yes.
Modern PHP projects can use tools such as:
PHPStan
Psalm
PHP_CodeSniffer
IDE type analysis
automated tests
PHP 8 type declarations
Static analysis is useful for large WordPress plugins as it has many loosely typed interfaces.
If the project knows that $key might be:
int|string
then static analysis can flag code that passes it directly into a function expecting:
string
Conceptually, the warning would be:
Argument #1 of substr() expects string,
int|string given.
That is exactly the kind of problem you want to discover during development rather than production.
What About Tests?
This is another important lesson.
A good test suite should have included something resembling:
$key = 123456;
$method = ‘callback’;
$result = substr((string) $key, -strlen($method));
More importantly, the test should exercise the actual WordPress callback structure.
The key test case isn’t:
“Does Cloudflare work?”
It is:
“What happens if WordPress gives us an integer callback key?”
That’s boundary testing.
And boundary testing is where many production bugs are found.
The Test That Matters
A robust test could conceptually assert:
public function test_unregister_callback_accepts_integer_callback_keys(): void
{
// Register a callback whose generated identifier
// becomes a numeric PHP array key.
// Run unregister_callback().
// Assert that no TypeError occurs.
}
The exact test would depend on WP Rocket’s setup, but the idea is simple.
Don’t only test the normal type.
Test the types the runtime can actually produce.
Is Casting Everything a Good Idea?
No.
This is an important distinction.
You shouldn’t blindly write:
(string) $everything
throughout a codebase.
For example, if you expect a database ID to be an integer, this:
$id = (string) $id;
may hide a genuine programming error.
Type conversion should be intentional.
In WP Rocket’s case, the code expected a string but received an integer. Since PHP can use integers as array keys, the code should have handled that explicitly.
Therefore:
(string) $key
makes semantic sense.
You’re saying:
“Regardless of how PHP represented this array key, I need its textual representation for this operation.”
That’s a legitimate normalization.
Strong Typing Is a Design Philosophy
Strong typing isn’t merely about adding:
string
int
bool
to function declarations.
It’s about establishing clear contracts.
For example:
function find_callback(string $callback_id): ?array
is better than:
function find_callback($callback_id)
because the first communicates what the function expects.
But when consuming external data:
$key = (string) $key;
may still be necessary.
A mature PHP codebase therefore combines:
Type declarations
+
Static analysis
+
Runtime validation
+
Explicit normalization
+
Tests for edge cases
rather than relying on any one technique.
What WP Rocket Could Have Done Better
From an engineering perspective, several improvements would make this class of bug less likely.
1. Explicit types
Functions and variables should have clear type contracts wherever practical.
Instead of:
function unregister_callback($hook, $method)
prefer something like:
function unregister_callback(string $hook, string $method): void
where those types are genuinely guaranteed.
2. Normalize external values
When iterating over WordPress callback keys:
$key = (string) $key;
before performing string operations.
3. Static analysis
Use PHPStan/Psalm at an appropriately strict level.
4. Test runtime edge cases
Test:
string key
integer key
numeric string key
non-numeric string key
empty key
unexpected callback structure
5. Avoid relying on undocumented assumptions
If code depends on:
“This WordPress array key will always be a string.”
then that assumption should either be guaranteed by the API or defended in the code.
There’s Also a Lesson for Plugin Developers
If you’re writing WordPress plugins, don’t assume that the WordPress ecosystem behaves like a controlled application.
Your plugin may run alongside hundreds of thousands of combinations of:
WordPress version
PHP version
Theme
Plugin A
Plugin B
Plugin C
Server configuration
Object cache
CDN
You don’t need to support every broken combination.
But you should make your own code resilient when it encounters valid variations in data representation.
That’s the difference between:
“This shouldn’t happen.”
and:
“If this happens, our software won’t crash.”
The second is the better engineering mindset.
The Most Important Takeaway
The actual fix for issue #8596 is tiny:
(string) $key
But the engineering lesson is much larger.
Types are contracts, not assumptions.
If your code requires a string, make sure it has a string.
If the value comes from a dynamic system, don’t blindly assume the runtime representation.
If an operation can receive many types, normalize or confirm before using it.
And if a single unexpected integer can bring down a request, your tests should probably include that integer.
The WP Rocket issue is so a useful example of why modern PHP development should move beyond the old WordPress-era mindset of:
$value = whatever;
toward:
$value = whatever;
assert / validate / normalize
// Now use it according to an explicit contract.
That doesn’t mean WordPress needs to become a completely rigid, statically typed ecosystem.
It means you should handle type differences carefully when systems work with dynamic and strict types.
In this case, WP Rocket treated an integer key as a string, and PHP 8 exposed the mistake.
That’s not a reason to blame PHP for becoming stricter.
It’s a reminder that good software should make its assumptions explicit.
Final Verdict
If we reduce the whole incident to one engineering principle:
Don’t make a type assumption silently when you can make the type contract explicit.
For WP Rocket, the immediate fix is a string cast.
For a mature codebase, the better approach is stronger typing, static analysis, defensive coding, and tests.
And that is a much more valuable lesson than simply saying, “add (string) to line 562.”
Read WP Rocket issue #8596 on GitHub
Read the WordPress WP_Hook implementation


Leave a Reply