[{"data":1,"prerenderedAt":417},["ShallowReactive",2],{"blog-postgresql-jsonb-laravel":3},{"id":4,"title":5,"body":6,"date":404,"description":54,"excerpt":405,"extension":406,"meta":407,"navigation":101,"path":408,"seo":409,"stem":410,"tags":411,"__hash__":416},"blog\u002Fblog\u002Fpostgresql-jsonb-laravel.md","Using PostgreSQL JSONB Columns in Laravel Without Losing Your Mind",{"type":7,"value":8,"toc":395},"minimark",[9,14,18,41,44,48,115,122,126,133,192,196,203,265,269,284,301,304,313,316,325,329,340,369,380,384,391],[10,11,13],"h2",{"id":12},"when-jsonb-makes-sense","When JSONB Makes Sense",[15,16,17],"p",{},"JSONB is not a replacement for relational design. It shines when:",[19,20,21,29,35],"ul",{},[22,23,24,28],"li",{},[25,26,27],"strong",{},"Schema varies per record"," — product attributes, event metadata, feature flags",[22,30,31,34],{},[25,32,33],{},"Third-party data"," — you're storing API responses you don't fully control",[22,36,37,40],{},[25,38,39],{},"Rapid iteration"," — the schema needs to evolve before normalization is justified",[15,42,43],{},"The anti-pattern is using JSONB to avoid designing a proper schema. If you know every field, use columns.",[10,45,47],{"id":46},"migration-and-index-setup","Migration and Index Setup",[49,50,55],"pre",{"className":51,"code":52,"language":53,"meta":54,"style":54},"language-php shiki shiki-themes github-light github-dark","Schema::create('products', function (Blueprint $table) {\n    $table->id();\n    $table->string('name');\n    $table->jsonb('attributes')->default('{}');\n    $table->timestamps();\n});\n\n\u002F\u002F GIN index enables containment and key-existence queries\nDB::statement('CREATE INDEX idx_products_attributes ON products USING GIN (attributes)');\n","php","",[56,57,58,66,72,78,84,90,96,103,109],"code",{"__ignoreMap":54},[59,60,63],"span",{"class":61,"line":62},"line",1,[59,64,65],{},"Schema::create('products', function (Blueprint $table) {\n",[59,67,69],{"class":61,"line":68},2,[59,70,71],{},"    $table->id();\n",[59,73,75],{"class":61,"line":74},3,[59,76,77],{},"    $table->string('name');\n",[59,79,81],{"class":61,"line":80},4,[59,82,83],{},"    $table->jsonb('attributes')->default('{}');\n",[59,85,87],{"class":61,"line":86},5,[59,88,89],{},"    $table->timestamps();\n",[59,91,93],{"class":61,"line":92},6,[59,94,95],{},"});\n",[59,97,99],{"class":61,"line":98},7,[59,100,102],{"emptyLinePlaceholder":101},true,"\n",[59,104,106],{"class":61,"line":105},8,[59,107,108],{},"\u002F\u002F GIN index enables containment and key-existence queries\n",[59,110,112],{"class":61,"line":111},9,[59,113,114],{},"DB::statement('CREATE INDEX idx_products_attributes ON products USING GIN (attributes)');\n",[15,116,117,118,121],{},"The GIN index is critical. Without it, every ",[56,119,120],{},"WHERE attributes @> '{\"color\":\"red\"}'"," query is a full table scan.",[10,123,125],{"id":124},"querying-with-eloquent","Querying with Eloquent",[15,127,128,129,132],{},"Laravel exposes JSONB querying through ",[56,130,131],{},"whereJsonContains"," and raw expressions:",[49,134,136],{"className":51,"code":135,"language":53,"meta":54,"style":54},"\u002F\u002F Containment: find products where attributes contains {color: \"red\"}\nProduct::whereJsonContains('attributes->color', 'red')->get();\n\n\u002F\u002F Numeric comparison via cast\nProduct::whereRaw(\"(attributes->>'weight')::numeric > ?\", [10.0])->get();\n\n\u002F\u002F Key existence\nProduct::whereRaw(\"attributes ? 'warranty'\")->get();\n\n\u002F\u002F Nested path\nProduct::whereJsonContains('attributes->dimensions->unit', 'cm')->get();\n",[56,137,138,143,148,152,157,162,166,171,176,180,186],{"__ignoreMap":54},[59,139,140],{"class":61,"line":62},[59,141,142],{},"\u002F\u002F Containment: find products where attributes contains {color: \"red\"}\n",[59,144,145],{"class":61,"line":68},[59,146,147],{},"Product::whereJsonContains('attributes->color', 'red')->get();\n",[59,149,150],{"class":61,"line":74},[59,151,102],{"emptyLinePlaceholder":101},[59,153,154],{"class":61,"line":80},[59,155,156],{},"\u002F\u002F Numeric comparison via cast\n",[59,158,159],{"class":61,"line":86},[59,160,161],{},"Product::whereRaw(\"(attributes->>'weight')::numeric > ?\", [10.0])->get();\n",[59,163,164],{"class":61,"line":92},[59,165,102],{"emptyLinePlaceholder":101},[59,167,168],{"class":61,"line":98},[59,169,170],{},"\u002F\u002F Key existence\n",[59,172,173],{"class":61,"line":105},[59,174,175],{},"Product::whereRaw(\"attributes ? 'warranty'\")->get();\n",[59,177,178],{"class":61,"line":111},[59,179,102],{"emptyLinePlaceholder":101},[59,181,183],{"class":61,"line":182},10,[59,184,185],{},"\u002F\u002F Nested path\n",[59,187,189],{"class":61,"line":188},11,[59,190,191],{},"Product::whereJsonContains('attributes->dimensions->unit', 'cm')->get();\n",[10,193,195],{"id":194},"eloquent-casting-for-type-safety","Eloquent Casting for Type Safety",[15,197,198,199,202],{},"Without a cast, ",[56,200,201],{},"attributes"," comes back as a JSON string. Add the cast:",[49,204,206],{"className":51,"code":205,"language":53,"meta":54,"style":54},"class Product extends Model\n{\n    protected $casts = [\n        'attributes' => 'array', \u002F\u002F or AsCollection::class for a fluent interface\n    ];\n}\n\n\u002F\u002F Now you can read\u002Fwrite like a PHP array\n$product = Product::find(1);\n$product->attributes['color'] = 'blue';\n$product->save(); \u002F\u002F Eloquent serializes it back to JSON automatically\n",[56,207,208,213,218,223,231,236,241,245,250,255,260],{"__ignoreMap":54},[59,209,210],{"class":61,"line":62},[59,211,212],{},"class Product extends Model\n",[59,214,215],{"class":61,"line":68},[59,216,217],{},"{\n",[59,219,220],{"class":61,"line":74},[59,221,222],{},"    protected $casts = [\n",[59,224,225,228],{"class":61,"line":80},[59,226,227],{},"        'attributes' => 'array',",[59,229,230],{}," \u002F\u002F or AsCollection::class for a fluent interface\n",[59,232,233],{"class":61,"line":86},[59,234,235],{},"    ];\n",[59,237,238],{"class":61,"line":92},[59,239,240],{},"}\n",[59,242,243],{"class":61,"line":98},[59,244,102],{"emptyLinePlaceholder":101},[59,246,247],{"class":61,"line":105},[59,248,249],{},"\u002F\u002F Now you can read\u002Fwrite like a PHP array\n",[59,251,252],{"class":61,"line":111},[59,253,254],{},"$product = Product::find(1);\n",[59,256,257],{"class":61,"line":182},[59,258,259],{},"$product->attributes['color'] = 'blue';\n",[59,261,262],{"class":61,"line":188},[59,263,264],{},"$product->save(); \u002F\u002F Eloquent serializes it back to JSON automatically\n",[10,266,268],{"id":267},"the-gotcha-when-the-gin-index-doesnt-help","The Gotcha: When the GIN Index Doesn't Help",[15,270,271,272,275,276,279,280,283],{},"The GIN index accelerates ",[56,273,274],{},"@>"," (containment) and ",[56,277,278],{},"?"," (key exists). It does ",[25,281,282],{},"not"," help extraction with comparison:",[49,285,289],{"className":286,"code":287,"language":288,"meta":54,"style":54},"language-sql shiki shiki-themes github-light github-dark","-- NOT covered by GIN — full table scan\nWHERE attributes->>'weight' > '10'\n","sql",[56,290,291,296],{"__ignoreMap":54},[59,292,293],{"class":61,"line":62},[59,294,295],{},"-- NOT covered by GIN — full table scan\n",[59,297,298],{"class":61,"line":68},[59,299,300],{},"WHERE attributes->>'weight' > '10'\n",[15,302,303],{},"For those queries, add a functional B-tree index:",[49,305,307],{"className":286,"code":306,"language":288,"meta":54,"style":54},"CREATE INDEX idx_products_weight ON products ((attributes->>'weight'));\n",[56,308,309],{"__ignoreMap":54},[59,310,311],{"class":61,"line":62},[59,312,306],{},[15,314,315],{},"And cast properly in the query:",[49,317,319],{"className":286,"code":318,"language":288,"meta":54,"style":54},"CREATE INDEX idx_products_weight ON products (((attributes->>'weight')::numeric));\n",[56,320,321],{"__ignoreMap":54},[59,322,323],{"class":61,"line":62},[59,324,318],{},[10,326,328],{"id":327},"updating-nested-fields","Updating Nested Fields",[15,330,331,332,335,336,339],{},"The ",[56,333,334],{},"-"," and ",[56,337,338],{},"||"," operators let you update JSONB without fetching the whole record:",[49,341,343],{"className":286,"code":342,"language":288,"meta":54,"style":54},"-- Remove a key\nUPDATE products SET attributes = attributes - 'legacy_field';\n\n-- Merge\u002Fupdate a nested path (requires PostgreSQL 14+)\nUPDATE products SET attributes = jsonb_set(attributes, '{dimensions,unit}', '\"mm\"');\n",[56,344,345,350,355,359,364],{"__ignoreMap":54},[59,346,347],{"class":61,"line":62},[59,348,349],{},"-- Remove a key\n",[59,351,352],{"class":61,"line":68},[59,353,354],{},"UPDATE products SET attributes = attributes - 'legacy_field';\n",[59,356,357],{"class":61,"line":74},[59,358,102],{"emptyLinePlaceholder":101},[59,360,361],{"class":61,"line":80},[59,362,363],{},"-- Merge\u002Fupdate a nested path (requires PostgreSQL 14+)\n",[59,365,366],{"class":61,"line":86},[59,367,368],{},"UPDATE products SET attributes = jsonb_set(attributes, '{dimensions,unit}', '\"mm\"');\n",[15,370,371,372,375,376,379],{},"In Laravel, wrap these in ",[56,373,374],{},"DB::statement()"," or a raw update for bulk operations — Eloquent's ",[56,377,378],{},"save()"," always replaces the entire column.",[10,381,383],{"id":382},"when-to-stop-using-jsonb","When to Stop Using JSONB",[15,385,386,387,390],{},"Once you find yourself writing ",[56,388,389],{},"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.",[392,393,394],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":54,"searchDepth":68,"depth":68,"links":396},[397,398,399,400,401,402,403],{"id":12,"depth":68,"text":13},{"id":46,"depth":68,"text":47},{"id":124,"depth":68,"text":125},{"id":194,"depth":68,"text":195},{"id":267,"depth":68,"text":268},{"id":327,"depth":68,"text":328},{"id":382,"depth":68,"text":383},"2024-06-10",null,"md",{},"\u002Fblog\u002Fpostgresql-jsonb-laravel",{"title":5,"description":54},"blog\u002Fpostgresql-jsonb-laravel",[412,413,414,415],"PostgreSQL","Laravel","PHP","Database","mpi6vEOBrgjrhzAt80bdmJHN79AcFSkJa05qc48msTU",1779430667061]