[{"data":1,"prerenderedAt":666},["ShallowReactive",2],{"case-study:optimizing-a-32-join-query":3},{"id":4,"title":5,"body":6,"description":637,"draft":353,"extension":638,"industry":639,"meta":640,"metrics":641,"navigation":353,"order":339,"path":650,"role":651,"seo":652,"stack":653,"stem":659,"tags":660,"year":664,"__hash__":665},"caseStudies\u002Fcase-studies\u002Foptimizing-a-32-join-query.md","From 23 seconds to 2: killing a 32-join view query",{"type":7,"value":8,"toc":627},"minimark",[9,14,23,27,39,50,54,60,63,124,150,158,164,225,235,242,246,253,259,393,398,444,450,456,502,506,568,571,575,620,623],[10,11,13],"h2",{"id":12},"in-one-line","In one line",[15,16,17,18,22],"p",{},"A support page everyone used took 23 seconds to load. I traced it to a\nclean-named database view hiding 32 joins across ~3.9M rows - run ",[19,20,21],"em",{},"twice"," per\nrequest just to get a list of IDs - and brought the page to ~2 seconds with\nsmarter querying and a one-line cache. No new infrastructure.",[10,24,26],{"id":25},"the-problem","The problem",[15,28,29,30,34,35,38],{},"A support agent reported that the customer ",[31,32,33],"strong",{},"store-listing"," page was painfully\nslow. Locally it took ",[31,36,37],{},"23.3 seconds"," - and this is a page support staff open\ndozens of times a day, on a platform handling real-time payment operations. \"Slow\"\nhere is a queue of agents waiting on a spinner.",[15,40,41,42,45,46,49],{},"The endpoint was a paginated list of a customer's stores, filtered by the\nlogged-in user's access restrictions. Two things about that sentence turned out to\nmatter: ",[19,43,44],{},"paginated"," (so it runs two queries - data + count) and ",[19,47,48],{},"filtered by\naccess restrictions"," (where the real cost was hiding).",[10,51,53],{"id":52},"where-the-time-went","Where the time went",[15,55,56,57],{},"Both the data query and the count query passed through the same access-control\nfilter, which layered two checks. One was cheap. The other called a method that\nasked a deceptively simple question: ",[19,58,59],{},"\"which customer IDs are visible to this\nuser, given their provider permissions?\"",[15,61,62],{},"The query behind it looked harmless:",[64,65,70],"pre",{"className":66,"code":67,"language":68,"meta":69,"style":69},"language-sql shiki shiki-themes one-dark-pro","SELECT DISTINCT pm.retailerId\nFROM view_product_details_retailer_mapping pm\nWHERE 1 = 1;  -- user has global rights → no extra filter\n","sql","",[71,72,73,93,102],"code",{"__ignoreMap":69},[74,75,78,82,86,90],"span",{"class":76,"line":77},"line",1,[74,79,81],{"class":80},"seHd6","SELECT DISTINCT",[74,83,85],{"class":84},"sVC51"," pm",[74,87,89],{"class":88},"sn6KH",".",[74,91,92],{"class":84},"retailerId\n",[74,94,96,99],{"class":76,"line":95},2,[74,97,98],{"class":80},"FROM",[74,100,101],{"class":88}," view_product_details_retailer_mapping pm\n",[74,103,105,108,111,115,117,120],{"class":76,"line":104},3,[74,106,107],{"class":80},"WHERE",[74,109,110],{"class":84}," 1",[74,112,114],{"class":113},"sjrmR"," =",[74,116,110],{"class":84},[74,118,119],{"class":88},";  ",[74,121,123],{"class":122},"sV9Aq","-- user has global rights → no extra filter\n",[15,125,126,127,130,131,134,135,142,143,146,147,89],{},"But ",[71,128,129],{},"view_product_details_retailer_mapping"," was not a table. It was a view built\non top of another view - ",[71,132,133],{},"view_product_details"," - which itself was ",[31,136,137,138,141],{},"32 ",[71,139,140],{},"LEFT JOIN","s",": products joined to providers, two currencies, countries, product type \u002F\nline \u002F brand \u002F category \u002F subcategory, validity, voucher and PIN types, ten\nattribute tables, assignment groups, divisions, customers… To answer \"give me a\nlist of distinct IDs,\" the database was materializing 32 joins across ",[31,144,145],{},"3.9\nmillion rows"," - and, because the page is paginated, doing it ",[31,148,149],{},"twice per\nrequest",[10,151,153,154,157],{"id":152},"investigation-explain-told-the-whole-story","Investigation: ",[71,155,156],{},"EXPLAIN"," told the whole story",[15,159,160,161,163],{},"The fix started with proving where the cost was. ",[71,162,156],{}," on the two ways of\ngetting the same list of IDs:",[165,166,167,186],"table",{},[168,169,170],"thead",{},[171,172,173,177,180,183],"tr",{},[174,175,176],"th",{},"Query",[174,178,179],{},"Plan rows",[174,181,182],{},"Strategy",[174,184,185],{},"Time",[187,188,189,207],"tbody",{},[171,190,191,198,201,204],{},[192,193,194,197],"td",{},[71,195,196],{},"DISTINCT"," through the 32-join view",[192,199,200],{},"15",[192,202,203],{},"full scan + temporary table",[192,205,206],{},"~6.6s",[171,208,209,214,217,222],{},[192,210,211,213],{},[71,212,196],{}," on the base table",[192,215,216],{},"1",[192,218,219],{},[31,220,221],{},"using index for group-by",[192,223,224],{},"~0.01s",[15,226,227,228,231,232,234],{},"Same 2,848 IDs. Same result. ",[31,229,230],{},"~660× difference"," - because the base-table query\nis fully answered by a composite index and never touches row data, while the view\nforces a full scan and a temporary table for the ",[71,233,196],{},". Run that twice per\nrequest, add the actual store queries and network overhead, and you land on 23\nseconds.",[15,236,237,238,241],{},"(A small but real detail: I was inspecting the view through a custom MCP database\nserver that let me query via the app's Doctrine connection - and found its output\nwas truncating ",[71,239,240],{},"SHOW CREATE VIEW"," at 80 characters, hiding the very joins I needed\nto see. Fixing that truncation is what unblocked the investigation. Tooling that\nlies to you costs more than slow tooling.)",[10,243,245],{"id":244},"the-fix-branch-by-what-the-caller-actually-needs","The fix: branch by what the caller actually needs",[15,247,248,249,252],{},"The expensive query treated every user the same. But the ",[19,250,251],{},"restriction type","\ndetermines how much work is genuinely required, so I split it into three paths:",[15,254,255,258],{},[31,256,257],{},"Path 1 - global rights (the common case): drop to DBAL on the base table.","\nMost users can see all providers, so the join was pure waste. For them, skip\nDoctrine's DQL and the view entirely and hit the indexed base table directly:",[64,260,264],{"className":261,"code":262,"language":263,"meta":69,"style":69},"language-php shiki shiki-themes one-dark-pro","if ($this->providerRestrictions->hasGlobalRights()) {\n    $rows = $this->connection\n        ->executeQuery('SELECT DISTINCT id_customer FROM product_retailer_mapping')\n        ->fetchFirstColumn();\n\n    return $this->cachedIds = array_map(intval(...), $rows);\n}\n","php",[71,265,266,294,309,337,348,355,387],{"__ignoreMap":69},[74,267,268,271,274,278,281,285,287,291],{"class":76,"line":77},[74,269,270],{"class":80},"if",[74,272,273],{"class":88}," (",[74,275,277],{"class":276},"sU0A5","$this",[74,279,280],{"class":88},"->",[74,282,284],{"class":283},"sVyAn","providerRestrictions",[74,286,280],{"class":88},[74,288,290],{"class":289},"sVbv2","hasGlobalRights",[74,292,293],{"class":88},"()) {\n",[74,295,296,299,301,304,306],{"class":76,"line":95},[74,297,298],{"class":283},"    $rows",[74,300,114],{"class":113},[74,302,303],{"class":276}," $this",[74,305,280],{"class":88},[74,307,308],{"class":283},"connection\n",[74,310,311,314,317,320,324,326,329,331,334],{"class":76,"line":104},[74,312,313],{"class":88},"        ->",[74,315,316],{"class":289},"executeQuery",[74,318,319],{"class":88},"(",[74,321,323],{"class":322},"subq3","'",[74,325,81],{"class":80},[74,327,328],{"class":322}," id_customer ",[74,330,98],{"class":80},[74,332,333],{"class":322}," product_retailer_mapping'",[74,335,336],{"class":88},")\n",[74,338,340,342,345],{"class":76,"line":339},4,[74,341,313],{"class":88},[74,343,344],{"class":289},"fetchFirstColumn",[74,346,347],{"class":88},"();\n",[74,349,351],{"class":76,"line":350},5,[74,352,354],{"emptyLinePlaceholder":353},true,"\n",[74,356,358,361,363,365,368,370,373,375,378,381,384],{"class":76,"line":357},6,[74,359,360],{"class":80},"    return",[74,362,303],{"class":276},[74,364,280],{"class":88},[74,366,367],{"class":283},"cachedIds",[74,369,114],{"class":113},[74,371,372],{"class":113}," array_map",[74,374,319],{"class":88},[74,376,377],{"class":113},"intval",[74,379,380],{"class":88},"(...),",[74,382,383],{"class":283}," $rows",[74,385,386],{"class":88},");\n",[74,388,390],{"class":76,"line":389},7,[74,391,392],{"class":88},"}\n",[15,394,395],{},[31,396,397],{},"Path 2 - no rights: return nothing, for free.",[64,399,401],{"className":261,"code":400,"language":263,"meta":69,"style":69},"if (!$this->providerRestrictions->hasRights()) {\n    return $this->cachedIds = [];\n}\n",[71,402,403,425,440],{"__ignoreMap":69},[74,404,405,407,409,412,414,416,418,420,423],{"class":76,"line":77},[74,406,270],{"class":80},[74,408,273],{"class":88},[74,410,411],{"class":113},"!",[74,413,277],{"class":276},[74,415,280],{"class":88},[74,417,284],{"class":283},[74,419,280],{"class":88},[74,421,422],{"class":289},"hasRights",[74,424,293],{"class":88},[74,426,427,429,431,433,435,437],{"class":76,"line":95},[74,428,360],{"class":80},[74,430,303],{"class":276},[74,432,280],{"class":88},[74,434,367],{"class":283},[74,436,114],{"class":113},[74,438,439],{"class":88}," [];\n",[74,441,442],{"class":76,"line":104},[74,443,392],{"class":88},[15,445,446,449],{},[31,447,448],{},"Path 3 - specific restrictions (rare): keep the view."," Only the genuinely\nrestricted users fall back to the original DQL-on-view query - the one case that\nactually needs the joined data.",[15,451,452,455],{},[31,453,454],{},"Then: a per-request cache, for free."," Because the page runs both a data and a\ncount query, the lookup happened twice. The repository instance is shared within a\nrequest by the DI container, so memoizing on a private property eliminates the\nsecond call with zero infrastructure:",[64,457,459],{"className":261,"code":458,"language":263,"meta":69,"style":69},"if ($this->cachedIds !== null) {\n    return $this->cachedIds; \u002F\u002F second caller (the count query) pays nothing\n}\n",[71,460,461,482,498],{"__ignoreMap":69},[74,462,463,465,467,469,471,473,476,479],{"class":76,"line":77},[74,464,270],{"class":80},[74,466,273],{"class":88},[74,468,277],{"class":276},[74,470,280],{"class":88},[74,472,367],{"class":283},[74,474,475],{"class":113}," !==",[74,477,478],{"class":84}," null",[74,480,481],{"class":88},") {\n",[74,483,484,486,488,490,492,495],{"class":76,"line":95},[74,485,360],{"class":80},[74,487,303],{"class":276},[74,489,280],{"class":88},[74,491,367],{"class":283},[74,493,494],{"class":88},"; ",[74,496,497],{"class":122},"\u002F\u002F second caller (the count query) pays nothing\n",[74,499,500],{"class":76,"line":104},[74,501,392],{"class":88},[10,503,505],{"id":504},"the-result","The result",[165,507,508,524],{},[168,509,510],{},[171,511,512,515,518,521],{},[174,513,514],{},"Metric",[174,516,517],{},"Before",[174,519,520],{},"After",[174,522,523],{},"Improvement",[187,525,526,542,556],{},[171,527,528,531,534,537],{},[192,529,530],{},"Page load (local)",[192,532,533],{},"23.3s",[192,535,536],{},"2.3s",[192,538,539],{},[31,540,541],{},"~10×",[171,543,544,547,550,553],{},[192,545,546],{},"Expensive view queries \u002F req",[192,548,549],{},"2",[192,551,552],{},"0",[192,554,555],{},"eliminated",[171,557,558,561,563,565],{},[192,559,560],{},"DB plan rows",[192,562,200],{},[192,564,216],{},[192,566,567],{},"15× simpler",[15,569,570],{},"No new dependencies. No schema changes. No Redis, no hardware. Just not doing 32\njoins when you only need a list of IDs.",[10,572,574],{"id":573},"why-this-is-an-architecture-story-not-just-a-sql-story","Why this is an architecture story, not just a SQL story",[576,577,578,594,604,610],"ul",{},[579,580,581,584,585,588,589,89],"li",{},[31,582,583],{},"The legacy system had the exact same query."," It was simply masked by fewer\nconcurrent users and different caching. When you migrate a feature, replicating\nthe old behavior faithfully also replicates its mistakes - so migration has to\nmean ",[19,586,587],{},"questioning"," the old design, not just porting it. That mindset is built\ninto how I run the ",[590,591,593],"a",{"href":592},"\u002Fcase-studies\u002Freplatforming-a-legacy-payments-system","whole re-platforming",[579,595,596,599,600,603],{},[31,597,598],{},"Views hide complexity behind a friendly name."," ",[71,601,602],{},"view_product_details_…","\nreads as harmless in a query. Always check what a view actually expands to\nbefore you build on it.",[579,605,606,609],{},[31,607,608],{},"The ORM doesn't know about your indexes."," DQL is the right default; when a\nhot path needs an index the ORM won't reach, drop to DBAL for that path and keep\nDQL for the complex-but-rare cases.",[579,611,612,615,616,619],{},[31,613,614],{},"The cheapest cache is the one you don't have to operate."," A ",[71,617,618],{},"private ?array","\nkilled half the cost before any infrastructure was involved.",[15,621,622],{},"Performance work isn't always about adding caches or scaling hardware. Often it's\nabout removing work that never needed to happen.",[624,625,626],"style",{},"html pre.shiki code .seHd6, html code.shiki .seHd6{--shiki-default:#C678DD}html pre.shiki code .sVC51, html code.shiki .sVC51{--shiki-default:#D19A66}html pre.shiki code .sn6KH, html code.shiki .sn6KH{--shiki-default:#ABB2BF}html pre.shiki code .sjrmR, html code.shiki .sjrmR{--shiki-default:#56B6C2}html pre.shiki code .sV9Aq, html code.shiki .sV9Aq{--shiki-default:#7F848E;--shiki-default-font-style:italic}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 pre.shiki code .sU0A5, html code.shiki .sU0A5{--shiki-default:#E5C07B}html pre.shiki code .sVyAn, html code.shiki .sVyAn{--shiki-default:#E06C75}html pre.shiki code .sVbv2, html code.shiki .sVbv2{--shiki-default:#61AFEF}html pre.shiki code .subq3, html code.shiki .subq3{--shiki-default:#98C379}",{"title":69,"searchDepth":95,"depth":95,"links":628},[629,630,631,632,634,635,636],{"id":12,"depth":95,"text":13},{"id":25,"depth":95,"text":26},{"id":52,"depth":95,"text":53},{"id":152,"depth":95,"text":633},"Investigation: EXPLAIN told the whole story",{"id":244,"depth":95,"text":245},{"id":504,"depth":95,"text":505},{"id":573,"depth":95,"text":574},"A store-listing page took 23 seconds to load. The culprit was an innocent-looking database view hiding 32 joins across 3.9 million rows - queried twice per request, just to fetch a list of IDs. Here is how I found it and made it 10× faster with no new infrastructure.","md","Payments \u002F Fintech",{},[642,645,648],{"value":643,"label":644},"10×","faster page load (23.3s → 2.3s)",{"value":646,"label":647},"660×","faster on the core access-control query",{"value":552,"label":649},"new infrastructure, dependencies, or schema changes","\u002Fcase-studies\u002Foptimizing-a-32-join-query","Senior Full-Stack Developer",{"title":5,"description":637},[654,655,656,657,658,156],"PHP 8.4","Doctrine ORM","Doctrine DBAL","MariaDB","SQL","case-studies\u002Foptimizing-a-32-join-query",[661,658,662,663],"Performance","Doctrine","Database","2026","itmw-LfeVXOUg902yGgmN9q96KI88ln0rYvLa5nc1OU",1781646389936]