When Cloudflare Sneezed and WordPress Caught a Fatal Error

A Cloudflare outage took down wp-admin on a site not on Cloudflare, through Yoast’s license check

1 comment
Risograph illustration of a cloud blowing a gust of wind that pulls a power plug out of a wall socket

On 12 June 2025 a Cloudflare outage took down wp-admin on a client site that does not use Cloudflare. Yoast SEO’s license check got an error body with a 200 status, and PHP 8 turned that into a fatal error on every admin page. I sent a fix the next day, and Yoast shipped a version of it in 25.8.

Symptom
Fatal error on every wp-admin page, plugins page included
Root cause
Yoast’s license API, behind Cloudflare, answered with an error body and HTTP 200. The plugin accepted it and ran array_map() over a field that did not exist
Fix
Yoast disabled on the day. Response validation upstream, shipped in Yoast SEO 25.8

The error

text
PHP Fatal error:  Uncaught TypeError: array_map(): Argument #2 ($array) must be of type array, null given

The stack trace pointed at wordpress-seo/inc/class-addon-manager.php, line 820. No dashboard, no plugins page.

The plugins page is the worst one to lose. Yoast caches the license answer for a day on most admin pages. On the plugins, updates, licenses and Yoast dashboard pages it only trusts a 60 second copy. So the one page I needed to deactivate the plugin was the page most likely to fire a fresh request.

Disabling Yoast cleared it.

How a site not on Cloudflare went down

The site had zero DNS records at Cloudflare. Yoast’s license API is on it. my.yoast.com resolves to Cloudflare addresses.

Yoast SEO’s add-on manager checks licenses on admin_init, against https://my.yoast.com/api/sites/current, whenever a Yoast add-on is installed and the cached answer has expired. Cloudflare’s own incident report puts that day’s outage at 17:52 to 20:28 UTC, with Workers KV, Access and a long list of other services affected.

So the site never touched Cloudflare and still inherited the outage, through a plugin, on every admin page load.

What the API returned

This, with an HTTP status of 200:

json
{"statusCode":404,"error":"Not Found","message":"No sites found"}

A 404, in a 200. The request class only checked the status code, so it decoded the body and handed it on. The add-on manager then mapped it:

php
protected function map_site_information( $site_information ) {
	return (object) [
		'url'           => $site_information->url,
		'subscriptions' => array_map( [ $this, 'map_subscription' ], $site_information->subscriptions ),
	];
}

$site_information->subscriptions did not exist, so it was null. In PHP 7, an internal function given the wrong type warned and returned null. Since PHP 8.0 it throws a TypeError, so a line in the log became a white screen.

Was the bad response cached?

No. The earlier version of this post said it was, and that was wrong.

The answer is only stored after map_site_information() returns, and that is the line that fatals. Nothing got cached. Every admin page load asked the API again and died again, for as long as the API kept sending that body. The cache is a per site transient too, so it could never have spread to other sites on the same server.

Was it Cloudflare’s 1200 error?

Also no. The request class accepted any status code containing 200:

wordpress-seo/inc/class-my-yoast-api-request.phpphp
if ( $response_code === 200 || strpos( $response_code, '200' ) !== false ) {

Cloudflare has an error 1200, and the Yoast issue for this asked whether it could have slipped through. Yoast’s own follow-up on the issue says it did not. The earlier version of this post repeated the theory and put the check in the wrong file. It lives in class-my-yoast-api-request.php, not the add-on manager.

The fix

I opened PR #22353 on 13 June. It checked that url and subscriptions exist and that subscriptions is an array, and fell back to the default site information if not.

Yoast closed it in favour of #22386, which moves the same check into the request class, adds unit tests and drops the strpos() check. It merged on 30 July and shipped in Yoast SEO 25.8 on 26 August 2025, with props. My PR was closed. My fix was merged.

wordpress-seo/inc/class-my-yoast-api-request.phpphp
private function validate_response( $response ) {
	if ( isset( $response->url, $response->subscriptions ) && is_array( $response->subscriptions ) ) {
		return $response;
	}

	throw new WPSEO_MyYoast_Invalid_JSON_Exception(
		esc_html__( 'Not all needed fields are present.', 'wordpress-seo' )
	);
}

The status check is now $response_code === 200 and nothing else.

What this does not cover

What a failed check does to licensed features. After the fix, a bad response falls back to the default site information, which has no subscriptions. That default is cached like a real answer, for up to a day on most admin pages. It no longer fatals. It can still say “no subscriptions” for a while.

1 comment

  1. Joel Abreo Avatar
    Joel Abreo

    “trust, but verify” applies to API responses from billion dollar companies too 🙂

Leave a comment

Seen this one too, or think I got something wrong? Your email is not published, and a first comment waits for me to approve it.