PostgreSQL JSONB in Laravel: Indexing, Querying, and Casting Without the Pain
#laravel #postgresql #eloquent #jsonb

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

4 min read Mohamed Said Mohamed Said

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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 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.
Q02 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.
Q03 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.

Continue reading

More Articles

View all