Skip to content

Early-bird lifetime licence: $199 once, for the first 200 buyers only. See the offer

How to load Google Analytics and the Meta Pixel only after cookie consent (no page reload)

You’ve installed a cookie banner. It looks right, the visitor clicks Reject, and Google Analytics loads anyway. Or the opposite: they click Accept, and nothing is tracked until they open a second page, so you lose every visitor who only reads one.

Both problems come from the same place: the banner and the tracking code don’t talk to each other. This post shows three ways to connect them, from “no code” to a small snippet that works with any banner. You don’t need any particular plugin for any of it.

This is a technical guide, not legal advice. What your site needs depends on where your visitors are and what you track.

What “after consent, no reload” means

In the EU and UK, analytics and advertising cookies need the visitor’s consent first. For your scripts, that means three things:

  1. Before a choice, Google Analytics and the Meta Pixel don’t load at all. No request to Google or Meta.
  2. The moment they click Accept, the scripts load on the same page, without a reload. Otherwise the landing page view, often the most important one, is never counted.
  3. On every later page, the scripts load straight away, because the choice is remembered.

Google calls the first approach basic consent mode: the tags wait until the visitor agrees. (In advanced consent mode, the Google tag loads before consent and sends cookieless pings. That’s a separate decision, and this post doesn’t cover it.)

Option 1: Let the banner load the scripts

The simplest fix is to stop loading GA and the Pixel yourself and let the consent tool do it.

  • Complianz asks for your GA measurement ID in its wizard and loads the tag itself, after statistics consent. Other scripts go into its Script Center, with a category.
  • CookieYes and Cookiebot can scan your site and block known scripts automatically until the right category is accepted.

If you go this way, remove every other copy of the tag: the theme’s “header scripts” box, Site Kit, a WooCommerce extension, an old snippet. Duplicate tags are the most common reason GA still loads after Reject: the banner blocks its own copy and another plugin prints a second one.

Option 2: Mark the script so the banner holds it back

If you’d rather keep the tag in your theme or snippets plugin, you can mark it so the consent tool treats it as blocked. Each tool uses its own attribute (from their documentation, as of October 2026):

ToolStatistics (Google Analytics)Marketing (Meta Pixel)
Complianztype="text/plain" data-category="statistics"type="text/plain" data-category="marketing"
CookieYesdata-cookieyes="cookieyes-analytics"data-cookieyes="cookieyes-advertisement"
Cookiebottype="text/plain" data-cookieconsent="statistics"type="text/plain" data-cookieconsent="marketing"

type="text/plain" is the trick: the browser doesn’t run a script whose type isn’t JavaScript. When the visitor accepts, the consent tool switches it back and runs it.

For Google Analytics with Complianz, both parts of the tag need the marking:

<script type="text/plain" data-category="statistics" async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script type="text/plain" data-category="statistics">
	window.dataLayer = window.dataLayer || [];
	function gtag() { dataLayer.push( arguments ); }
	gtag( 'js', new Date() );
	gtag( 'config', 'G-XXXXXXXXXX' );
</script>

The downside: the snippet is tied to one banner. Switch from Cookiebot to Complianz, and every tag needs new attributes. Forget one, and it either never runs or always runs.

Option 3: One snippet for any banner (the WP Consent API)

The WP Consent API is a free plugin that gives every consent tool and every tracking script one shared language. Complianz, CookieYes and Cookiebot are all listed as supporting it on its WordPress.org page, as of October 2026, along with about twenty others. Install it, check that your banner’s integration is on, and your code only has to ask one question: wp_has_consent( 'statistics' ).

It also fires an event, wp_listen_for_consent_change, the moment the visitor makes a choice. That’s what makes “no reload” possible.

Here’s the snippet. Put it in a child theme’s functions.php, a must-use plugin, or a snippets plugin, and replace the two IDs:

<?php

/**
 * Load GA4 and the Meta Pixel only after cookie consent,
 * through the WP Consent API (works with any banner that supports it).
 */
add_action(
	'wp_footer',
	static function (): void {
		$config = array(
			'ga'    => 'G-XXXXXXXXXX',    // Your GA4 measurement ID, or '' for none.
			'pixel' => '000000000000000', // Your Meta Pixel ID, or '' for none.
		);

		$js = <<<'JS'
( function ( cfg ) {
	var done = {};

	function loadGa() {
		if ( done.ga || ! cfg.ga ) { return; }
		done.ga = true;
		window.dataLayer = window.dataLayer || [];
		window.gtag = window.gtag || function () { window.dataLayer.push( arguments ); };
		window.gtag( 'js', new Date() );
		window.gtag( 'config', cfg.ga );
		var s = document.createElement( 'script' );
		s.async = true;
		s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent( cfg.ga );
		document.head.appendChild( s );
	}

	function loadPixel() {
		if ( done.pixel || ! cfg.pixel || window.fbq ) { return; }
		done.pixel = true;
		var n = window.fbq = function () {
			n.callMethod ? n.callMethod.apply( n, arguments ) : n.queue.push( arguments );
		};
		window._fbq = window._fbq || n;
		n.push = n; n.loaded = true; n.version = '2.0'; n.queue = [];
		var s = document.createElement( 'script' );
		s.async = true;
		s.src = 'https://connect.facebook.net/en_US/fbevents.js';
		document.head.appendChild( s );
		window.fbq( 'init', cfg.pixel );
		window.fbq( 'track', 'PageView' );
	}

	function check() {
		if ( typeof window.wp_has_consent !== 'function' ) { return; } // No consent API: load nothing.
		if ( window.wp_has_consent( 'statistics' ) ) { loadGa(); }
		if ( window.wp_has_consent( 'marketing' ) ) { loadPixel(); }
	}

	// On page load (returning visitors), and the moment consent changes (no reload).
	document.addEventListener( 'DOMContentLoaded', check );
	document.addEventListener( 'wp_listen_for_consent_change', check );
} )( CONFIG );
JS;

		wp_print_inline_script_tag(
			str_replace( 'CONFIG', (string) wp_json_encode( $config, JSON_HEX_TAG | JSON_HEX_AMP ), $js )
		);
	},
	100
);

A few things worth knowing about it:

  • It fails closed. If the WP Consent API isn’t loaded, nothing is tracked. That’s the safe default for EU sites. If you see no data at all, check this first.
  • It loads each tag once, however many times consent changes, and it leaves the Pixel alone if another plugin already loaded one.
  • It works with page caching. Every visitor gets the same HTML, and the decision happens in the browser. A PHP check like if ( wp_has_consent( 'statistics' ) ) around the tag would break here: the first visitor’s choice would be cached and served to everyone.
  • The IDs are printed with wp_json_encode(), and wp_print_inline_script_tag() lets security plugins add a CSP nonce.

One limit: if a visitor withdraws consent, scripts that already ran stay in memory until the next page. Google and Meta both have consent commands (gtag( 'consent', 'update', … ) and fbq( 'consent', 'revoke' )) if you need to handle that on the same page.

How to test it (two minutes)

  1. Open the site in a private window, open DevTools → Network, and type collect|facebook into the filter (tick the regex option, or filter one word at a time).
  2. Before you choose: no requests to google-analytics.com, googletagmanager.com or facebook.com.
  3. Click Reject, browse two pages: still nothing.
  4. In a new private window, click Accept: the requests appear right away, on the same page.
  5. Reload: they load straight away, with no banner.

If GA appears before you’ve chosen anything, there’s a second copy of the tag somewhere. View the page source and search for your G- ID.

Where Snipfire helps

In Snipfire, the plugin I make, this is a setting instead of a snippet. Paste the Google Analytics 4 or Meta Pixel snippet from the free library, and with Snipfire Pro set Cookie consent to “Wait for: Statistics” or “Marketing”. The snippet stays inert until the visitor accepts that category, then runs on the spot, with no reload. It works with Complianz, CookieYes, Cookiebot, Borlabs Cookie, the WP Consent API and Google Consent Mode v2, with page caching, and it respects Global Privacy Control for marketing scripts.

But you don’t need it: Option 3 above does the job with any banner that supports the WP Consent API.

The short version

  • Load GA and the Pixel in one place only: duplicates are why tags ignore Reject.
  • Let your banner load them, or mark them with its attribute.
  • For a banner-independent setup, use the WP Consent API and the snippet above.
  • Test in a private window with the Network tab, before and after Accept.

Using a banner I didn’t mention, or seeing something odd in your tests? Tell me and I’ll take a look.

Antony, maker of Snipfire

How to fix “Cannot redeclare function” and the white screen after adding a snippet

You find a useful snippet, paste it into functions.php or a snippets plugin, click Save, and the site is gone. You get a blank white page, or WordPress’s “There has been a critical error on this website.”

I’ve done this myself more times than I’d like to admit. When it happens right after adding a snippet, one of the most common causes is a PHP fatal error called “Cannot redeclare”. It’s easy to fix once you know what it means. This post covers how to get back in, how to find the real error, and how to write snippets that can’t cause it again. You don’t need any particular plugin for any of it.

Step 1: Get back into your site

Don’t start deleting things. Pick the way back that matches where you added the code.

You edited a file over FTP or your host’s file manager. Open the same file and undo your change: remove the snippet or restore your backup copy. The site comes back as soon as the file is saved.

You used Appearance → Theme File Editor. Since WordPress 4.9, the built-in editor checks the site right after you save a PHP change and undoes the edit if it caused a fatal error (when your server allows that check). If the editor showed an error, your site is probably fine. Reload it to check.

You got the “critical error” message. Check the inbox of the site’s admin email address. Since WordPress 5.2, WordPress emails a recovery mode link when a plugin or theme causes a fatal error. Open the link and log in. The plugin or theme that broke is paused for you only, and you can fix or deactivate it. Note that recovery mode can only pause whole plugins or themes. It can’t pause a single snippet, and it doesn’t cover code in wp-content/mu-plugins/.

You used a snippets plugin. Most have a safe mode that loads the admin without running any snippets. As of October 2026:

  • WPCode: add ?wpcode-safe-mode=1 to your admin URL, for example https://example.com/wp-admin/?wpcode-safe-mode=1.
  • Code Snippets: add define( 'CODE_SNIPPETS_SAFE_MODE', true ); to wp-config.php. Logged-in admins can also add snippets-safe-mode=1 to the address.

Then switch off the snippet you just added. If nothing else works, rename the snippets plugin’s folder in wp-content/plugins/. WordPress deactivates it, and every snippet in it stops running until you rename it back.

Step 2: Read the real error

The “critical error” page hides the details, which is right for visitors but no help to you. Turn on WordPress’s debug log by adding these lines to wp-config.php, above the line that says “That’s all, stop editing!”:

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );

Reload the broken page, then open wp-content/debug.log. You’ll see something like this (paths shortened; the exact wording varies a little between PHP versions):

PHP Fatal error:  Cannot redeclare function show_last_updated()
(previously declared in /wp-content/themes/my-child/functions.php:14)
in /wp-content/plugins/some-plugin/snippets.php on line 3

That one line tells you everything: the function’s name, where it was declared first, and where PHP tried to declare it again.

When you’re done, set WP_DEBUG back to false, and delete debug.log. It sits at a well-known address, and on many servers anyone can open it.

Step 3: Understand why it happens

In PHP, every named function exists once for the whole request. Your theme, every plugin and every snippet share the same space. When two pieces of code both say function show_last_updated(), PHP stops at the second one with a fatal error.

After adding a snippet, the usual causes are:

  1. The same snippet in two places. It’s in your child theme’s functions.php and in a snippets plugin, often left over from an earlier attempt.
  2. A generic name. Tutorials love names like custom_excerpt_length() or add_custom_fonts(), and so do some themes and plugins.
  3. A function declared inside another function that runs twice (for example, inside a hook callback). The first run declares it, the second run crashes.
  4. A file loaded twice with include or require instead of include_once or require_once.

Syntax checkers like php -l won’t catch this, because each file is valid PHP on its own. The problem only appears when both are loaded together.

Step 4: Fix it

Take this typical snippet, which adds a “Last updated” line to blog posts:

<?php

function show_last_updated( $content ) {
	// …
}
add_filter( 'the_content', 'show_last_updated' );

You have four options, from best to least good.

1. Delete the duplicate. If the debug log shows the same code in two places, keep one. Done.

2. Use a closure, so there’s no name to clash. This is how I write every snippet now:

<?php

add_filter(
	'the_content',
	static function ( $content ) {
		if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
			return $content;
		}

		$updated = sprintf(
			/* translators: %s: the date the post was last changed. */
			__( 'Last updated: %s', 'my-site' ),
			get_the_modified_date()
		);

		return $content . '<p class="last-updated">' . esc_html( $updated ) . '</p>';
	}
);

A closure (an anonymous function) has no global name, so it can’t collide with anything, even if you paste the snippet twice. The one trade-off is that other code can’t remove it later with remove_filter(). For a site’s own snippets, that’s rarely a problem.

3. Give it a unique prefix. If you need a named function (for example, because another snippet calls it), prefix it with something only you would use, like your initials or the site’s name: acme_show_last_updated(). A clash then becomes very unlikely.

4. Wrap it in function_exists(), carefully.

<?php

if ( ! function_exists( 'acme_format_price' ) ) {
	function acme_format_price( float $amount ): string {
		return number_format_i18n( $amount, 2 );
	}
}

This stops the crash, but read it closely: if a function with that name already exists, yours is silently skipped and the other one runs instead, even if it does something completely different. Use it for helpers you deliberately share between your own snippets, not as a quick way to silence the error.

If the function is declared inside another function or hook, move the declaration out to the top level of the file (or turn it into a closure). If a file is included twice, switch to require_once.

The same rules apply to classes. “Cannot declare class X, because the name is already in use” has the same causes and the same fixes: delete the duplicate, use a unique prefix or namespace, or check with class_exists().

Step 5: Make the next snippet safer

  • Keep all your custom code in one place: a child theme, a must-use plugin or a snippets plugin, not a mix of all three.
  • Try new code on a staging copy first. Many hosts create one in a click.
  • Prefer closures for hook callbacks, and prefix anything that must have a name.
  • Keep a way back you’ve already tested: FTP access, your snippets plugin’s safe mode, or a backup you know how to restore.

Where Snipfire helps

I built Snipfire, a free snippets plugin, partly because of this exact error. When you save a PHP snippet, it checks the syntax and warns you if a function or class you’re declaring already exists, before anything runs. When you switch a snippet on, it test-loads your site first and switches the snippet straight back off if the site would crash. If a snippet causes a fatal error later, it’s switched off automatically, and you see the exact line. If all else fails, safe mode (a secret link, or define( 'SNIPFIRE_SAFE_MODE', true ); in wp-config.php) gets you back in. The Safety page shows how each step works.

You can try it in your browser without installing anything, or download it free. But everything above works with whatever you use today.

The short version

  • Get back in first: undo the file edit, use the recovery mode email, or use your snippets plugin’s safe mode.
  • Turn on WP_DEBUG_LOG and read debug.log. It names both places the function was declared.
  • Delete the duplicate, or rewrite the snippet as a closure so there’s no name to clash.
  • Use function_exists() only when you understand that it skips your version.

Stuck on an error that isn’t covered here? Send it to me and I’ll take a look.

Antony, maker of Snipfire

Why WordPress.org no longer accepts code-snippet plugins, and what we did instead

When I finished the first version of Snipfire, I did what every WordPress plugin author does: I zipped it up and got ready to submit it to the WordPress.org plugin directory. Then I read the Plugin Developer FAQ properly, and that plan was over before it started.

This post explains what the rule says, why it makes sense, what it means for you if you use or need a snippet plugin, and how I ship Snipfire instead. Most of it is useful even if you never install Snipfire.

What the rule actually says

The rule isn’t in the numbered plugin guidelines. It’s in the Plugin Developer FAQ on developer.wordpress.org, and as of October 2026 it reads:

“We do not accept new plugins that allow arbitrary code insertion or execution. Examples include PHP or JavaScript editors, file managers, and AI tools that generate code intended to be executed on the site.”

The same answer adds that HTML output is still fine, “provided it is properly escaped and handled securely”.

Three things are worth noticing:

  1. It’s about new plugins. WPCode (3+ million active installs) and Code Snippets (1+ million) are still listed and still updated, as of October 2026. If you already use one of them, nothing changes for you.
  2. It’s broad. It covers PHP and JavaScript editors, file managers, and AI tools that write code which then runs on the site. Snippet plugins are the obvious case, but not the only one.
  3. It’s a review rule, not a technical one. You can still install any GPL plugin you like from anywhere. The directory just won’t host new ones in this category.

Why the rule makes sense

The FAQ doesn’t spell out the reasoning, so this is my reading, as someone who builds one of these plugins.

A snippet plugin is, by design, a way to run code on a server through a web page. That’s the whole point, and it’s also the biggest risk in the plugin. If a snippet plugin has a permissions bug, an attacker doesn’t need to find a clever exploit: the plugin itself is the “run my code” button. That’s why vulnerabilities in this kind of feature tend to be rated critical: the damage is total.

The review team checks thousands of submissions with a small team. Reviewing a plugin whose job is to execute code safely is much harder than reviewing a contact form. Closing the category to new entries is a sensible trade-off for them, even if it’s a nuisance for people like me.

What it means for you

If you need a snippet plugin today, you have three honest options:

  • An established one from WordPress.org. WPCode, Code Snippets and others are still there and still maintained. Directory listing gives you automatic updates and the WordPress.org review history.
  • No plugin at all. Put your code in a must-use plugin (below). It’s the simplest, fastest option, and nothing can switch it off by accident.
  • A plugin from its author’s own site. That’s where any new snippet plugin has to live now, including mine. It can be perfectly fine, but you should check a few things first (see the checklist).

The no-plugin way: a must-use plugin

Create the folder wp-content/mu-plugins/ if it doesn’t exist, and add a file such as site-tweaks.php:

<?php
/**
 * Plugin Name: Site tweaks
 * Description: Small custom code for this site. Loaded automatically, can't be deactivated from the admin.
 * Requires PHP: 8.1
 */

defined( 'ABSPATH' ) || exit;

// Turn off XML-RPC if nothing on the site uses it (Jetpack and some mobile apps do).
add_filter( 'xmlrpc_enabled', '__return_false' );

// Replace "Thank you for creating with WordPress" in the admin footer.
add_filter(
	'admin_footer_text',
	static function ( string $text ): string {
		return esc_html__( 'Site built and maintained by Example Studio.', 'site-tweaks' );
	}
);

WordPress loads every PHP file in that folder on every request, before normal plugins, with no switch to turn it off. It’s also visible under Plugins → Must-Use.

The honest downside: there’s no safety net. A typo or a function name that already exists (Cannot redeclare…) gives you a white screen, and the only way back is FTP or your host’s file manager. Always test on a staging copy first, keep a backup of the file, and use closures (as above) instead of named functions, so two snippets can’t clash.

A checklist for plugins that aren’t on WordPress.org

This applies to any plugin you download from an author’s site, not just snippet plugins:

  1. Who makes it? A real name, a company, an address, a way to reach them. Anonymous plugins that run code on your server are a hard no.
  2. Is the code readable? WordPress plugins are GPL. Open the zip and look. Obfuscated or encoded PHP in a free plugin is a red flag.
  3. Does the file match what the author published? If the author lists a checksum, compare it:
# macOS
shasum -a 256 plugin.zip
# Linux
sha256sum plugin.zip
# Windows (PowerShell)
Get-FileHash .\plugin.zip -Algorithm SHA256

Note that wp plugin verify-checksums only knows about plugins hosted on WordPress.org, so it can’t check these for you.

  1. How does it update? Updates from outside WordPress.org need their own update checker. Find out what it sends home and how it makes sure an update really comes from the author.
  2. What does it send home? Read the privacy section or the About page. If the answer isn’t written down, ask.
  3. Is there a changelog and a security contact? Regular, specific changelog entries and a published way to report vulnerabilities tell you someone is looking after it.
  4. Try it somewhere safe first. A staging site, a local install, or a WordPress Playground demo in your browser.

What we did with Snipfire

We split it in two.

Snipfire Tweaks is on WordPress.org. It’s the part of our snippet library that doesn’t need you to write code: 71 ready-made changes as on/off switches (turn off XML-RPC, stop user-name listing, drop the emoji script, WooCommerce minimum order, and so on). No code editor, no user code, no outside requests, so it fits the rules. It went live on 1 October 2026 after review, and you can read every tweak’s code on our Tweaks page before you install it.

Snipfire itself, the editor, is free from snipfirewp.com. I’ve tried to answer every point in the checklist above on the site itself:

  • The Download page lists the SHA-256 checksum of every release.
  • From 0.17.0, every release is signed. Snipfire checks the signature before it installs an update and refuses one that doesn’t match, so even someone who took over our website couldn’t push code to your site. Updates otherwise appear under Dashboard → Updates like any other plugin.
  • It’s GPL, with no account, email address or licence key needed for the free version.
  • There’s a security page for reporting problems, and a public changelog.
  • You can try it in your browser first with the live demo, which runs WordPress Playground and nothing on our server.

And because a snippet plugin is the riskiest kind of plugin, the safety features are the core of it: code is syntax-checked before it’s saved, Snipfire warns before you redeclare a function that already exists, it test-loads your site when you switch a snippet on, and a snippet that causes a fatal error later is switched off automatically, with the exact line shown. Safe mode gets you back in if all else fails.

If you’re on WPCode, Code Snippets or another snippet plugin and happy with it, stay. If you’d like to try Snipfire, it imports your snippets switched off, so nothing on your site changes until you say so.

The short version

  • WordPress.org doesn’t accept new plugins that let you run arbitrary code. Existing ones stay.
  • For a handful of snippets, a must-use plugin is a fine, free answer. Test first.
  • For any plugin from outside WordPress.org: check who makes it, read the code, compare the checksum, and know how it updates.
  • Snipfire Tweaks is on WordPress.org; the Snipfire editor comes from snipfirewp.com, signed.

Questions, or think I’ve got the rule wrong? Get in touch. I read everything.

Antony, maker of Snipfire

Post by email in WordPress: how the built-in feature really works

People ask this every week: “My client gets a daily email (a newsletter, an AI news summary, a report). Can it go on the blog automatically?” The answers are usually “use Jetpack” or “use Zapier”. WordPress has had this built in since its early days. It’s just poorly explained.

Where it is

Settings → Writing → Post via email. You enter a mailbox’s POP3 server, port, login and password, and choose a category for emailed posts.

What it does, step by step

These come from reading core’s wp-mail.php and testing it against a real mailbox:

  1. It needs a secret mailbox. Every email that arrives there becomes a post. Use an address nobody can guess, and nothing else should be delivered to it.
  2. Nothing checks the mailbox by itself. WordPress only reads it when someone opens yoursite.com/wp-mail.php. Many people set it up, see nothing happen, and give up.
  3. It can publish straight away. If the sender’s address belongs to a user who can publish, the post goes live immediately. Otherwise it’s saved as Pending, with the admin as author.
  4. The subject becomes the title and the email body becomes the content.
  5. Formatting is basic. HTML emails are cut down to a few tags: paragraphs and bold survive, links, headings and lists don’t.
  6. It’s rate-limited to one check every 5 minutes (the WP_MAIL_INTERVAL constant).

Make it safe for automated email

For automated or AI-written content, you want a person to check every post before it goes live, and you want the mailbox checked without anyone visiting a URL. This snippet does both:

<?php

// Everything that arrives through Settings → Writing → Post via email is saved as a draft.
add_filter(
	'wp_insert_post_data',
	static function ( $data ) {
		if ( did_action( 'wp-mail.php' ) && 'post' === $data['post_type'] ) {
			$data['post_status'] = 'draft';
		}
		return $data;
	}
);

// Check the mailbox once an hour (WordPress never checks it by itself).
add_action(
	'init',
	static function () {
		if ( get_option( 'mailserver_login' ) && 'mail.example.com' !== get_option( 'mailserver_url' ) && ! wp_next_scheduled( 'check_post_by_email' ) ) {
			wp_schedule_event( time(), 'hourly', 'check_post_by_email' );
		}
	}
);
add_action(
	'check_post_by_email',
	static function () {
		wp_remote_get( site_url( 'wp-mail.php' ), array( 'timeout' => 30 ) );
	}
);

Add it to your child theme’s functions.php or a code snippets plugin, after filling in the Post via email settings.

  • Every emailed post is saved as a draft, whoever sent it.
  • The mailbox is checked every hour through WP-Cron. WP-Cron runs when the site gets visits. On a quiet site, or if your host blocks the site from calling itself, use a real server cron job (or a free cron service) to open https://yoursite.com/wp-mail.php every hour instead.

When to use something else

If the formatting matters (links, headings, images), use Zapier or Make: “new email → create WordPress post (draft)” keeps the HTML. Jetpack’s Post by Email is a hosted alternative. For everything else, the built-in feature plus two hooks is enough, and it’s free.

Tested with

WordPress 7.1 and a test POP3 mailbox: without the snippet, mail from an admin address was published and mail from an unknown address was pending. With it, both arrived as drafts, and the hourly event fetched the mail.

In Snipfire, this is “Posts sent by email wait as drafts” in the Posts and the editor library.

Use a YouTube thumbnail as the featured image (no plugin)

If you post a lot of videos, your blog list and related posts are full of empty boxes, unless you download every thumbnail and upload it as a featured image. WordPress itself has never done this for you. When thumbnails “just appeared” before, they came from a plugin (Jetpack’s Related Posts does it, for example). When that plugin changes, they disappear.

How it works

YouTube serves a thumbnail for every video at a predictable address: https://i.ytimg.com/vi/VIDEO_ID/hqdefault.jpg. The snippet finds the first YouTube video in the post (a YouTube embed, a youtu.be link or a Short) and, only when the post has no featured image, uses that thumbnail instead.

<?php

// The ID of the first YouTube video in a post, or ''.
$video_id = static function ( $post_id ) {
	$content = (string) get_post_field( 'post_content', $post_id );
	return preg_match( '~(?:youtube\.com/(?:watch\?(?:[^"\s<]*&(?:amp;)?)?v=|embed/|shorts/|live/)|youtu\.be/)([A-Za-z0-9_-]{11})~', $content, $match ) ? $match[1] : '';
};

// Classic themes ask "is there a featured image?" first.
add_filter(
	'has_post_thumbnail',
	static function ( $has, $post ) use ( $video_id ) {
		$post = get_post( $post );
		if ( $has || ! $post || is_admin() || ( is_singular() && get_queried_object_id() === $post->ID ) ) {
			return $has;
		}
		return '' !== $video_id( $post->ID );
	},
	10,
	2
);

add_filter(
	'post_thumbnail_html',
	static function ( $html, $post_id, $thumbnail_id, $size, $attr ) use ( $video_id ) {
		if ( '' !== $html || is_admin() || ( is_singular() && get_queried_object_id() === (int) $post_id ) ) {
			return $html; // A real featured image wins; on the post itself the video is already there.
		}
		$id = $video_id( $post_id );
		if ( '' === $id ) {
			return $html;
		}
		$attr  = is_array( $attr ) ? $attr : wp_parse_args( $attr );
		$style = empty( $attr['style'] ) ? '' : ' style="' . esc_attr( $attr['style'] ) . '"';
		return '<img src="' . esc_url( 'https://i.ytimg.com/vi/' . $id . '/hqdefault.jpg' ) . '" alt="' . esc_attr( get_the_title( $post_id ) ) . '" class="wp-post-image" width="480" height="360" loading="lazy" decoding="async"' . $style . '>';
	},
	10,
	5
);

Add it to your child theme’s functions.php or a code snippets plugin.

Details that matter

  • A real featured image always wins. The snippet only fills the gap.
  • Not on the post itself. There the video is already playing, so a thumbnail above it would be a duplicate. It shows in lists, related posts and query loops.
  • Block and classic themes. Block themes use the Featured Image block, which goes through post_thumbnail_html. Classic themes usually ask has_post_thumbnail() first, so the snippet answers that too.
  • CSS backgrounds won’t work. Themes that use the featured image’s URL as a background image read it in another way, so they won’t pick it up.
  • Privacy: the image loads from YouTube’s servers, so the visitor’s browser contacts Google. In the EU, mention it in your privacy policy.

Tested with

WordPress 7.1 with Twenty Twenty-Five (a query loop) and Storefront (the blog list): YouTube embeds and Shorts get thumbnails, posts without a video are untouched, and the video post’s own page doesn’t show the thumbnail.

In Snipfire, this is “YouTube thumbnail when there’s no featured image” in the Images and media library.

Let customers buy several sizes in one click (no plugin)

A WooCommerce variable product has one set of dropdowns and one quantity box. To buy two pairs in size 5 and four in size 7, a customer picks size 5, sets 2, adds to cart, goes back, picks size 7, sets 4, and adds to cart again. Wholesale buyers and parents shopping for a family give up.

The usual advice is to rebuild the product as a grouped product, which means turning 13 sizes into 13 separate products. Or buy a product-table plugin. You don’t need either.

What the snippet does

It replaces the dropdowns on the product page with a table: one row per size, with its price and its own quantity box. Sizes that are out of stock say so. One Add to cart button adds everything with a quantity above 0.

It uses WooCommerce’s own pieces throughout: its quantity boxes, cart, stock checks and “added to your cart” message. Stock limits, maximum quantities and other plugins’ add-to-cart checks still apply.

The code

<?php

// Product IDs that get the grid, e.g. array( 12, 34 ). Leave it empty for every variable product.
$only_products = array();

$grid = static function ( WC_Product_Variable $product ) {
	$attributes = $product->get_variation_attributes();
	$heading    = 1 === count( $attributes ) ? wc_attribute_label( (string) array_key_first( $attributes ), $product ) : __( 'Product', 'woocommerce' );

	$rows = '';
	foreach ( $product->get_children() as $variation_id ) {
		$variation = wc_get_product( $variation_id );
		if ( ! $variation instanceof WC_Product_Variation || ! $variation->variation_is_visible() ) {
			continue;
		}
		$quantity = esc_html__( 'Out of stock', 'woocommerce' );
		if ( $variation->is_purchasable() && $variation->is_in_stock() ) {
			$quantity = woocommerce_quantity_input(
				array(
					'input_name'  => 'size_grid_qty[' . $variation_id . ']',
					'input_value' => 0,
					'min_value'   => 0,
					'max_value'   => $variation->get_max_purchase_quantity(),
				),
				$variation,
				false
			);
		}
		$rows .= '<tr><td>' . esc_html( wc_get_formatted_variation( $variation, true, false ) ) . '</td><td>' . wp_kses_post( $variation->get_price_html() ) . '</td><td>' . $quantity . '</td></tr>';
	}

	return '<form class="size-grid" method="post" action="' . esc_url( get_permalink( $product->get_id() ) ) . '">'
		. '<table class="shop_table"><thead><tr><th>' . esc_html( $heading ) . '</th><th>' . esc_html__( 'Price', 'woocommerce' ) . '</th><th>' . esc_html__( 'Quantity', 'woocommerce' ) . '</th></tr></thead>'
		. '<tbody>' . $rows . '</tbody></table>'
		. '<input type="hidden" name="size_grid_product" value="' . absint( $product->get_id() ) . '">'
		. '<button type="submit" class="single_add_to_cart_button button alt">' . esc_html__( 'Add to cart', 'woocommerce' ) . '</button></form>';
};

// Swap the dropdowns for the grid on product pages.
add_action(
	'wp',
	static function () use ( $grid, $only_products ) {
		if ( ! function_exists( 'is_product' ) || ! is_product() ) {
			return;
		}
		$product = wc_get_product( get_queried_object_id() );
		if ( ! $product instanceof WC_Product_Variable || ( $only_products && ! in_array( $product->get_id(), $only_products, true ) ) ) {
			return;
		}
		remove_action( 'woocommerce_variable_add_to_cart', 'woocommerce_variable_add_to_cart', 30 );
		add_action(
			'woocommerce_variable_add_to_cart',
			static function () use ( $grid, $product ) {
				echo $grid( $product ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped when built.
			},
			30
		);
	}
);

// Add every size with a quantity above 0 to the cart.
add_action(
	'wp_loaded',
	static function () {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Like WooCommerce's own add-to-cart form, so it works on cached pages.
		if ( ! isset( $_POST['size_grid_product'], $_POST['size_grid_qty'] ) || ! function_exists( 'WC' ) || null === WC()->cart ) {
			return;
		}
		$product_id = absint( $_POST['size_grid_product'] );
		$quantities = map_deep( (array) wp_unslash( $_POST['size_grid_qty'] ), 'sanitize_text_field' );
		// phpcs:enable
		$added = array();
		foreach ( $quantities as $variation_id => $qty ) {
			$variation_id = absint( $variation_id );
			$qty          = is_scalar( $qty ) ? wc_stock_amount( $qty ) : 0;
			$variation    = $qty > 0 ? wc_get_product( $variation_id ) : null;
			if ( ! $variation instanceof WC_Product_Variation || $variation->get_parent_id() !== $product_id ) {
				continue;
			}
			$chosen = $variation->get_variation_attributes();
			if ( ! apply_filters( 'woocommerce_add_to_cart_validation', true, $product_id, $qty, $variation_id, $chosen ) ) {
				continue;
			}
			if ( false !== WC()->cart->add_to_cart( $product_id, $qty, $variation_id, $chosen ) ) {
				$added[ $variation_id ] = $qty;
			}
		}
		if ( ! $added ) {
			if ( ! wc_notice_count( 'error' ) ) {
				wc_add_notice( 'Please enter a quantity for at least one size.', 'error' );
			}
			return;
		}
		wc_add_to_cart_message( $added, true );
		wp_safe_redirect( 'yes' === get_option( 'woocommerce_cart_redirect_after_add' ) ? wc_get_cart_url() : get_permalink( $product_id ) );
		exit;
	},
	20
);

Add it to your child theme’s functions.php or a code snippets plugin.

  • Only some products? Put their IDs in $only_products, e.g. array( 12, 34 ). Empty means every variable product.
  • Block themes: it works with the standard Single Product template (WooCommerce’s Add to Cart with Options block), which we tested with Twenty Twenty-Five.
  • “Any size” variations won’t work in a grid. Each row needs its own value, which is how size ranges are normally set up.

Why there’s no nonce

Forms usually carry a security token (nonce). WooCommerce’s own Add to cart form doesn’t, because adding to a cart isn’t a privileged action, and a token would break on cached product pages. This form follows WooCommerce. It still checks that every size belongs to the product being added.

Tested with

WordPress 7.1, WooCommerce 11.1, on Storefront and Twenty Twenty-Five, in a real browser: grid rendering, out-of-stock rows, adding two sizes at once, the cart contents, empty and invalid submissions.

In Snipfire, this is “Order several sizes at once” in the WooCommerce library. It arrives switched off so you can choose the products first.

WordPress 7.0 locked your patterns. Here’s how to edit them freely again

Since WordPress 7.0, when you insert a pattern, you can change its text and images but not much else. To move a block, change a colour or add something new, you first click Edit pattern (or Detach). For sites built from patterns, that’s a lot of extra clicks, and plenty of people on the support forums thought their site was broken.

What changed

WordPress 7.0 treats an inserted (unsynced) pattern as one section. Inside it, blocks are in “content only” mode: text and media can be changed, layout and design can’t. The idea is to stop people from accidentally breaking a carefully designed layout. That makes sense for clients, but it’s slow for the person who builds the site.

Synced patterns are a different thing. They’ve always needed “Edit original”, because changing one changes them everywhere.

The quick way, without code

Double-click inside the pattern. That opens it for full editing, without the toolbar button. It works per pattern, every time.

The setting that switches it off

WordPress 7.0 includes an official opt-out for this, from the dev note “Pattern editing in WordPress 7.0” on make.wordpress.org. It’s an editor setting called disableContentOnlyForUnsyncedPatterns. Turn it on with this filter:

<?php

add_filter(
	'block_editor_settings_all',
	static function ( $settings ) {
		$settings['disableContentOnlyForUnsyncedPatterns'] = true;
		return $settings;
	}
);

Add it to your child theme’s functions.php, a code snippets plugin, or a file in wp-content/mu-plugins/. It has to be PHP that runs on the server, so pasting it into a Custom HTML block won’t work.

With it on, clicking into an inserted pattern selects the block you clicked, fully editable, like before 7.0.

What it doesn’t change

  • Synced patterns still need “Edit original”. That’s on purpose.
  • Template parts (header, footer) behave as before.
  • Patterns you’ve already inserted are fine. Nothing in your content changes; only the editor behaviour does.

Want a switch your clients can’t break?

If other people edit the site, you may want the 7.0 behaviour for them and direct editing for yourself. A snippet plugin with a per-snippet on/off switch handles that. Or add a check such as current_user_can( 'manage_options' ) inside the filter so it applies to administrators only.

In Snipfire, this is “Edit patterns freely again” in the snippet library, and the snippet’s switch is your on/off toggle.