PostgreSQL JSONB in Laravel: Indexing, Querying, and Schema-less Columns Done Right
#laravel #postgresql #eloquent #jsonb

PostgreSQL JSONB in Laravel: Indexing, Querying, and Schema-less Columns Done Right

3 min read Mohamed Said Mohamed Said

Why JSONB Belongs in Your Laravel Toolkit

PostgreSQL's jsonb type stores JSON as a decomposed binary, making it faster to query than plain json. When your domain has genuinely variable attributes — product metadata, feature flags per tenant, user preferences — a jsonb column beats an EAV table or a pile of nullable columns. The catch: most Laravel codebases use it without indexes, then wonder why queries crawl at 100k rows.

Migration: Declaring the Column

Schema::table('products', function (Blueprint $table) {
    $table->jsonb('attributes')->default('{}');
});

Always default to '{}' rather than null — it simplifies whereJsonContains logic and avoids null-coalescing in every query.

GIN Indexes: The Non-Negotiable Step

A full-column GIN index lets PostgreSQL answer containment (@>) and existence (?) operators in milliseconds:

CREATE INDEX idx_products_attributes_gin
    ON products USING GIN (attributes);

In a migration:

DB::statement(
    'CREATE INDEX idx_products_attributes_gin ON products USING GIN (attributes)'
);

For queries on a specific key, a functional B-tree index is cheaper:

CREATE INDEX idx_products_brand
    ON products ((attributes->>'brand'));

Use GIN for "does this document contain this sub-object?" and functional B-tree for equality on a known key.

Querying Through Eloquent

Laravel's query builder wraps the most common JSONB operators cleanly:

// Containment: find products where attributes includes {"color": "red"}
Product::whereJsonContains('attributes->color', 'red')->get();

// Key existence (raw, no built-in helper)
Product::whereRaw("attributes \? 'warranty'");

// Nested path
Product::whereJsonContains('attributes->dimensions->unit', 'cm')->get();

// Ordering by a JSONB key
Product::orderByRaw("attributes->>'price_usd' DESC NULLS LAST")->get();

whereJsonContains compiles to the @> operator, which the GIN index can satisfy. Raw ? queries also hit the GIN index. Avoid ->>'key' LIKE '%value%' — it forces a sequential scan.

Custom Cast: Typed Value Object from JSONB

Raw arrays leak into your domain. A cast converts the column to a value object on read and back to JSON on write:

final class ProductAttributes
{
    public function __construct(
        public readonly string $brand,
        public readonly string $color,
        public readonly ?float $weightKg = null,
    ) {}

    public static function fromArray(array $data): self
    {
        return new self(
            brand: $data['brand'] ?? '',
            color: $data['color'] ?? '',
            weightKg: isset($data['weight_kg']) ? (float) $data['weight_kg'] : null,
        );
    }

    public function toArray(): array
    {
        return array_filter([
            'brand'     => $this->brand,
            'color'     => $this->color,
            'weight_kg' => $this->weightKg,
        ], fn ($v) => $v !== null);
    }
}
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class ProductAttributesCast implements CastsAttributes
{
    public function get($model, $key, $value, $attributes): ProductAttributes
    {
        return ProductAttributes::fromArray(
            is_string($value) ? json_decode($value, true) : ($value ?? [])
        );
    }

    public function set($model, $key, $value, $attributes): string
    {
        $array = $value instanceof ProductAttributes
            ? $value->toArray()
            : (array) $value;

        return json_encode($array, JSON_THROW_ON_ERROR);
    }
}

Register it on the model:

protected $casts = [
    'attributes' => ProductAttributesCast::class,
];

Now $product->attributes->brand is always a typed string, never null from a missing array key.

Partial GIN Index for Sparse Data

If only 20% of rows have a warranty key, index only those rows:

CREATE INDEX idx_products_warranty_gin
    ON products USING GIN (attributes)
    WHERE attributes ? 'warranty';

Smaller index, faster maintenance, same query speed for the filtered subset.

Takeaways

  • Always add a GIN index before querying JSONB at scale; without it every query is a sequential scan.
  • Use functional B-tree indexes for equality on a single known key — they are smaller and faster than GIN for that case.
  • whereJsonContains compiles to @> and is index-friendly; raw LIKE on a JSONB path is not.
  • Wrap JSONB columns in a typed cast so domain code never touches raw arrays.
  • Partial GIN indexes cut index size dramatically when the key is sparse.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does whereJsonContains use the GIN index automatically?
Yes. whereJsonContains compiles to the @> containment operator, which PostgreSQL's GIN index is designed to satisfy. Verify with EXPLAIN ANALYZE — you should see 'Bitmap Index Scan on idx_products_attributes_gin'.
Q02 When should I use a functional B-tree index instead of GIN?
When you query a single, well-known key with equality or range operators — for example (attributes->>'price_usd')::numeric > 100. A functional B-tree on that expression is smaller and faster for that specific access pattern than a full GIN index.
Q03 Can I run Laravel schema migrations for GIN indexes without raw SQL?
Not with Blueprint alone — there is no first-party GIN helper. Use DB::statement() inside your migration's up() method. Wrap it in a try/catch if you want idempotent re-runs, or check pg_indexes before creating.

Continue reading

More Articles

View all