A client came to us with a WooCommerce site in bad shape. Their pricing wasn’t one number. It carried rules for different user roles and order quantities, and the previous developer had scattered that logic across theme templates, a couple of AJAX handlers, and even a shortcode. Changing one pricing rule meant digging through all of it. The code wasn’t the problem. The structure was. They were using WordPress custom post types as plain data buckets, and it was costing them.
When you stuff everything into post meta, you’re treating WordPress like a database instead of a framework. The business logic, the rules that decide what a “product” is and how it behaves, gets buried. It’s a common beginner mistake, and it always ends in a mess.
Why helper functions aren’t enough
My first instinct was to refactor the mess into one helper function, something like my_get_product_price(). That does tidy things up. The logic lands in one place. But it’s a patch, not a fix. The data (the WP_Post object) and the logic (the function) still live apart. You end up passing a dozen arguments into these functions, and a “product” is still just a loose pile of data in the database.
Here’s the point: your business logic shouldn’t care about the database at all. I first saw this laid out on carlalexander.ca, and it stuck with me. You want “Entities,” plain PHP objects that stand for your business concepts. A Product entity, an Order entity, a Shipment entity. These classes hold the data and, more to the point, the rules.
Building a product entity
Instead of pulling post meta and running it through a function, you create a Product object that’s responsible for its own integrity. Say you want to change the price. You don’t call update_post_meta() directly. You call a method on the entity and let it handle the details.
class MyPlugin_Product
{
private $price;
/**
* Change the price of a product.
*
* @param mixed $new_price
*/
public function change_price($new_price)
{
if (!is_numeric($new_price) || $new_price < 0) {
throw new \InvalidArgumentException('Price must be a non-negative number.');
}
// The entity enforces its own rules.
$this->price = number_format((float) $new_price, 2, '.', '');
}
public function get_price()
{
return $this->price;
}
}
See that? The validation lives inside the change_price method, so the object protects its own state. At that point the custom post type is just a place to save the object. You write a bit of mapping code that takes your Product entity and stores its properties in the wp_posts and wp_postmeta tables. The entity has no idea how it’s being saved, and it doesn’t need to.
So, what’s the point?
Custom post types are more than a way to add another content type to the admin menu. They’re a built-in persistence layer. Define your business logic in self-contained, testable classes, your entities, then map those entities onto CPTs for storage.
- Your business logic is isolated: It’s all in one place, easy to find and change.
- Your code is testable: You can write unit tests for your
Productentity without ever touching the WordPress database. - Your system is flexible: If you decide to store products in a CSV file or an external API tomorrow, you change the mapping layer and nothing else. The entity stays the same.
This stuff gets complicated fast. If you’re tired of debugging someone else’s mess and you just want your site to work, drop my team a line. We’ve probably seen it before.
Get this right and you’ll build plugins and themes that are a hell of a lot easier to maintain, for you and for the next dev who inherits it.