Using PostgreSQL JSONB Columns in Laravel Without Losing Your Mind
When JSONB Makes Sense
JSONB is not a replacement for relational design. It shines when:
- Schema varies per record — product attributes, event metadata, feature flags
- Third-party data — you're storing API responses you don't fully control
- Rapid iteration — the schema needs to evolve before normalization is justified
The anti-pattern is using JSONB to avoid designing a proper schema. If you know every field, use columns.
Migration and Index Setup
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->jsonb('attributes')->default('{}');
$table->timestamps();
});
// GIN index enables containment and key-existence queries
DB::statement('CREATE INDEX idx_products_attributes ON products USING GIN (attributes)');
The GIN index is critical. Without it, every WHERE attributes @> '{"color":"red"}' query is a full table scan.
Querying with Eloquent
Laravel exposes JSONB querying through whereJsonContains and raw expressions:
// Containment: find products where attributes contains {color: "red"}
Product::whereJsonContains('attributes->color', 'red')->get();
// Numeric comparison via cast
Product::whereRaw("(attributes->>'weight')::numeric > ?", [10.0])->get();
// Key existence
Product::whereRaw("attributes ? 'warranty'")->get();
// Nested path
Product::whereJsonContains('attributes->dimensions->unit', 'cm')->get();
Eloquent Casting for Type Safety
Without a cast, attributes comes back as a JSON string. Add the cast:
class Product extends Model
{
protected $casts = [
'attributes' => 'array', // or AsCollection::class for a fluent interface
];
}
// Now you can read/write like a PHP array
$product = Product::find(1);
$product->attributes['color'] = 'blue';
$product->save(); // Eloquent serializes it back to JSON automatically
The Gotcha: When the GIN Index Doesn't Help
The GIN index accelerates @> (containment) and ? (key exists). It does not help extraction with comparison:
-- NOT covered by GIN — full table scan
WHERE attributes->>'weight' > '10'
For those queries, add a functional B-tree index:
CREATE INDEX idx_products_weight ON products ((attributes->>'weight'));
And cast properly in the query:
CREATE INDEX idx_products_weight ON products (((attributes->>'weight')::numeric));
Updating Nested Fields
The - and || operators let you update JSONB without fetching the whole record:
-- Remove a key
UPDATE products SET attributes = attributes - 'legacy_field';
-- Merge/update a nested path (requires PostgreSQL 14+)
UPDATE products SET attributes = jsonb_set(attributes, '{dimensions,unit}', '"mm"');
In Laravel, wrap these in DB::statement() or a raw update for bulk operations — Eloquent's save() always replaces the entire column.
When to Stop Using JSONB
Once you find yourself writing jsonb_path_query expressions in production queries, it's time to normalize. The flexibility tax compounds — indexes multiply, query complexity grows, and future schema migrations become painful. JSONB buys you iteration speed early on; relational design pays dividends at scale.