Workflow 1.0: creating the workflow

So far, we’ve set up our custom post type, capabilities, and roles. We’ve used built-in WP Core functionality. We haven’t crossed any lines or forgotten to dot our i’s and cross our t’s.

Here’s where things get dirtier.

I want you to know right now, I know the Policy Library 1.0 workflow code isn’t great. It avoids the cardinal sin – hacking Core – but it still manages to hijack Core functionality and muddy the waters. So I present to you this code not as a shining example, but as the first way we managed to get our workflow set up. We’re open to suggestions – though again, if you’re following along, you know that we’re working on version 2.0 right now and it’s already a lot cleaner, so feedback on the new approach would be more helpful at this point.

Now that the fine print is out of the way, here’s what we did.

No-JS lockout

First, the ugliest part of the code. Because parts of the 1.0 workflow require a little jQuery intervention, we completely prevent anyone with JavaScript disabled from accessing wp-admin. I know – I feel dirty just typing this out, between locking out no-JS users and admitting I actually used inline styles on purpose. Bear with me here.

Still in our custom plugin:

add_action('admin_print_scripts', 'workflow_1_no_js_lockout');
function workflow_1_no_js_lockout() {
	echo '
	<noscript><div style="width:220px;padding:50px;margin:30px auto;font:bold 22px arial, sans-serif;background:#ffff00;color:#bb0000;">JavaScript is required.</div><meta http-equiv="refresh" content="0; URL=/"></noscript>

Disable Quick Edit

Again, not ideal, but to get the site up and running with limited resources, we disabled a little bit more of Core’s functionality.

add_filter('post_row_actions', 'workflow_1_disable_quick_edit', 10, 2);
function workflow_1_disable_quick_edit($actions = array(), $post = null) {
	if(isset($actions['inline hide-if-no-js'])) {
		unset($actions['inline hide-if-no-js']);
	return $actions;

We disabled Quick Edit because if a Policy Editor were to quick-edit a published policy, it would follow Core functionality and revert to “pending” status – just what we were trying to avoid. Also, our policies relied on quite a bit of postmeta, which isn’t surfaced in Quick Edit, so in our case it was best to just prevent people from using it at all. I don’t honestly know whether this same code would disable Quick Edit in a Block Editor world – this code was released long before Gutenberg was unleashed.

What about postmeta?

If you don’t rely much on postmeta, you can skip this section. But as I just mentioned, our policies rely on a lot of postmeta. It helps us keep the data structured. For example, each policy has a “Policy Approver” field. Now we could just include that heading and value within the post content itself, but if we ever decided to present that field differently, we’d have to go back and edit each and every policy. Not exactly ideal. So we created custom postmeta instead (in 1.0, using Advanced Custom Fields) to hold this data in a structured way.

WordPress Core saves revisions if you enable them, but it does not save any postmeta with the revisions. All postmeta gets saved only to the parent post. So, if one of our users changed the Policy Approver value at some point, we wouldn’t have any way to tell who changed it, or when. We needed the ability to audit who changed what when – not just the post content, but the metadata as well. So we found a solution that mostly worked.

There is a plugin called WP-Post-Meta-Revisions, which as of this writing works with both WordPress 4.x and 5.x. You must install the plugin and then explicitly enable versioning on each meta_key you want to keep revisions for. So, if you’re following along and you’d like to version some postmeta too, you’ll need to actually add that postmeta to our CPT and then add this code to version it:

add_filter('wp_post_revision_meta_keys', 'workflow_1_versioned_meta_keys');
function workflow_1_versioned_meta_keys() {
	global $versionedKeys;
	$versionedKeys[] = 'policy_type';
	return $versionedKeys;

The reason I say this “mostly worked” is because:

  • For the very first revision, the one that WordPress automatically creates when you publish a brand-new post, postmeta was not saved.
  • WP-Post-Meta-Revisions saves the metadata as a serialized array, so if you just want to save plain easily-queryable data, you can’t do that.

In Workflow 2.0, we’ve found a way to avoid both of these problems and also avoid requiring any additional plugins, but this is the best we could do for 1.0. It was better than not having any postmeta revisions at all.

And now, for the workflow!

Here’s where things get exciting. Remember how we made sure everyone accessing wp-admin had JavaScript enabled? Here’s why. If, and only if, the current user has “edit” capability on the current post type, but not “publish” capability, we change the text of the “Publish” heading and the “Publish” button on the post edit screen, and we send all the normal $_POST data not to WordPress – but to our own custom PHP script – for processing.

add_action('admin_footer-post.php', 'workflow_1_hijack_update_button');
function workflow_1_hijack_update_button() {
	$screen = get_current_screen();
	$cpt = $screen->post_type;
	$capability = 'publish_' . $cpt . 's';
	$editCapability = 'edit_' . $cpt . 's';
	/* Prevent Policy Editors from publishing */
		&& !current_user_can('publish_posts')
		&& ($cpt != 'acf-field' && $cpt != 'acf-field-group')
		&& (get_post_status() == 'publish' || get_post_status() == 'draft' || get_post_status() == 'private' || get_post_status() == 'inherit')
	) {
				function($) {
					/* Prevent 'restore backup' notice from appearing - this would allow editors to publish their changes */
					/* Change 'Publish' heading to 'Save Changes' */
					$('#submitdiv h2 span').text('Save Changes');
					/* Change 'Update' button text to 'Submit for Review' */
					$('#publish').val('Submit for Review');
					/* Remove the "minor-publishing" box so the policy can't be previewed and status can't be changed */
					/* Send entries to custom script */
					var siteUrl = '<?php echo get_site_url(); ?>';
					var pluginUrl = '/wp-content/plugins/workflow-1/workflow-1-update.php';
					var scriptUrl = siteUrl + pluginUrl;
					$('form#post').attr("action", scriptUrl);

If, on the other hand, the current user has publishing rights, nothing happens and the normal workflow carries on.

Down the rabbit hole

When a Policy Editor submits a policy for review, they’re submitting all the same post data (like the content, the excerpt, the slug, the postmeta) they would normally submit. We’ve just hijacked it with JavaScript and rerouted all that information to flow into our own script. Again, this is not the WordPress Way, nor likely the best way to accomplish our goals – but it does work.

Basically, we take all the information about the post and then manually insert it ourselves. Then, we insert an additional revision. This is the same thing WP Core does – except we’re using different post statuses. In Workflow 2.0, instead of hijacking everything and duplicating Core functionality, we use hooks to make sure everything goes into the database the way we want it to. Finally, since our custom script doesn’t trigger the normal post status transitions, we explicitly call our send-email function, we display a confirmation message on the screen so the Policy Editor knows their work here is done, and we remove the post lock so that the Policy Approver can immediately see the policy. If we didn’t remove the post lock, and the Policy Approver dove straight into WordPress to see the changes, they would encounter the “Policy Editor is currently editing this post. Would you like to take over?” screen and potentially not see the particular revision screen we’re trying to send them to.

Caveat: if this were a publicly-released plugin, we’d spend some time cleaning up things like hard-coded prefixes (instead of “wp_posts” in our MySQL statements, we’d use whatever prefix the user’s database is set up with).

global $wpdb;
/* Save the Policy Editor's version as a Revision - this way it's not published until approved */
$date = date("Y-m-d H:i:s");
$date_gmt = gmdate("Y-m-d H:i:s");
$post_author = get_current_user_id();
$post_name = $_POST['post_ID'] . '-revision-v1';
$guid = get_site_url() . '/' . $_POST['post_ID'] . '-revision-v1/';
$post_content = $_POST['content'];
$post_title = $_POST['post_title'];
$post_excerpt = $_POST['excerpt'];
$post_id = $_POST['post_ID'];
/* Build postmeta array */
/* ACF fields */
foreach($_POST['acf'] as $key => $value) {
	/* Each is saved twice: once like next_review:20160601 and once like _next_review:field_57cf121ad45ea */
	$acf_field_object = get_field_object($key);
	$meta_key1 = $acf_field_object['name'];
	$meta_value1 = $value;
	/* Strip hyphens; dates should only contain digits */
	if($meta_key1 == 'date_adopted' || $meta_key1 == 'next_review') { $meta_value1 = preg_replace('~\D~', '', $meta_value1); }
	$meta_key2 = '_' . $meta_key1;
	$meta_value2 = $key;
	/* Add to array */
	$newpostmeta[$meta_key1] = $meta_value1;
	$newpostmeta[$meta_key2] = $meta_value2;
/* Yoast fields */
foreach($_POST as $key => $value) {
	if(strpos($key, 'yoast') !== false) {
		/* Ignore "Meta Robots Advanced" because Policy Editors should not be editing and there is a lot of logic to build as it comes through as an array. */
		if(is_array($value)) {
			$value = '';
		} else {
			$key = '_' . $key;
		/* Save as post meta if there is a value. */
		if(!empty($value)) {
			$newpostmeta[$key] = $value;
/* Insert the post revision */
$args = array(
	'post_author' => get_current_user_id(),
	'post_date' => $date,
	'post_date_gmt' => $date_gmt,
	'post_content' => $post_content,
	'post_title' => $post_title,
	'post_excerpt' => $post_excerpt,
	'post_status' => 'inherit',
	'comment_status' => 'closed',
	'ping_status' => 'closed',
	'post_password' => '',
	'post_name' => $post_id . '-revision-v1',
	'to_ping' => '',
	'pinged' => '',
	'post_parent' => $post_id,
	'guid' => get_site_url() . '/' . $post_id . '-revision-v1/',
	'menu_order' => '',
	'post_type' => 'cipp',
	'meta_input' => $newpostmeta
$new_post_id = wp_insert_post($args);
if($new_post_id) {
	/* Change the post type to revision */
	$results = $wpdb->get_results($wpdb->prepare("UPDATE wp_posts SET post_type = 'revision' WHERE ID = %s", "$new_post_id"), OBJECT);
	/* Save the currently-published version as a Revision. This way the Policy Approver can access the updates by "reverting" even though it is not actually reverting, it is updating. */
	$post = get_post($post_id);
	$rawpostmeta = get_post_meta($post_id);
	$postmeta = array();
	foreach($rawpostmeta as $key => $value) {
		$postmeta[$key] = $value[0];
	$post_date = date("Y-m-d H:i:s", strtotime("+ 1 minutes"));
	$args = array(
		'post_author' => $post->post_author,
		'post_content' => $post->post_content,
		'post_title' => $post->post_title,
		'post_excerpt' => $post->post_excerpt,
		'post_status' => 'inherit',
		'comment_status' => 'closed',
		'ping_status' => 'closed',
		'post_password' => '',
		'post_name' => $post_id . '-revision-v1',
		'to_ping' => '',
		'pinged' => '',
		'post_parent' => $post_id,
		'guid' => get_site_url() . '/' . $post_id . '-revision-v1/',
		'menu_order' => '',
		'post_type' => $post->post_type,
		'meta_input' => $postmeta,
		'post_date' => $post_date,
		'post_modified' => $post_date
	$copy_id = wp_insert_post($args);
	$results = $wpdb->get_results($wpdb->prepare("UPDATE wp_posts SET post_type = 'revision' WHERE ID = %s", "$copy_id"), OBJECT);
	/* Send approvers an email */
	$post = get_post($new_post_id);
	$oldstatus = $_POST['original_post_status'];
	policy_library_send_review_email('pendingupdate', $oldstatus, $post);
	/* Display a success message */
	$url = admin_url();
	echo '
	<center><div style="border:1px solid #aaa;padding:20px;font:18px/24px arial,sans-serif;">
	You have successfully submitted your revisions for review.
	<a href="' . $url . '">Return to the dashboard</a>
	/* Remove post lock on the published post and all revisions */
	$allPostIds = $post_id;
	$allRevs = wp_get_post_revisions($post_id);
	foreach($allRevs as $key => $value) {
		$allPostIds .= ',' . $value->ID;
	$args = "DELETE FROM wp_postmeta WHERE meta_key = '_edit_lock' AND post_id IN ($allPostIds)";
	$results = $wpdb->get_results($wpdb->prepare("DELETE FROM wp_postmeta WHERE meta_key = '_edit_lock' AND post_id IN %s", "$allPostIds"), OBJECT);

Email notifications

At last, we’re done with the hinky code and back to the WordPress Way of doing things. There are actually several ways you could approach this step: you could use a hook such as “pending_to_publish”, which fires – as you might guess – when a post of any post type goes from pending to publish; you could use “publish_aap” (where “aap” is your post type) and that would fire whenever a post of that particular CPT is published. To keep all of our code neatly grouped, we ended up using the “transition_post_status” hook, which encompasses that and more.

add_action('transition_post_status', 'workflow_1_trigger_email', 10, 3);
function workflow_1_trigger_email($new_status, $old_status, $post) {
	/* Email type 1: policy has been submitted for review */
	/* There is no actual "pendingupdate" status. This value is passed by our custom Update script so we can tell it is a pending update rather than a pending brand-new policy. */
	if($new_status == 'pendingupdate' || $new_status == 'pending') {
		global $wpdb;
		/* Determine whether this is a new policy or an update */
		if($new_status == 'pendingupdate') { /* It's an update */
			$author = get_the_author_meta('display_name', $post->post_author); // Convert ID to Display Name
			$parent_id = $post->post_parent;
			$post_type = get_post_type($parent_id);
			$pe_date = $post->post_date;
			/* Find the revision whose timestamp is the latest - but before (older than) the $post (the Policy Editor revision) */
			$revisions = $wpdb->get_results("SELECT ID FROM wp_posts WHERE post_status = 'inherit' AND post_parent = \"$parent_id\" AND post_modified < \"$pe_date\" ORDER BY post_date desc LIMIT 1");
			$compare_id = $revisions[0]->ID;
			/* Link to the revision comparison screen */
			$previewUrl = get_site_url() . '/wp-admin/revision.php?from=' . $compare_id . '&to=' . $post->ID . '&policyrev=1';
		} else { /* It's a new policy */
			$author = get_the_modified_author($post->ID); /* Display Name */
			$post_type = $post->post_type;
			/* Link to preview */
			$previewUrl = get_site_url() . '/?post_type=' . $post_type . '&p=' . $post->ID;
		$adminUrl = get_admin_url();
		/* Get all users except Administrators with the ability to publish this post type */
		$query = array(
			'role__not_in' => array('administrator')
		$all_users = get_users($query);
		/* Limit to those with the ability to publish this post type */
		$cap = 'publish_' . $post_type . 's';
		foreach($all_users as $user) {
			if(user_can($user, $cap)) {
				$approvers[] = $user;
		$strSubject = 'StMU Policies - please review ' . $post->post_title;
		if(count($approvers) >= 1) { /* Email everyone with Approver capability. */
			$strTo = array();
			foreach($approvers as $approver) {
				$strTo[] = $approver->user_email;
		} else { /* Something went wrong and we have no Approvers, so email the site admin. */
			$strTo = array(get_bloginfo('admin_email'));
			$strSubject = 'Policy Library could not find an Approver';
		$strMessage = "$author has submitted a policy called \"" . $post->post_title . "\" for your review.\n\n" . '1. Please log into the Policy Library ( ' . $adminUrl . ' ).' . "\n\n2. Please review the policy ( " . $previewUrl . ' ).' . "\n\n3. Please make edits if necessary and publish the policy.";
		wp_mail($strTo, $strSubject, $strMessage);
	/* Email type 2: policy has been published */
	elseif($new_status == 'publish') {
		$permalink = get_permalink($post->ID);
		/* Get all users with the "CPT Editor" role. */
		$role = 'policy_editor_' . $post->post_type;
		$query = array(
			'fields' => array('user_email'),
			'role' => "$role"
		$editors = get_users($query);
		if(count($editors) >= 1) { /* Email everyone with Editor capability. */
			$strTo = array();
			foreach($editors as $editor) {
				$strTo[] = $editor->user_email;
		} else { /* Something went wrong and we have no Approvers, so email the site admin. */
			$strTo = array(get_bloginfo('admin_email'));
		/* Only email if it's a policy - not a nav menu item, or ACF configuration, etc. */
		if($post->post_type != 'post' && $post->post_type != 'page' && $post->post_type != 'acf' && $post->post_type != 'acf-field' && $post->post_type != 'acf-field-group' && $post->post_type != 'attachment' && $post->post_type != 'nav_menu_item' && $post->post_type != 'customize_changeset') {
			$strSubject = 'StMU Policies - ' . $post->post_title . ' has been published';
			$strMessage = "Your policy has been published or updated at $permalink .";
			wp_mail($strTo, $strSubject, $strMessage);

That’s a pretty long block of code to follow, but basically it comes in two parts:

  • If the new status is either “pending” or “pendingupdate” (which our custom PHP above set it to), send an email to the appropriate Policy Approver(s).
  • If the new status is “publish”, send an email to the appropriate Policy Editor(s).

This is where things went a little sideways. In the initial project scope, each office (CPT) would have a single Policy Editor and a single Policy Approver. But in the real world, we ended up with several people who needed access to more than one office (CPT). Because we were enforcing Single Sign-On, each person could only have one login, so we initially used the Multiple Roles plugin. That part worked great – you could add however many roles you needed to each user.

The part that didn’t work great was our Single Sign-On plugin, Authorizer, didn’t support multiple roles. Whenever one of these users logged in, Authorizer didn’t recognize their array of roles, and so they actually ended up being reset to no role on the site.

Authorizer’s developers were quite kind in helping set up logging and troubleshooting this issue, but in the end they determined that it was not possible for their plugin to support multiple roles (not the Multiple Roles plugin in particular, but a single WordPress user having multiple Roles via any means – it’s also possible to add multiple roles using User Role Editor, or with custom code).

The call to “get_users()” that pulls Policy Editors uses our initial code, which was much simpler. If the “Editor of Academic Affairs” also needed to edit University Communications policies, we could just use Multiple Roles to also grant them an “Editor of University Communications” role. As you can see above, you can specify a role in “get_users()” and get all the users with that role. Nice and easy.

However, once we realized each user could only have one role, we had to create new compound roles for some users – things like “Approver of Academic Affairs AND Approver of University Communications.” That’s why the call to “get_users()” that pulls Policy Approvers gets everyone except Administrators, then loops through to check whether each user has the ability to publish the current CPT. Searching for a role no longer works, because the combined role does not match “Approver of Academic Affairs” and it also does not match “Approver of University Communications”, even though the user can in fact do both of those capabilities.

So that’s our Workflow 1.0 plugin: it creates one CPT, adds roles and capabilities, and enforces a workflow in a quirky but effective way.

Next up, how we’re building Workflow 2.0 to handle the Block Editor (guess what, you can’t hijack the Publish button the same way anymore!), attempt to enforce the same workflow in a less-hacky way, and solve our almost-versioned postmeta problems.

This post is part of a series. Next up: Workflow 2.0: creating the CPTs and roles

Leave a Reply

Your email address will not be published. Required fields are marked *