Stop Treating Custom Post Types Like Data Dumps

A client came to us with a WooCommerce site that was a total mess. Their product pricing wasn’t just a simple number; it had layers of rules for different user roles and order quantities. The previous developer had scattered this logic everywhere. In theme templates, in random AJAX handlers, even in a shortcode. Changing a simple pricing rule was a nightmare. This wasn’t a code problem; it was a structure problem. They were using WordPress custom post types as simple data buckets, and it was costing them dearly.

When you just stuff data into post meta, you’re treating WordPress like a database, not a framework. The real business logic—the rules that define what a “product” is and how it behaves—gets lost in the shuffle. It’s a classic rookie move, and it always leads to chaos.

Why Helper Functions Aren’t Enough

My first thought was to just refactor the mess into a single helper function, like my_get_product_price(). And yeah, that cleans things up a bit. At least the logic is in one place. But it’s a patch, not a fix. The data (the WP_Post object) and the logic (the function) are still completely separate. You end up with functions that take a dozen arguments, and the core concept of a “product” is still just a loose collection of data in the database.

Here’s the kicker: your business logic shouldn’t care about the database. At all. A concept I first saw detailed over at carlalexander.ca really drives this home. You need to create “Entities”—plain PHP objects that represent your business concepts. A Product entity, an Order entity, a Shipment entity. These classes hold the data and, more importantly, the rules.

Building a Product Entity

Instead of fetching post meta and running it through a function, you create a Product object. This object is responsible for its own integrity. For example, if you want to change the price, you don’t just call update_post_meta(). You call a method on your entity. Trust me on this. It’s the right way to do it.

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? All the validation logic lives inside the change_price method. The object protects its own state. Now, the custom post type becomes nothing more than a way to *persist* (a fancy word for save) this object. You can write simple mapping code that takes your Product entity and saves its properties to the wp_posts and wp_postmeta tables. The entity itself doesn’t know or care how it’s being saved.

So, What’s the Point?

Stop thinking about custom post types as just a way to add more content types to the admin menu. Start seeing them for what they are: a built-in persistence layer. Your job is to define your business logic in self-contained, testable classes (entities). Then, you simply map those entities to CPTs for storage.

  • Your business logic is isolated: It’s in one place, easy to find and modify.
  • Your code is testable: You can write unit tests for your Product entity without ever touching the WordPress database.
  • Your system is flexible: If you decide to store your products in a CSV file or an external API tomorrow, you just change the mapping layer. The entity itself doesn’t change.

Look, this stuff gets complicated fast. If you’re tired of debugging someone else’s mess and 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 down the line.

Leave a Reply

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