Skip to content

PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Pain

JSONB columns unlock flexible schemas, but most Laravel apps leave performance on the table. Learn how to index, query, and cast JSONB properly so you get the flexibility without the full-table-scan tax.

Published · Updated · 4 min read

PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Pain

PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Pain

JSONB is one of PostgreSQL's most powerful features, but it is also one of the easiest to misuse. Teams reach for it when they want a flexible schema, then wonder why their queries are slow and their application code is littered with json_decode calls. This article covers the three layers you need to get right: indexing, querying through Eloquent, and casting at the model layer.


Why JSONB and Not JSON?

PostgreSQL stores json as plain text and re-parses it on every access. jsonb is stored in a decomposed binary format, supports indexing, and allows operators like @> (contains) and ? (key exists) to use those indexes. Always use jsonb unless you need to preserve key order or duplicate keys — which you almost certainly do not.

// migration
$table->jsonb('settings')->nullable();
$table->jsonb('metadata')->default('{}');

GIN Indexes: The Only Index That Matters for JSONB

A plain B-tree index on a jsonb column is useless for containment queries. You need a GIN index.

// migration — raw statement because Blueprint has no jsonb GIN helper
DB::statement('CREATE INDEX users_metadata_gin ON users USING GIN (metadata)');

// For a specific path only (smaller, faster for known keys)
DB::statement(
    "CREATE INDEX users_metadata_role ON users USING GIN ((metadata->'role'))"
);

A jsonb_path_ops GIN index is smaller and faster for @> queries but does not support ? or ?|:

CREATE INDEX users_metadata_path_ops
    ON users USING GIN (metadata jsonb_path_ops);

Use jsonb_path_ops when you only need containment checks. Use the default operator class when you also need key-existence checks.


Querying JSONB Through Eloquent

Laravel ships with whereJsonContains, whereJsonLength, and arrow-notation column references. They cover most cases.

// Containment — uses GIN index
User::whereJsonContains('metadata->roles', 'admin')->get();

// Key existence — also uses GIN index
User::whereRaw("metadata ? 'verified'")-> get();

// Nested path extraction
User::whereRaw("(metadata->>'plan') = ?", ['pro'])->get();

// Ordering by a JSONB value
User::orderByRaw("metadata->>'last_login' DESC NULLS LAST")->get();

Avoid casting inside the WHERE clause (metadata::text LIKE '%admin%'). That defeats every index and forces a sequential scan.


Custom Eloquent Casts for Typed JSONB

Raw arrays in application code are a maintenance hazard. A custom cast turns a JSONB column into a typed value object with zero overhead.

// app/Casts/UserSettings.php
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

final class UserSettingsCast implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes): Settings
    {
        return Settings::fromArray(json_decode($value ?? '{}', true));
    }

    public function set($model, string $key, $value, array $attributes): string
    {
        return json_encode(
            $value instanceof Settings ? $value->toArray() : $value
        );
    }
}
// app/Models/User.php
protected function casts(): array
{
    return [
        'settings' => UserSettingsCast::class,
        'metadata' => 'array', // fine for simple bags
    ];
}

The Settings value object can expose typed getters, enforce invariants, and be unit-tested independently of the database.


Partial GIN Indexes for High-Cardinality Columns

If only a subset of rows carry a particular key, a partial index keeps the index small and writes fast:

CREATE INDEX users_metadata_enterprise
    ON users USING GIN (metadata)
    WHERE (metadata->>'plan') = 'enterprise';

Eloquent can hit this with:

User::whereRaw("(metadata->>'plan') = 'enterprise'")
    ->whereJsonContains('metadata->features', 'sso')
    ->get();

PostgreSQL's planner will select the partial index automatically.


Key Takeaways

  • Always use jsonb, never json, for any column you intend to query or index.
  • Add a GIN index immediately; without it every containment query is a sequential scan.
  • Use jsonb_path_ops when you only need @> — it is smaller and faster.
  • Prefer whereJsonContains and whereRaw with path extraction over casting inside SQL.
  • Wrap JSONB columns in typed Eloquent casts to keep application code clean and testable.
  • Partial GIN indexes on high-cardinality JSONB columns reduce index bloat significantly.

Frequently asked questions

Does `whereJsonContains` in Laravel actually use a GIN index?

Yes, provided you have a GIN index on the column. Laravel compiles `whereJsonContains('metadata->roles', 'admin')` to a `@>` containment operator, which PostgreSQL can satisfy with a GIN index. Without the index the query still works but performs a sequential scan.

When should I use a JSONB column instead of a proper relational table?

Use JSONB for genuinely variable, sparse attributes — user preferences, feature flags, third-party webhook payloads — where the key set differs per row. If you find yourself querying the same key on every request or joining on JSONB values, extract it into a typed column or a related table instead.

Can I use Laravel's built-in `array` cast instead of a custom cast?

The built-in `array` cast works for simple key-value bags and is fine for write-heavy columns you rarely query. For columns with business logic, invariants, or complex nested structures, a custom cast backed by a value object gives you type safety and testability that a plain array cannot.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project