I Gave a WordPress Taxonomy an Expiration Date Using BerlinDB
I had one of my clients come to me with a fun challenge. They wanted to be able to "flag" a post, and have that flag automatically disappear on the site after a certain period of time. The idea is to use it like tags, but they wanted it to only be set for a short period of time. The use case is to be able to set something like "breaking news" on a post, and have it automatically remove the "breaking news" status after a few hours. They also wanted to be able to manually set the expiration date of each flag from within the post editor.
Forming The Approach
Before I jumped in, I needed to take a second to make sense of how to accomplish what they've requested. I needed to answer these questions:
- How the customer add or remove new flags?
- How can the customer specify the expiration date for a flag?
- How can we fetch posts that have an active flag?
- How can we fetch the flags for a given post?
- How can we remove the flag after it has expired?
Flag Data Structure
After a little bit of back-and-forth, we determined the best way to handle this was with a custom WordPress taxonomy. This approach had many benefits that made it a clear choice:
- Any number of flags can be created from the WP Dashboard.
- Content can be queried by these flags using WP_Query.
- The content could be turned into an archive page quickly.
This was the most-obvious part of this entire project, and didn't take much consideration to choose.
Expiring The Flags
Originally, we had discussed the idea of creating a WP Cron job that runs behind the scenes and automatically un-sets flags after their expiration date passed. This seemed like a straightforward choice that would solve the problem. Since the flags would automatically un-set, all of the queries would automatically work, no questions asked. There's a few things I didn't like about this approach, however:
- It requires that we use WP_Cron, which can be unreliable.
- The accuracy of the expiration time was never going to be accurate because the cron would only run, at most, once every 10 minutes or so
- There's extra overhead and load that comes with running the cron job every 10 minutes, when in most cases nothing would actually happen.
Instead, I suggested that we extend the various query objects in WordPress to make it possible to filter out flags that have expired. This means that the flag isn't unset, but instead is given an expiration date that we can query against, thus filtering the expired content from the results on the site. This prevented us from needing to run a WP_Cron job, and made the expiration time a lot more accurate.
Storing the Expiration Date
Now for the tricky part - how do we actually do that? It's simple enough to hook into WP_Query and WP_Term_Query to customize the query that ultimately runs, but what does that query actually look like? I realized that the actual question I was asking here was "where should the data for the expiration date live?"
The customer wanted to be able to specify the expiration date for a flag in the post interface, and wanted it to be able to be different for any given post. Because of this, I figured post meta seemed like an obvious choice. This, however, proved to be a bad choice, I need to query against this meta field not only with posts, but also with the taxonomy terms. In other words, I needed to be able to associate this expiration date with both the post and the flag (term) at the same time. WordPress doesn't really have a good place to add such a thing natively.
The conclusion? A new database table specifically for this expiration date relationship. With such a table, I would be able to make any query needed to fetch the data.exactly like WordPress queries, and natively handles caching the WordPress way, which, in this case, is exactly what I needed.
This called for one of my favorite open source utilities that I've had the pleasure of contributing to, BerlinDB. BerlinDB was a perfect choice for this because it works exactly like WordPress, and does a lot of things for you, like caching.
The Block Editor Interface
Most of the interface bits were already in-place. Since this is technically just a taxonomy, I was able to lean on WordPress for the entire flag creation interface.
The only thing that this approach would require is a customization to the sidebar panel containing the flag taxonomy interface. WordPress automatically assumes that it needs to work exactly like a tag, but for us that's not quite right.
Fetching Flagged Posts
With the approach outlined above, I was able to customize WP_Query to have a custom argument, called feature_flags. With a couple well-placed hooks, this would automatically cause WP_Query to introduce a JOIN statement with the expiration table, and filter the results that have expired automatically.
$posts = new WP_Query([ 'feature_flags' => [ 'slugs' => ['breaking'] ] ]);
I just love how this feels native. If you're a WordPress developer who has never used what I've built here, you'd still have a pretty good idea of what it does just by looking at it.
Fetching a Post's Flags
To accomplish this using my custom table, I needed to basically do what I just did with WP_Query, only instead with the WP_Term_Query class. Again, a couple hooks, and a quick JOIN, and this filters out the flags that are not expired. The 'expired' argument allows you to specify the expiration date to set as the cutoff. WIthout this, it just gets all of the terms like it normally does.
$flags = wp_get_post_terms( post_id: 123, taxonomy: 'flags', args: ['expiration' => 'now'] );
The Block Interface
This was the easiest customization I had to make, this time around. The block editor has an extensive set of components, and I was able to lean entirely on them to build out a nice little interface that makes it possible to choose any number of flags, and set the expiration date for each one. I ended up doing something a lot like what I show in my beer plugin course, where I customize the "style" taxonomy to be a select box. In this case, though, I just replaced it entirely with a list of checkboxes that includes a way to set the expiration date using the WP time picker component. I was so excited by how well this worked.
As soon as you involve taxonomies in WordPress, things get complex with the database. It's one of the few (perhaps only?) many-to-many relationship that exists in WordPress, and working with it usually results in needing to have the context of both the post, and the term, which is exactly what happened here. Overall though, I think this came out really well, and I think the customer is going to get a lot of use out of this feature.