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

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

3 min read Mohamed Said Mohamed Said

Why JSONB Deserves More Than a json Column

PostgreSQL's jsonb type stores JSON in a decomposed binary format, enabling indexing and operator-based querying that the plain json type simply cannot match. Laravel's json cast gets you to the finish line for simple storage, but the moment you need to filter or sort on nested keys at scale, you need to think about what PostgreSQL is actually doing under the hood.


Choosing the Right GIN Index

A generic GIN index on a jsonb column supports containment (@>) and existence (?) operators:

CREATE INDEX idx_orders_meta ON orders USING GIN (meta);

For deep path queries (meta->'shipping'->>'country'), a functional B-tree index is often faster:

CREATE INDEX idx_orders_shipping_country
  ON orders ((meta->'shipping'->>'country'));

In a Laravel migration:

Schema::table('orders', function (Blueprint $table) {
    // GIN for containment queries
    $table->rawIndex('meta', 'idx_orders_meta_gin', 'gin');

    // Functional B-tree for a known path
    DB::statement(
        "CREATE INDEX idx_orders_shipping_country "
        . "ON orders ((meta->'shipping'->>'country'))"
    );
});

Rule of thumb: GIN when you query arbitrary keys; functional B-tree when you always query the same path.


Querying JSONB in Eloquent

Laravel's query builder exposes whereJsonContains, whereJsonLength, and raw expressions. Use them deliberately.

Containment — hits the GIN index

// Find orders where meta contains a specific shipping country
Order::whereJsonContains('meta->shipping->country', 'DE')->get();

Path extraction — hits the functional B-tree index

Order::whereRaw("meta->'shipping'->>'country' = ?", ['DE'])->get();

Encapsulate in a scope to avoid raw SQL leaking everywhere

// app/Models/Order.php
public function scopeShippingCountry(Builder $query, string $country): Builder
{
    return $query->whereRaw(
        "meta->'shipping'->>'country' = ?",
        [$country]
    );
}

// Usage
Order::shippingCountry('DE')->paginate();

Type-Safe JSONB with a Custom Eloquent Cast

A plain array cast gives you an untyped array. A custom cast backed by a DTO gives you autocomplete, validation, and a clear contract.

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

class ShippingMetaCast implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes): ShippingMeta
    {
        $data = json_decode($value ?? '{}', true);
        return new ShippingMeta(
            country: $data['country'] ?? '',
            postalCode: $data['postal_code'] ?? '',
            carrier: $data['carrier'] ?? null,
        );
    }

    public function set($model, string $key, $value, array $attributes): string
    {
        if ($value instanceof ShippingMeta) {
            return json_encode([
                'country'     => $value->country,
                'postal_code' => $value->postalCode,
                'carrier'     => $value->carrier,
            ]);
        }
        return json_encode($value);
    }
}
// app/Data/ShippingMeta.php
readonly class ShippingMeta
{
    public function __construct(
        public string $country,
        public string $postalCode,
        public ?string $carrier,
    ) {}
}
// app/Models/Order.php
protected $casts = [
    'meta' => ShippingMetaCast::class,
];

// Now fully typed:
$order->meta->country; // string

Avoiding the Silent Performance Trap

The most common mistake: storing deeply nested, frequently queried data in JSONB and then filtering with LIKE on a cast text value. PostgreSQL cannot use any index for that.

Run EXPLAIN (ANALYZE, BUFFERS) on any JSONB query before shipping:

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE meta->'shipping'->>'country' = 'DE';

Look for Bitmap Index Scan or Index Scan on your functional index. If you see Seq Scan, your index is missing or the planner is ignoring it — check column statistics with ANALYZE orders.


Key Takeaways

  • Use GIN for containment/existence queries; use functional B-tree for fixed-path equality filters.
  • Wrap raw JSONB expressions in Eloquent scopes to keep models readable and testable.
  • Replace the generic array cast with a typed custom cast backed by a readonly DTO.
  • Always verify index usage with EXPLAIN (ANALYZE, BUFFERS) — never assume the planner will do what you expect.
  • ANALYZE your table after bulk inserts so the planner has fresh statistics for JSONB columns.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use JSONB instead of dedicated columns in PostgreSQL?
Use JSONB for genuinely variable or schema-less attributes — feature flags, third-party webhook payloads, or per-tenant configuration. If you query the same field in every WHERE clause, a dedicated typed column with a standard B-tree index will almost always outperform JSONB.
Q02 Does `whereJsonContains` in Laravel use the GIN index automatically?
Yes, if a GIN index exists on the column. `whereJsonContains` compiles to the `@>` containment operator, which PostgreSQL's GIN index supports natively. Verify with EXPLAIN that the planner chooses an index scan rather than a sequential scan.
Q03 Can I use a custom JSONB cast alongside Filament form fields?
Yes. Filament reads and writes through Eloquent, so your custom cast is transparent. You will need to map the DTO's properties to individual form fields using `afterStateHydrated` and `dehydrateStateUsing` callbacks, or flatten the DTO to an array before passing it to the form schema.

Continue reading

More Articles

View all