Blog
PublishedJune 20268 min read

Your Laravel App Keeps Growing Columns. WordPress Solved This 15 Years Ago.

How I rebuilt the Entity-Meta pattern into a Laravel package - and what it taught me about vertical scaling, pivot tables, and building for small business.

LaravelArchitectureDatabase DesignPackagesScaling
Your Laravel App Keeps Growing Columns. WordPress Solved This 15 Years Ago.

You're building a Laravel application for a small business. It starts simple - users, products, orders. Then the client asks: can we add a GST number to each customer? Can products have a warranty period field? Can orders track a preferred delivery time slot?

The naive approach: add a column for each. Three months later you have 40 columns per table, half of them NULL for most rows, and every new business requirement means a new migration. This is the problem the Entity-Meta pattern solves. And WordPress figured this out over a decade ago.

What WordPress Actually Got Right

WordPress has four core tables - posts, users, terms, comments. For each, there's a corresponding meta table: wp_posts + wp_postmeta, wp_users + wp_usermeta. The meta table is always the same shape: (meta_id, object_id, meta_key, meta_value).

Every plugin, theme, and page builder stores flexible attributes there without touching the schema. A new plugin doesn't create new columns - it adds new meta keys. This is why you can install WooCommerce and suddenly products have prices, weights, and dimensions without schema collision between plugins. The WordPress Codex database description and the official get_post_meta() reference both reflect how central metadata is to the system. As of July 26, 2026, W3Techs reported WordPress on 41.2% of all websites and 59.1% of websites whose CMS it could identify. That scale does not prove the meta pattern alone caused WordPress adoption, but it does show the model has held up in a very large ecosystem.

WordPress is used by 41.2% of all the websites.

W3Techs, WordPress market report, July 26, 2026

The Laravel Package Implementation

The Tables

The foundation is two tables. The entities table holds the core object with fixed, always-queried columns. The entity_meta table holds flexible key-value pairs.

sql
-- entities: the core object
CREATE TABLE entities (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    type        VARCHAR(100) NOT NULL,
    name        VARCHAR(255),
    status      VARCHAR(50) DEFAULT 'active',
    created_at  TIMESTAMP,
    updated_at  TIMESTAMP
);

-- entity_meta: flexible key-value store
CREATE TABLE entity_meta (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    entity_id   BIGINT UNSIGNED NOT NULL,
    meta_key    VARCHAR(191) NOT NULL,
    meta_value  LONGTEXT,
    FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
    INDEX idx_entity_meta (entity_id, meta_key)
);

Two tables. That's the foundation. The type column on entities is your namespace - user, product, order, whatever your application needs. The composite index on (entity_id, meta_key) is what keeps lookups fast at scale.

The HasEntity Trait

The trait is what makes this installable rather than a copy-paste pattern. Any model gets meta storage by adding one line.

php
trait HasEntity
{
    public function entity(): MorphOne
    {
        return $this->morphOne(Entity::class, 'entityable');
    }

    public function getMeta(string $key, mixed $default = null): mixed
    {
        return $this->entity?->meta
            ->firstWhere('meta_key', $key)
            ?->meta_value ?? $default;
    }

    public function setMeta(string $key, mixed $value): void
    {
        $this->entity()->firstOrCreate([])->meta()->updateOrCreate(
            ['meta_key' => $key],
            ['meta_value' => $value]
        );
    }
}
php
class User extends Authenticatable
{
    use HasEntity;
}

// Usage - no migrations, no new columns
$user->setMeta('gst_number', 'GST123456789');
$user->setMeta('preferred_delivery', 'morning');

$gst = $user->getMeta('gst_number'); // "GST123456789"

Relationships: One-to-Many and Many-to-Many

One-to-Many: Standard Eloquent Still Applies

For simple parent-child relationships, standard Eloquent foreign keys still apply. A User has many Orders - that's a user_id on the orders table. The entity-meta pattern doesn't replace this. It handles flexible attributes, not structured relational data.

Many-to-Many: The Pivot Table Pattern

For many-to-many relationships, the package follows the same pattern that Spatie's laravel-permission package popularized - model_has_roles, model_has_permissions. For every many-to-many entity relationship, a pivot table is created with a polymorphic structure:

sql
CREATE TABLE model_has_tags (
    entity_id   BIGINT UNSIGNED NOT NULL,
    model_type  VARCHAR(255) NOT NULL,
    model_id    BIGINT UNSIGNED NOT NULL,
    PRIMARY KEY (entity_id, model_type, model_id),
    INDEX idx_model (model_type, model_id)
);

The model_type + model_id columns make this polymorphic - the same pivot table works for User-has-tags, Product-has-tags, and Order-has-tags. No separate tables per model pair. One table, every model that needs it.

The HasRoles trait must be added to the User model.

Spatie laravel-permission docs
php
// Attach tags to any model
$user->attachTag('vip');
$product->attachTag('featured');

// Query all VIP users
User::withTag('vip')->get();

Why This Works for Small Applications

A small business application has two requirements that pull in opposite directions: defined structure (things must be stored reliably and queryable) and flexible attributes (every business tracks different things). Column-per-attribute satisfies the first but breaks the second. JSON columns satisfy the second but make the first harder - you cannot index inside a JSON column efficiently without generated columns.

The entity-meta pattern satisfies both. Fixed, indexed columns for things that are always queried (type, status, name). Meta table for variable attributes - meta_key + entity_id indexed, so lookups are fast. Pivot tables for relationships - clean, queryable, no array serialization.

For a small retail business: products get base attributes (name, price, stock) and use meta for anything custom (warranty period, country of origin, clearance flag) - without a developer involved for every new field type. This is exactly what WordPress gave non-technical users. This package gives it to Laravel applications.

Vertical vs Horizontal Scaling: Where This Pattern Fits

This is the architectural decision most tutorials skip entirely, so let's be direct about it.

Vertical Scaling

Vertical scaling means making your single server bigger - more CPU, more RAM, faster storage. I still think that is the right default for most small-to-medium applications. The entity-meta pattern is generally well-suited to that approach because queries stay inside one database boundary and the hot lookup path is usually entity plus key. But the exact ceiling depends on indexing, query shape, caching, row width, and workload. The earlier version of this article stated specific row counts and VPS pricing as if they were universal benchmarks; they are better treated as design intuition than as measured guarantees.

Horizontal Scaling

Horizontal scaling means distributing across multiple servers - read replicas, sharding, distributed caches. This is where the entity-meta pattern needs attention. EAV queries are harder to shard because querying WHERE meta_key = 'gst_number' AND meta_value = 'GST123' doesn't split cleanly across shards by entity_id alone. Read replicas help - reporting queries that scan meta rows can go to a replica. Redis caching bridges the gap for hot meta values that are frequently read and rarely changed.

VerticalHorizontal
ApproachBigger single serverMultiple servers
ComplexityLowHigh
Cost modelPredictableScales with traffic
Entity-meta fitExcellentNeeds caching layer first
Right forMost small-medium appsHigh-traffic SaaS
When to chooseUntil you hit the ceilingWhen vertical isn't enough

The practical implication, in my experience, is that this pattern can let a small application stay vertical longer than many teams expect. When you genuinely need horizontal scale, caching and read replicas are usually the first pressure-relief valves to try before redesigning the schema. That is an engineering judgment, not a universal law, but it is the tradeoff I would start with for this kind of system.

The mistake most developers make is reaching for horizontal architecture before exhausting vertical options. A properly indexed server with a Redis cache layer handles more traffic than most small business applications will ever generate.

What I Learned Building It

Two things surprised me during implementation. First, eager loading is non-negotiable. Without with('entity.meta') on queries, you hit N+1 problems immediately. The trait handles this internally, but it's something to be aware of when writing custom queries outside the trait.

Second, meta keys need discipline. The flexibility of key-value storage is also its risk. If different parts of your codebase store gst_number sometimes and gst-number other times, you'll have silent inconsistencies. Define meta key constants in a dedicated class and reference those - never use raw strings scattered across the codebase.

Summary

The entity-meta pattern is not new - WordPress proved it at internet scale. What this Laravel package does is bring that architecture into a composer-installable, trait-driven module that any Laravel application can use. For small business applications it means flexible attributes without schema migrations, polymorphic many-to-many relationships via pivot tables, and a foundation that scales vertically before requiring distributed complexity. It's the kind of architecture decision that looks obvious in hindsight but saves weeks of rework on real projects.