[{"data":1,"prerenderedAt":6642},["ShallowReactive",2],{"articles-en":3},[4,273,1298,2207,2645,3301,3945,4094,4709,4901,5765],{"id":5,"title":6,"articleId":7,"body":8,"category":248,"codeLang":62,"date":249,"deploys":250,"description":14,"excerpt":251,"extension":252,"lang":251,"meta":253,"navigation":85,"path":254,"pos":255,"readMin":260,"related":261,"seo":264,"service":265,"stem":266,"tags":267,"version":271,"__hash__":272},"articles\u002Farticles\u002Fagent-graphs.md","Building production AI agents with stateful graph orchestration","agent-graphs",{"type":9,"value":10,"toc":241},"minimark",[11,15,18,21,26,29,41,47,53,57,169,173,184,212,215,219,226,230,237],[12,13,14],"p",{},"Once an agent has more than two tools, the \"ReAct loop\" stops being an architecture and starts being a liability. Latency stacks, error modes multiply, and there is nowhere honest to draw a boundary for retries.",[12,16,17],{},"The shift we made — and the one I now recommend to every team I advise — is to stop modelling the agent as a loop and start modelling it as a state machine where every transition is named, every state is checkpointable, and every tool call is a node, not a side effect.",[12,19,20],{},"In LangGraph terms: the graph is the contract. The model is a participant in the graph, not its owner. That single inversion is what lets you do everything that an agent in production actually needs to do — pause, resume, fan out, hand off to a human, replay yesterday's session for a regression suite.",[22,23,25],"h2",{"id":24},"why-loops-fail-at-scale","Why loops fail at scale",[12,27,28],{},"The canonical ReAct loop is elegant for demos: observe, think, act, repeat. In production it accumulates three failure modes that compound with every additional tool:",[12,30,31,35,36,40],{},[32,33,34],"strong",{},"Unbounded retries."," When a tool call fails, the loop has no natural place to draw a boundary. You add a ",[37,38,39],"code",{},"max_steps"," guard. Then a team member raises it \"just this once\". Three months later your p99 latency is 45 seconds and you don't know why.",[12,42,43,46],{},[32,44,45],{},"Non-deterministic replay."," When a customer files a bug, you want to replay the exact sequence of decisions. A loop with no named checkpoints gives you a log. A graph gives you a resumable snapshot.",[12,48,49,52],{},[32,50,51],{},"Human handoff."," The loop cannot pause and wait. The graph can interrupt at any named node and wait indefinitely for human input, then resume from exactly that state.",[22,54,56],{"id":55},"the-graph-topology","The graph topology",[58,59,64],"pre",{"className":60,"code":61,"language":62,"meta":63,"style":63},"language-python shiki shiki-themes github-light github-dark","# the only loop is the runtime's. the agent is a graph.\ngraph = StateGraph(AgentState)\n\ngraph.add_node(\"plan\",     planner)\ngraph.add_node(\"retrieve\", retriever)\ngraph.add_node(\"act\",      tool_runner)\ngraph.add_node(\"reflect\",  critic)\n\ngraph.add_edge(START, \"plan\")\ngraph.add_conditional_edges(\"plan\",\n    route=lambda s: \"retrieve\" if s.needs_context else \"act\")\ngraph.add_edge(\"retrieve\", \"act\")\ngraph.add_conditional_edges(\"act\",\n    route=lambda s: END if s.done else \"reflect\")\ngraph.add_edge(\"reflect\", \"plan\")\n\napp = graph.compile(checkpointer=PostgresSaver(dsn))\n","python","",[37,65,66,74,80,87,93,99,105,111,116,122,128,134,140,146,152,158,163],{"__ignoreMap":63},[67,68,71],"span",{"class":69,"line":70},"line",1,[67,72,73],{},"# the only loop is the runtime's. the agent is a graph.\n",[67,75,77],{"class":69,"line":76},2,[67,78,79],{},"graph = StateGraph(AgentState)\n",[67,81,83],{"class":69,"line":82},3,[67,84,86],{"emptyLinePlaceholder":85},true,"\n",[67,88,90],{"class":69,"line":89},4,[67,91,92],{},"graph.add_node(\"plan\",     planner)\n",[67,94,96],{"class":69,"line":95},5,[67,97,98],{},"graph.add_node(\"retrieve\", retriever)\n",[67,100,102],{"class":69,"line":101},6,[67,103,104],{},"graph.add_node(\"act\",      tool_runner)\n",[67,106,108],{"class":69,"line":107},7,[67,109,110],{},"graph.add_node(\"reflect\",  critic)\n",[67,112,114],{"class":69,"line":113},8,[67,115,86],{"emptyLinePlaceholder":85},[67,117,119],{"class":69,"line":118},9,[67,120,121],{},"graph.add_edge(START, \"plan\")\n",[67,123,125],{"class":69,"line":124},10,[67,126,127],{},"graph.add_conditional_edges(\"plan\",\n",[67,129,131],{"class":69,"line":130},11,[67,132,133],{},"    route=lambda s: \"retrieve\" if s.needs_context else \"act\")\n",[67,135,137],{"class":69,"line":136},12,[67,138,139],{},"graph.add_edge(\"retrieve\", \"act\")\n",[67,141,143],{"class":69,"line":142},13,[67,144,145],{},"graph.add_conditional_edges(\"act\",\n",[67,147,149],{"class":69,"line":148},14,[67,150,151],{},"    route=lambda s: END if s.done else \"reflect\")\n",[67,153,155],{"class":69,"line":154},15,[67,156,157],{},"graph.add_edge(\"reflect\", \"plan\")\n",[67,159,161],{"class":69,"line":160},16,[67,162,86],{"emptyLinePlaceholder":85},[67,164,166],{"class":69,"line":165},17,[67,167,168],{},"app = graph.compile(checkpointer=PostgresSaver(dsn))\n",[22,170,172],{"id":171},"checkpointing-with-postgres","Checkpointing with Postgres",[12,174,175,176,179,180,183],{},"We use ",[37,177,178],{},"PostgresSaver"," as the checkpointer. Each transition writes a row to ",[37,181,182],{},"agent_checkpoints(thread_id, step, state_json, created_at)",". This gives us:",[185,186,187,194,200,206],"ul",{},[188,189,190,193],"li",{},[32,191,192],{},"Resume on crash"," — the runner picks up from the last completed step",[188,195,196,199],{},[32,197,198],{},"Human review"," — we can inspect any intermediate state",[188,201,202,205],{},[32,203,204],{},"Regression testing"," — replay from any checkpoint with mocked tools",[188,207,208,211],{},[32,209,210],{},"Cost attribution"," — each step row includes token counts",[12,213,214],{},"The Postgres write adds ~3ms per step. For a 20-step agent that's 60ms. Acceptable.",[22,216,218],{"id":217},"what-you-cannot-checkpoint","What you cannot checkpoint",[12,220,221,222,225],{},"Streaming tool outputs. If your tool streams data to the user mid-graph, you cannot safely replay that transition without re-triggering the stream. We solved this by marking streaming nodes as ",[37,223,224],{},"non_resumable"," and re-running them fresh on any replay.",[22,227,229],{"id":228},"observability","Observability",[12,231,232,233,236],{},"Every graph execution emits a structured event per step: ",[37,234,235],{},"{thread_id, step_name, input_tokens, output_tokens, latency_ms, tool_calls, error}",". We ship these to a time-series store and build dashboards per agent per week. When a new tool slows down the median step time, we see it in one day, not one sprint.",[238,239,240],"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":63,"searchDepth":76,"depth":76,"links":242},[243,244,245,246,247],{"id":24,"depth":76,"text":25},{"id":55,"depth":76,"text":56},{"id":171,"depth":76,"text":172},{"id":217,"depth":76,"text":218},{"id":228,"depth":76,"text":229},"agents","2026-05-09",32,null,"md",{},"\u002Farticles\u002Fagent-graphs",{"x":256,"y":257,"depth":258,"size":259},0.66,0.27,1.5,"xl",18,[262,263],"microservice-cost","postgres-edge",{"title":6,"description":14},"orchestrator-graphs","articles\u002Fagent-graphs",[268,269,270,228],"ai-agents","langgraph","state-machines","v0.4.7","cIIyuFA4698dRGc4qkKZeBzE89ADrLMs-L496alQBzU",{"id":274,"title":275,"articleId":276,"body":277,"category":1276,"codeLang":303,"date":1277,"deploys":70,"description":1278,"excerpt":251,"extension":252,"lang":251,"meta":1279,"navigation":85,"path":1280,"pos":1281,"readMin":142,"related":1285,"seo":1286,"service":1287,"stem":1288,"tags":1289,"version":1296,"__hash__":1297},"articles\u002Farticles\u002Fansible-production.md","Ansible beyond the tutorial: idempotency, drift detection, and the playbook that saved a 3am incident","ansible-production",{"type":9,"value":278,"toc":1269},[279,286,289,293,296,299,368,383,386,528,531,535,538,541,949,958,962,969,1051,1060,1064,1067,1073,1149,1165,1198,1208,1212,1239,1252,1266],[12,280,281,282,285],{},"The demo playbook installs nginx and starts it. It runs once on a clean VM and works. Everyone watching the demo nods. The thing that nobody demonstrates is running that same playbook six months later on a server where an engineer manually edited ",[37,283,284],{},"\u002Fetc\u002Fnginx\u002Fnginx.conf"," to temporarily fix a production issue and then forgot to document it. Or running it after the nginx package was upgraded by an unattended apt cron job. Or running it on a server that was never properly converged because someone cancelled the playbook halfway through.",[12,287,288],{},"Production Ansible is not about running playbooks. It is about reliably converging infrastructure to a known state — including infrastructure that has drifted from what Ansible last configured.",[22,290,292],{"id":291},"idempotency-is-a-contract-not-a-feature","Idempotency is a contract, not a feature",[12,294,295],{},"Ansible modules are documented as idempotent, and most are. But \"idempotent\" in Ansible means \"running this module twice with the same arguments produces the same result\" — it does not mean \"this module is safe to run on a system in an unknown state.\"",[12,297,298],{},"Consider a common pattern that breaks under drift:",[58,300,304],{"className":301,"code":302,"language":303,"meta":63,"style":63},"language-yaml shiki shiki-themes github-light github-dark","# This looks fine. It is not fine if the service was manually disabled.\n- name: Ensure application service is running\n  ansible.builtin.service:\n    name: myapp\n    state: started\n    enabled: true\n","yaml",[37,305,306,312,329,337,347,357],{"__ignoreMap":63},[67,307,308],{"class":69,"line":70},[67,309,311],{"class":310},"sJ8bj","# This looks fine. It is not fine if the service was manually disabled.\n",[67,313,314,318,322,325],{"class":69,"line":76},[67,315,317],{"class":316},"sVt8B","- ",[67,319,321],{"class":320},"s9eBZ","name",[67,323,324],{"class":316},": ",[67,326,328],{"class":327},"sZZnC","Ensure application service is running\n",[67,330,331,334],{"class":69,"line":82},[67,332,333],{"class":320},"  ansible.builtin.service",[67,335,336],{"class":316},":\n",[67,338,339,342,344],{"class":69,"line":89},[67,340,341],{"class":320},"    name",[67,343,324],{"class":316},[67,345,346],{"class":327},"myapp\n",[67,348,349,352,354],{"class":69,"line":95},[67,350,351],{"class":320},"    state",[67,353,324],{"class":316},[67,355,356],{"class":327},"started\n",[67,358,359,362,364],{"class":69,"line":101},[67,360,361],{"class":320},"    enabled",[67,363,324],{"class":316},[67,365,367],{"class":366},"sj4cs","true\n",[12,369,370,371,374,375,378,379,382],{},"If an engineer ran ",[37,372,373],{},"systemctl disable myapp --now"," on a server to debug a CPU spike and then forgot, this task reports ",[37,376,377],{},"ok"," (already started) or ",[37,380,381],{},"changed"," (re-enabled), but it does not tell you that the manual intervention happened. Your playbook converges the state, but you have lost the signal that there was drift.",[12,384,385],{},"The pattern I use instead:",[58,387,389],{"className":301,"code":388,"language":303,"meta":63,"style":63},"- name: Check if service has been manually overridden\n  ansible.builtin.command: systemctl is-enabled myapp\n  register: svc_enabled\n  changed_when: false\n  failed_when: false\n\n- name: Warn on manual override\n  ansible.builtin.debug:\n    msg: \"WARNING: myapp service is {{ svc_enabled.stdout }} — expected 'enabled'\"\n  when: svc_enabled.stdout != 'enabled'\n\n- name: Converge service state\n  ansible.builtin.service:\n    name: myapp\n    state: started\n    enabled: true\n",[37,390,391,402,412,422,432,441,445,456,463,473,483,487,498,504,512,520],{"__ignoreMap":63},[67,392,393,395,397,399],{"class":69,"line":70},[67,394,317],{"class":316},[67,396,321],{"class":320},[67,398,324],{"class":316},[67,400,401],{"class":327},"Check if service has been manually overridden\n",[67,403,404,407,409],{"class":69,"line":76},[67,405,406],{"class":320},"  ansible.builtin.command",[67,408,324],{"class":316},[67,410,411],{"class":327},"systemctl is-enabled myapp\n",[67,413,414,417,419],{"class":69,"line":82},[67,415,416],{"class":320},"  register",[67,418,324],{"class":316},[67,420,421],{"class":327},"svc_enabled\n",[67,423,424,427,429],{"class":69,"line":89},[67,425,426],{"class":320},"  changed_when",[67,428,324],{"class":316},[67,430,431],{"class":366},"false\n",[67,433,434,437,439],{"class":69,"line":95},[67,435,436],{"class":320},"  failed_when",[67,438,324],{"class":316},[67,440,431],{"class":366},[67,442,443],{"class":69,"line":101},[67,444,86],{"emptyLinePlaceholder":85},[67,446,447,449,451,453],{"class":69,"line":107},[67,448,317],{"class":316},[67,450,321],{"class":320},[67,452,324],{"class":316},[67,454,455],{"class":327},"Warn on manual override\n",[67,457,458,461],{"class":69,"line":113},[67,459,460],{"class":320},"  ansible.builtin.debug",[67,462,336],{"class":316},[67,464,465,468,470],{"class":69,"line":118},[67,466,467],{"class":320},"    msg",[67,469,324],{"class":316},[67,471,472],{"class":327},"\"WARNING: myapp service is {{ svc_enabled.stdout }} — expected 'enabled'\"\n",[67,474,475,478,480],{"class":69,"line":124},[67,476,477],{"class":320},"  when",[67,479,324],{"class":316},[67,481,482],{"class":327},"svc_enabled.stdout != 'enabled'\n",[67,484,485],{"class":69,"line":130},[67,486,86],{"emptyLinePlaceholder":85},[67,488,489,491,493,495],{"class":69,"line":136},[67,490,317],{"class":316},[67,492,321],{"class":320},[67,494,324],{"class":316},[67,496,497],{"class":327},"Converge service state\n",[67,499,500,502],{"class":69,"line":142},[67,501,333],{"class":320},[67,503,336],{"class":316},[67,505,506,508,510],{"class":69,"line":148},[67,507,341],{"class":320},[67,509,324],{"class":316},[67,511,346],{"class":327},[67,513,514,516,518],{"class":69,"line":154},[67,515,351],{"class":320},[67,517,324],{"class":316},[67,519,356],{"class":327},[67,521,522,524,526],{"class":69,"line":160},[67,523,361],{"class":320},[67,525,324],{"class":316},[67,527,367],{"class":366},[12,529,530],{},"The warning does not block the playbook. It produces a visible signal that a human made a change that Ansible is now overwriting. In a CI\u002FCD context, you parse this output and create an alert.",[22,532,534],{"id":533},"the-3am-playbook","The 3am playbook",[12,536,537],{},"Here is the scenario: production API servers are returning 502. The load balancer health checks are failing. The on-call engineer has 90 seconds before customers notice. The root cause is that a deploy job timed out halfway through updating the nginx upstream configuration, leaving three of eight servers with the old config and five with the new.",[12,539,540],{},"The remediation playbook is what you write when you are not under pressure, so that when you are under pressure, you can run one command:",[58,542,544],{"className":301,"code":543,"language":303,"meta":63,"style":63},"---\n- name: Emergency nginx config convergence\n  hosts: api_servers\n  serial: 2               # converge two at a time, keep 6\u002F8 serving traffic\n  max_fail_percentage: 25 # abort if more than 2 servers fail convergence\n\n  tasks:\n    - name: Validate config template renders without errors\n      ansible.builtin.template:\n        src:  templates\u002Fnginx-upstream.conf.j2\n        dest: \u002Ftmp\u002Fnginx-upstream-validate.conf\n        mode: '0600'\n      changed_when: false\n\n    - name: Syntax check the rendered config\n      ansible.builtin.command: nginx -t -c \u002Ftmp\u002Fnginx-upstream-validate.conf\n      changed_when: false\n      # If nginx -t fails, the play fails here — before touching the live config\n\n    - name: Deploy nginx upstream config\n      ansible.builtin.template:\n        src:   templates\u002Fnginx-upstream.conf.j2\n        dest:  \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n        owner: root\n        group: root\n        mode:  '0644'\n        backup: true    # keeps upstream.conf.TIMESTAMP on the server\n      notify: reload nginx\n\n    - name: Verify health endpoint responds after reload\n      ansible.builtin.uri:\n        url:            \"http:\u002F\u002Flocalhost:{{ app_port }}\u002Fhealth\"\n        status_code:    200\n        timeout:        10\n      retries: 3\n      delay: 2\n\n  handlers:\n    - name: reload nginx\n      ansible.builtin.service:\n        name:  nginx\n        state: reloaded\n      # reloaded, not restarted — zero downtime config update\n",[37,545,546,552,563,573,586,599,603,610,622,629,640,650,660,669,673,684,694,702,707,712,724,731,741,751,762,772,782,796,807,812,824,832,843,855,867,878,889,894,902,913,921,932,943],{"__ignoreMap":63},[67,547,548],{"class":69,"line":70},[67,549,551],{"class":550},"sScJk","---\n",[67,553,554,556,558,560],{"class":69,"line":76},[67,555,317],{"class":316},[67,557,321],{"class":320},[67,559,324],{"class":316},[67,561,562],{"class":327},"Emergency nginx config convergence\n",[67,564,565,568,570],{"class":69,"line":82},[67,566,567],{"class":320},"  hosts",[67,569,324],{"class":316},[67,571,572],{"class":327},"api_servers\n",[67,574,575,578,580,583],{"class":69,"line":89},[67,576,577],{"class":320},"  serial",[67,579,324],{"class":316},[67,581,582],{"class":366},"2",[67,584,585],{"class":310},"               # converge two at a time, keep 6\u002F8 serving traffic\n",[67,587,588,591,593,596],{"class":69,"line":95},[67,589,590],{"class":320},"  max_fail_percentage",[67,592,324],{"class":316},[67,594,595],{"class":366},"25",[67,597,598],{"class":310}," # abort if more than 2 servers fail convergence\n",[67,600,601],{"class":69,"line":101},[67,602,86],{"emptyLinePlaceholder":85},[67,604,605,608],{"class":69,"line":107},[67,606,607],{"class":320},"  tasks",[67,609,336],{"class":316},[67,611,612,615,617,619],{"class":69,"line":113},[67,613,614],{"class":316},"    - ",[67,616,321],{"class":320},[67,618,324],{"class":316},[67,620,621],{"class":327},"Validate config template renders without errors\n",[67,623,624,627],{"class":69,"line":118},[67,625,626],{"class":320},"      ansible.builtin.template",[67,628,336],{"class":316},[67,630,631,634,637],{"class":69,"line":124},[67,632,633],{"class":320},"        src",[67,635,636],{"class":316},":  ",[67,638,639],{"class":327},"templates\u002Fnginx-upstream.conf.j2\n",[67,641,642,645,647],{"class":69,"line":130},[67,643,644],{"class":320},"        dest",[67,646,324],{"class":316},[67,648,649],{"class":327},"\u002Ftmp\u002Fnginx-upstream-validate.conf\n",[67,651,652,655,657],{"class":69,"line":136},[67,653,654],{"class":320},"        mode",[67,656,324],{"class":316},[67,658,659],{"class":327},"'0600'\n",[67,661,662,665,667],{"class":69,"line":142},[67,663,664],{"class":320},"      changed_when",[67,666,324],{"class":316},[67,668,431],{"class":366},[67,670,671],{"class":69,"line":148},[67,672,86],{"emptyLinePlaceholder":85},[67,674,675,677,679,681],{"class":69,"line":154},[67,676,614],{"class":316},[67,678,321],{"class":320},[67,680,324],{"class":316},[67,682,683],{"class":327},"Syntax check the rendered config\n",[67,685,686,689,691],{"class":69,"line":160},[67,687,688],{"class":320},"      ansible.builtin.command",[67,690,324],{"class":316},[67,692,693],{"class":327},"nginx -t -c \u002Ftmp\u002Fnginx-upstream-validate.conf\n",[67,695,696,698,700],{"class":69,"line":165},[67,697,664],{"class":320},[67,699,324],{"class":316},[67,701,431],{"class":366},[67,703,704],{"class":69,"line":260},[67,705,706],{"class":310},"      # If nginx -t fails, the play fails here — before touching the live config\n",[67,708,710],{"class":69,"line":709},19,[67,711,86],{"emptyLinePlaceholder":85},[67,713,715,717,719,721],{"class":69,"line":714},20,[67,716,614],{"class":316},[67,718,321],{"class":320},[67,720,324],{"class":316},[67,722,723],{"class":327},"Deploy nginx upstream config\n",[67,725,727,729],{"class":69,"line":726},21,[67,728,626],{"class":320},[67,730,336],{"class":316},[67,732,734,736,739],{"class":69,"line":733},22,[67,735,633],{"class":320},[67,737,738],{"class":316},":   ",[67,740,639],{"class":327},[67,742,744,746,748],{"class":69,"line":743},23,[67,745,644],{"class":320},[67,747,636],{"class":316},[67,749,750],{"class":327},"\u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n",[67,752,754,757,759],{"class":69,"line":753},24,[67,755,756],{"class":320},"        owner",[67,758,324],{"class":316},[67,760,761],{"class":327},"root\n",[67,763,765,768,770],{"class":69,"line":764},25,[67,766,767],{"class":320},"        group",[67,769,324],{"class":316},[67,771,761],{"class":327},[67,773,775,777,779],{"class":69,"line":774},26,[67,776,654],{"class":320},[67,778,636],{"class":316},[67,780,781],{"class":327},"'0644'\n",[67,783,785,788,790,793],{"class":69,"line":784},27,[67,786,787],{"class":320},"        backup",[67,789,324],{"class":316},[67,791,792],{"class":366},"true",[67,794,795],{"class":310},"    # keeps upstream.conf.TIMESTAMP on the server\n",[67,797,799,802,804],{"class":69,"line":798},28,[67,800,801],{"class":320},"      notify",[67,803,324],{"class":316},[67,805,806],{"class":327},"reload nginx\n",[67,808,810],{"class":69,"line":809},29,[67,811,86],{"emptyLinePlaceholder":85},[67,813,815,817,819,821],{"class":69,"line":814},30,[67,816,614],{"class":316},[67,818,321],{"class":320},[67,820,324],{"class":316},[67,822,823],{"class":327},"Verify health endpoint responds after reload\n",[67,825,827,830],{"class":69,"line":826},31,[67,828,829],{"class":320},"      ansible.builtin.uri",[67,831,336],{"class":316},[67,833,834,837,840],{"class":69,"line":250},[67,835,836],{"class":320},"        url",[67,838,839],{"class":316},":            ",[67,841,842],{"class":327},"\"http:\u002F\u002Flocalhost:{{ app_port }}\u002Fhealth\"\n",[67,844,846,849,852],{"class":69,"line":845},33,[67,847,848],{"class":320},"        status_code",[67,850,851],{"class":316},":    ",[67,853,854],{"class":366},"200\n",[67,856,858,861,864],{"class":69,"line":857},34,[67,859,860],{"class":320},"        timeout",[67,862,863],{"class":316},":        ",[67,865,866],{"class":366},"10\n",[67,868,870,873,875],{"class":69,"line":869},35,[67,871,872],{"class":320},"      retries",[67,874,324],{"class":316},[67,876,877],{"class":366},"3\n",[67,879,881,884,886],{"class":69,"line":880},36,[67,882,883],{"class":320},"      delay",[67,885,324],{"class":316},[67,887,888],{"class":366},"2\n",[67,890,892],{"class":69,"line":891},37,[67,893,86],{"emptyLinePlaceholder":85},[67,895,897,900],{"class":69,"line":896},38,[67,898,899],{"class":320},"  handlers",[67,901,336],{"class":316},[67,903,905,907,909,911],{"class":69,"line":904},39,[67,906,614],{"class":316},[67,908,321],{"class":320},[67,910,324],{"class":316},[67,912,806],{"class":327},[67,914,916,919],{"class":69,"line":915},40,[67,917,918],{"class":320},"      ansible.builtin.service",[67,920,336],{"class":316},[67,922,924,927,929],{"class":69,"line":923},41,[67,925,926],{"class":320},"        name",[67,928,636],{"class":316},[67,930,931],{"class":327},"nginx\n",[67,933,935,938,940],{"class":69,"line":934},42,[67,936,937],{"class":320},"        state",[67,939,324],{"class":316},[67,941,942],{"class":327},"reloaded\n",[67,944,946],{"class":69,"line":945},43,[67,947,948],{"class":310},"      # reloaded, not restarted — zero downtime config update\n",[12,950,951,954,955,957],{},[37,952,953],{},"serial: 2"," is the critical parameter here. With eight servers and ",[37,956,953],{},", you always have at least six servers serving traffic during convergence. Without it, Ansible converges all hosts in parallel and you get a brief window where all eight are simultaneously reloading nginx.",[22,959,961],{"id":960},"vault-and-the-secret-you-accidentally-committed","Vault and the secret you accidentally committed",[12,963,964,965,968],{},"Every team eventually commits a secret to their Ansible repository. The textbook answer is Ansible Vault. The production answer is: Ansible Vault for secrets that belong to the playbook, external secrets management (HashiCorp Vault, AWS Secrets Manager) for secrets that are shared across systems, and ",[37,966,967],{},"no_log: true"," on every task that handles either.",[58,970,972],{"className":301,"code":971,"language":303,"meta":63,"style":63},"- name: Set database credentials in application config\n  ansible.builtin.template:\n    src:  templates\u002Fdatabase.php.j2\n    dest: \u002Fvar\u002Fwww\u002Fhtml\u002Fconfig\u002Fdatabase.php\n    mode: '0640'\n  vars:\n    db_password: \"{{ lookup('aws_ssm', '\u002Fprod\u002Fapp\u002Fdb_password', region='eu-west-1') }}\"\n  no_log: true   # prevents the rendered template (containing the password) from appearing in logs\n",[37,973,974,985,992,1002,1012,1022,1029,1039],{"__ignoreMap":63},[67,975,976,978,980,982],{"class":69,"line":70},[67,977,317],{"class":316},[67,979,321],{"class":320},[67,981,324],{"class":316},[67,983,984],{"class":327},"Set database credentials in application config\n",[67,986,987,990],{"class":69,"line":76},[67,988,989],{"class":320},"  ansible.builtin.template",[67,991,336],{"class":316},[67,993,994,997,999],{"class":69,"line":82},[67,995,996],{"class":320},"    src",[67,998,636],{"class":316},[67,1000,1001],{"class":327},"templates\u002Fdatabase.php.j2\n",[67,1003,1004,1007,1009],{"class":69,"line":89},[67,1005,1006],{"class":320},"    dest",[67,1008,324],{"class":316},[67,1010,1011],{"class":327},"\u002Fvar\u002Fwww\u002Fhtml\u002Fconfig\u002Fdatabase.php\n",[67,1013,1014,1017,1019],{"class":69,"line":95},[67,1015,1016],{"class":320},"    mode",[67,1018,324],{"class":316},[67,1020,1021],{"class":327},"'0640'\n",[67,1023,1024,1027],{"class":69,"line":101},[67,1025,1026],{"class":320},"  vars",[67,1028,336],{"class":316},[67,1030,1031,1034,1036],{"class":69,"line":107},[67,1032,1033],{"class":320},"    db_password",[67,1035,324],{"class":316},[67,1037,1038],{"class":327},"\"{{ lookup('aws_ssm', '\u002Fprod\u002Fapp\u002Fdb_password', region='eu-west-1') }}\"\n",[67,1040,1041,1044,1046,1048],{"class":69,"line":113},[67,1042,1043],{"class":320},"  no_log",[67,1045,324],{"class":316},[67,1047,792],{"class":366},[67,1049,1050],{"class":310},"   # prevents the rendered template (containing the password) from appearing in logs\n",[12,1052,1053,1055,1056,1059],{},[37,1054,967],{}," does not just suppress the task output — it suppresses the diff output too. If you are running ",[37,1057,1058],{},"--diff"," to review what changed, you will not see the rendered template. That is a feature, not a bug.",[22,1061,1063],{"id":1062},"testing-playbooks-before-they-matter","Testing playbooks before they matter",[12,1065,1066],{},"There are two tools I use for every non-trivial role:",[12,1068,1069,1072],{},[32,1070,1071],{},"Molecule"," for role-level testing: it spins up a container (or VM), runs the role, runs the verifier (usually Testinfra), and checks that the desired state was actually achieved — not just that Ansible reported success.",[58,1074,1076],{"className":60,"code":1075,"language":62,"meta":63,"style":63},"# molecule\u002Fdefault\u002Ftests\u002Ftest_nginx.py\nimport testinfra\n\ndef test_nginx_is_running(host):\n    nginx = host.service(\"nginx\")\n    assert nginx.is_running\n    assert nginx.is_enabled\n\ndef test_nginx_config_is_valid(host):\n    result = host.run(\"nginx -t\")\n    assert result.rc == 0\n\ndef test_health_endpoint_responds(host):\n    result = host.run(\"curl -sf http:\u002F\u002Flocalhost:80\u002Fhealth\")\n    assert result.rc == 0\n",[37,1077,1078,1083,1088,1092,1097,1102,1107,1112,1116,1121,1126,1131,1135,1140,1145],{"__ignoreMap":63},[67,1079,1080],{"class":69,"line":70},[67,1081,1082],{},"# molecule\u002Fdefault\u002Ftests\u002Ftest_nginx.py\n",[67,1084,1085],{"class":69,"line":76},[67,1086,1087],{},"import testinfra\n",[67,1089,1090],{"class":69,"line":82},[67,1091,86],{"emptyLinePlaceholder":85},[67,1093,1094],{"class":69,"line":89},[67,1095,1096],{},"def test_nginx_is_running(host):\n",[67,1098,1099],{"class":69,"line":95},[67,1100,1101],{},"    nginx = host.service(\"nginx\")\n",[67,1103,1104],{"class":69,"line":101},[67,1105,1106],{},"    assert nginx.is_running\n",[67,1108,1109],{"class":69,"line":107},[67,1110,1111],{},"    assert nginx.is_enabled\n",[67,1113,1114],{"class":69,"line":113},[67,1115,86],{"emptyLinePlaceholder":85},[67,1117,1118],{"class":69,"line":118},[67,1119,1120],{},"def test_nginx_config_is_valid(host):\n",[67,1122,1123],{"class":69,"line":124},[67,1124,1125],{},"    result = host.run(\"nginx -t\")\n",[67,1127,1128],{"class":69,"line":130},[67,1129,1130],{},"    assert result.rc == 0\n",[67,1132,1133],{"class":69,"line":136},[67,1134,86],{"emptyLinePlaceholder":85},[67,1136,1137],{"class":69,"line":142},[67,1138,1139],{},"def test_health_endpoint_responds(host):\n",[67,1141,1142],{"class":69,"line":148},[67,1143,1144],{},"    result = host.run(\"curl -sf http:\u002F\u002Flocalhost:80\u002Fhealth\")\n",[67,1146,1147],{"class":69,"line":154},[67,1148,1130],{},[12,1150,1151,1159,1160,1164],{},[32,1152,1153,1156,1157],{},[37,1154,1155],{},"--check"," mode with ",[37,1158,1058],{}," before any production run: it shows you what Ansible ",[1161,1162,1163],"em",{},"would"," change without changing it. The diff output on template tasks is particularly useful — you see exactly which lines in the config file would be modified.",[58,1166,1170],{"className":1167,"code":1168,"language":1169,"meta":63,"style":63},"language-bash shiki shiki-themes github-light github-dark","ansible-playbook deploy.yml -i inventories\u002Fproduction --check --diff --limit api_servers[0]\n","bash",[37,1171,1172],{"__ignoreMap":63},[67,1173,1174,1177,1180,1183,1186,1189,1192,1195],{"class":69,"line":70},[67,1175,1176],{"class":550},"ansible-playbook",[67,1178,1179],{"class":327}," deploy.yml",[67,1181,1182],{"class":366}," -i",[67,1184,1185],{"class":327}," inventories\u002Fproduction",[67,1187,1188],{"class":366}," --check",[67,1190,1191],{"class":366}," --diff",[67,1193,1194],{"class":366}," --limit",[67,1196,1197],{"class":327}," api_servers[0]\n",[12,1199,1200,1201,1204,1205,1207],{},"Limiting to one server with ",[37,1202,1203],{},"--limit"," is essential. Running ",[37,1206,1155],{}," across the full production inventory can take minutes. Running it on one representative server takes seconds.",[22,1209,1211],{"id":1210},"what-i-watch-for-in-ansible-code-reviews","What I watch for in Ansible code reviews",[12,1213,1214,1221,1222,1225,1226,1229,1230,1232,1233,1235,1236,1238],{},[32,1215,1216,1217,1220],{},"Tasks without ",[37,1218,1219],{},"changed_when","."," If a ",[37,1223,1224],{},"command"," or ",[37,1227,1228],{},"shell"," task has no ",[37,1231,1219],{}," defined, it reports ",[37,1234,381],{}," every time it runs, even if nothing changed. This makes your ",[37,1237,1155],{}," diff useless and makes post-run reports unreliable for auditing.",[12,1240,1241,1247,1248,1251],{},[32,1242,1243,1246],{},[37,1244,1245],{},"ignore_errors: true"," on anything infrastructure-related."," This is the Ansible equivalent of ",[37,1249,1250],{},"catch (Exception e) {}",". If a task fails, it usually means the system is not in the state you expected. The playbook should stop, not continue with a potentially broken server in the mix.",[12,1253,1254,1261,1262,1265],{},[32,1255,1256,1257,1260],{},"Missing ",[37,1258,1259],{},"become: false"," on tasks that do not need root."," The principle of least privilege applies to Ansible too. Tasks that only read files, run user-space commands, or query service status do not need ",[37,1263,1264],{},"become: true",". A playbook where every task runs as root is a playbook where a bug has the blast radius of the entire server.",[238,1267,1268],{},"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);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}",{"title":63,"searchDepth":76,"depth":76,"links":1270},[1271,1272,1273,1274,1275],{"id":291,"depth":76,"text":292},{"id":533,"depth":76,"text":534},{"id":960,"depth":76,"text":961},{"id":1062,"depth":76,"text":1063},{"id":1210,"depth":76,"text":1211},"backend","2024-05-06","The demo playbook installs nginx and starts it. It runs once on a clean VM and works. Everyone watching the demo nods. The thing that nobody demonstrates is running that same playbook six months later on a server where an engineer manually edited \u002Fetc\u002Fnginx\u002Fnginx.conf to temporarily fix a production issue and then forgot to document it. Or running it after the nginx package was upgraded by an unattended apt cron job. Or running it on a server that was never properly converged because someone cancelled the playbook halfway through.",{},"\u002Farticles\u002Fansible-production",{"x":1282,"y":1283,"depth":1284,"size":252},0.36,0.8,0.9,[262,263],{"title":275,"description":1278},"infra-automation","articles\u002Fansible-production",[1290,1291,1292,1293,1294,1295],"ansible","devops","infrastructure","automation","idempotency","configuration-management","v1.0.0","CVg5Oy-VvwytuNTaRsijeI3zN7Eu6-DtRNWlv_eao4Y",{"id":1299,"title":1300,"articleId":1301,"body":1302,"category":1350,"codeLang":1350,"date":2187,"deploys":70,"description":2188,"excerpt":251,"extension":252,"lang":251,"meta":2189,"navigation":85,"path":2190,"pos":2191,"readMin":118,"related":2194,"seo":2197,"service":2198,"stem":2199,"tags":2200,"version":1296,"__hash__":2206},"articles\u002Farticles\u002Fbridge-pattern.md","The Bridge pattern: separating what you send from how you send it","bridge-pattern",{"type":9,"value":1303,"toc":2179},[1304,1315,1318,1322,1325,1328,1336,1339,1343,1346,1561,1745,1748,1752,1755,1821,1828,1898,1902,1905,1973,1984,1995,1999,2002,2156,2159,2163,2177],[12,1305,1306,1307,1310,1311,1314],{},"The Bridge pattern is explained in most textbooks with shapes and rendering APIs. A ",[37,1308,1309],{},"Shape"," hierarchy and a ",[37,1312,1313],{},"Renderer"," hierarchy, bridged together. The examples are correct and entirely useless as design guidance because nobody's production system is drawing shapes.",[12,1316,1317],{},"The pattern solves a specific, recognisable problem: you have two independently variable dimensions, and you need to combine them without creating a class for every combination. I have seen this problem most often in notification systems, and that is the example this article uses.",[22,1319,1321],{"id":1320},"the-problem-m-n-classes","The problem: M × N classes",[12,1323,1324],{},"You have a notification system. It sends notifications. It sends them via three channels: Email, SMS, and Slack. It sends four types of notifications: PaymentConfirmation, LowInventoryAlert, AccountSuspended, and WeeklyReport.",[12,1326,1327],{},"Without a structure:",[58,1329,1334],{"className":1330,"code":1332,"language":1333},[1331],"language-text","PaymentConfirmationEmail\nPaymentConfirmationSMS\nPaymentConfirmationSlack\nLowInventoryAlertEmail\nLowInventoryAlertSMS\nLowInventoryAlertSlack\nAccountSuspendedEmail\nAccountSuspendedSMS\nAccountSuspendedSlack\nWeeklyReportEmail\nWeeklyReportSMS\nWeeklyReportSlack\n","text",[37,1335,1332],{"__ignoreMap":63},[12,1337,1338],{},"12 classes. Add a fourth channel (push notifications): 16 classes. Add a fifth notification type (PasswordReset): 20 classes. The structure scales as M × N, and each class contains the logic for one type of notification formatted for one channel.",[22,1340,1342],{"id":1341},"the-bridge-extracted","The Bridge extracted",[12,1344,1345],{},"The Bridge separates the two hierarchies and connects them through composition rather than inheritance:",[58,1347,1351],{"className":1348,"code":1349,"language":1350,"meta":63,"style":63},"language-php shiki shiki-themes github-light github-dark","\u002F\u002F The \"implementor\" side: how to send\ninterface NotificationChannel\n{\n    public function send(string $recipient, string $subject, string $body): void;\n}\n\nfinal class EmailChannel implements NotificationChannel\n{\n    public function __construct(private readonly Mailer $mailer) {}\n\n    public function send(string $recipient, string $subject, string $body): void\n    {\n        $this->mailer->send(\n            to:      $recipient,\n            subject: $subject,\n            html:    $body,\n        );\n    }\n}\n\nfinal class SlackChannel implements NotificationChannel\n{\n    public function __construct(private readonly SlackClient $slack) {}\n\n    public function send(string $recipient, string $subject, string $body): void\n    {\n        \u002F\u002F Slack does not have a subject — prepend it to the body\n        $this->slack->postMessage(\n            channel: $recipient,\n            text:    \"*{$subject}*\\n{$body}\",\n        );\n    }\n}\n\nfinal class SmsChannel implements NotificationChannel\n{\n    public function __construct(private readonly SmsProvider $sms) {}\n\n    public function send(string $recipient, string $subject, string $body): void\n    {\n        \u002F\u002F SMS is length-constrained — truncate body to 160 chars\n        $text = \"{$subject}: \" . substr(strip_tags($body), 0, 140);\n        $this->sms->send(phone: $recipient, message: $text);\n    }\n}\n","php",[37,1352,1353,1358,1363,1368,1373,1378,1382,1387,1391,1396,1400,1405,1410,1415,1420,1425,1430,1435,1440,1444,1448,1453,1457,1462,1466,1470,1474,1479,1484,1489,1494,1498,1502,1506,1510,1515,1519,1524,1528,1532,1536,1541,1546,1551,1556],{"__ignoreMap":63},[67,1354,1355],{"class":69,"line":70},[67,1356,1357],{},"\u002F\u002F The \"implementor\" side: how to send\n",[67,1359,1360],{"class":69,"line":76},[67,1361,1362],{},"interface NotificationChannel\n",[67,1364,1365],{"class":69,"line":82},[67,1366,1367],{},"{\n",[67,1369,1370],{"class":69,"line":89},[67,1371,1372],{},"    public function send(string $recipient, string $subject, string $body): void;\n",[67,1374,1375],{"class":69,"line":95},[67,1376,1377],{},"}\n",[67,1379,1380],{"class":69,"line":101},[67,1381,86],{"emptyLinePlaceholder":85},[67,1383,1384],{"class":69,"line":107},[67,1385,1386],{},"final class EmailChannel implements NotificationChannel\n",[67,1388,1389],{"class":69,"line":113},[67,1390,1367],{},[67,1392,1393],{"class":69,"line":118},[67,1394,1395],{},"    public function __construct(private readonly Mailer $mailer) {}\n",[67,1397,1398],{"class":69,"line":124},[67,1399,86],{"emptyLinePlaceholder":85},[67,1401,1402],{"class":69,"line":130},[67,1403,1404],{},"    public function send(string $recipient, string $subject, string $body): void\n",[67,1406,1407],{"class":69,"line":136},[67,1408,1409],{},"    {\n",[67,1411,1412],{"class":69,"line":142},[67,1413,1414],{},"        $this->mailer->send(\n",[67,1416,1417],{"class":69,"line":148},[67,1418,1419],{},"            to:      $recipient,\n",[67,1421,1422],{"class":69,"line":154},[67,1423,1424],{},"            subject: $subject,\n",[67,1426,1427],{"class":69,"line":160},[67,1428,1429],{},"            html:    $body,\n",[67,1431,1432],{"class":69,"line":165},[67,1433,1434],{},"        );\n",[67,1436,1437],{"class":69,"line":260},[67,1438,1439],{},"    }\n",[67,1441,1442],{"class":69,"line":709},[67,1443,1377],{},[67,1445,1446],{"class":69,"line":714},[67,1447,86],{"emptyLinePlaceholder":85},[67,1449,1450],{"class":69,"line":726},[67,1451,1452],{},"final class SlackChannel implements NotificationChannel\n",[67,1454,1455],{"class":69,"line":733},[67,1456,1367],{},[67,1458,1459],{"class":69,"line":743},[67,1460,1461],{},"    public function __construct(private readonly SlackClient $slack) {}\n",[67,1463,1464],{"class":69,"line":753},[67,1465,86],{"emptyLinePlaceholder":85},[67,1467,1468],{"class":69,"line":764},[67,1469,1404],{},[67,1471,1472],{"class":69,"line":774},[67,1473,1409],{},[67,1475,1476],{"class":69,"line":784},[67,1477,1478],{},"        \u002F\u002F Slack does not have a subject — prepend it to the body\n",[67,1480,1481],{"class":69,"line":798},[67,1482,1483],{},"        $this->slack->postMessage(\n",[67,1485,1486],{"class":69,"line":809},[67,1487,1488],{},"            channel: $recipient,\n",[67,1490,1491],{"class":69,"line":814},[67,1492,1493],{},"            text:    \"*{$subject}*\\n{$body}\",\n",[67,1495,1496],{"class":69,"line":826},[67,1497,1434],{},[67,1499,1500],{"class":69,"line":250},[67,1501,1439],{},[67,1503,1504],{"class":69,"line":845},[67,1505,1377],{},[67,1507,1508],{"class":69,"line":857},[67,1509,86],{"emptyLinePlaceholder":85},[67,1511,1512],{"class":69,"line":869},[67,1513,1514],{},"final class SmsChannel implements NotificationChannel\n",[67,1516,1517],{"class":69,"line":880},[67,1518,1367],{},[67,1520,1521],{"class":69,"line":891},[67,1522,1523],{},"    public function __construct(private readonly SmsProvider $sms) {}\n",[67,1525,1526],{"class":69,"line":896},[67,1527,86],{"emptyLinePlaceholder":85},[67,1529,1530],{"class":69,"line":904},[67,1531,1404],{},[67,1533,1534],{"class":69,"line":915},[67,1535,1409],{},[67,1537,1538],{"class":69,"line":923},[67,1539,1540],{},"        \u002F\u002F SMS is length-constrained — truncate body to 160 chars\n",[67,1542,1543],{"class":69,"line":934},[67,1544,1545],{},"        $text = \"{$subject}: \" . substr(strip_tags($body), 0, 140);\n",[67,1547,1548],{"class":69,"line":945},[67,1549,1550],{},"        $this->sms->send(phone: $recipient, message: $text);\n",[67,1552,1554],{"class":69,"line":1553},44,[67,1555,1439],{},[67,1557,1559],{"class":69,"line":1558},45,[67,1560,1377],{},[58,1562,1564],{"className":1348,"code":1563,"language":1350,"meta":63,"style":63},"\u002F\u002F The \"abstraction\" side: what to send\nabstract class Notification\n{\n    public function __construct(\n        protected readonly NotificationChannel $channel,\n    ) {}\n\n    abstract public function send(string $recipient): void;\n}\n\nfinal class PaymentConfirmationNotification extends Notification\n{\n    public function __construct(\n        NotificationChannel $channel,\n        private readonly Payment $payment,\n    ) {\n        parent::__construct($channel);\n    }\n\n    public function send(string $recipient): void\n    {\n        $this->channel->send(\n            recipient: $recipient,\n            subject:   \"Payment confirmed — {$this->payment->reference}\",\n            body:      $this->renderBody(),\n        );\n    }\n\n    private function renderBody(): string\n    {\n        return sprintf(\n            \"Your payment of %s %s has been confirmed.\\nReference: %s\\nDate: %s\",\n            number_format($this->payment->amount \u002F 100, 2),\n            $this->payment->currency,\n            $this->payment->reference,\n            $this->payment->created_at->format('Y-m-d H:i'),\n        );\n    }\n}\n",[37,1565,1566,1571,1576,1580,1585,1590,1595,1599,1604,1608,1612,1617,1621,1625,1630,1635,1640,1645,1649,1653,1658,1662,1667,1672,1677,1682,1686,1690,1694,1699,1703,1708,1713,1718,1723,1728,1733,1737,1741],{"__ignoreMap":63},[67,1567,1568],{"class":69,"line":70},[67,1569,1570],{},"\u002F\u002F The \"abstraction\" side: what to send\n",[67,1572,1573],{"class":69,"line":76},[67,1574,1575],{},"abstract class Notification\n",[67,1577,1578],{"class":69,"line":82},[67,1579,1367],{},[67,1581,1582],{"class":69,"line":89},[67,1583,1584],{},"    public function __construct(\n",[67,1586,1587],{"class":69,"line":95},[67,1588,1589],{},"        protected readonly NotificationChannel $channel,\n",[67,1591,1592],{"class":69,"line":101},[67,1593,1594],{},"    ) {}\n",[67,1596,1597],{"class":69,"line":107},[67,1598,86],{"emptyLinePlaceholder":85},[67,1600,1601],{"class":69,"line":113},[67,1602,1603],{},"    abstract public function send(string $recipient): void;\n",[67,1605,1606],{"class":69,"line":118},[67,1607,1377],{},[67,1609,1610],{"class":69,"line":124},[67,1611,86],{"emptyLinePlaceholder":85},[67,1613,1614],{"class":69,"line":130},[67,1615,1616],{},"final class PaymentConfirmationNotification extends Notification\n",[67,1618,1619],{"class":69,"line":136},[67,1620,1367],{},[67,1622,1623],{"class":69,"line":142},[67,1624,1584],{},[67,1626,1627],{"class":69,"line":148},[67,1628,1629],{},"        NotificationChannel $channel,\n",[67,1631,1632],{"class":69,"line":154},[67,1633,1634],{},"        private readonly Payment $payment,\n",[67,1636,1637],{"class":69,"line":160},[67,1638,1639],{},"    ) {\n",[67,1641,1642],{"class":69,"line":165},[67,1643,1644],{},"        parent::__construct($channel);\n",[67,1646,1647],{"class":69,"line":260},[67,1648,1439],{},[67,1650,1651],{"class":69,"line":709},[67,1652,86],{"emptyLinePlaceholder":85},[67,1654,1655],{"class":69,"line":714},[67,1656,1657],{},"    public function send(string $recipient): void\n",[67,1659,1660],{"class":69,"line":726},[67,1661,1409],{},[67,1663,1664],{"class":69,"line":733},[67,1665,1666],{},"        $this->channel->send(\n",[67,1668,1669],{"class":69,"line":743},[67,1670,1671],{},"            recipient: $recipient,\n",[67,1673,1674],{"class":69,"line":753},[67,1675,1676],{},"            subject:   \"Payment confirmed — {$this->payment->reference}\",\n",[67,1678,1679],{"class":69,"line":764},[67,1680,1681],{},"            body:      $this->renderBody(),\n",[67,1683,1684],{"class":69,"line":774},[67,1685,1434],{},[67,1687,1688],{"class":69,"line":784},[67,1689,1439],{},[67,1691,1692],{"class":69,"line":798},[67,1693,86],{"emptyLinePlaceholder":85},[67,1695,1696],{"class":69,"line":809},[67,1697,1698],{},"    private function renderBody(): string\n",[67,1700,1701],{"class":69,"line":814},[67,1702,1409],{},[67,1704,1705],{"class":69,"line":826},[67,1706,1707],{},"        return sprintf(\n",[67,1709,1710],{"class":69,"line":250},[67,1711,1712],{},"            \"Your payment of %s %s has been confirmed.\\nReference: %s\\nDate: %s\",\n",[67,1714,1715],{"class":69,"line":845},[67,1716,1717],{},"            number_format($this->payment->amount \u002F 100, 2),\n",[67,1719,1720],{"class":69,"line":857},[67,1721,1722],{},"            $this->payment->currency,\n",[67,1724,1725],{"class":69,"line":869},[67,1726,1727],{},"            $this->payment->reference,\n",[67,1729,1730],{"class":69,"line":880},[67,1731,1732],{},"            $this->payment->created_at->format('Y-m-d H:i'),\n",[67,1734,1735],{"class":69,"line":891},[67,1736,1434],{},[67,1738,1739],{"class":69,"line":896},[67,1740,1439],{},[67,1742,1743],{"class":69,"line":904},[67,1744,1377],{},[12,1746,1747],{},"Now the structure is M + N: 4 notification classes plus 3 channel classes. Adding a fourth channel (PushNotification) is one new class. Adding a fifth notification type (PasswordReset) is one new class. Nothing else changes.",[22,1749,1751],{"id":1750},"the-composition-point","The composition point",[12,1753,1754],{},"The Bridge happens at construction: you compose a notification type with a channel:",[58,1756,1758],{"className":1348,"code":1757,"language":1350,"meta":63,"style":63},"\u002F\u002F Wired by the application layer — typically a notification dispatcher\n$notification = new PaymentConfirmationNotification(\n    channel: new EmailChannel($mailer),\n    payment: $payment,\n);\n$notification->send(recipient: $user->email);\n\n\u002F\u002F Same notification type, different channel\n$notification = new PaymentConfirmationNotification(\n    channel: new SmsChannel($smsProvider),\n    payment: $payment,\n);\n$notification->send(recipient: $user->phone);\n",[37,1759,1760,1765,1770,1775,1780,1785,1790,1794,1799,1803,1808,1812,1816],{"__ignoreMap":63},[67,1761,1762],{"class":69,"line":70},[67,1763,1764],{},"\u002F\u002F Wired by the application layer — typically a notification dispatcher\n",[67,1766,1767],{"class":69,"line":76},[67,1768,1769],{},"$notification = new PaymentConfirmationNotification(\n",[67,1771,1772],{"class":69,"line":82},[67,1773,1774],{},"    channel: new EmailChannel($mailer),\n",[67,1776,1777],{"class":69,"line":89},[67,1778,1779],{},"    payment: $payment,\n",[67,1781,1782],{"class":69,"line":95},[67,1783,1784],{},");\n",[67,1786,1787],{"class":69,"line":101},[67,1788,1789],{},"$notification->send(recipient: $user->email);\n",[67,1791,1792],{"class":69,"line":107},[67,1793,86],{"emptyLinePlaceholder":85},[67,1795,1796],{"class":69,"line":113},[67,1797,1798],{},"\u002F\u002F Same notification type, different channel\n",[67,1800,1801],{"class":69,"line":118},[67,1802,1769],{},[67,1804,1805],{"class":69,"line":124},[67,1806,1807],{},"    channel: new SmsChannel($smsProvider),\n",[67,1809,1810],{"class":69,"line":130},[67,1811,1779],{},[67,1813,1814],{"class":69,"line":136},[67,1815,1784],{},[67,1817,1818],{"class":69,"line":142},[67,1819,1820],{},"$notification->send(recipient: $user->phone);\n",[12,1822,1823,1824,1827],{},"In practice, this composition is handled by a ",[37,1825,1826],{},"NotificationDispatcher"," that looks up which channels a given user has opted into:",[58,1829,1831],{"className":1348,"code":1830,"language":1350,"meta":63,"style":63},"final class NotificationDispatcher\n{\n    \u002F** @param NotificationChannel[] $channels *\u002F\n    public function __construct(private readonly array $channels) {}\n\n    public function dispatch(string $notificationType, array $payload, User $user): void\n    {\n        foreach ($user->enabledChannels() as $channelName) {\n            $channel      = $this->channels[$channelName] ?? throw new UnknownChannelException($channelName);\n            $notification = $this->buildNotification($notificationType, $channel, $payload);\n            $notification->send($user->contactFor($channelName));\n        }\n    }\n}\n",[37,1832,1833,1838,1842,1847,1852,1856,1861,1865,1870,1875,1880,1885,1890,1894],{"__ignoreMap":63},[67,1834,1835],{"class":69,"line":70},[67,1836,1837],{},"final class NotificationDispatcher\n",[67,1839,1840],{"class":69,"line":76},[67,1841,1367],{},[67,1843,1844],{"class":69,"line":82},[67,1845,1846],{},"    \u002F** @param NotificationChannel[] $channels *\u002F\n",[67,1848,1849],{"class":69,"line":89},[67,1850,1851],{},"    public function __construct(private readonly array $channels) {}\n",[67,1853,1854],{"class":69,"line":95},[67,1855,86],{"emptyLinePlaceholder":85},[67,1857,1858],{"class":69,"line":101},[67,1859,1860],{},"    public function dispatch(string $notificationType, array $payload, User $user): void\n",[67,1862,1863],{"class":69,"line":107},[67,1864,1409],{},[67,1866,1867],{"class":69,"line":113},[67,1868,1869],{},"        foreach ($user->enabledChannels() as $channelName) {\n",[67,1871,1872],{"class":69,"line":118},[67,1873,1874],{},"            $channel      = $this->channels[$channelName] ?? throw new UnknownChannelException($channelName);\n",[67,1876,1877],{"class":69,"line":124},[67,1878,1879],{},"            $notification = $this->buildNotification($notificationType, $channel, $payload);\n",[67,1881,1882],{"class":69,"line":130},[67,1883,1884],{},"            $notification->send($user->contactFor($channelName));\n",[67,1886,1887],{"class":69,"line":136},[67,1888,1889],{},"        }\n",[67,1891,1892],{"class":69,"line":142},[67,1893,1439],{},[67,1895,1896],{"class":69,"line":148},[67,1897,1377],{},[22,1899,1901],{"id":1900},"where-the-abstraction-starts-leaking","Where the abstraction starts leaking",[12,1903,1904],{},"The Bridge works cleanly when the two dimensions are genuinely independent. They stop being independent when a notification type has content that only makes sense on one channel — a detailed HTML report for email, with no meaningful SMS equivalent.",[58,1906,1908],{"className":1348,"code":1907,"language":1350,"meta":63,"style":63},"\u002F\u002F This is the first sign of a leaking abstraction\nfinal class WeeklyReportNotification extends Notification\n{\n    public function send(string $recipient): void\n    {\n        if ($this->channel instanceof SmsChannel) {\n            \u002F\u002F What do we send? A summary? A link? Nothing?\n            $this->channel->send($recipient, 'Weekly report', 'See your email for the full report.');\n            return;\n        }\n\n        $this->channel->send($recipient, 'Weekly Report', $this->renderFullHtmlReport());\n    }\n}\n",[37,1909,1910,1915,1920,1924,1928,1932,1937,1942,1947,1952,1956,1960,1965,1969],{"__ignoreMap":63},[67,1911,1912],{"class":69,"line":70},[67,1913,1914],{},"\u002F\u002F This is the first sign of a leaking abstraction\n",[67,1916,1917],{"class":69,"line":76},[67,1918,1919],{},"final class WeeklyReportNotification extends Notification\n",[67,1921,1922],{"class":69,"line":82},[67,1923,1367],{},[67,1925,1926],{"class":69,"line":89},[67,1927,1657],{},[67,1929,1930],{"class":69,"line":95},[67,1931,1409],{},[67,1933,1934],{"class":69,"line":101},[67,1935,1936],{},"        if ($this->channel instanceof SmsChannel) {\n",[67,1938,1939],{"class":69,"line":107},[67,1940,1941],{},"            \u002F\u002F What do we send? A summary? A link? Nothing?\n",[67,1943,1944],{"class":69,"line":113},[67,1945,1946],{},"            $this->channel->send($recipient, 'Weekly report', 'See your email for the full report.');\n",[67,1948,1949],{"class":69,"line":118},[67,1950,1951],{},"            return;\n",[67,1953,1954],{"class":69,"line":124},[67,1955,1889],{},[67,1957,1958],{"class":69,"line":130},[67,1959,86],{"emptyLinePlaceholder":85},[67,1961,1962],{"class":69,"line":136},[67,1963,1964],{},"        $this->channel->send($recipient, 'Weekly Report', $this->renderFullHtmlReport());\n",[67,1966,1967],{"class":69,"line":142},[67,1968,1439],{},[67,1970,1971],{"class":69,"line":148},[67,1972,1377],{},[12,1974,1975,1976,1979,1980,1983],{},"The ",[37,1977,1978],{},"instanceof"," check is the Bridge pattern breaking down. The ",[37,1981,1982],{},"WeeklyReport"," notification is not channel-agnostic — it has different behaviour per channel, which means the abstraction no longer holds.",[12,1985,1986,1987,1990,1991,1994],{},"The honest fix is to not force the pattern. Some notifications have channel-specific implementations. Create ",[37,1988,1989],{},"WeeklyReportEmailNotification"," and ",[37,1992,1993],{},"WeeklyReportSmsSummaryNotification"," as separate classes. Forcing M + N where the problem is genuinely M × N produces worse code than just writing the M × N classes clearly.",[22,1996,1998],{"id":1997},"testing-the-pattern","Testing the pattern",[12,2000,2001],{},"The value of the Bridge for testing is that each dimension is independently testable:",[58,2003,2005],{"className":1348,"code":2004,"language":1350,"meta":63,"style":63},"\u002F\u002F Test the channel independently\nclass EmailChannelTest extends TestCase\n{\n    public function testSendsDelegatesCorrectlyToMailer(): void\n    {\n        $mailer = $this->createMock(Mailer::class);\n        $mailer->expects($this->once())\n               ->method('send')\n               ->with(to: 'alice@example.com', subject: 'Test', html: '\u003Cp>Body\u003C\u002Fp>');\n\n        (new EmailChannel($mailer))->send('alice@example.com', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n    }\n}\n\n\u002F\u002F Test the notification independently using a mock channel\nclass PaymentConfirmationNotificationTest extends TestCase\n{\n    public function testSendsCorrectSubjectAndBody(): void\n    {\n        $channel = $this->createMock(NotificationChannel::class);\n        $channel->expects($this->once())\n                ->method('send')\n                ->with(\n                    recipient: 'alice@example.com',\n                    subject:   $this->stringContains('PAY-2024-001'),\n                    body:      $this->stringContains('100.00'),\n                );\n\n        $payment = new Payment(id: 1, reference: 'PAY-2024-001', amount: 10000, currency: 'PLN', ...);\n        (new PaymentConfirmationNotification($channel, $payment))->send('alice@example.com');\n    }\n}\n",[37,2006,2007,2012,2017,2021,2026,2030,2035,2040,2045,2050,2054,2059,2063,2067,2071,2076,2081,2085,2090,2094,2099,2104,2109,2114,2119,2124,2129,2134,2138,2143,2148,2152],{"__ignoreMap":63},[67,2008,2009],{"class":69,"line":70},[67,2010,2011],{},"\u002F\u002F Test the channel independently\n",[67,2013,2014],{"class":69,"line":76},[67,2015,2016],{},"class EmailChannelTest extends TestCase\n",[67,2018,2019],{"class":69,"line":82},[67,2020,1367],{},[67,2022,2023],{"class":69,"line":89},[67,2024,2025],{},"    public function testSendsDelegatesCorrectlyToMailer(): void\n",[67,2027,2028],{"class":69,"line":95},[67,2029,1409],{},[67,2031,2032],{"class":69,"line":101},[67,2033,2034],{},"        $mailer = $this->createMock(Mailer::class);\n",[67,2036,2037],{"class":69,"line":107},[67,2038,2039],{},"        $mailer->expects($this->once())\n",[67,2041,2042],{"class":69,"line":113},[67,2043,2044],{},"               ->method('send')\n",[67,2046,2047],{"class":69,"line":118},[67,2048,2049],{},"               ->with(to: 'alice@example.com', subject: 'Test', html: '\u003Cp>Body\u003C\u002Fp>');\n",[67,2051,2052],{"class":69,"line":124},[67,2053,86],{"emptyLinePlaceholder":85},[67,2055,2056],{"class":69,"line":130},[67,2057,2058],{},"        (new EmailChannel($mailer))->send('alice@example.com', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n",[67,2060,2061],{"class":69,"line":136},[67,2062,1439],{},[67,2064,2065],{"class":69,"line":142},[67,2066,1377],{},[67,2068,2069],{"class":69,"line":148},[67,2070,86],{"emptyLinePlaceholder":85},[67,2072,2073],{"class":69,"line":154},[67,2074,2075],{},"\u002F\u002F Test the notification independently using a mock channel\n",[67,2077,2078],{"class":69,"line":160},[67,2079,2080],{},"class PaymentConfirmationNotificationTest extends TestCase\n",[67,2082,2083],{"class":69,"line":165},[67,2084,1367],{},[67,2086,2087],{"class":69,"line":260},[67,2088,2089],{},"    public function testSendsCorrectSubjectAndBody(): void\n",[67,2091,2092],{"class":69,"line":709},[67,2093,1409],{},[67,2095,2096],{"class":69,"line":714},[67,2097,2098],{},"        $channel = $this->createMock(NotificationChannel::class);\n",[67,2100,2101],{"class":69,"line":726},[67,2102,2103],{},"        $channel->expects($this->once())\n",[67,2105,2106],{"class":69,"line":733},[67,2107,2108],{},"                ->method('send')\n",[67,2110,2111],{"class":69,"line":743},[67,2112,2113],{},"                ->with(\n",[67,2115,2116],{"class":69,"line":753},[67,2117,2118],{},"                    recipient: 'alice@example.com',\n",[67,2120,2121],{"class":69,"line":764},[67,2122,2123],{},"                    subject:   $this->stringContains('PAY-2024-001'),\n",[67,2125,2126],{"class":69,"line":774},[67,2127,2128],{},"                    body:      $this->stringContains('100.00'),\n",[67,2130,2131],{"class":69,"line":784},[67,2132,2133],{},"                );\n",[67,2135,2136],{"class":69,"line":798},[67,2137,86],{"emptyLinePlaceholder":85},[67,2139,2140],{"class":69,"line":809},[67,2141,2142],{},"        $payment = new Payment(id: 1, reference: 'PAY-2024-001', amount: 10000, currency: 'PLN', ...);\n",[67,2144,2145],{"class":69,"line":814},[67,2146,2147],{},"        (new PaymentConfirmationNotification($channel, $payment))->send('alice@example.com');\n",[67,2149,2150],{"class":69,"line":826},[67,2151,1439],{},[67,2153,2154],{"class":69,"line":250},[67,2155,1377],{},[12,2157,2158],{},"Twelve notification–channel combinations, zero tests that spin up a real mailer or Slack API. Each test is fast, isolated, and covers exactly one unit of behaviour.",[22,2160,2162],{"id":2161},"when-to-reach-for-bridge","When to reach for Bridge",[12,2164,2165,2166,2169,2170,2169,2173,2176],{},"One clear signal: you are about to write a class whose name is two concepts joined together — ",[37,2167,2168],{},"PaymentEmailNotification",", ",[37,2171,2172],{},"PdfReportExporter",[37,2174,2175],{},"CsvLogFormatter",". The name itself tells you that two independent dimensions have been fused into a single class. That is the moment to ask whether they could be separated and composed instead.",[238,2178,240],{},{"title":63,"searchDepth":76,"depth":76,"links":2180},[2181,2182,2183,2184,2185,2186],{"id":1320,"depth":76,"text":1321},{"id":1341,"depth":76,"text":1342},{"id":1750,"depth":76,"text":1751},{"id":1900,"depth":76,"text":1901},{"id":1997,"depth":76,"text":1998},{"id":2161,"depth":76,"text":2162},"2023-10-19","The Bridge pattern is explained in most textbooks with shapes and rendering APIs. A Shape hierarchy and a Renderer hierarchy, bridged together. The examples are correct and entirely useless as design guidance because nobody's production system is drawing shapes.",{},"\u002Farticles\u002Fbridge-pattern",{"x":2192,"y":2193,"depth":1284,"size":252},0.86,0.18,[2195,2196],"factory-method","design-patterns-production",{"title":1300,"description":2188},"notification-hub","articles\u002Fbridge-pattern",[1350,2201,2202,2203,2204,2205],"design-patterns","bridge","architecture","notifications","abstraction","_vT5vt8gDVQaSB3M_MVM7Xlknvpa5Acx3mjjzn3Od28",{"id":2208,"title":2209,"articleId":2196,"body":2210,"category":1350,"codeLang":1350,"date":2629,"deploys":70,"description":2214,"excerpt":251,"extension":252,"lang":251,"meta":2630,"navigation":85,"path":2631,"pos":2632,"readMin":154,"related":2636,"seo":2638,"service":2639,"stem":2640,"tags":2641,"version":1296,"__hash__":2644},"articles\u002Farticles\u002Fdesign-patterns-production.md","Design patterns in production: what they solve, what they cost, and when to skip them",{"type":9,"value":2211,"toc":2622},[2212,2215,2218,2221,2225,2236,2246,2271,2277,2301,2305,2311,2403,2409,2482,2492,2502,2508,2512,2533,2561,2567,2577,2583,2587,2593,2599,2605,2609,2614,2617,2620],[12,2213,2214],{},"The GoF book was published in 1994. In the thirty years since, design patterns have gone through at least three complete cycles: introduction, over-application, backlash, and cautious re-adoption. We are somewhere in the fourth or fifth cycle now, depending on which part of the industry you are in.",[12,2216,2217],{},"My view, formed by reading a lot of codebases that apply patterns and a lot that do not: patterns are not solutions. They are a shared vocabulary for naming the shape of a problem you have already solved. The value is not in the implementation — it is in the naming, because naming what you have built is how you communicate it to the next engineer.",[12,2219,2220],{},"What follows is a production-oriented field guide. Not every pattern — the twenty-three canonical ones plus however many the community has added since — but the ones I reach for regularly, the ones I see misapplied most often, and the ones I have never found a genuine use for in application-layer PHP.",[22,2222,2224],{"id":2223},"creational-patterns-object-construction-is-harder-than-it-looks","Creational patterns: object construction is harder than it looks",[12,2226,2227,2230,2231],{},[32,2228,2229],{},"Singleton"," — The pattern that aged worst. Valid exactly when you have immutable configuration that is expensive to load and needs to be shared within a single process. Invalid in almost every other case. ",[2232,2233,2235],"a",{"href":2234},"\u002Farticles\u002Fsingleton-pattern","Covered in depth elsewhere in this series.",[12,2237,2238,2241,2242],{},[32,2239,2240],{},"Factory Method"," — The pattern I reach for most often among the creational group. Necessary when the type of object you need is a runtime decision — the payment gateway selection, the notification channel, the document parser for an unknown file type. The factory does not build objects; it delegates construction to the DI container and returns an interface. ",[2232,2243,2245],{"href":2244},"\u002Farticles\u002Ffactory-method","Covered in depth in this series.",[12,2247,2248,2251,2252,2255,2256,2259,2260,2263,2264,2267,2268,2270],{},[32,2249,2250],{},"Builder"," — Underused for complex domain objects, overused for query construction. A ",[37,2253,2254],{},"QueryBuilder"," is a legitimate Builder: it accumulates conditions, then produces an immutable query object. A ",[37,2257,2258],{},"UserBuilder"," that exists only to make tests readable (",[37,2261,2262],{},"UserBuilder::new()->withName('Alice')->withRole('admin')->build()",") is fine in tests, but if you need a builder to construct a ",[37,2265,2266],{},"User"," in production code, your ",[37,2269,2266],{}," constructor probably does too much.",[12,2272,2273,2276],{},[32,2274,2275],{},"Abstract Factory"," — Rarely necessary in application code. Where I have seen it used correctly: a UI component library that needs to swap between a light and dark theme, producing consistent button, input, and modal components without the caller knowing which theme is active. In backend systems, the DI container typically replaces the need for abstract factories.",[12,2278,2279,2282,2283,2286,2287,2289,2290,2292,2293,2296,2297,2300],{},[32,2280,2281],{},"Prototype"," — I have written production PHP for ten years and needed the Prototype pattern deliberately once. It was for a document template system where copying a template's structure was expensive enough to justify a dedicated ",[37,2284,2285],{},"clone"," interface. For everything else, ",[37,2288,2285],{}," works directly. If you are creating a ",[37,2291,2281],{}," interface with a ",[37,2294,2295],{},"copy()"," method that just calls ",[37,2298,2299],{},"clone $this",", you have added indirection for no benefit.",[22,2302,2304],{"id":2303},"structural-patterns-the-ones-that-pay-rent","Structural patterns: the ones that pay rent",[12,2306,2307,2310],{},[32,2308,2309],{},"Adapter"," — The highest-value structural pattern in application code. Every third-party integration you write is an adapter: it takes the external system's interface and translates it into your domain's interface. The key discipline is keeping the adapter thin. If your Stripe adapter contains business logic about when to retry or how to calculate fees, it is not an adapter — it is a service that happens to call Stripe.",[58,2312,2314],{"className":1348,"code":2313,"language":1350,"meta":63,"style":63},"\u002F\u002F Thin adapter: translates types, nothing more\nfinal class StripePaymentGateway implements PaymentGatewayInterface\n{\n    public function __construct(private readonly \\Stripe\\StripeClient $stripe) {}\n\n    public function charge(Money $amount, string $currency): ChargeResult\n    {\n        try {\n            $intent = $this->stripe->paymentIntents->create([\n                'amount'   => $amount->getAmount(),   \u002F\u002F Stripe wants cents\n                'currency' => strtolower($currency),\n            ]);\n            return new ChargeResult(chargeId: $intent->id, status: ChargeStatus::Pending);\n        } catch (\\Stripe\\Exception\\CardException $e) {\n            return new ChargeResult(chargeId: null, status: ChargeStatus::Declined, error: $e->getMessage());\n        }\n    }\n}\n",[37,2315,2316,2321,2326,2330,2335,2339,2344,2348,2353,2358,2366,2371,2376,2381,2386,2391,2395,2399],{"__ignoreMap":63},[67,2317,2318],{"class":69,"line":70},[67,2319,2320],{},"\u002F\u002F Thin adapter: translates types, nothing more\n",[67,2322,2323],{"class":69,"line":76},[67,2324,2325],{},"final class StripePaymentGateway implements PaymentGatewayInterface\n",[67,2327,2328],{"class":69,"line":82},[67,2329,1367],{},[67,2331,2332],{"class":69,"line":89},[67,2333,2334],{},"    public function __construct(private readonly \\Stripe\\StripeClient $stripe) {}\n",[67,2336,2337],{"class":69,"line":95},[67,2338,86],{"emptyLinePlaceholder":85},[67,2340,2341],{"class":69,"line":101},[67,2342,2343],{},"    public function charge(Money $amount, string $currency): ChargeResult\n",[67,2345,2346],{"class":69,"line":107},[67,2347,1409],{},[67,2349,2350],{"class":69,"line":113},[67,2351,2352],{},"        try {\n",[67,2354,2355],{"class":69,"line":118},[67,2356,2357],{},"            $intent = $this->stripe->paymentIntents->create([\n",[67,2359,2360,2363],{"class":69,"line":124},[67,2361,2362],{},"                'amount'   => $amount->getAmount(),",[67,2364,2365],{},"   \u002F\u002F Stripe wants cents\n",[67,2367,2368],{"class":69,"line":130},[67,2369,2370],{},"                'currency' => strtolower($currency),\n",[67,2372,2373],{"class":69,"line":136},[67,2374,2375],{},"            ]);\n",[67,2377,2378],{"class":69,"line":142},[67,2379,2380],{},"            return new ChargeResult(chargeId: $intent->id, status: ChargeStatus::Pending);\n",[67,2382,2383],{"class":69,"line":148},[67,2384,2385],{},"        } catch (\\Stripe\\Exception\\CardException $e) {\n",[67,2387,2388],{"class":69,"line":154},[67,2389,2390],{},"            return new ChargeResult(chargeId: null, status: ChargeStatus::Declined, error: $e->getMessage());\n",[67,2392,2393],{"class":69,"line":160},[67,2394,1889],{},[67,2396,2397],{"class":69,"line":165},[67,2398,1439],{},[67,2400,2401],{"class":69,"line":260},[67,2402,1377],{},[12,2404,2405,2408],{},[32,2406,2407],{},"Decorator"," — The structural pattern I see over-engineered most often. A decorator adds behaviour to an object without changing its interface. The canonical PHP use case: caching decorators, logging decorators, rate-limiting decorators. These are powerful and correct. What I see instead: decorator chains seven levels deep, where debugging requires understanding which decorator is active in which context and why.",[58,2410,2412],{"className":1348,"code":2411,"language":1350,"meta":63,"style":63},"\u002F\u002F Correct use: transparent caching\nfinal class CachingUserRepository implements UserRepositoryInterface\n{\n    public function __construct(\n        private readonly UserRepositoryInterface $inner,\n        private readonly CacheInterface $cache,\n        private readonly int $ttl = 300,\n    ) {}\n\n    public function findById(int $id): ?User\n    {\n        $key = \"user.{$id}\";\n        return $this->cache->remember($key, $this->ttl, fn() => $this->inner->findById($id));\n    }\n}\n",[37,2413,2414,2419,2424,2428,2432,2437,2442,2447,2451,2455,2460,2464,2469,2474,2478],{"__ignoreMap":63},[67,2415,2416],{"class":69,"line":70},[67,2417,2418],{},"\u002F\u002F Correct use: transparent caching\n",[67,2420,2421],{"class":69,"line":76},[67,2422,2423],{},"final class CachingUserRepository implements UserRepositoryInterface\n",[67,2425,2426],{"class":69,"line":82},[67,2427,1367],{},[67,2429,2430],{"class":69,"line":89},[67,2431,1584],{},[67,2433,2434],{"class":69,"line":95},[67,2435,2436],{},"        private readonly UserRepositoryInterface $inner,\n",[67,2438,2439],{"class":69,"line":101},[67,2440,2441],{},"        private readonly CacheInterface $cache,\n",[67,2443,2444],{"class":69,"line":107},[67,2445,2446],{},"        private readonly int $ttl = 300,\n",[67,2448,2449],{"class":69,"line":113},[67,2450,1594],{},[67,2452,2453],{"class":69,"line":118},[67,2454,86],{"emptyLinePlaceholder":85},[67,2456,2457],{"class":69,"line":124},[67,2458,2459],{},"    public function findById(int $id): ?User\n",[67,2461,2462],{"class":69,"line":130},[67,2463,1409],{},[67,2465,2466],{"class":69,"line":136},[67,2467,2468],{},"        $key = \"user.{$id}\";\n",[67,2470,2471],{"class":69,"line":142},[67,2472,2473],{},"        return $this->cache->remember($key, $this->ttl, fn() => $this->inner->findById($id));\n",[67,2475,2476],{"class":69,"line":148},[67,2477,1439],{},[67,2479,2480],{"class":69,"line":154},[67,2481,1377],{},[12,2483,2484,2485,2488,2489,2491],{},"The test for the caching decorator verifies that it calls ",[37,2486,2487],{},"inner"," on a cache miss and skips ",[37,2490,2487],{}," on a hit. The test for the database repository verifies data access. They are independently testable, independently deployable.",[12,2493,2494,2497,2498,2501],{},[32,2495,2496],{},"Facade"," — Overused as a band-aid. A facade simplifies a complex subsystem behind a single interface. Laravel's static facades (",[37,2499,2500],{},"DB::table('users')",") are an opinionated implementation of this. The correct use: when a subsystem has ten classes and the caller needs to interact with only two or three operations from it. The misuse: wrapping a single class behind a facade to avoid injecting it.",[12,2503,2504,2507],{},[32,2505,2506],{},"Proxy"," — Most commonly encountered in PHP via lazy-loading proxy generators (Doctrine, Symfony). Building your own proxy is rare and usually wrong. If you need to intercept method calls for logging, caching, or access control, a decorator is almost always the better tool because it is explicit. A proxy that intercepts calls transparently is harder to test and harder to reason about.",[22,2509,2511],{"id":2510},"behavioural-patterns-where-most-of-the-real-design-work-happens","Behavioural patterns: where most of the real design work happens",[12,2513,2514,2517,2518,2521,2522,2525,2526,2529,2530,2532],{},[32,2515,2516],{},"Observer \u002F Event"," — The pattern that scales most cleanly in modern PHP, because every framework has an event dispatcher. When ",[37,2519,2520],{},"Order"," transitions to ",[37,2523,2524],{},"Paid",", it dispatches ",[37,2527,2528],{},"OrderPaid",". The email listener, the inventory listener, and the analytics listener all subscribe independently. Adding a fourth listener requires no changes to ",[37,2531,2520],{}," or to the other three listeners.",[12,2534,2535,2536,2538,2539,2538,2542,2538,2545,2538,2548,2538,2551,2538,2554,2557,2558,2560],{},"The failure mode: event listeners that have cascading effects with no circuit breaker. I have seen a chain where ",[37,2537,2528],{}," → ",[37,2540,2541],{},"ReserveInventory",[37,2543,2544],{},"InventoryLow",[37,2546,2547],{},"SendSupplierEmail",[37,2549,2550],{},"EmailDeliveryFailed",[37,2552,2553],{},"CreateAlertTask",[37,2555,2556],{},"AlertTaskCreated"," → five more listeners. The original ",[37,2559,2528],{}," event triggered 47 database queries across 9 listener classes. Each was individually reasonable. Together, they made every order completion take 800ms.",[12,2562,2563,2566],{},[32,2564,2565],{},"Strategy"," — The pattern that most clearly separates \"what to do\" from \"how to do it.\" A shipping cost calculator that picks between flat-rate, weight-based, and zone-based strategies based on the carrier is using Strategy correctly. The key: the strategy selection should happen once per request, not inside the hot path of the calculation.",[12,2568,2569,2572,2573,2576],{},[32,2570,2571],{},"Command"," — The pattern underlying every modern queue system. A ",[37,2574,2575],{},"ChargeCustomerCommand"," is a serialisable, self-contained description of an intention. It does not execute anything — it describes what should be executed. The command bus (queue) picks it up and dispatches it to the handler. The value: commands can be delayed, retried, audited, and replayed in ways that a direct method call cannot.",[12,2578,2579,2582],{},[32,2580,2581],{},"Template Method"," — The pattern I use most often without realising it. If you have two classes that share 90% of their logic and differ in one step — a report generator that formats identically but exports to CSV or PDF — the base class implements the shared structure and the subclass overrides the differing step. This is Template Method. The caution: inheritance for the sake of code sharing is fine; inheritance for the sake of polymorphism where composition would work is not.",[22,2584,2586],{"id":2585},"the-ones-i-have-not-found-a-genuine-use-for","The ones I have not found a genuine use for",[12,2588,2589,2592],{},[32,2590,2591],{},"Interpreter"," — Building a parser and evaluator for a custom language in application-layer PHP. I have seen it once, in a rules engine for a pricing system. It was the right tool there. In the twenty other times I have seen it proposed, a simpler expression evaluator or a library like Symfony's ExpressionLanguage would have been less code and more maintainable.",[12,2594,2595,2598],{},[32,2596,2597],{},"Mediator"," — Often described as \"Observer but the observers know about each other through a central broker.\" In practice, the added complexity over a standard event dispatcher has not been justified in any system I have worked on.",[12,2600,2601,2604],{},[32,2602,2603],{},"Flyweight"," — Memory optimisation for large numbers of fine-grained objects. PHP's memory model (request-scoped, process-isolated) makes this rarely necessary. Where I have seen it used correctly: a parser that creates thousands of token objects and caches identical tokens by value.",[22,2606,2608],{"id":2607},"the-question-i-ask-before-applying-any-pattern","The question I ask before applying any pattern",[12,2610,2611],{},[32,2612,2613],{},"What does this pattern make easier to change?",[12,2615,2616],{},"A Decorator makes it easier to add or remove cross-cutting behaviour (caching, logging) without modifying the decorated class. A Strategy makes it easier to add a new algorithm without modifying the context. An Adapter makes it easier to swap an external dependency.",[12,2618,2619],{},"If the answer is \"I am not sure what it makes easier to change — I just think it is a good architecture,\" the pattern is probably solving a problem you do not have yet. The overhead of the abstraction is real and immediate. The benefit is hypothetical. Pay the overhead when the benefit is also real.",[238,2621,240],{},{"title":63,"searchDepth":76,"depth":76,"links":2623},[2624,2625,2626,2627,2628],{"id":2223,"depth":76,"text":2224},{"id":2303,"depth":76,"text":2304},{"id":2510,"depth":76,"text":2511},{"id":2585,"depth":76,"text":2586},{"id":2607,"depth":76,"text":2608},"2023-02-10",{},"\u002Farticles\u002Fdesign-patterns-production",{"x":2633,"y":2634,"depth":70,"size":2635},0.62,0.85,"lg",[2637,262,2195],"singleton-pattern",{"title":2209,"description":2214},"pattern-reference","articles\u002Fdesign-patterns-production",[2201,2203,1350,2642,2643],"refactoring","code-review","aCZuT4sC6cVY78QJsHwOgc8gdhbIkCp8-ipaCl06-Qg",{"id":2646,"title":2647,"articleId":2195,"body":2648,"category":1350,"codeLang":1350,"date":3287,"deploys":70,"description":3288,"excerpt":251,"extension":252,"lang":251,"meta":3289,"navigation":85,"path":2244,"pos":3290,"readMin":130,"related":3293,"seo":3294,"service":3295,"stem":3296,"tags":3297,"version":1296,"__hash__":3300},"articles\u002Farticles\u002Ffactory-method.md","Factory Method: the pattern nobody needs until they need it for everything",{"type":9,"value":2649,"toc":3279},[2650,2675,2678,2682,2685,2688,2786,2793,2797,2936,2966,2969,3025,3028,3032,3037,3044,3049,3067,3071,3074,3097,3100,3104,3107,3248,3255,3259,3265,3268,3271,3277],[12,2651,2652,2653,2656,2657,1225,2660,2663,2664,2667,2668,1225,2671,2674],{},"The textbook examples for Factory Method involve shapes and animals. A ",[37,2654,2655],{},"ShapeFactory"," that returns ",[37,2658,2659],{},"Circle",[37,2661,2662],{},"Square"," based on a string. A ",[37,2665,2666],{},"AnimalFactory"," that constructs ",[37,2669,2670],{},"Dog",[37,2672,2673],{},"Cat",". These examples are correct. They are also useless as design guidance because in production, nobody's business domain involves shapes.",[12,2676,2677],{},"The pattern becomes important the moment you have a runtime decision about which implementation to create — and the point at which you have that decision should not be scattered across your codebase. Here is where I have seen it actually matter.",[22,2679,2681],{"id":2680},"the-payment-gateway-problem","The payment gateway problem",[12,2683,2684],{},"We supported four payment methods: card (Stripe), bank transfer (local PSP), BLIK (Polish mobile payments), and instalment financing (third-party integration). Each had a different API, different error modes, different retry semantics, different webhook formats.",[12,2686,2687],{},"Without a factory, the selection logic ended up in the controller:",[58,2689,2691],{"className":1348,"code":2690,"language":1350,"meta":63,"style":63},"\u002F\u002F Before: selection logic in the wrong place\nclass PaymentController\n{\n    public function charge(Request $request): Response\n    {\n        $method = $request->input('payment_method');\n\n        if ($method === 'card') {\n            $gateway = new StripeGateway(config('stripe.secret'));\n        } elseif ($method === 'blik') {\n            $gateway = new BlikGateway(config('blik.merchant_id'), config('blik.api_key'));\n        } elseif ($method === 'transfer') {\n            $gateway = new BankTransferGateway(config('psp.endpoint'));\n        } else {\n            throw new \\InvalidArgumentException(\"Unknown payment method: {$method}\");\n        }\n\n        return $gateway->charge($request->input('amount'), $request->input('currency'));\n    }\n}\n",[37,2692,2693,2698,2703,2707,2712,2716,2721,2725,2730,2735,2740,2745,2750,2755,2760,2765,2769,2773,2778,2782],{"__ignoreMap":63},[67,2694,2695],{"class":69,"line":70},[67,2696,2697],{},"\u002F\u002F Before: selection logic in the wrong place\n",[67,2699,2700],{"class":69,"line":76},[67,2701,2702],{},"class PaymentController\n",[67,2704,2705],{"class":69,"line":82},[67,2706,1367],{},[67,2708,2709],{"class":69,"line":89},[67,2710,2711],{},"    public function charge(Request $request): Response\n",[67,2713,2714],{"class":69,"line":95},[67,2715,1409],{},[67,2717,2718],{"class":69,"line":101},[67,2719,2720],{},"        $method = $request->input('payment_method');\n",[67,2722,2723],{"class":69,"line":107},[67,2724,86],{"emptyLinePlaceholder":85},[67,2726,2727],{"class":69,"line":113},[67,2728,2729],{},"        if ($method === 'card') {\n",[67,2731,2732],{"class":69,"line":118},[67,2733,2734],{},"            $gateway = new StripeGateway(config('stripe.secret'));\n",[67,2736,2737],{"class":69,"line":124},[67,2738,2739],{},"        } elseif ($method === 'blik') {\n",[67,2741,2742],{"class":69,"line":130},[67,2743,2744],{},"            $gateway = new BlikGateway(config('blik.merchant_id'), config('blik.api_key'));\n",[67,2746,2747],{"class":69,"line":136},[67,2748,2749],{},"        } elseif ($method === 'transfer') {\n",[67,2751,2752],{"class":69,"line":142},[67,2753,2754],{},"            $gateway = new BankTransferGateway(config('psp.endpoint'));\n",[67,2756,2757],{"class":69,"line":148},[67,2758,2759],{},"        } else {\n",[67,2761,2762],{"class":69,"line":154},[67,2763,2764],{},"            throw new \\InvalidArgumentException(\"Unknown payment method: {$method}\");\n",[67,2766,2767],{"class":69,"line":160},[67,2768,1889],{},[67,2770,2771],{"class":69,"line":165},[67,2772,86],{"emptyLinePlaceholder":85},[67,2774,2775],{"class":69,"line":260},[67,2776,2777],{},"        return $gateway->charge($request->input('amount'), $request->input('currency'));\n",[67,2779,2780],{"class":69,"line":709},[67,2781,1439],{},[67,2783,2784],{"class":69,"line":714},[67,2785,1377],{},[12,2787,2788,2789,2792],{},"This is readable when there are two options. By the time we added the fourth, the same ",[37,2790,2791],{},"if-elseif"," chain existed in the controller, in the refund handler, in the webhook router, and in the admin reconciliation job. Adding a fifth gateway meant finding all four locations.",[22,2794,2796],{"id":2795},"the-factory-extracted","The factory extracted",[58,2798,2800],{"className":1348,"code":2799,"language":1350,"meta":63,"style":63},"interface PaymentGatewayInterface\n{\n    public function charge(Money $amount, string $currency, array $metadata): ChargeResult;\n    public function refund(string $chargeId, Money $amount): RefundResult;\n    public function parseWebhook(array $payload, string $signature): WebhookEvent;\n}\n\nfinal class PaymentGatewayFactory\n{\n    private array $resolvers = [];\n\n    public function __construct(\n        private readonly ContainerInterface $container,\n    ) {}\n\n    public function register(string $method, string $gatewayClass): void\n    {\n        $this->resolvers[$method] = $gatewayClass;\n    }\n\n    public function make(string $method): PaymentGatewayInterface\n    {\n        if (!isset($this->resolvers[$method])) {\n            throw new UnsupportedPaymentMethodException($method);\n        }\n\n        \u002F\u002F Container resolves the gateway's own dependencies (credentials, HTTP client, logger)\n        return $this->container->make($this->resolvers[$method]);\n    }\n}\n",[37,2801,2802,2807,2811,2816,2821,2826,2830,2834,2839,2843,2848,2852,2856,2861,2865,2869,2874,2878,2883,2887,2891,2896,2900,2905,2910,2914,2918,2923,2928,2932],{"__ignoreMap":63},[67,2803,2804],{"class":69,"line":70},[67,2805,2806],{},"interface PaymentGatewayInterface\n",[67,2808,2809],{"class":69,"line":76},[67,2810,1367],{},[67,2812,2813],{"class":69,"line":82},[67,2814,2815],{},"    public function charge(Money $amount, string $currency, array $metadata): ChargeResult;\n",[67,2817,2818],{"class":69,"line":89},[67,2819,2820],{},"    public function refund(string $chargeId, Money $amount): RefundResult;\n",[67,2822,2823],{"class":69,"line":95},[67,2824,2825],{},"    public function parseWebhook(array $payload, string $signature): WebhookEvent;\n",[67,2827,2828],{"class":69,"line":101},[67,2829,1377],{},[67,2831,2832],{"class":69,"line":107},[67,2833,86],{"emptyLinePlaceholder":85},[67,2835,2836],{"class":69,"line":113},[67,2837,2838],{},"final class PaymentGatewayFactory\n",[67,2840,2841],{"class":69,"line":118},[67,2842,1367],{},[67,2844,2845],{"class":69,"line":124},[67,2846,2847],{},"    private array $resolvers = [];\n",[67,2849,2850],{"class":69,"line":130},[67,2851,86],{"emptyLinePlaceholder":85},[67,2853,2854],{"class":69,"line":136},[67,2855,1584],{},[67,2857,2858],{"class":69,"line":142},[67,2859,2860],{},"        private readonly ContainerInterface $container,\n",[67,2862,2863],{"class":69,"line":148},[67,2864,1594],{},[67,2866,2867],{"class":69,"line":154},[67,2868,86],{"emptyLinePlaceholder":85},[67,2870,2871],{"class":69,"line":160},[67,2872,2873],{},"    public function register(string $method, string $gatewayClass): void\n",[67,2875,2876],{"class":69,"line":165},[67,2877,1409],{},[67,2879,2880],{"class":69,"line":260},[67,2881,2882],{},"        $this->resolvers[$method] = $gatewayClass;\n",[67,2884,2885],{"class":69,"line":709},[67,2886,1439],{},[67,2888,2889],{"class":69,"line":714},[67,2890,86],{"emptyLinePlaceholder":85},[67,2892,2893],{"class":69,"line":726},[67,2894,2895],{},"    public function make(string $method): PaymentGatewayInterface\n",[67,2897,2898],{"class":69,"line":733},[67,2899,1409],{},[67,2901,2902],{"class":69,"line":743},[67,2903,2904],{},"        if (!isset($this->resolvers[$method])) {\n",[67,2906,2907],{"class":69,"line":753},[67,2908,2909],{},"            throw new UnsupportedPaymentMethodException($method);\n",[67,2911,2912],{"class":69,"line":764},[67,2913,1889],{},[67,2915,2916],{"class":69,"line":774},[67,2917,86],{"emptyLinePlaceholder":85},[67,2919,2920],{"class":69,"line":784},[67,2921,2922],{},"        \u002F\u002F Container resolves the gateway's own dependencies (credentials, HTTP client, logger)\n",[67,2924,2925],{"class":69,"line":798},[67,2926,2927],{},"        return $this->container->make($this->resolvers[$method]);\n",[67,2929,2930],{"class":69,"line":809},[67,2931,1439],{},[67,2933,2934],{"class":69,"line":814},[67,2935,1377],{},[58,2937,2939],{"className":1348,"code":2938,"language":1350,"meta":63,"style":63},"\u002F\u002F Registered in a service provider — one place, one time\n$factory->register('card',     StripeGateway::class);\n$factory->register('blik',     BlikGateway::class);\n$factory->register('transfer', BankTransferGateway::class);\n$factory->register('financing',FinancingGateway::class);\n",[37,2940,2941,2946,2951,2956,2961],{"__ignoreMap":63},[67,2942,2943],{"class":69,"line":70},[67,2944,2945],{},"\u002F\u002F Registered in a service provider — one place, one time\n",[67,2947,2948],{"class":69,"line":76},[67,2949,2950],{},"$factory->register('card',     StripeGateway::class);\n",[67,2952,2953],{"class":69,"line":82},[67,2954,2955],{},"$factory->register('blik',     BlikGateway::class);\n",[67,2957,2958],{"class":69,"line":89},[67,2959,2960],{},"$factory->register('transfer', BankTransferGateway::class);\n",[67,2962,2963],{"class":69,"line":95},[67,2964,2965],{},"$factory->register('financing',FinancingGateway::class);\n",[12,2967,2968],{},"The controller becomes:",[58,2970,2972],{"className":1348,"code":2971,"language":1350,"meta":63,"style":63},"class PaymentController\n{\n    public function __construct(\n        private readonly PaymentGatewayFactory $factory,\n    ) {}\n\n    public function charge(Request $request): Response\n    {\n        $gateway = $this->factory->make($request->input('payment_method'));\n        return $gateway->charge(...);\n    }\n}\n",[37,2973,2974,2978,2982,2986,2991,2995,2999,3003,3007,3012,3017,3021],{"__ignoreMap":63},[67,2975,2976],{"class":69,"line":70},[67,2977,2702],{},[67,2979,2980],{"class":69,"line":76},[67,2981,1367],{},[67,2983,2984],{"class":69,"line":82},[67,2985,1584],{},[67,2987,2988],{"class":69,"line":89},[67,2989,2990],{},"        private readonly PaymentGatewayFactory $factory,\n",[67,2992,2993],{"class":69,"line":95},[67,2994,1594],{},[67,2996,2997],{"class":69,"line":101},[67,2998,86],{"emptyLinePlaceholder":85},[67,3000,3001],{"class":69,"line":107},[67,3002,2711],{},[67,3004,3005],{"class":69,"line":113},[67,3006,1409],{},[67,3008,3009],{"class":69,"line":118},[67,3010,3011],{},"        $gateway = $this->factory->make($request->input('payment_method'));\n",[67,3013,3014],{"class":69,"line":124},[67,3015,3016],{},"        return $gateway->charge(...);\n",[67,3018,3019],{"class":69,"line":130},[67,3020,1439],{},[67,3022,3023],{"class":69,"line":136},[67,3024,1377],{},[12,3026,3027],{},"Adding a fifth gateway is: implement the interface, register it. Touch nothing else.",[22,3029,3031],{"id":3030},"the-two-failure-modes-i-see-in-factory-implementations","The two failure modes I see in factory implementations",[12,3033,3034],{},[32,3035,3036],{},"1. Factories that construct, not resolve.",[12,3038,3039,3040,3043],{},"A factory that calls ",[37,3041,3042],{},"new GatewayClass(config('...'))"," inline is a factory that will silently break when the gateway gains a new dependency. The factory should delegate construction to the DI container. If you are not on a framework with a container, at minimum the factory should accept gateway instances via constructor, not build them internally.",[12,3045,3046],{},[32,3047,3048],{},"2. Factories that return the wrong abstraction.",[12,3050,3051,3052,3055,3056,3059,3060,2169,3063,3066],{},"The factory above returns ",[37,3053,3054],{},"PaymentGatewayInterface",". I have seen factories that return ",[37,3057,3058],{},"StripeGateway"," with an interface that is effectively the Stripe API — ",[37,3061,3062],{},"createPaymentIntent()",[37,3064,3065],{},"retrieveBalance()"," — with thin wrappers over the other gateways that break under load because BLIK has no concept of a \"payment intent\". The interface should represent your domain's vocabulary, not any provider's API.",[22,3068,3070],{"id":3069},"when-to-use-a-factory-vs-a-strategy","When to use a factory vs. a strategy",[12,3072,3073],{},"The confusion between Factory Method and Strategy is common enough to address directly. They look similar but solve different problems:",[185,3075,3076,3089],{},[188,3077,3078,3080,3081,3084,3085,3088],{},[32,3079,2240],{},": the ",[1161,3082,3083],{},"type"," of object varies. You create different classes based on runtime input. The caller does not hold a reference to the factory — it just calls ",[37,3086,3087],{},"make()"," and gets an abstraction.",[188,3090,3091,3080,3093,3096],{},[32,3092,2565],{},[1161,3094,3095],{},"algorithm"," varies. You inject a different implementation of the same interface into a class that uses it. The class holds a reference to the strategy and calls methods on it directly.",[12,3098,3099],{},"In practice: if the selection happens once at the start of a request and the result is used throughout, that is a factory. If the selection happens repeatedly within a single computation (sort this list using whichever comparator is configured), that is a strategy.",[22,3101,3103],{"id":3102},"testing-the-factory","Testing the factory",[12,3105,3106],{},"The factory itself has a trivial test surface: does it return the correct type for a known input, and does it throw for an unknown one? The meaningful tests are on the interface contract — write a shared test that every gateway must pass:",[58,3108,3110],{"className":1348,"code":3109,"language":1350,"meta":63,"style":63},"abstract class PaymentGatewayContractTest extends TestCase\n{\n    abstract protected function makeGateway(): PaymentGatewayInterface;\n\n    public function testChargeReturnsChargeResult(): void\n    {\n        $gateway = $this->makeGateway();\n        $result  = $gateway->charge(\n            new Money(1000, 'PLN'),\n            'PLN',\n            ['order_id' => 'test-123']\n        );\n        $this->assertInstanceOf(ChargeResult::class, $result);\n        $this->assertNotEmpty($result->chargeId);\n    }\n\n    public function testParseWebhookThrowsOnInvalidSignature(): void\n    {\n        $this->expectException(InvalidWebhookSignatureException::class);\n        $this->makeGateway()->parseWebhook(['event' => 'charge.success'], 'bad-sig');\n    }\n}\n\nclass StripeGatewayTest extends PaymentGatewayContractTest\n{\n    protected function makeGateway(): PaymentGatewayInterface\n    {\n        return new StripeGateway(apiKey: 'sk_test_fake', httpClient: $this->mockClient());\n    }\n}\n",[37,3111,3112,3117,3121,3126,3130,3135,3139,3144,3149,3154,3159,3164,3168,3173,3178,3182,3186,3191,3195,3200,3205,3209,3213,3217,3222,3226,3231,3235,3240,3244],{"__ignoreMap":63},[67,3113,3114],{"class":69,"line":70},[67,3115,3116],{},"abstract class PaymentGatewayContractTest extends TestCase\n",[67,3118,3119],{"class":69,"line":76},[67,3120,1367],{},[67,3122,3123],{"class":69,"line":82},[67,3124,3125],{},"    abstract protected function makeGateway(): PaymentGatewayInterface;\n",[67,3127,3128],{"class":69,"line":89},[67,3129,86],{"emptyLinePlaceholder":85},[67,3131,3132],{"class":69,"line":95},[67,3133,3134],{},"    public function testChargeReturnsChargeResult(): void\n",[67,3136,3137],{"class":69,"line":101},[67,3138,1409],{},[67,3140,3141],{"class":69,"line":107},[67,3142,3143],{},"        $gateway = $this->makeGateway();\n",[67,3145,3146],{"class":69,"line":113},[67,3147,3148],{},"        $result  = $gateway->charge(\n",[67,3150,3151],{"class":69,"line":118},[67,3152,3153],{},"            new Money(1000, 'PLN'),\n",[67,3155,3156],{"class":69,"line":124},[67,3157,3158],{},"            'PLN',\n",[67,3160,3161],{"class":69,"line":130},[67,3162,3163],{},"            ['order_id' => 'test-123']\n",[67,3165,3166],{"class":69,"line":136},[67,3167,1434],{},[67,3169,3170],{"class":69,"line":142},[67,3171,3172],{},"        $this->assertInstanceOf(ChargeResult::class, $result);\n",[67,3174,3175],{"class":69,"line":148},[67,3176,3177],{},"        $this->assertNotEmpty($result->chargeId);\n",[67,3179,3180],{"class":69,"line":154},[67,3181,1439],{},[67,3183,3184],{"class":69,"line":160},[67,3185,86],{"emptyLinePlaceholder":85},[67,3187,3188],{"class":69,"line":165},[67,3189,3190],{},"    public function testParseWebhookThrowsOnInvalidSignature(): void\n",[67,3192,3193],{"class":69,"line":260},[67,3194,1409],{},[67,3196,3197],{"class":69,"line":709},[67,3198,3199],{},"        $this->expectException(InvalidWebhookSignatureException::class);\n",[67,3201,3202],{"class":69,"line":714},[67,3203,3204],{},"        $this->makeGateway()->parseWebhook(['event' => 'charge.success'], 'bad-sig');\n",[67,3206,3207],{"class":69,"line":726},[67,3208,1439],{},[67,3210,3211],{"class":69,"line":733},[67,3212,1377],{},[67,3214,3215],{"class":69,"line":743},[67,3216,86],{"emptyLinePlaceholder":85},[67,3218,3219],{"class":69,"line":753},[67,3220,3221],{},"class StripeGatewayTest extends PaymentGatewayContractTest\n",[67,3223,3224],{"class":69,"line":764},[67,3225,1367],{},[67,3227,3228],{"class":69,"line":774},[67,3229,3230],{},"    protected function makeGateway(): PaymentGatewayInterface\n",[67,3232,3233],{"class":69,"line":784},[67,3234,1409],{},[67,3236,3237],{"class":69,"line":798},[67,3238,3239],{},"        return new StripeGateway(apiKey: 'sk_test_fake', httpClient: $this->mockClient());\n",[67,3241,3242],{"class":69,"line":809},[67,3243,1439],{},[67,3245,3246],{"class":69,"line":814},[67,3247,1377],{},[12,3249,3250,3251,3254],{},"When you add the fifth gateway, you extend ",[37,3252,3253],{},"PaymentGatewayContractTest"," and the contract is verified automatically. The tests encode your interface's promises, not the implementation's details.",[22,3256,3258],{"id":3257},"what-i-watch-for-in-code-review","What I watch for in code review",[12,3260,3261,3262],{},"When I see a factory, my first question is: ",[32,3263,3264],{},"what triggers the decision?",[12,3266,3267],{},"If the answer is a string from user input or a database column — correct use.",[12,3269,3270],{},"If the answer is a compile-time constant or an environment variable that never changes at runtime — wrong tool. Use the DI container to bind the concrete type once and inject it directly.",[12,3272,3273,3274,3276],{},"If the answer is a growing ",[37,3275,2791],{}," chain that the team is already nervous about — the factory is late but still the right fix.",[238,3278,240],{},{"title":63,"searchDepth":76,"depth":76,"links":3280},[3281,3282,3283,3284,3285,3286],{"id":2680,"depth":76,"text":2681},{"id":2795,"depth":76,"text":2796},{"id":3030,"depth":76,"text":3031},{"id":3069,"depth":76,"text":3070},{"id":3102,"depth":76,"text":3103},{"id":3257,"depth":76,"text":3258},"2024-07-15","The textbook examples for Factory Method involve shapes and animals. A ShapeFactory that returns Circle or Square based on a string. A AnimalFactory that constructs Dog or Cat. These examples are correct. They are also useless as design guidance because in production, nobody's business domain involves shapes.",{},{"x":3291,"y":3292,"depth":70,"size":252},0.58,0.48,[2637,262],{"title":2647,"description":3288},"object-creation","articles\u002Ffactory-method",[1350,2201,3298,3299,2203],"factory","dependency-injection","uP8iULGjbgiR8s8hLo-ZJgcyzmqMydR0YnzHu6UfTww",{"id":3302,"title":3303,"articleId":3304,"body":3305,"category":248,"codeLang":1350,"date":3929,"deploys":70,"description":3309,"excerpt":251,"extension":252,"lang":251,"meta":3930,"navigation":85,"path":3931,"pos":3932,"readMin":148,"related":3935,"seo":3936,"service":3937,"stem":3938,"tags":3939,"version":1296,"__hash__":3944},"articles\u002Farticles\u002Fllm-in-php.md","LLMs in PHP: integrating language models into production systems without rewriting everything","llm-in-php",{"type":9,"value":3306,"toc":3921},[3307,3310,3313,3316,3320,3323,3329,3335,3341,3344,3348,3351,3354,3409,3522,3525,3529,3532,3611,3678,3685,3689,3692,3845,3859,3863,3866,3872,3897,3903,3909,3913,3916,3919],[12,3308,3309],{},"Every team I have talked to in the last two years has had the same conversation: the engineers want to add LLM features, the CTO says Python, and the platform team — who owns a PHP monolith with ten years of business logic — goes quiet. The argument is that ML tooling is Python-first, the LLM SDKs are better in Python, and the talent pool is there.",[12,3311,3312],{},"The argument is also mostly wrong, and the teams that act on it spend six months building a Python microservice that calls their PHP monolith for business logic over HTTP, introducing a network boundary, two deployment pipelines, and a latency budget they did not plan for.",[12,3314,3315],{},"This is what integrating LLMs into a PHP production system actually looks like — not a demo, but a system that has been running under real traffic.",[22,3317,3319],{"id":3318},"the-php-llm-landscape","The PHP LLM landscape",[12,3321,3322],{},"The PHP ecosystem has three credible options for LLM integration:",[12,3324,3325,3328],{},[32,3326,3327],{},"Direct HTTP to the API."," Every LLM provider — OpenAI, Anthropic, Mistral — exposes a REST API. A PHP HTTP client and a JSON decoder is all you technically need. I have used this for simple completions in systems where adding a dependency was harder than writing 40 lines of wrapper code.",[12,3330,3331,3334],{},[32,3332,3333],{},"LLPhant."," The most complete PHP library for production LLM work. It wraps OpenAI and Anthropic, handles streaming, implements RAG (retrieval-augmented generation) patterns, and supports function calling. It is the option I reach for now in new PHP projects.",[12,3336,3337,3340],{},[32,3338,3339],{},"Symfony AI integration."," Symfony 7.2 shipped a first-party AI component. If you are on Symfony, this is increasingly the right answer — it has proper dependency injection, event system integration, and respects the framework's conventions.",[12,3342,3343],{},"The benchmark for \"production-ready\" in LLM integration is: does it handle streaming correctly, does it support function calling, does it let you inject observability, and does it fail gracefully when the API returns a 500. LLPhant clears all four.",[22,3345,3347],{"id":3346},"what-i-got-wrong-on-the-first-deployment","What I got wrong on the first deployment",[12,3349,3350],{},"Our first LLM integration was a customer support triage system. The model read incoming tickets and classified them by urgency and department. The PHP code was clean. The deployment was a disaster.",[12,3352,3353],{},"We did not account for API latency in our queue worker timeout. The LLM call averaged 3.2 seconds. The queue worker's default timeout was 30 seconds. Under burst load, workers processing multiple tickets simultaneously hit the timeout, the job was retried, and we billed the API twice for the same ticket — with different classifications, which broke downstream routing logic.",[58,3355,3357],{"className":1348,"code":3356,"language":1350,"meta":63,"style":63},"\u002F\u002F What we had:\nclass TicketTriageJob implements ShouldQueue\n{\n    public $timeout = 30;  \u002F\u002F default — did not think about LLM latency\n\n    public function handle(LLMClient $client): void\n    {\n        $classification = $client->classify($this->ticket->body);\n        $this->ticket->update(['department' => $classification->department]);\n    }\n}\n",[37,3358,3359,3364,3369,3373,3378,3382,3387,3391,3396,3401,3405],{"__ignoreMap":63},[67,3360,3361],{"class":69,"line":70},[67,3362,3363],{},"\u002F\u002F What we had:\n",[67,3365,3366],{"class":69,"line":76},[67,3367,3368],{},"class TicketTriageJob implements ShouldQueue\n",[67,3370,3371],{"class":69,"line":82},[67,3372,1367],{},[67,3374,3375],{"class":69,"line":89},[67,3376,3377],{},"    public $timeout = 30;  \u002F\u002F default — did not think about LLM latency\n",[67,3379,3380],{"class":69,"line":95},[67,3381,86],{"emptyLinePlaceholder":85},[67,3383,3384],{"class":69,"line":101},[67,3385,3386],{},"    public function handle(LLMClient $client): void\n",[67,3388,3389],{"class":69,"line":107},[67,3390,1409],{},[67,3392,3393],{"class":69,"line":113},[67,3394,3395],{},"        $classification = $client->classify($this->ticket->body);\n",[67,3397,3398],{"class":69,"line":118},[67,3399,3400],{},"        $this->ticket->update(['department' => $classification->department]);\n",[67,3402,3403],{"class":69,"line":124},[67,3404,1439],{},[67,3406,3407],{"class":69,"line":130},[67,3408,1377],{},[58,3410,3412],{"className":1348,"code":3411,"language":1350,"meta":63,"style":63},"\u002F\u002F What we needed:\nclass TicketTriageJob implements ShouldQueue\n{\n    public $timeout = 120;       \u002F\u002F LLM call + processing overhead\n    public $tries = 1;           \u002F\u002F never retry — LLM calls are not idempotent\n    public $uniqueFor = 3600;    \u002F\u002F prevent duplicate processing\n\n    public function handle(LLMClient $client): void\n    {\n        if ($this->ticket->fresh()->triaged_at !== null) {\n            return;  \u002F\u002F already processed by a previous attempt\n        }\n\n        $classification = $client->classify($this->ticket->body);\n\n        DB::transaction(function () use ($classification) {\n            $this->ticket->update([\n                'department'  => $classification->department,\n                'priority'    => $classification->priority,\n                'triaged_at'  => now(),\n            ]);\n        });\n    }\n}\n",[37,3413,3414,3419,3423,3427,3432,3437,3442,3446,3450,3454,3459,3464,3468,3472,3476,3480,3485,3490,3495,3500,3505,3509,3514,3518],{"__ignoreMap":63},[67,3415,3416],{"class":69,"line":70},[67,3417,3418],{},"\u002F\u002F What we needed:\n",[67,3420,3421],{"class":69,"line":76},[67,3422,3368],{},[67,3424,3425],{"class":69,"line":82},[67,3426,1367],{},[67,3428,3429],{"class":69,"line":89},[67,3430,3431],{},"    public $timeout = 120;       \u002F\u002F LLM call + processing overhead\n",[67,3433,3434],{"class":69,"line":95},[67,3435,3436],{},"    public $tries = 1;           \u002F\u002F never retry — LLM calls are not idempotent\n",[67,3438,3439],{"class":69,"line":101},[67,3440,3441],{},"    public $uniqueFor = 3600;    \u002F\u002F prevent duplicate processing\n",[67,3443,3444],{"class":69,"line":107},[67,3445,86],{"emptyLinePlaceholder":85},[67,3447,3448],{"class":69,"line":113},[67,3449,3386],{},[67,3451,3452],{"class":69,"line":118},[67,3453,1409],{},[67,3455,3456],{"class":69,"line":124},[67,3457,3458],{},"        if ($this->ticket->fresh()->triaged_at !== null) {\n",[67,3460,3461],{"class":69,"line":130},[67,3462,3463],{},"            return;  \u002F\u002F already processed by a previous attempt\n",[67,3465,3466],{"class":69,"line":136},[67,3467,1889],{},[67,3469,3470],{"class":69,"line":142},[67,3471,86],{"emptyLinePlaceholder":85},[67,3473,3474],{"class":69,"line":148},[67,3475,3395],{},[67,3477,3478],{"class":69,"line":154},[67,3479,86],{"emptyLinePlaceholder":85},[67,3481,3482],{"class":69,"line":160},[67,3483,3484],{},"        DB::transaction(function () use ($classification) {\n",[67,3486,3487],{"class":69,"line":165},[67,3488,3489],{},"            $this->ticket->update([\n",[67,3491,3492],{"class":69,"line":260},[67,3493,3494],{},"                'department'  => $classification->department,\n",[67,3496,3497],{"class":69,"line":709},[67,3498,3499],{},"                'priority'    => $classification->priority,\n",[67,3501,3502],{"class":69,"line":714},[67,3503,3504],{},"                'triaged_at'  => now(),\n",[67,3506,3507],{"class":69,"line":726},[67,3508,2375],{},[67,3510,3511],{"class":69,"line":733},[67,3512,3513],{},"        });\n",[67,3515,3516],{"class":69,"line":743},[67,3517,1439],{},[67,3519,3520],{"class":69,"line":753},[67,3521,1377],{},[12,3523,3524],{},"The non-idempotency of LLM calls is the thing teams consistently underestimate. The model does not return the same output for the same input, and re-running a classification after a partial failure is not safe if downstream systems have already acted on the first result.",[22,3526,3528],{"id":3527},"rag-in-production-the-index-is-the-product","RAG in production: the index is the product",[12,3530,3531],{},"Retrieval-augmented generation is where PHP LLM integrations get interesting and where the gap with Python narrows to nearly nothing. The heavy work — embedding generation, vector storage, similarity search — happens at indexing time, not at query time. By query time, you are doing an HTTP call and a database query.",[58,3533,3535],{"className":1348,"code":3534,"language":1350,"meta":63,"style":63},"use LLPhant\\Embeddings\\EmbeddingGenerator\\OpenAI\\OpenAI3LargeEmbeddingGenerator;\nuse LLPhant\\Embeddings\\VectorStores\\Doctrine\\DoctrineVectorStore;\n\n\u002F\u002F Indexing (run once, or on content update)\n$generator  = new OpenAI3LargeEmbeddingGenerator();\n$vectorStore = new DoctrineVectorStore($entityManager, DocumentChunk::class);\n\nforeach ($documents as $doc) {\n    $chunks = $splitter->splitDocument($doc, chunkSize: 512, overlap: 64);\n\n    foreach ($chunks as $chunk) {\n        $chunk->embedding = $generator->embedText($chunk->content);\n    }\n\n    $vectorStore->addDocuments($chunks);\n}\n",[37,3536,3537,3542,3547,3551,3556,3561,3566,3570,3575,3580,3584,3589,3594,3598,3602,3607],{"__ignoreMap":63},[67,3538,3539],{"class":69,"line":70},[67,3540,3541],{},"use LLPhant\\Embeddings\\EmbeddingGenerator\\OpenAI\\OpenAI3LargeEmbeddingGenerator;\n",[67,3543,3544],{"class":69,"line":76},[67,3545,3546],{},"use LLPhant\\Embeddings\\VectorStores\\Doctrine\\DoctrineVectorStore;\n",[67,3548,3549],{"class":69,"line":82},[67,3550,86],{"emptyLinePlaceholder":85},[67,3552,3553],{"class":69,"line":89},[67,3554,3555],{},"\u002F\u002F Indexing (run once, or on content update)\n",[67,3557,3558],{"class":69,"line":95},[67,3559,3560],{},"$generator  = new OpenAI3LargeEmbeddingGenerator();\n",[67,3562,3563],{"class":69,"line":101},[67,3564,3565],{},"$vectorStore = new DoctrineVectorStore($entityManager, DocumentChunk::class);\n",[67,3567,3568],{"class":69,"line":107},[67,3569,86],{"emptyLinePlaceholder":85},[67,3571,3572],{"class":69,"line":113},[67,3573,3574],{},"foreach ($documents as $doc) {\n",[67,3576,3577],{"class":69,"line":118},[67,3578,3579],{},"    $chunks = $splitter->splitDocument($doc, chunkSize: 512, overlap: 64);\n",[67,3581,3582],{"class":69,"line":124},[67,3583,86],{"emptyLinePlaceholder":85},[67,3585,3586],{"class":69,"line":130},[67,3587,3588],{},"    foreach ($chunks as $chunk) {\n",[67,3590,3591],{"class":69,"line":136},[67,3592,3593],{},"        $chunk->embedding = $generator->embedText($chunk->content);\n",[67,3595,3596],{"class":69,"line":142},[67,3597,1439],{},[67,3599,3600],{"class":69,"line":148},[67,3601,86],{"emptyLinePlaceholder":85},[67,3603,3604],{"class":69,"line":154},[67,3605,3606],{},"    $vectorStore->addDocuments($chunks);\n",[67,3608,3609],{"class":69,"line":160},[67,3610,1377],{},[58,3612,3614],{"className":1348,"code":3613,"language":1350,"meta":63,"style":63},"\u002F\u002F Query time (per user request)\n$query     = $request->input('question');\n$embedding = $generator->embedText($query);\n\n\u002F\u002F pgvector cosine similarity — single query, \u003C 20ms on indexed data\n$relevant  = $vectorStore->similaritySearch($embedding, maxResults: 5, minScore: 0.78);\n\n$context   = implode(\"\\n\\n\", array_map(fn($c) => $c->content, $relevant));\n\n$answer = $llm->chat([\n    ['role' => 'system',    'content' => \"Answer using only the provided context.\\n\\n{$context}\"],\n    ['role' => 'user',      'content' => $query],\n]);\n",[37,3615,3616,3621,3626,3631,3635,3640,3645,3649,3654,3658,3663,3668,3673],{"__ignoreMap":63},[67,3617,3618],{"class":69,"line":70},[67,3619,3620],{},"\u002F\u002F Query time (per user request)\n",[67,3622,3623],{"class":69,"line":76},[67,3624,3625],{},"$query     = $request->input('question');\n",[67,3627,3628],{"class":69,"line":82},[67,3629,3630],{},"$embedding = $generator->embedText($query);\n",[67,3632,3633],{"class":69,"line":89},[67,3634,86],{"emptyLinePlaceholder":85},[67,3636,3637],{"class":69,"line":95},[67,3638,3639],{},"\u002F\u002F pgvector cosine similarity — single query, \u003C 20ms on indexed data\n",[67,3641,3642],{"class":69,"line":101},[67,3643,3644],{},"$relevant  = $vectorStore->similaritySearch($embedding, maxResults: 5, minScore: 0.78);\n",[67,3646,3647],{"class":69,"line":107},[67,3648,86],{"emptyLinePlaceholder":85},[67,3650,3651],{"class":69,"line":113},[67,3652,3653],{},"$context   = implode(\"\\n\\n\", array_map(fn($c) => $c->content, $relevant));\n",[67,3655,3656],{"class":69,"line":118},[67,3657,86],{"emptyLinePlaceholder":85},[67,3659,3660],{"class":69,"line":124},[67,3661,3662],{},"$answer = $llm->chat([\n",[67,3664,3665],{"class":69,"line":130},[67,3666,3667],{},"    ['role' => 'system',    'content' => \"Answer using only the provided context.\\n\\n{$context}\"],\n",[67,3669,3670],{"class":69,"line":136},[67,3671,3672],{},"    ['role' => 'user',      'content' => $query],\n",[67,3674,3675],{"class":69,"line":142},[67,3676,3677],{},"]);\n",[12,3679,3680,3681,3684],{},"The 0.78 similarity threshold is not a default — it is tuned. Too low and you retrieve irrelevant context that confuses the model. Too high and you retrieve nothing. We ran 200 sample queries against held-out answers and measured recall at different thresholds before shipping. ",[37,3682,3683],{},"0.78"," was the point where recall was stable and hallucinations dropped to an acceptable rate.",[22,3686,3688],{"id":3687},"function-calling-where-php-fits-better-than-expected","Function calling: where PHP fits better than expected",[12,3690,3691],{},"Function calling — the model deciding to invoke a tool and returning structured arguments — is the core mechanism that makes LLM agents practical. PHP is well-suited for this because the \"tools\" are typically existing domain logic: fetch a customer, check an order status, run a calculation. You already have that code.",[58,3693,3695],{"className":1348,"code":3694,"language":1350,"meta":63,"style":63},"$tools = [\n    Tool::create('get_order_status')\n        ->description('Returns the current status and ETA for a given order ID')\n        ->parameter('order_id', 'string', 'The order UUID', required: true),\n\n    Tool::create('calculate_refund')\n        ->description('Calculates eligible refund amount based on order ID and reason')\n        ->parameter('order_id', 'string', required: true)\n        ->parameter('reason', 'string', 'cancellation | defect | not_received', required: true),\n];\n\n$response = $llm->chat($messages, tools: $tools);\n\n\u002F\u002F The model may return a tool call rather than text\nwhile ($response->hasToolCalls()) {\n    foreach ($response->toolCalls() as $call) {\n        $result = match ($call->name) {\n            'get_order_status'  => $orderService->getStatus($call->arguments['order_id']),\n            'calculate_refund'  => $refundCalculator->calculate(\n                $call->arguments['order_id'],\n                $call->arguments['reason']\n            ),\n            default => throw new UnknownToolException($call->name),\n        };\n\n        \u002F\u002F Feed the tool result back into the conversation\n        $messages[] = ['role' => 'tool', 'tool_call_id' => $call->id, 'content' => json_encode($result)];\n    }\n\n    $response = $llm->chat($messages, tools: $tools);\n}\n",[37,3696,3697,3702,3707,3712,3717,3721,3726,3731,3736,3741,3746,3750,3755,3759,3764,3769,3774,3779,3784,3789,3794,3799,3804,3809,3814,3818,3823,3828,3832,3836,3841],{"__ignoreMap":63},[67,3698,3699],{"class":69,"line":70},[67,3700,3701],{},"$tools = [\n",[67,3703,3704],{"class":69,"line":76},[67,3705,3706],{},"    Tool::create('get_order_status')\n",[67,3708,3709],{"class":69,"line":82},[67,3710,3711],{},"        ->description('Returns the current status and ETA for a given order ID')\n",[67,3713,3714],{"class":69,"line":89},[67,3715,3716],{},"        ->parameter('order_id', 'string', 'The order UUID', required: true),\n",[67,3718,3719],{"class":69,"line":95},[67,3720,86],{"emptyLinePlaceholder":85},[67,3722,3723],{"class":69,"line":101},[67,3724,3725],{},"    Tool::create('calculate_refund')\n",[67,3727,3728],{"class":69,"line":107},[67,3729,3730],{},"        ->description('Calculates eligible refund amount based on order ID and reason')\n",[67,3732,3733],{"class":69,"line":113},[67,3734,3735],{},"        ->parameter('order_id', 'string', required: true)\n",[67,3737,3738],{"class":69,"line":118},[67,3739,3740],{},"        ->parameter('reason', 'string', 'cancellation | defect | not_received', required: true),\n",[67,3742,3743],{"class":69,"line":124},[67,3744,3745],{},"];\n",[67,3747,3748],{"class":69,"line":130},[67,3749,86],{"emptyLinePlaceholder":85},[67,3751,3752],{"class":69,"line":136},[67,3753,3754],{},"$response = $llm->chat($messages, tools: $tools);\n",[67,3756,3757],{"class":69,"line":142},[67,3758,86],{"emptyLinePlaceholder":85},[67,3760,3761],{"class":69,"line":148},[67,3762,3763],{},"\u002F\u002F The model may return a tool call rather than text\n",[67,3765,3766],{"class":69,"line":154},[67,3767,3768],{},"while ($response->hasToolCalls()) {\n",[67,3770,3771],{"class":69,"line":160},[67,3772,3773],{},"    foreach ($response->toolCalls() as $call) {\n",[67,3775,3776],{"class":69,"line":165},[67,3777,3778],{},"        $result = match ($call->name) {\n",[67,3780,3781],{"class":69,"line":260},[67,3782,3783],{},"            'get_order_status'  => $orderService->getStatus($call->arguments['order_id']),\n",[67,3785,3786],{"class":69,"line":709},[67,3787,3788],{},"            'calculate_refund'  => $refundCalculator->calculate(\n",[67,3790,3791],{"class":69,"line":714},[67,3792,3793],{},"                $call->arguments['order_id'],\n",[67,3795,3796],{"class":69,"line":726},[67,3797,3798],{},"                $call->arguments['reason']\n",[67,3800,3801],{"class":69,"line":733},[67,3802,3803],{},"            ),\n",[67,3805,3806],{"class":69,"line":743},[67,3807,3808],{},"            default => throw new UnknownToolException($call->name),\n",[67,3810,3811],{"class":69,"line":753},[67,3812,3813],{},"        };\n",[67,3815,3816],{"class":69,"line":764},[67,3817,86],{"emptyLinePlaceholder":85},[67,3819,3820],{"class":69,"line":774},[67,3821,3822],{},"        \u002F\u002F Feed the tool result back into the conversation\n",[67,3824,3825],{"class":69,"line":784},[67,3826,3827],{},"        $messages[] = ['role' => 'tool', 'tool_call_id' => $call->id, 'content' => json_encode($result)];\n",[67,3829,3830],{"class":69,"line":798},[67,3831,1439],{},[67,3833,3834],{"class":69,"line":809},[67,3835,86],{"emptyLinePlaceholder":85},[67,3837,3838],{"class":69,"line":814},[67,3839,3840],{},"    $response = $llm->chat($messages, tools: $tools);\n",[67,3842,3843],{"class":69,"line":826},[67,3844,1377],{},[12,3846,1975,3847,3850,3851,3854,3855,3858],{},[37,3848,3849],{},"while"," loop handles multi-step tool use: the model may call ",[37,3852,3853],{},"get_order_status",", decide it needs to call ",[37,3856,3857],{},"calculate_refund",", and only then produce a final answer. In practice, most production agents run 1–3 tool calls per conversation turn. More than that and latency becomes the dominant user experience problem.",[22,3860,3862],{"id":3861},"observability-you-actually-need","Observability you actually need",[12,3864,3865],{},"The three metrics I track for every LLM integration:",[12,3867,3868,3871],{},[32,3869,3870],{},"Token usage by endpoint."," LLM costs scale with tokens, not requests. A single endpoint that passes a 10,000-token system prompt on every call will dominate your API bill within days.",[58,3873,3875],{"className":1348,"code":3874,"language":1350,"meta":63,"style":63},"\u002F\u002F After every LLM call\n$this->metrics->increment('llm.tokens.prompt',     $response->usage()->promptTokens);\n$this->metrics->increment('llm.tokens.completion', $response->usage()->completionTokens);\n$this->metrics->timing('llm.latency_ms',           $response->latencyMs());\n",[37,3876,3877,3882,3887,3892],{"__ignoreMap":63},[67,3878,3879],{"class":69,"line":70},[67,3880,3881],{},"\u002F\u002F After every LLM call\n",[67,3883,3884],{"class":69,"line":76},[67,3885,3886],{},"$this->metrics->increment('llm.tokens.prompt',     $response->usage()->promptTokens);\n",[67,3888,3889],{"class":69,"line":82},[67,3890,3891],{},"$this->metrics->increment('llm.tokens.completion', $response->usage()->completionTokens);\n",[67,3893,3894],{"class":69,"line":89},[67,3895,3896],{},"$this->metrics->timing('llm.latency_ms',           $response->latencyMs());\n",[12,3898,3899,3902],{},[32,3900,3901],{},"Classification confidence \u002F tool call success rate."," For structured outputs — classifications, function calls, JSON extraction — the model will occasionally return malformed output. Track the parse failure rate. If it climbs above 2%, your prompt is degrading, the model was silently updated, or the input distribution shifted.",[12,3904,3905,3908],{},[32,3906,3907],{},"Queue depth before and after."," If you are doing LLM work in background jobs, queue depth is the leading indicator of whether your worker count is keeping pace with request volume.",[22,3910,3912],{"id":3911},"the-rewrite-question-answered-honestly","The rewrite question, answered honestly",[12,3914,3915],{},"Is Python better for LLM work? For pure ML research, training, and fine-tuning — yes, unambiguously. For building LLM-augmented features on top of an existing PHP system: the gap is smaller than the migration cost in almost every case I have evaluated.",[12,3917,3918],{},"The question to ask is not \"which language is better for LLMs\" but \"where does the business logic live that the LLM needs to act on?\" If it is in a PHP system with ten years of domain modelling, you are not going to replicate that in six months in a new Python service. You will end up with a thin Python wrapper calling your PHP API, and you will have paid full price for the rewrite without gaining anything that LLPhant could not have done from within PHP.",[238,3920,240],{},{"title":63,"searchDepth":76,"depth":76,"links":3922},[3923,3924,3925,3926,3927,3928],{"id":3318,"depth":76,"text":3319},{"id":3346,"depth":76,"text":3347},{"id":3527,"depth":76,"text":3528},{"id":3687,"depth":76,"text":3688},{"id":3861,"depth":76,"text":3862},{"id":3911,"depth":76,"text":3912},"2024-10-28",{},"\u002Farticles\u002Fllm-in-php",{"x":3933,"y":2193,"depth":3934,"size":2635},0.47,1.3,[7,2637],{"title":3303,"description":3309},"llm-integration","articles\u002Fllm-in-php",[1350,3940,268,3941,3942,3943],"llm","llphant","openai","production","8no9sF1tx3rOMauqYn2b2G-PlWVHXnmVoiSp2nvNyUg",{"id":3946,"title":3947,"articleId":262,"body":3948,"category":4076,"codeLang":1333,"date":4077,"deploys":70,"description":3952,"excerpt":251,"extension":252,"lang":251,"meta":4078,"navigation":85,"path":4079,"pos":4080,"readMin":733,"related":4084,"seo":4085,"service":4086,"stem":4087,"tags":4088,"version":4092,"__hash__":4093},"articles\u002Farticles\u002Fmicroservice-cost.md","The hidden cost of microservice boundaries: a five-year retrospective",{"type":9,"value":3949,"toc":4069},[3950,3953,3956,3960,3963,3966,3969,3973,3976,3982,4004,4014,4018,4024,4027,4030,4034,4041,4044,4048,4054,4063],[12,3951,3952],{},"In 2021 we drew 47 boxes on a whiteboard. By 2024, 22 of those boxes were back inside other boxes. This is not a story about microservices being wrong. It is a story about the boundary between two services being the most expensive thing in your system to change — and us not knowing that yet.",[12,3954,3955],{},"The single question I now ask before drawing a line: \"what is the smallest change that has to cross this boundary, and how often will we make it?\" If the answer is \"every sprint, in lockstep\", the line should not be there. There is no architecture clever enough to make that line cheap.",[22,3957,3959],{"id":3958},"what-we-thought-we-were-buying","What we thought we were buying",[12,3961,3962],{},"We had a monolith. It deployed slowly, tested slowly, owned by one team, feared by all. The pitch for microservices was independence: teams could deploy their services on their own cadence, with their own language, their own database. The coordination overhead of the monolith would dissolve.",[12,3964,3965],{},"For the first 18 months, it worked. Teams moved faster. Services deployed without coordination. The on-call burden distributed. The incident radius shrank.",[12,3967,3968],{},"Then we started building features.",[22,3970,3972],{"id":3971},"the-boundary-cost-framework","The boundary cost framework",[12,3974,3975],{},"Every boundary between services has a cost. It is not constant — it scales with how often you need to change both sides simultaneously.",[58,3977,3980],{"className":3978,"code":3979,"language":1333,"meta":63},[1331],"# the boundary cost framework, on the back of a napkin\n\n  cost(boundary) =\n        f_change  *  cost_per_change\n      + f_failure *  blast_radius\n      - autonomy_gained\n\n# if the first term dominates, the boundary is in the wrong place.\n# if the second term dominates, the boundary is fine — invest in failure isolation.\n# if the third term dominates, ship it.\n",[37,3981,3979],{"__ignoreMap":63},[12,3983,3984,3987,3988,3991,3992,3995,3996,3999,4000,4003],{},[37,3985,3986],{},"f_change"," is the frequency of coordinated changes across the boundary. ",[37,3989,3990],{},"cost_per_change"," is the overhead: versioning, contract tests, deploy coordination, separate PR queues. ",[37,3993,3994],{},"f_failure"," is how often one service's failure affects the other. ",[37,3997,3998],{},"blast_radius"," is how bad that failure is. ",[37,4001,4002],{},"autonomy_gained"," is the actual team-level independence the boundary enables.",[12,4005,4006,4007,1990,4010,4013],{},"In our case, 22 of the 47 services had ",[37,4008,4009],{},"f_change > 1 per sprint",[37,4011,4012],{},"autonomy_gained ≈ 0"," because they were owned by the same team. The boundary added overhead with no benefit.",[22,4015,4017],{"id":4016},"the-12-that-survived-intact","The 12 that survived intact",[12,4019,4020,4021,1220],{},"The services that retained independent value after five years shared one property: ",[32,4022,4023],{},"they were owned by teams with genuinely different deployment cadences",[12,4025,4026],{},"The payments service deploys every day. The fraud model service deploys every six weeks (when a new model is validated). The boundary between them is valuable because it lets payments ship without waiting for fraud model validation.",[12,4028,4029],{},"The notification service deploys whenever email templates change. The customer profile service deploys with every product sprint. Different cadences, different failure modes. The boundary earns its cost.",[22,4031,4033],{"id":4032},"merging-back","Merging back",[12,4035,4036,4037,4040],{},"The re-merger process was unexpectedly cheap in most cases. Services with tightly-coupled databases were a single ",[37,4038,4039],{},"ALTER TABLE ... INHERIT"," + application change. Services with separate databases required a data migration — but for the services that should have been merged, the data models were nearly identical.",[12,4042,4043],{},"The expensive mergers were the ones where each service had accumulated its own dialect of the domain model. Four years of divergence compressed into one migration. We scheduled those for Q1 2025.",[22,4045,4047],{"id":4046},"what-i-would-do-differently","What I would do differently",[12,4049,4050,4053],{},[32,4051,4052],{},"Start with modules, not services."," A well-structured monolith with internal module boundaries is cheaper to extract than to merge. If a module proves it needs independent deployment, extract it. The cost of extraction is one time. The cost of premature extraction is every feature for five years.",[12,4055,4056,4062],{},[32,4057,4058,4059,4061],{},"Measure ",[37,4060,3986],{}," before drawing the line."," We had no tooling for this in 2021. Today there are cross-repository change coupling tools that can tell you, from git history, which files change together. Use them before the architecture review, not after.",[12,4064,4065,4068],{},[32,4066,4067],{},"Own the platform layer first."," The first six months of microservices should be spent on: shared build system, shared observability, shared CI\u002FCD. We spent them on business logic. The technical debt from that decision cost us more than the wrong service boundaries.",{"title":63,"searchDepth":76,"depth":76,"links":4070},[4071,4072,4073,4074,4075],{"id":3958,"depth":76,"text":3959},{"id":3971,"depth":76,"text":3972},{"id":4016,"depth":76,"text":4017},{"id":4032,"depth":76,"text":4033},{"id":4046,"depth":76,"text":4047},"arch","2026-05-14",{},"\u002Farticles\u002Fmicroservice-cost",{"x":4081,"y":4082,"depth":4083,"size":2635},0.72,0.68,1.1,[7,2196],{"title":3947,"description":3952},"service-boundaries","articles\u002Fmicroservice-cost",[2203,4089,4090,4091],"organisation","platform","devex","v5.0.0","rpELSVHmCXKlc2GRy_POySO7ZmrXkE8Mk3CAKf34V40",{"id":4095,"title":4096,"articleId":4097,"body":4098,"category":1276,"codeLang":1350,"date":4692,"deploys":70,"description":4102,"excerpt":251,"extension":252,"lang":251,"meta":4693,"navigation":85,"path":4694,"pos":4695,"readMin":124,"related":4698,"seo":4699,"service":4700,"stem":4701,"tags":4702,"version":1296,"__hash__":4708},"articles\u002Farticles\u002Fphp-references.md","PHP references: the footgun that ships faster than you think","php-references",{"type":9,"value":4099,"toc":4684},[4100,4103,4110,4114,4117,4124,4172,4182,4186,4189,4250,4253,4283,4310,4329,4335,4339,4345,4413,4416,4468,4471,4474,4478,4481,4486,4489,4526,4531,4545,4549,4552,4621,4627,4650,4653,4655,4664,4679,4682],[12,4101,4102],{},"PHP references are one of the few language features where the PHP manual explicitly warns you not to use them unnecessarily. The warning is warranted. I have debugged three separate production incidents caused by references, and in two of them the original developer was not aware they had introduced a reference at all.",[12,4104,4105,4106,4109],{},"This is not an article about why ",[37,4107,4108],{},"&"," is a code smell. It is about understanding exactly what it does — because you will encounter it in legacy codebases, you will occasionally need it, and you will definitely debug a bug caused by it at some point.",[22,4111,4113],{"id":4112},"what-a-reference-actually-is","What a reference actually is",[12,4115,4116],{},"PHP's default behaviour is copy-on-write: when you assign one variable to another, they initially share the same underlying value in memory. The copy only happens when one of them is modified. This is already quite efficient for reading data.",[12,4118,4119,4120,4123],{},"A reference bypasses copy-on-write entirely. Two variables that are references to the same value share memory ",[1161,4121,4122],{},"regardless of modification",". Modifying either one modifies the underlying value that both point to.",[58,4125,4127],{"className":1348,"code":4126,"language":1350,"meta":63,"style":63},"$a = 'original';\n$b = $a;   \u002F\u002F copy-on-write: $b points to the same memory, but...\n$b = 'modified';  \u002F\u002F ...the copy happens here. $a is still 'original'.\nvar_dump($a);     \u002F\u002F string(8) \"original\"\n\n$a = 'original';\n$b = &$a;  \u002F\u002F reference: $b is an alias for the same memory location as $a\n$b = 'modified';  \u002F\u002F no copy — modifies the underlying value directly\nvar_dump($a);     \u002F\u002F string(8) \"modified\"  ← $a changed, not $b\n",[37,4128,4129,4134,4139,4144,4149,4153,4157,4162,4167],{"__ignoreMap":63},[67,4130,4131],{"class":69,"line":70},[67,4132,4133],{},"$a = 'original';\n",[67,4135,4136],{"class":69,"line":76},[67,4137,4138],{},"$b = $a;   \u002F\u002F copy-on-write: $b points to the same memory, but...\n",[67,4140,4141],{"class":69,"line":82},[67,4142,4143],{},"$b = 'modified';  \u002F\u002F ...the copy happens here. $a is still 'original'.\n",[67,4145,4146],{"class":69,"line":89},[67,4147,4148],{},"var_dump($a);     \u002F\u002F string(8) \"original\"\n",[67,4150,4151],{"class":69,"line":95},[67,4152,86],{"emptyLinePlaceholder":85},[67,4154,4155],{"class":69,"line":101},[67,4156,4133],{},[67,4158,4159],{"class":69,"line":107},[67,4160,4161],{},"$b = &$a;  \u002F\u002F reference: $b is an alias for the same memory location as $a\n",[67,4163,4164],{"class":69,"line":113},[67,4165,4166],{},"$b = 'modified';  \u002F\u002F no copy — modifies the underlying value directly\n",[67,4168,4169],{"class":69,"line":118},[67,4170,4171],{},"var_dump($a);     \u002F\u002F string(8) \"modified\"  ← $a changed, not $b\n",[12,4173,4174,4175,4178,4179,4181],{},"The distinction matters because PHP's reference behaviour is not always obvious when you are reading code. References do not look different from regular variables after they are assigned — ",[37,4176,4177],{},"$b"," looks the same in both cases. You have to track back to where the ",[37,4180,4108],{}," was introduced.",[22,4183,4185],{"id":4184},"incident-1-the-foreach-that-corrupted-the-array","Incident 1: the foreach that corrupted the array",[12,4187,4188],{},"This is the most common reference bug I have seen in production codebases. It appears in code that predates PHP 7 and was never refactored:",[58,4190,4192],{"className":1348,"code":4191,"language":1350,"meta":63,"style":63},"$prices = [100, 200, 300, 400, 500];\n\n\u002F\u002F Apply a 10% discount to each price\nforeach ($prices as &$price) {\n    $price = $price * 0.9;\n}\n\u002F\u002F After the loop: $prices = [90, 180, 270, 360, 450] ✓\n\n\u002F\u002F Some other code, three lines later, iterates the same array:\nforeach ($prices as $price) {\n    echo $price . \"\\n\";\n}\n",[37,4193,4194,4199,4203,4208,4213,4218,4222,4227,4231,4236,4241,4246],{"__ignoreMap":63},[67,4195,4196],{"class":69,"line":70},[67,4197,4198],{},"$prices = [100, 200, 300, 400, 500];\n",[67,4200,4201],{"class":69,"line":76},[67,4202,86],{"emptyLinePlaceholder":85},[67,4204,4205],{"class":69,"line":82},[67,4206,4207],{},"\u002F\u002F Apply a 10% discount to each price\n",[67,4209,4210],{"class":69,"line":89},[67,4211,4212],{},"foreach ($prices as &$price) {\n",[67,4214,4215],{"class":69,"line":95},[67,4216,4217],{},"    $price = $price * 0.9;\n",[67,4219,4220],{"class":69,"line":101},[67,4221,1377],{},[67,4223,4224],{"class":69,"line":107},[67,4225,4226],{},"\u002F\u002F After the loop: $prices = [90, 180, 270, 360, 450] ✓\n",[67,4228,4229],{"class":69,"line":113},[67,4230,86],{"emptyLinePlaceholder":85},[67,4232,4233],{"class":69,"line":118},[67,4234,4235],{},"\u002F\u002F Some other code, three lines later, iterates the same array:\n",[67,4237,4238],{"class":69,"line":124},[67,4239,4240],{},"foreach ($prices as $price) {\n",[67,4242,4243],{"class":69,"line":130},[67,4244,4245],{},"    echo $price . \"\\n\";\n",[67,4247,4248],{"class":69,"line":136},[67,4249,1377],{},[12,4251,4252],{},"Expected output: 90, 180, 270, 360, 450.\nActual output: 90, 180, 270, 360, 360.",[12,4254,4255,4256,2169,4259,4262,4263,4266,4267,4269,4270,4272,4273,4275,4276,4279,4280,4282],{},"The last element is wrong. Here is why: after the first ",[37,4257,4258],{},"foreach",[37,4260,4261],{},"$price"," is still a reference to the last element of ",[37,4264,4265],{},"$prices"," — the value at index 4. The second ",[37,4268,4258],{}," assigns each value to ",[37,4271,4261],{}," in turn. When it assigns the fourth value (360) to ",[37,4274,4261],{},", it writes 360 to ",[37,4277,4278],{},"$prices[4]",". Then it tries to read ",[37,4281,4278],{}," for the fifth iteration — and finds 360, not 450.",[58,4284,4286],{"className":1348,"code":4285,"language":1350,"meta":63,"style":63},"\u002F\u002F The fix\nforeach ($prices as &$price) {\n    $price = $price * 0.9;\n}\nunset($price);  \u002F\u002F break the reference before the variable goes out of scope\n",[37,4287,4288,4293,4297,4301,4305],{"__ignoreMap":63},[67,4289,4290],{"class":69,"line":70},[67,4291,4292],{},"\u002F\u002F The fix\n",[67,4294,4295],{"class":69,"line":76},[67,4296,4212],{},[67,4298,4299],{"class":69,"line":82},[67,4300,4217],{},[67,4302,4303],{"class":69,"line":89},[67,4304,1377],{},[67,4306,4307],{"class":69,"line":95},[67,4308,4309],{},"unset($price);  \u002F\u002F break the reference before the variable goes out of scope\n",[12,4311,4312,4315,4316,1990,4318,4320,4321,2169,4323,4325,4326,4328],{},[37,4313,4314],{},"unset($price)"," does not destroy the last element of the array. It destroys the reference link between ",[37,4317,4261],{},[37,4319,4278],{},". After ",[37,4322,4314],{},[37,4324,4278],{}," is still 450 (× 0.9 = 405), and ",[37,4327,4261],{}," is a freed variable.",[12,4330,4331,4332,4334],{},"In every codebase I have seen this bug, the ",[37,4333,4314],{}," was missing. PHP's documentation explicitly mentions this. It is still missing in codebases today.",[22,4336,4338],{"id":4337},"incident-2-the-function-that-silently-mutated-the-callers-data","Incident 2: the function that silently mutated the caller's data",[12,4340,4341,4342,4344],{},"A data transformation pipeline had a function that was supposed to normalise product data. It was called with large arrays, and someone added ",[37,4343,4108],{}," to avoid copying:",[58,4346,4348],{"className":1348,"code":4347,"language":1350,"meta":63,"style":63},"\u002F\u002F Original: safe, no side effects\nfunction normaliseProduct(array $product): array\n{\n    $product['title'] = trim(strtolower($product['title']));\n    $product['price'] = round($product['price'] * 100) \u002F 100;\n    return $product;\n}\n\n\u002F\u002F \"Optimised\" version: unsafe\nfunction normaliseProduct(array &$product): void\n{\n    $product['title'] = trim(strtolower($product['title']));\n    $product['price'] = round($product['price'] * 100) \u002F 100;\n}\n",[37,4349,4350,4355,4360,4364,4369,4374,4379,4383,4387,4392,4397,4401,4405,4409],{"__ignoreMap":63},[67,4351,4352],{"class":69,"line":70},[67,4353,4354],{},"\u002F\u002F Original: safe, no side effects\n",[67,4356,4357],{"class":69,"line":76},[67,4358,4359],{},"function normaliseProduct(array $product): array\n",[67,4361,4362],{"class":69,"line":82},[67,4363,1367],{},[67,4365,4366],{"class":69,"line":89},[67,4367,4368],{},"    $product['title'] = trim(strtolower($product['title']));\n",[67,4370,4371],{"class":69,"line":95},[67,4372,4373],{},"    $product['price'] = round($product['price'] * 100) \u002F 100;\n",[67,4375,4376],{"class":69,"line":101},[67,4377,4378],{},"    return $product;\n",[67,4380,4381],{"class":69,"line":107},[67,4382,1377],{},[67,4384,4385],{"class":69,"line":113},[67,4386,86],{"emptyLinePlaceholder":85},[67,4388,4389],{"class":69,"line":118},[67,4390,4391],{},"\u002F\u002F \"Optimised\" version: unsafe\n",[67,4393,4394],{"class":69,"line":124},[67,4395,4396],{},"function normaliseProduct(array &$product): void\n",[67,4398,4399],{"class":69,"line":130},[67,4400,1367],{},[67,4402,4403],{"class":69,"line":136},[67,4404,4368],{},[67,4406,4407],{"class":69,"line":142},[67,4408,4373],{},[67,4410,4411],{"class":69,"line":148},[67,4412,1377],{},[12,4414,4415],{},"The caller:",[58,4417,4419],{"className":1348,"code":4418,"language":1350,"meta":63,"style":63},"$products = $this->repository->findAll();\n\nforeach ($products as $product) {\n    $normalised = normaliseProduct($product);\n    \u002F\u002F In the original: $normalised is a modified copy, $product is unchanged\n    \u002F\u002F In the \"optimised\" version: $product is modified in place\n    \u002F\u002F $normalised does not exist — the function returns void\n\n    $this->cache->store($product['id'], $normalised);  \u002F\u002F now stores null\n}\n",[37,4420,4421,4426,4430,4435,4440,4445,4450,4455,4459,4464],{"__ignoreMap":63},[67,4422,4423],{"class":69,"line":70},[67,4424,4425],{},"$products = $this->repository->findAll();\n",[67,4427,4428],{"class":69,"line":76},[67,4429,86],{"emptyLinePlaceholder":85},[67,4431,4432],{"class":69,"line":82},[67,4433,4434],{},"foreach ($products as $product) {\n",[67,4436,4437],{"class":69,"line":89},[67,4438,4439],{},"    $normalised = normaliseProduct($product);\n",[67,4441,4442],{"class":69,"line":95},[67,4443,4444],{},"    \u002F\u002F In the original: $normalised is a modified copy, $product is unchanged\n",[67,4446,4447],{"class":69,"line":101},[67,4448,4449],{},"    \u002F\u002F In the \"optimised\" version: $product is modified in place\n",[67,4451,4452],{"class":69,"line":107},[67,4453,4454],{},"    \u002F\u002F $normalised does not exist — the function returns void\n",[67,4456,4457],{"class":69,"line":113},[67,4458,86],{"emptyLinePlaceholder":85},[67,4460,4461],{"class":69,"line":118},[67,4462,4463],{},"    $this->cache->store($product['id'], $normalised);  \u002F\u002F now stores null\n",[67,4465,4466],{"class":69,"line":124},[67,4467,1377],{},[12,4469,4470],{},"The cached data was null for every product. The reporting system that read the cache showed nothing. Nobody noticed for two days because the primary read path hit the database, not the cache.",[12,4472,4473],{},"The reference \"optimisation\" saved approximately zero memory — PHP arrays use copy-on-write anyway, and the function only reads two keys. It introduced a silent mutation and removed the return value that callers depended on.",[22,4475,4477],{"id":4476},"when-references-are-actually-correct","When references are actually correct",[12,4479,4480],{},"References are appropriate in exactly two situations I have encountered:",[12,4482,4483],{},[32,4484,4485],{},"1. Large data structures modified in place in a recursive algorithm.",[12,4487,4488],{},"If you are traversing and modifying a deeply nested array (XML parsing, tree manipulation), passing by reference avoids copying the entire structure at each recursion depth. This is a genuine performance concern only at meaningful scale — I would not reach for it under 10MB of data.",[58,4490,4492],{"className":1348,"code":4491,"language":1350,"meta":63,"style":63},"function flattenTree(array &$node, array &$result): void\n{\n    $result[] = $node['value'];\n    foreach ($node['children'] as &$child) {\n        flattenTree($child, $result);\n    }\n}\n",[37,4493,4494,4499,4503,4508,4513,4518,4522],{"__ignoreMap":63},[67,4495,4496],{"class":69,"line":70},[67,4497,4498],{},"function flattenTree(array &$node, array &$result): void\n",[67,4500,4501],{"class":69,"line":76},[67,4502,1367],{},[67,4504,4505],{"class":69,"line":82},[67,4506,4507],{},"    $result[] = $node['value'];\n",[67,4509,4510],{"class":69,"line":89},[67,4511,4512],{},"    foreach ($node['children'] as &$child) {\n",[67,4514,4515],{"class":69,"line":95},[67,4516,4517],{},"        flattenTree($child, $result);\n",[67,4519,4520],{"class":69,"line":101},[67,4521,1439],{},[67,4523,4524],{"class":69,"line":107},[67,4525,1377],{},[12,4527,4528],{},[32,4529,4530],{},"2. Output parameters in low-level C-extension-style functions.",[12,4532,4533,4536,4537,4540,4541,4544],{},[37,4534,4535],{},"preg_match()"," uses a reference for the matches array. ",[37,4538,4539],{},"fscanf()"," uses references for parsed values. These are C-heritage interfaces. If you are writing a function that genuinely needs multiple return values and cannot return a structured object (rare in modern PHP), a reference output parameter is one option — though returning a typed DTO or ",[37,4542,4543],{},"[$value, $error]"," tuple is usually cleaner.",[22,4546,4548],{"id":4547},"the-object-reference-misconception","The object reference misconception",[12,4550,4551],{},"A very common misunderstanding: objects in PHP are already \"passed by reference.\" They are not. Objects are passed by handle — a pointer to the object, not the object itself. Reassigning the handle inside a function does not affect the caller's handle. Modifying the object through the handle does.",[58,4553,4555],{"className":1348,"code":4554,"language":1350,"meta":63,"style":63},"class Counter\n{\n    public int $count = 0;\n}\n\nfunction increment(Counter $counter): void\n{\n    $counter->count++;       \u002F\u002F modifies the object — caller sees this\n    $counter = new Counter;  \u002F\u002F reassigns the handle — caller does NOT see this\n}\n\n$c = new Counter;\nincrement($c);\nvar_dump($c->count);  \u002F\u002F int(1) — the increment happened, the reassignment did not\n",[37,4556,4557,4562,4566,4571,4575,4579,4584,4588,4593,4598,4602,4606,4611,4616],{"__ignoreMap":63},[67,4558,4559],{"class":69,"line":70},[67,4560,4561],{},"class Counter\n",[67,4563,4564],{"class":69,"line":76},[67,4565,1367],{},[67,4567,4568],{"class":69,"line":82},[67,4569,4570],{},"    public int $count = 0;\n",[67,4572,4573],{"class":69,"line":89},[67,4574,1377],{},[67,4576,4577],{"class":69,"line":95},[67,4578,86],{"emptyLinePlaceholder":85},[67,4580,4581],{"class":69,"line":101},[67,4582,4583],{},"function increment(Counter $counter): void\n",[67,4585,4586],{"class":69,"line":107},[67,4587,1367],{},[67,4589,4590],{"class":69,"line":113},[67,4591,4592],{},"    $counter->count++;       \u002F\u002F modifies the object — caller sees this\n",[67,4594,4595],{"class":69,"line":118},[67,4596,4597],{},"    $counter = new Counter;  \u002F\u002F reassigns the handle — caller does NOT see this\n",[67,4599,4600],{"class":69,"line":124},[67,4601,1377],{},[67,4603,4604],{"class":69,"line":130},[67,4605,86],{"emptyLinePlaceholder":85},[67,4607,4608],{"class":69,"line":136},[67,4609,4610],{},"$c = new Counter;\n",[67,4612,4613],{"class":69,"line":142},[67,4614,4615],{},"increment($c);\n",[67,4617,4618],{"class":69,"line":148},[67,4619,4620],{},"var_dump($c->count);  \u002F\u002F int(1) — the increment happened, the reassignment did not\n",[12,4622,4623,4624,4626],{},"If you need to replace the caller's object reference entirely (rare but real), you use ",[37,4625,4108],{},":",[58,4628,4630],{"className":1348,"code":4629,"language":1350,"meta":63,"style":63},"function replaceWithFresh(Counter &$counter): void\n{\n    $counter = new Counter;  \u002F\u002F now the caller's $c is replaced\n}\n",[37,4631,4632,4637,4641,4646],{"__ignoreMap":63},[67,4633,4634],{"class":69,"line":70},[67,4635,4636],{},"function replaceWithFresh(Counter &$counter): void\n",[67,4638,4639],{"class":69,"line":76},[67,4640,1367],{},[67,4642,4643],{"class":69,"line":82},[67,4644,4645],{},"    $counter = new Counter;  \u002F\u002F now the caller's $c is replaced\n",[67,4647,4648],{"class":69,"line":89},[67,4649,1377],{},[12,4651,4652],{},"I have seen this used intentionally once, in an object pooling implementation. In normal application code, it is a signal to redesign the API.",[22,4654,3258],{"id":3257},[12,4656,4657,4658,4660,4661,4663],{},"When I see ",[37,4659,4108],{}," in a function signature or a ",[37,4662,4258],{},", I stop and read the surrounding twenty lines carefully. The questions:",[185,4665,4666,4673,4676],{},[188,4667,4668,4669,4672],{},"Is this reference still active after the loop? (",[37,4670,4671],{},"unset()"," check)",[188,4674,4675],{},"Does the caller expect this function to have no side effects on the argument?",[188,4677,4678],{},"Is the performance justification real, or is it premature optimisation?",[12,4680,4681],{},"A reference in user-land PHP code is a yellow flag. Not because it is always wrong, but because it means the code is relying on aliasing semantics that are non-obvious to the next reader — and \"non-obvious to the next reader\" is where bugs live.",[238,4683,240],{},{"title":63,"searchDepth":76,"depth":76,"links":4685},[4686,4687,4688,4689,4690,4691],{"id":4112,"depth":76,"text":4113},{"id":4184,"depth":76,"text":4185},{"id":4337,"depth":76,"text":4338},{"id":4476,"depth":76,"text":4477},{"id":4547,"depth":76,"text":4548},{"id":3257,"depth":76,"text":3258},"2023-09-30",{},"\u002Farticles\u002Fphp-references",{"x":4696,"y":4697,"depth":1283,"size":252},0.12,0.5,[2637,2195],{"title":4096,"description":4102},"memory-management","articles\u002Fphp-references",[1350,4703,4704,4705,4706,4707],"memory","debugging","references","performance","footguns","EMgj0-oTJhsD0nGCzA_ePT_UFmqqhtLODEFG0dfFRTM",{"id":4710,"title":4711,"articleId":263,"body":4712,"category":1276,"codeLang":4761,"date":4883,"deploys":113,"description":4716,"excerpt":251,"extension":252,"lang":251,"meta":4884,"navigation":85,"path":4885,"pos":4886,"readMin":118,"related":4889,"seo":4891,"service":4892,"stem":4893,"tags":4894,"version":4899,"__hash__":4900},"articles\u002Farticles\u002Fpostgres-edge.md","Postgres at the edge: rethinking primary keys for global writes",{"type":9,"value":4713,"toc":4876},[4714,4717,4720,4724,4731,4734,4740,4746,4750,4757,4831,4841,4845,4852,4856,4863,4867,4874],[12,4715,4716],{},"A serial primary key is not a key. It is a coordinated agreement between every writer that they will take turns. Honour that agreement across two regions and you have, by definition, given up either availability or freshness.",[12,4718,4719],{},"ULIDs and UUIDv7 are not exotic. They are the most boring possible answer to \"how do I let a second region write without phoning home for a sequence number\". The interesting part is everything that depends on the old key — foreign keys, indexes, audit tables, the analytics pipeline — and how you migrate without a Saturday-night cutover.",[22,4721,4723],{"id":4722},"why-sequences-break-at-the-edge","Why sequences break at the edge",[12,4725,4726,4727,4730],{},"A ",[37,4728,4729],{},"bigserial"," primary key requires a centralised sequence counter. Every insert in region B must either wait for a round-trip to region A, or risk a gap in the sequence. At 40ms cross-region latency and 5,000 writes per second, that's 200 concurrent requests stacking up waiting for a number.",[12,4732,4733],{},"The alternatives split into two families:",[12,4735,4736,4739],{},[32,4737,4738],{},"Random identifiers (UUIDv4)."," Globally unique without coordination, but unsortable. Index fragmentation on large tables is severe. Joining on UUID columns is slower than joining on integers.",[12,4741,4742,4745],{},[32,4743,4744],{},"Time-ordered identifiers (UUIDv7, ULID)."," Globally unique, roughly sortable by creation time, insert-friendly B-tree behaviour. The right answer for new tables.",[22,4747,4749],{"id":4748},"the-migration-path-no-downtime","The migration path (no downtime)",[12,4751,4752,4753,4756],{},"We moved 240M rows in the ",[37,4754,4755],{},"orders"," table. The strategy is the shadow column approach: add the new key alongside the old, backfill, build a covering index, swap behind a view.",[58,4758,4762],{"className":4759,"code":4760,"language":4761,"meta":63,"style":63},"language-sql shiki shiki-themes github-light github-dark","-- shadow column, backfill, swap. boring on purpose.\nalter table orders add column id_v2 uuid;\n\nupdate orders set id_v2 = uuidv7_from_timestamp(created_at)\nwhere id_v2 is null;\n\ncreate unique index concurrently orders_id_v2_uq on orders (id_v2);\n\n-- swap behind a view; cut over in one transaction.\nbegin;\n  alter table orders rename to orders_legacy;\n  create view orders as\n    select id_v2 as id, \u002F* ...other cols... *\u002F from orders_legacy;\ncommit;\n","sql",[37,4763,4764,4769,4774,4778,4783,4788,4792,4797,4801,4806,4811,4816,4821,4826],{"__ignoreMap":63},[67,4765,4766],{"class":69,"line":70},[67,4767,4768],{},"-- shadow column, backfill, swap. boring on purpose.\n",[67,4770,4771],{"class":69,"line":76},[67,4772,4773],{},"alter table orders add column id_v2 uuid;\n",[67,4775,4776],{"class":69,"line":82},[67,4777,86],{"emptyLinePlaceholder":85},[67,4779,4780],{"class":69,"line":89},[67,4781,4782],{},"update orders set id_v2 = uuidv7_from_timestamp(created_at)\n",[67,4784,4785],{"class":69,"line":95},[67,4786,4787],{},"where id_v2 is null;\n",[67,4789,4790],{"class":69,"line":101},[67,4791,86],{"emptyLinePlaceholder":85},[67,4793,4794],{"class":69,"line":107},[67,4795,4796],{},"create unique index concurrently orders_id_v2_uq on orders (id_v2);\n",[67,4798,4799],{"class":69,"line":113},[67,4800,86],{"emptyLinePlaceholder":85},[67,4802,4803],{"class":69,"line":118},[67,4804,4805],{},"-- swap behind a view; cut over in one transaction.\n",[67,4807,4808],{"class":69,"line":124},[67,4809,4810],{},"begin;\n",[67,4812,4813],{"class":69,"line":130},[67,4814,4815],{},"  alter table orders rename to orders_legacy;\n",[67,4817,4818],{"class":69,"line":136},[67,4819,4820],{},"  create view orders as\n",[67,4822,4823],{"class":69,"line":142},[67,4824,4825],{},"    select id_v2 as id, \u002F* ...other cols... *\u002F from orders_legacy;\n",[67,4827,4828],{"class":69,"line":148},[67,4829,4830],{},"commit;\n",[12,4832,1975,4833,4836,4837,4840],{},[37,4834,4835],{},"uuidv7_from_timestamp"," function converts the existing ",[37,4838,4839],{},"created_at"," to a time-ordered UUIDv7, preserving the sort order that the downstream analytics pipeline depends on. It's a single C extension, ~50 lines.",[22,4842,4844],{"id":4843},"the-view-trick","The view trick",[12,4846,4847,4848,4851],{},"The view lets us rename the column atomically from the application's perspective. Old code that reads ",[37,4849,4850],{},"orders.id"," keeps working. New code uses the same column name. We run both in production for two weeks, verify foreign key references, then drop the legacy table.",[22,4853,4855],{"id":4854},"what-we-didnt-anticipate","What we didn't anticipate",[12,4857,4858,4859,4862],{},"The audit table had ",[37,4860,4861],{},"order_id bigint"," as a foreign key. We had to backfill that too. It took longer than the orders table backfill. Next time we would migrate the audit table first using logical replication and swap it in the same transaction.",[22,4864,4866],{"id":4865},"results","Results",[12,4868,4869,4870,4873],{},"Write throughput in region B went from 1,200 to 4,800 inserts per second. The p99 write latency dropped from 38ms to 4ms. The sequence server (a single Postgres instance that did nothing but serve ",[37,4871,4872],{},"nextval",") was decommissioned.",[238,4875,240],{},{"title":63,"searchDepth":76,"depth":76,"links":4877},[4878,4879,4880,4881,4882],{"id":4722,"depth":76,"text":4723},{"id":4748,"depth":76,"text":4749},{"id":4843,"depth":76,"text":4844},{"id":4854,"depth":76,"text":4855},{"id":4865,"depth":76,"text":4866},"2026-03-30",{},"\u002Farticles\u002Fpostgres-edge",{"x":4887,"y":4888,"depth":1284,"size":252},0.21,0.71,[262,4890],"state-machine",{"title":4711,"description":4716},"pg-primary-keys","articles\u002Fpostgres-edge",[4895,4896,4897,4898],"postgres","distributed-systems","ulid","replication","v1.3.2","d4I_yuFxOfEtuZ8xR_uF6gf9qJEhIQsMn8IxnZ9J9fc",{"id":4902,"title":4903,"articleId":2637,"body":4904,"category":1350,"codeLang":1350,"date":5750,"deploys":82,"description":4908,"excerpt":251,"extension":252,"lang":251,"meta":5751,"navigation":85,"path":2234,"pos":5752,"readMin":160,"related":5756,"seo":5757,"service":5758,"stem":5759,"tags":5760,"version":5763,"__hash__":5764},"articles\u002Farticles\u002Fsingleton-pattern.md","The Singleton trap: global state, PHP-FPM workers, and the pattern that aged poorly",{"type":9,"value":4905,"toc":5741},[4906,4909,4912,4916,4919,4922,5062,5065,5068,5072,5079,5082,5088,5092,5095,5100,5219,5225,5230,5237,5290,5295,5302,5306,5309,5370,5381,5384,5498,5501,5572,5575,5579,5582,5594,5597,5655,5658,5662,5669,5711,5722,5724,5730,5733,5736,5739],[12,4907,4908],{},"I have been in exactly two code reviews where a developer proposed a Singleton and was right to do so. I have been in perhaps forty where they were not. The pattern is not the problem. The problem is that \"I only want one of these\" sounds like the right motivation almost every time, and it almost never is.",[12,4910,4911],{},"This is a production retrospective. The code is real. The incidents happened.",[22,4913,4915],{"id":4914},"the-php-fpm-lie","The PHP-FPM lie",[12,4917,4918],{},"The most dangerous misconception about Singleton in PHP is that it gives you one instance per application. It does not. It gives you one instance per worker process. On a standard PHP-FPM pool of 32 workers, you have 32 singletons.",[12,4920,4921],{},"This is not a niche edge case. It is the default. Every PHP application under any meaningful load runs this way.",[58,4923,4925],{"className":1348,"code":4924,"language":1350,"meta":63,"style":63},"\u002F\u002F You think you have this:\n\u002F\u002F   Application → Singleton → one instance\n\u002F\u002F\n\u002F\u002F You actually have this:\n\u002F\u002F   Request #1 → Worker #1 → Singleton::$instance (object A)\n\u002F\u002F   Request #2 → Worker #7 → Singleton::$instance (object B)\n\u002F\u002F   Request #3 → Worker #7 → Singleton::$instance (object B)  ← same as #2\n\u002F\u002F   Request #4 → Worker #1 → Singleton::$instance (object A)  ← same as #1\n\nclass RateLimiter\n{\n    private static ?self $instance = null;\n    private array $buckets = [];  \u002F\u002F mutable state: requests per IP per minute\n\n    public static function getInstance(): static\n    {\n        if (static::$instance === null) {\n            static::$instance = new static();\n        }\n        return static::$instance;\n    }\n\n    public function check(string $ip, int $limit): bool\n    {\n        $key = $ip . ':' . floor(time() \u002F 60);\n        $this->buckets[$key] = ($this->buckets[$key] ?? 0) + 1;\n        return $this->buckets[$key] \u003C= $limit;\n    }\n}\n",[37,4926,4927,4932,4937,4942,4947,4952,4957,4962,4967,4971,4976,4980,4985,4990,4994,4999,5003,5008,5013,5017,5022,5026,5030,5035,5039,5044,5049,5054,5058],{"__ignoreMap":63},[67,4928,4929],{"class":69,"line":70},[67,4930,4931],{},"\u002F\u002F You think you have this:\n",[67,4933,4934],{"class":69,"line":76},[67,4935,4936],{},"\u002F\u002F   Application → Singleton → one instance\n",[67,4938,4939],{"class":69,"line":82},[67,4940,4941],{},"\u002F\u002F\n",[67,4943,4944],{"class":69,"line":89},[67,4945,4946],{},"\u002F\u002F You actually have this:\n",[67,4948,4949],{"class":69,"line":95},[67,4950,4951],{},"\u002F\u002F   Request #1 → Worker #1 → Singleton::$instance (object A)\n",[67,4953,4954],{"class":69,"line":101},[67,4955,4956],{},"\u002F\u002F   Request #2 → Worker #7 → Singleton::$instance (object B)\n",[67,4958,4959],{"class":69,"line":107},[67,4960,4961],{},"\u002F\u002F   Request #3 → Worker #7 → Singleton::$instance (object B)  ← same as #2\n",[67,4963,4964],{"class":69,"line":113},[67,4965,4966],{},"\u002F\u002F   Request #4 → Worker #1 → Singleton::$instance (object A)  ← same as #1\n",[67,4968,4969],{"class":69,"line":118},[67,4970,86],{"emptyLinePlaceholder":85},[67,4972,4973],{"class":69,"line":124},[67,4974,4975],{},"class RateLimiter\n",[67,4977,4978],{"class":69,"line":130},[67,4979,1367],{},[67,4981,4982],{"class":69,"line":136},[67,4983,4984],{},"    private static ?self $instance = null;\n",[67,4986,4987],{"class":69,"line":142},[67,4988,4989],{},"    private array $buckets = [];  \u002F\u002F mutable state: requests per IP per minute\n",[67,4991,4992],{"class":69,"line":148},[67,4993,86],{"emptyLinePlaceholder":85},[67,4995,4996],{"class":69,"line":154},[67,4997,4998],{},"    public static function getInstance(): static\n",[67,5000,5001],{"class":69,"line":160},[67,5002,1409],{},[67,5004,5005],{"class":69,"line":165},[67,5006,5007],{},"        if (static::$instance === null) {\n",[67,5009,5010],{"class":69,"line":260},[67,5011,5012],{},"            static::$instance = new static();\n",[67,5014,5015],{"class":69,"line":709},[67,5016,1889],{},[67,5018,5019],{"class":69,"line":714},[67,5020,5021],{},"        return static::$instance;\n",[67,5023,5024],{"class":69,"line":726},[67,5025,1439],{},[67,5027,5028],{"class":69,"line":733},[67,5029,86],{"emptyLinePlaceholder":85},[67,5031,5032],{"class":69,"line":743},[67,5033,5034],{},"    public function check(string $ip, int $limit): bool\n",[67,5036,5037],{"class":69,"line":753},[67,5038,1409],{},[67,5040,5041],{"class":69,"line":764},[67,5042,5043],{},"        $key = $ip . ':' . floor(time() \u002F 60);\n",[67,5045,5046],{"class":69,"line":774},[67,5047,5048],{},"        $this->buckets[$key] = ($this->buckets[$key] ?? 0) + 1;\n",[67,5050,5051],{"class":69,"line":784},[67,5052,5053],{},"        return $this->buckets[$key] \u003C= $limit;\n",[67,5055,5056],{"class":69,"line":798},[67,5057,1439],{},[67,5059,5060],{"class":69,"line":809},[67,5061,1377],{},[12,5063,5064],{},"We ran this rate limiter in production for three weeks before noticing that our 100 requests-per-minute limit was enforced as roughly 100 \u002F 32 = 3 requests per minute on any single worker, or not enforced at all when requests spread across workers. The in-memory bucket was never shared. Each worker accumulated its own counter, and the limits were meaningless.",[12,5066,5067],{},"The fix was not \"use a better Singleton.\" The fix was Redis. The pattern was wrong for the requirement from the beginning. The requirement was \"one limit per IP across the entire application\" — and that is an explicitly distributed constraint. No in-process pattern satisfies it.",[22,5069,5071],{"id":5070},"what-the-pattern-actually-guarantees","What the pattern actually guarantees",[12,5073,5074,5075,5078],{},"Stripped to its essence, Singleton guarantees ",[32,5076,5077],{},"one instance per process per class per class-loading context",". Nothing more.",[12,5080,5081],{},"In PHP-FPM: one per worker.\nIn a CLI script: one per execution.\nIn a test suite running in a single process: one across all tests — which is usually a disaster.",[12,5083,1975,5084,5087],{},[37,5085,5086],{},"getInstance()"," mechanism is essentially a lazy constructor with global access. The valuable guarantee is the lazy construction. The dangerous part is the global access. These two concerns are bundled together by the pattern, and most of the time you want one without the other.",[22,5089,5091],{"id":5090},"when-it-is-genuinely-correct","When it is genuinely correct",[12,5093,5094],{},"There are three scenarios where I have seen Singleton used correctly in production PHP systems:",[12,5096,5097],{},[32,5098,5099],{},"1. Immutable application configuration loaded once from environment",[58,5101,5103],{"className":1348,"code":5102,"language":1350,"meta":63,"style":63},"final class AppConfig\n{\n    private static ?self $instance = null;\n\n    private function __construct(\n        public readonly string $appEnv,\n        public readonly string $dbDsn,\n        public readonly int    $dbPoolSize,\n        public readonly string $redisUrl,\n    ) {}\n\n    public static function load(): static\n    {\n        if (static::$instance !== null) {\n            return static::$instance;\n        }\n\n        return static::$instance = new static(\n            appEnv:      (string) ($_ENV['APP_ENV']       ?? 'production'),\n            dbDsn:       (string) ($_ENV['DATABASE_URL']  ?? throw new \\RuntimeException('DATABASE_URL not set')),\n            dbPoolSize:  (int)    ($_ENV['DB_POOL_SIZE']  ?? 10),\n            redisUrl:    (string) ($_ENV['REDIS_URL']     ?? throw new \\RuntimeException('REDIS_URL not set')),\n        );\n    }\n}\n",[37,5104,5105,5110,5114,5118,5122,5127,5132,5137,5142,5147,5151,5155,5160,5164,5169,5174,5178,5182,5187,5192,5197,5202,5207,5211,5215],{"__ignoreMap":63},[67,5106,5107],{"class":69,"line":70},[67,5108,5109],{},"final class AppConfig\n",[67,5111,5112],{"class":69,"line":76},[67,5113,1367],{},[67,5115,5116],{"class":69,"line":82},[67,5117,4984],{},[67,5119,5120],{"class":69,"line":89},[67,5121,86],{"emptyLinePlaceholder":85},[67,5123,5124],{"class":69,"line":95},[67,5125,5126],{},"    private function __construct(\n",[67,5128,5129],{"class":69,"line":101},[67,5130,5131],{},"        public readonly string $appEnv,\n",[67,5133,5134],{"class":69,"line":107},[67,5135,5136],{},"        public readonly string $dbDsn,\n",[67,5138,5139],{"class":69,"line":113},[67,5140,5141],{},"        public readonly int    $dbPoolSize,\n",[67,5143,5144],{"class":69,"line":118},[67,5145,5146],{},"        public readonly string $redisUrl,\n",[67,5148,5149],{"class":69,"line":124},[67,5150,1594],{},[67,5152,5153],{"class":69,"line":130},[67,5154,86],{"emptyLinePlaceholder":85},[67,5156,5157],{"class":69,"line":136},[67,5158,5159],{},"    public static function load(): static\n",[67,5161,5162],{"class":69,"line":142},[67,5163,1409],{},[67,5165,5166],{"class":69,"line":148},[67,5167,5168],{},"        if (static::$instance !== null) {\n",[67,5170,5171],{"class":69,"line":154},[67,5172,5173],{},"            return static::$instance;\n",[67,5175,5176],{"class":69,"line":160},[67,5177,1889],{},[67,5179,5180],{"class":69,"line":165},[67,5181,86],{"emptyLinePlaceholder":85},[67,5183,5184],{"class":69,"line":260},[67,5185,5186],{},"        return static::$instance = new static(\n",[67,5188,5189],{"class":69,"line":709},[67,5190,5191],{},"            appEnv:      (string) ($_ENV['APP_ENV']       ?? 'production'),\n",[67,5193,5194],{"class":69,"line":714},[67,5195,5196],{},"            dbDsn:       (string) ($_ENV['DATABASE_URL']  ?? throw new \\RuntimeException('DATABASE_URL not set')),\n",[67,5198,5199],{"class":69,"line":726},[67,5200,5201],{},"            dbPoolSize:  (int)    ($_ENV['DB_POOL_SIZE']  ?? 10),\n",[67,5203,5204],{"class":69,"line":733},[67,5205,5206],{},"            redisUrl:    (string) ($_ENV['REDIS_URL']     ?? throw new \\RuntimeException('REDIS_URL not set')),\n",[67,5208,5209],{"class":69,"line":743},[67,5210,1434],{},[67,5212,5213],{"class":69,"line":753},[67,5214,1439],{},[67,5216,5217],{"class":69,"line":764},[67,5218,1377],{},[12,5220,5221,5224],{},[37,5222,5223],{},"readonly"," properties make this safe: there is no mutable state to corrupt across requests in a long-running process, and no shared-memory state to worry about in PHP-FPM. The validation on construction means misconfiguration fails immediately at boot rather than silently at runtime.",[12,5226,5227],{},[32,5228,5229],{},"2. The IoC container itself",[12,5231,5232,5233,5236],{},"Every modern PHP framework — Laravel, Symfony, Laminas — has an IoC container that is effectively a Singleton. The container is created once, populated with bindings, and resolved throughout the request lifecycle. This is correct because the container is itself stateless between resolutions: it holds ",[1161,5234,5235],{},"definitions",", not instances of every bound class.",[58,5238,5240],{"className":1348,"code":5239,"language":1350,"meta":63,"style":63},"\u002F\u002F Laravel's Application class extends Container and is bootstrapped once.\n\u002F\u002F $app is passed around, but it is the same object everywhere.\n\u002F\u002F What makes this acceptable: the container holds closures, not resolved objects.\n\u002F\u002F Resolution happens on demand, and resolved instances have their own lifetime rules.\n\n$app->bind(PaymentGatewayInterface::class, StripeGateway::class);\n\u002F\u002F ↑ stores a binding definition — no side effects\n\n$gateway = $app->make(PaymentGatewayInterface::class);\n\u002F\u002F ↑ resolves on demand — fresh instance by default\n",[37,5241,5242,5247,5252,5257,5262,5266,5271,5276,5280,5285],{"__ignoreMap":63},[67,5243,5244],{"class":69,"line":70},[67,5245,5246],{},"\u002F\u002F Laravel's Application class extends Container and is bootstrapped once.\n",[67,5248,5249],{"class":69,"line":76},[67,5250,5251],{},"\u002F\u002F $app is passed around, but it is the same object everywhere.\n",[67,5253,5254],{"class":69,"line":82},[67,5255,5256],{},"\u002F\u002F What makes this acceptable: the container holds closures, not resolved objects.\n",[67,5258,5259],{"class":69,"line":89},[67,5260,5261],{},"\u002F\u002F Resolution happens on demand, and resolved instances have their own lifetime rules.\n",[67,5263,5264],{"class":69,"line":95},[67,5265,86],{"emptyLinePlaceholder":85},[67,5267,5268],{"class":69,"line":101},[67,5269,5270],{},"$app->bind(PaymentGatewayInterface::class, StripeGateway::class);\n",[67,5272,5273],{"class":69,"line":107},[67,5274,5275],{},"\u002F\u002F ↑ stores a binding definition — no side effects\n",[67,5277,5278],{"class":69,"line":113},[67,5279,86],{"emptyLinePlaceholder":85},[67,5281,5282],{"class":69,"line":118},[67,5283,5284],{},"$gateway = $app->make(PaymentGatewayInterface::class);\n",[67,5286,5287],{"class":69,"line":124},[67,5288,5289],{},"\u002F\u002F ↑ resolves on demand — fresh instance by default\n",[12,5291,5292],{},[32,5293,5294],{},"3. Connection pool managers — but not the connections",[12,5296,5297,5298,5301],{},"A pool manager that tracks available connections is legitimately a per-process singleton: there should be exactly one pool per worker, and it should live for the entire worker lifetime. What should ",[1161,5299,5300],{},"not"," be a singleton: the connections themselves, which need to be checked out and returned.",[22,5303,5305],{"id":5304},"the-testing-catastrophe","The testing catastrophe",[12,5307,5308],{},"The reason \"Singleton is an antipattern\" became conventional wisdom is almost entirely about tests. Static state persists across test cases in a single process. Every test that touches a Singleton leaks state into the next one.",[58,5310,5312],{"className":1348,"code":5311,"language":1350,"meta":63,"style":63},"class UserRepositoryTest extends TestCase\n{\n    public function testFindByEmail(): void\n    {\n        \u002F\u002F Database::getInstance() opens a real connection in __construct.\n        \u002F\u002F If the previous test left a transaction open, this one\n        \u002F\u002F will deadlock waiting for a lock that was never released.\n        $repo = new UserRepository(Database::getInstance());\n        $user = $repo->findByEmail('alice@example.com');\n        $this->assertNotNull($user);\n    }\n}\n",[37,5313,5314,5319,5323,5328,5332,5337,5342,5347,5352,5357,5362,5366],{"__ignoreMap":63},[67,5315,5316],{"class":69,"line":70},[67,5317,5318],{},"class UserRepositoryTest extends TestCase\n",[67,5320,5321],{"class":69,"line":76},[67,5322,1367],{},[67,5324,5325],{"class":69,"line":82},[67,5326,5327],{},"    public function testFindByEmail(): void\n",[67,5329,5330],{"class":69,"line":89},[67,5331,1409],{},[67,5333,5334],{"class":69,"line":95},[67,5335,5336],{},"        \u002F\u002F Database::getInstance() opens a real connection in __construct.\n",[67,5338,5339],{"class":69,"line":101},[67,5340,5341],{},"        \u002F\u002F If the previous test left a transaction open, this one\n",[67,5343,5344],{"class":69,"line":107},[67,5345,5346],{},"        \u002F\u002F will deadlock waiting for a lock that was never released.\n",[67,5348,5349],{"class":69,"line":113},[67,5350,5351],{},"        $repo = new UserRepository(Database::getInstance());\n",[67,5353,5354],{"class":69,"line":118},[67,5355,5356],{},"        $user = $repo->findByEmail('alice@example.com');\n",[67,5358,5359],{"class":69,"line":124},[67,5360,5361],{},"        $this->assertNotNull($user);\n",[67,5363,5364],{"class":69,"line":130},[67,5365,1439],{},[67,5367,5368],{"class":69,"line":136},[67,5369,1377],{},[12,5371,5372,5373,5376,5377,5380],{},"The standard workaround — ",[37,5374,5375],{},"Database::resetInstance()"," in ",[37,5378,5379],{},"tearDown()"," — is worse than the disease. You are now testing the reset behavior, and forgetting one teardown corrupts the rest of the suite in ways that are time-consuming to diagnose.",[12,5382,5383],{},"The structural fix is dependency injection with interfaces:",[58,5385,5387],{"className":1348,"code":5386,"language":1350,"meta":63,"style":63},"interface DatabaseConnectionInterface\n{\n    public function query(string $sql, array $params = []): array;\n    public function execute(string $sql, array $params = []): int;\n    public function beginTransaction(): void;\n    public function commit(): void;\n    public function rollback(): void;\n}\n\nclass UserRepository\n{\n    public function __construct(\n        private readonly DatabaseConnectionInterface $db\n    ) {}\n\n    public function findByEmail(string $email): ?User\n    {\n        $rows = $this->db->query(\n            'SELECT * FROM users WHERE email = ? LIMIT 1',\n            [$email]\n        );\n        return $rows ? User::fromRow($rows[0]) : null;\n    }\n}\n",[37,5388,5389,5394,5398,5403,5408,5413,5418,5423,5427,5431,5436,5440,5444,5449,5453,5457,5462,5466,5471,5476,5481,5485,5490,5494],{"__ignoreMap":63},[67,5390,5391],{"class":69,"line":70},[67,5392,5393],{},"interface DatabaseConnectionInterface\n",[67,5395,5396],{"class":69,"line":76},[67,5397,1367],{},[67,5399,5400],{"class":69,"line":82},[67,5401,5402],{},"    public function query(string $sql, array $params = []): array;\n",[67,5404,5405],{"class":69,"line":89},[67,5406,5407],{},"    public function execute(string $sql, array $params = []): int;\n",[67,5409,5410],{"class":69,"line":95},[67,5411,5412],{},"    public function beginTransaction(): void;\n",[67,5414,5415],{"class":69,"line":101},[67,5416,5417],{},"    public function commit(): void;\n",[67,5419,5420],{"class":69,"line":107},[67,5421,5422],{},"    public function rollback(): void;\n",[67,5424,5425],{"class":69,"line":113},[67,5426,1377],{},[67,5428,5429],{"class":69,"line":118},[67,5430,86],{"emptyLinePlaceholder":85},[67,5432,5433],{"class":69,"line":124},[67,5434,5435],{},"class UserRepository\n",[67,5437,5438],{"class":69,"line":130},[67,5439,1367],{},[67,5441,5442],{"class":69,"line":136},[67,5443,1584],{},[67,5445,5446],{"class":69,"line":142},[67,5447,5448],{},"        private readonly DatabaseConnectionInterface $db\n",[67,5450,5451],{"class":69,"line":148},[67,5452,1594],{},[67,5454,5455],{"class":69,"line":154},[67,5456,86],{"emptyLinePlaceholder":85},[67,5458,5459],{"class":69,"line":160},[67,5460,5461],{},"    public function findByEmail(string $email): ?User\n",[67,5463,5464],{"class":69,"line":165},[67,5465,1409],{},[67,5467,5468],{"class":69,"line":260},[67,5469,5470],{},"        $rows = $this->db->query(\n",[67,5472,5473],{"class":69,"line":709},[67,5474,5475],{},"            'SELECT * FROM users WHERE email = ? LIMIT 1',\n",[67,5477,5478],{"class":69,"line":714},[67,5479,5480],{},"            [$email]\n",[67,5482,5483],{"class":69,"line":726},[67,5484,1434],{},[67,5486,5487],{"class":69,"line":733},[67,5488,5489],{},"        return $rows ? User::fromRow($rows[0]) : null;\n",[67,5491,5492],{"class":69,"line":743},[67,5493,1439],{},[67,5495,5496],{"class":69,"line":753},[67,5497,1377],{},[12,5499,5500],{},"Now the test injects a mock. The real connection is wired by the container. The test never touches global state.",[58,5502,5504],{"className":1348,"code":5503,"language":1350,"meta":63,"style":63},"class UserRepositoryTest extends TestCase\n{\n    public function testFindByEmail(): void\n    {\n        $db = $this->createMock(DatabaseConnectionInterface::class);\n        $db->method('query')\n           ->with('SELECT * FROM users WHERE email = ? LIMIT 1', ['alice@example.com'])\n           ->willReturn([['id' => 1, 'email' => 'alice@example.com', 'name' => 'Alice']]);\n\n        $repo = new UserRepository($db);\n        $user = $repo->findByEmail('alice@example.com');\n\n        $this->assertSame('alice@example.com', $user->email);\n    }\n}\n",[37,5505,5506,5510,5514,5518,5522,5527,5532,5537,5542,5546,5551,5555,5559,5564,5568],{"__ignoreMap":63},[67,5507,5508],{"class":69,"line":70},[67,5509,5318],{},[67,5511,5512],{"class":69,"line":76},[67,5513,1367],{},[67,5515,5516],{"class":69,"line":82},[67,5517,5327],{},[67,5519,5520],{"class":69,"line":89},[67,5521,1409],{},[67,5523,5524],{"class":69,"line":95},[67,5525,5526],{},"        $db = $this->createMock(DatabaseConnectionInterface::class);\n",[67,5528,5529],{"class":69,"line":101},[67,5530,5531],{},"        $db->method('query')\n",[67,5533,5534],{"class":69,"line":107},[67,5535,5536],{},"           ->with('SELECT * FROM users WHERE email = ? LIMIT 1', ['alice@example.com'])\n",[67,5538,5539],{"class":69,"line":113},[67,5540,5541],{},"           ->willReturn([['id' => 1, 'email' => 'alice@example.com', 'name' => 'Alice']]);\n",[67,5543,5544],{"class":69,"line":118},[67,5545,86],{"emptyLinePlaceholder":85},[67,5547,5548],{"class":69,"line":124},[67,5549,5550],{},"        $repo = new UserRepository($db);\n",[67,5552,5553],{"class":69,"line":130},[67,5554,5356],{},[67,5556,5557],{"class":69,"line":136},[67,5558,86],{"emptyLinePlaceholder":85},[67,5560,5561],{"class":69,"line":142},[67,5562,5563],{},"        $this->assertSame('alice@example.com', $user->email);\n",[67,5565,5566],{"class":69,"line":148},[67,5567,1439],{},[67,5569,5570],{"class":69,"line":154},[67,5571,1377],{},[12,5573,5574],{},"No static state. No teardown. No cross-test contamination. The test is faster, deterministic, and tests exactly one thing.",[22,5576,5578],{"id":5577},"the-di-container-does-what-you-wanted-singleton-to-do","The DI container does what you wanted Singleton to do",[12,5580,5581],{},"The reason developers reach for Singleton is usually one of three things:",[5583,5584,5585,5588,5591],"ol",{},[188,5586,5587],{},"They want lazy initialisation (don't pay for the object until it's needed)",[188,5589,5590],{},"They want shared state (everyone reads the same config \u002F talks to the same pool)",[188,5592,5593],{},"They want convenience (no need to pass the object around)",[12,5595,5596],{},"A DI container with scoped lifetimes addresses all three without the global state problem:",[58,5598,5600],{"className":1348,"code":5599,"language":1350,"meta":63,"style":63},"\u002F\u002F Register as singleton-scoped: instantiated once per container lifetime\n$container->singleton(AppConfig::class, fn() => AppConfig::load());\n\n\u002F\u002F Register as transient: fresh instance on every resolve\n$container->bind(PaymentGatewayInterface::class, StripeGateway::class);\n\n\u002F\u002F Register as request-scoped (in a long-running process like Swoole\u002FRoadRunner):\n\u002F\u002F one instance per incoming request, released after the request ends\n$container->scoped(UserSession::class, fn($c) => new UserSession(\n    $c->make(RequestInterface::class)\n));\n",[37,5601,5602,5607,5612,5616,5621,5626,5630,5635,5640,5645,5650],{"__ignoreMap":63},[67,5603,5604],{"class":69,"line":70},[67,5605,5606],{},"\u002F\u002F Register as singleton-scoped: instantiated once per container lifetime\n",[67,5608,5609],{"class":69,"line":76},[67,5610,5611],{},"$container->singleton(AppConfig::class, fn() => AppConfig::load());\n",[67,5613,5614],{"class":69,"line":82},[67,5615,86],{"emptyLinePlaceholder":85},[67,5617,5618],{"class":69,"line":89},[67,5619,5620],{},"\u002F\u002F Register as transient: fresh instance on every resolve\n",[67,5622,5623],{"class":69,"line":95},[67,5624,5625],{},"$container->bind(PaymentGatewayInterface::class, StripeGateway::class);\n",[67,5627,5628],{"class":69,"line":101},[67,5629,86],{"emptyLinePlaceholder":85},[67,5631,5632],{"class":69,"line":107},[67,5633,5634],{},"\u002F\u002F Register as request-scoped (in a long-running process like Swoole\u002FRoadRunner):\n",[67,5636,5637],{"class":69,"line":113},[67,5638,5639],{},"\u002F\u002F one instance per incoming request, released after the request ends\n",[67,5641,5642],{"class":69,"line":118},[67,5643,5644],{},"$container->scoped(UserSession::class, fn($c) => new UserSession(\n",[67,5646,5647],{"class":69,"line":124},[67,5648,5649],{},"    $c->make(RequestInterface::class)\n",[67,5651,5652],{"class":69,"line":130},[67,5653,5654],{},"));\n",[12,5656,5657],{},"The container is the one legitimate application-wide singleton. Everything else gets its lifetime managed by the container. This is not a semantic trick — it changes the operational properties of the system. You can swap implementations for tests, reset scoped state between requests in long-running processes, and inspect the dependency graph without reading every class.",[22,5659,5661],{"id":5660},"the-pattern-i-actually-use","The pattern I actually use",[12,5663,5664,5665,5668],{},"For objects that genuinely need one-per-process existence, I use a ",[37,5666,5667],{},"once()"," helper rather than a Singleton class:",[58,5670,5672],{"className":1348,"code":5671,"language":1350,"meta":63,"style":63},"function once(string $key, callable $factory): mixed\n{\n    static $store = [];\n    return $store[$key] ??= $factory();\n}\n\n\u002F\u002F Usage: lazily initialised, shared within a process, no class pollution\n$config = once(AppConfig::class, fn() => AppConfig::load());\n",[37,5673,5674,5679,5683,5688,5693,5697,5701,5706],{"__ignoreMap":63},[67,5675,5676],{"class":69,"line":70},[67,5677,5678],{},"function once(string $key, callable $factory): mixed\n",[67,5680,5681],{"class":69,"line":76},[67,5682,1367],{},[67,5684,5685],{"class":69,"line":82},[67,5686,5687],{},"    static $store = [];\n",[67,5689,5690],{"class":69,"line":89},[67,5691,5692],{},"    return $store[$key] ??= $factory();\n",[67,5694,5695],{"class":69,"line":95},[67,5696,1377],{},[67,5698,5699],{"class":69,"line":101},[67,5700,86],{"emptyLinePlaceholder":85},[67,5702,5703],{"class":69,"line":107},[67,5704,5705],{},"\u002F\u002F Usage: lazily initialised, shared within a process, no class pollution\n",[67,5707,5708],{"class":69,"line":113},[67,5709,5710],{},"$config = once(AppConfig::class, fn() => AppConfig::load());\n",[12,5712,5713,5714,5717,5718,5721],{},"It is sixty characters. It survives code review. It does not spread ",[37,5715,5716],{},"private static $instance"," across the codebase. And — critically — in a test you can clear the static store entirely with a single reset function rather than hunting down ",[37,5719,5720],{},"resetInstance()"," methods across twenty classes.",[22,5723,3258],{"id":3257},[12,5725,5726,5727],{},"A Singleton is a yellow flag, not a red one. My first question is always: ",[32,5728,5729],{},"what is the lifetime of the mutable state this object holds?",[12,5731,5732],{},"If the answer is \"it only holds configuration values set at construction time, never modified after\", the pattern is defensible.",[12,5734,5735],{},"If the answer is \"it accumulates state across requests — counters, caches, buffers\" — that is the PHP-FPM trap. The fix is either Redis\u002FMemcached for state that needs to survive beyond a single worker, or a scoped lifetime managed by the container for state that should reset per request.",[12,5737,5738],{},"The pattern is not evil. Global mutable state is evil. Singleton makes global mutable state easy to introduce and hard to notice until it causes an incident on a Friday afternoon.",[238,5740,240],{},{"title":63,"searchDepth":76,"depth":76,"links":5742},[5743,5744,5745,5746,5747,5748,5749],{"id":4914,"depth":76,"text":4915},{"id":5070,"depth":76,"text":5071},{"id":5090,"depth":76,"text":5091},{"id":5304,"depth":76,"text":5305},{"id":5577,"depth":76,"text":5578},{"id":5660,"depth":76,"text":5661},{"id":3257,"depth":76,"text":3258},"2024-10-15",{},{"x":5753,"y":5754,"depth":5755,"size":2635},0.22,0.34,1.2,[262,7],{"title":4903,"description":4908},"global-state-mgmt","articles\u002Fsingleton-pattern",[1350,2201,3299,2203,5761,5762],"testing","php-fpm","v4.0.0","TUYZm70TUsOjDpyL4GPN_9TvRuQhoPMDsbxDsyMpktk",{"id":5766,"title":5767,"articleId":4890,"body":5768,"category":1350,"codeLang":1350,"date":6626,"deploys":70,"description":6627,"excerpt":251,"extension":252,"lang":251,"meta":6628,"navigation":85,"path":6629,"pos":6630,"readMin":136,"related":6633,"seo":6634,"service":6635,"stem":6636,"tags":6637,"version":1296,"__hash__":6641},"articles\u002Farticles\u002Fstate-machine.md","Finite state machines in PHP: modelling order lifecycle without the spaghetti",{"type":9,"value":5769,"toc":6618},[5770,5777,5792,5796,5807,5932,5942,5946,6270,6280,6284,6287,6353,6356,6359,6415,6418,6422,6425,6501,6517,6521,6532,6586,6589,6591,6599,6613,6616],[12,5771,5772,5773,5776],{},"The bug report said: \"Customer was charged twice for the same order.\" The order was in ",[37,5774,5775],{},"payment_pending"," status. A frontend timeout caused the customer to click \"Pay\" again. The second click triggered a new payment intent. Both intents succeeded within 200 milliseconds of each other. Neither the frontend nor the backend had a mechanism to prevent a second payment on an order that was mid-flight through payment collection.",[12,5778,5779,5780,5782,5783,5786,5787,2538,5789,5791],{},"The fix was not a mutex. It was a state machine. The transition from ",[37,5781,5775],{}," to ",[37,5784,5785],{},"paid"," can only happen once, and once it has happened, the transition from ",[37,5788,5775],{},[37,5790,5785],{}," no longer exists. The second payment attempt had nowhere valid to go.",[22,5793,5795],{"id":5794},"the-implicit-state-machine-you-already-have","The implicit state machine you already have",[12,5797,5798,5799,5802,5803,5806],{},"Every application with workflow concepts — orders, subscriptions, support tickets, loan applications — already has a state machine. It is just implicit: a ",[37,5800,5801],{},"status"," column in the database, and ",[37,5804,5805],{},"if"," statements scattered across services that check it.",[58,5808,5810],{"className":1348,"code":5809,"language":1350,"meta":63,"style":63},"\u002F\u002F The implicit version — found in most codebases\nclass OrderService\n{\n    public function processPayment(Order $order, PaymentResult $result): void\n    {\n        if ($order->status !== 'payment_pending') {\n            throw new \\LogicException(\"Cannot process payment for order in status: {$order->status}\");\n        }\n        \u002F\u002F ...\n    }\n\n    public function ship(Order $order): void\n    {\n        if (!in_array($order->status, ['paid', 'partially_paid'])) {\n            throw new \\LogicException(\"Cannot ship order in status: {$order->status}\");\n        }\n        \u002F\u002F ...\n    }\n\n    public function cancel(Order $order): void\n    {\n        if (in_array($order->status, ['shipped', 'delivered', 'refunded'])) {\n            throw new \\LogicException(\"Cannot cancel order in status: {$order->status}\");\n        }\n        \u002F\u002F ...\n    }\n}\n",[37,5811,5812,5817,5822,5826,5831,5835,5840,5845,5849,5854,5858,5862,5867,5871,5876,5881,5885,5889,5893,5897,5902,5906,5911,5916,5920,5924,5928],{"__ignoreMap":63},[67,5813,5814],{"class":69,"line":70},[67,5815,5816],{},"\u002F\u002F The implicit version — found in most codebases\n",[67,5818,5819],{"class":69,"line":76},[67,5820,5821],{},"class OrderService\n",[67,5823,5824],{"class":69,"line":82},[67,5825,1367],{},[67,5827,5828],{"class":69,"line":89},[67,5829,5830],{},"    public function processPayment(Order $order, PaymentResult $result): void\n",[67,5832,5833],{"class":69,"line":95},[67,5834,1409],{},[67,5836,5837],{"class":69,"line":101},[67,5838,5839],{},"        if ($order->status !== 'payment_pending') {\n",[67,5841,5842],{"class":69,"line":107},[67,5843,5844],{},"            throw new \\LogicException(\"Cannot process payment for order in status: {$order->status}\");\n",[67,5846,5847],{"class":69,"line":113},[67,5848,1889],{},[67,5850,5851],{"class":69,"line":118},[67,5852,5853],{},"        \u002F\u002F ...\n",[67,5855,5856],{"class":69,"line":124},[67,5857,1439],{},[67,5859,5860],{"class":69,"line":130},[67,5861,86],{"emptyLinePlaceholder":85},[67,5863,5864],{"class":69,"line":136},[67,5865,5866],{},"    public function ship(Order $order): void\n",[67,5868,5869],{"class":69,"line":142},[67,5870,1409],{},[67,5872,5873],{"class":69,"line":148},[67,5874,5875],{},"        if (!in_array($order->status, ['paid', 'partially_paid'])) {\n",[67,5877,5878],{"class":69,"line":154},[67,5879,5880],{},"            throw new \\LogicException(\"Cannot ship order in status: {$order->status}\");\n",[67,5882,5883],{"class":69,"line":160},[67,5884,1889],{},[67,5886,5887],{"class":69,"line":165},[67,5888,5853],{},[67,5890,5891],{"class":69,"line":260},[67,5892,1439],{},[67,5894,5895],{"class":69,"line":709},[67,5896,86],{"emptyLinePlaceholder":85},[67,5898,5899],{"class":69,"line":714},[67,5900,5901],{},"    public function cancel(Order $order): void\n",[67,5903,5904],{"class":69,"line":726},[67,5905,1409],{},[67,5907,5908],{"class":69,"line":733},[67,5909,5910],{},"        if (in_array($order->status, ['shipped', 'delivered', 'refunded'])) {\n",[67,5912,5913],{"class":69,"line":743},[67,5914,5915],{},"            throw new \\LogicException(\"Cannot cancel order in status: {$order->status}\");\n",[67,5917,5918],{"class":69,"line":753},[67,5919,1889],{},[67,5921,5922],{"class":69,"line":764},[67,5923,5853],{},[67,5925,5926],{"class":69,"line":774},[67,5927,1439],{},[67,5929,5930],{"class":69,"line":784},[67,5931,1377],{},[12,5933,5934,5935,5938,5939,5941],{},"This works until a new developer adds ",[37,5936,5937],{},"cancel()"," logic in a controller, forgets to check the status, and an order gets cancelled after it has already been shipped. The valid transitions are nowhere explicit. They exist only as the sum of all the ",[37,5940,5805],{}," checks across all methods.",[22,5943,5945],{"id":5944},"making-the-machine-explicit","Making the machine explicit",[58,5947,5949],{"className":1348,"code":5948,"language":1350,"meta":63,"style":63},"enum OrderStatus: string\n{\n    case Draft          = 'draft';\n    case PaymentPending = 'payment_pending';\n    case Paid           = 'paid';\n    case Shipped        = 'shipped';\n    case Delivered      = 'delivered';\n    case Cancelled      = 'cancelled';\n    case Refunded       = 'refunded';\n}\n\nfinal class OrderStateMachine\n{\n    \u002F\u002F The complete allowed transition graph — one place, one truth\n    private const TRANSITIONS = [\n        OrderStatus::Draft->value => [\n            OrderStatus::PaymentPending,\n            OrderStatus::Cancelled,\n        ],\n        OrderStatus::PaymentPending->value => [\n            OrderStatus::Paid,\n            OrderStatus::Cancelled,\n        ],\n        OrderStatus::Paid->value => [\n            OrderStatus::Shipped,\n            OrderStatus::Refunded,\n        ],\n        OrderStatus::Shipped->value => [\n            OrderStatus::Delivered,\n        ],\n        OrderStatus::Delivered->value => [\n            OrderStatus::Refunded,\n        ],\n        OrderStatus::Cancelled->value => [],  \u002F\u002F terminal\n        OrderStatus::Refunded->value  => [],  \u002F\u002F terminal\n    ];\n\n    public function canTransition(Order $order, OrderStatus $to): bool\n    {\n        return in_array($to, self::TRANSITIONS[$order->status->value] ?? [], strict: true);\n    }\n\n    public function transition(Order $order, OrderStatus $to): void\n    {\n        if (!$this->canTransition($order, $to)) {\n            throw new InvalidTransitionException(\n                from: $order->status,\n                to:   $to,\n                orderId: $order->id,\n            );\n        }\n\n        $previousStatus   = $order->status;\n        $order->status    = $to;\n        $order->status_changed_at = now();\n\n        \u002F\u002F Dispatch transition event — side effects happen in listeners, not here\n        event(new OrderStatusTransitioned(\n            order:    $order,\n            from:     $previousStatus,\n            to:       $to,\n        ));\n    }\n}\n",[37,5950,5951,5956,5960,5965,5970,5975,5980,5985,5990,5995,5999,6003,6008,6012,6017,6022,6027,6032,6037,6042,6047,6052,6056,6060,6065,6070,6075,6079,6084,6089,6093,6098,6102,6106,6111,6116,6121,6125,6130,6134,6139,6143,6147,6152,6156,6161,6167,6173,6179,6185,6191,6196,6201,6207,6213,6219,6224,6230,6236,6242,6248,6254,6260,6265],{"__ignoreMap":63},[67,5952,5953],{"class":69,"line":70},[67,5954,5955],{},"enum OrderStatus: string\n",[67,5957,5958],{"class":69,"line":76},[67,5959,1367],{},[67,5961,5962],{"class":69,"line":82},[67,5963,5964],{},"    case Draft          = 'draft';\n",[67,5966,5967],{"class":69,"line":89},[67,5968,5969],{},"    case PaymentPending = 'payment_pending';\n",[67,5971,5972],{"class":69,"line":95},[67,5973,5974],{},"    case Paid           = 'paid';\n",[67,5976,5977],{"class":69,"line":101},[67,5978,5979],{},"    case Shipped        = 'shipped';\n",[67,5981,5982],{"class":69,"line":107},[67,5983,5984],{},"    case Delivered      = 'delivered';\n",[67,5986,5987],{"class":69,"line":113},[67,5988,5989],{},"    case Cancelled      = 'cancelled';\n",[67,5991,5992],{"class":69,"line":118},[67,5993,5994],{},"    case Refunded       = 'refunded';\n",[67,5996,5997],{"class":69,"line":124},[67,5998,1377],{},[67,6000,6001],{"class":69,"line":130},[67,6002,86],{"emptyLinePlaceholder":85},[67,6004,6005],{"class":69,"line":136},[67,6006,6007],{},"final class OrderStateMachine\n",[67,6009,6010],{"class":69,"line":142},[67,6011,1367],{},[67,6013,6014],{"class":69,"line":148},[67,6015,6016],{},"    \u002F\u002F The complete allowed transition graph — one place, one truth\n",[67,6018,6019],{"class":69,"line":154},[67,6020,6021],{},"    private const TRANSITIONS = [\n",[67,6023,6024],{"class":69,"line":160},[67,6025,6026],{},"        OrderStatus::Draft->value => [\n",[67,6028,6029],{"class":69,"line":165},[67,6030,6031],{},"            OrderStatus::PaymentPending,\n",[67,6033,6034],{"class":69,"line":260},[67,6035,6036],{},"            OrderStatus::Cancelled,\n",[67,6038,6039],{"class":69,"line":709},[67,6040,6041],{},"        ],\n",[67,6043,6044],{"class":69,"line":714},[67,6045,6046],{},"        OrderStatus::PaymentPending->value => [\n",[67,6048,6049],{"class":69,"line":726},[67,6050,6051],{},"            OrderStatus::Paid,\n",[67,6053,6054],{"class":69,"line":733},[67,6055,6036],{},[67,6057,6058],{"class":69,"line":743},[67,6059,6041],{},[67,6061,6062],{"class":69,"line":753},[67,6063,6064],{},"        OrderStatus::Paid->value => [\n",[67,6066,6067],{"class":69,"line":764},[67,6068,6069],{},"            OrderStatus::Shipped,\n",[67,6071,6072],{"class":69,"line":774},[67,6073,6074],{},"            OrderStatus::Refunded,\n",[67,6076,6077],{"class":69,"line":784},[67,6078,6041],{},[67,6080,6081],{"class":69,"line":798},[67,6082,6083],{},"        OrderStatus::Shipped->value => [\n",[67,6085,6086],{"class":69,"line":809},[67,6087,6088],{},"            OrderStatus::Delivered,\n",[67,6090,6091],{"class":69,"line":814},[67,6092,6041],{},[67,6094,6095],{"class":69,"line":826},[67,6096,6097],{},"        OrderStatus::Delivered->value => [\n",[67,6099,6100],{"class":69,"line":250},[67,6101,6074],{},[67,6103,6104],{"class":69,"line":845},[67,6105,6041],{},[67,6107,6108],{"class":69,"line":857},[67,6109,6110],{},"        OrderStatus::Cancelled->value => [],  \u002F\u002F terminal\n",[67,6112,6113],{"class":69,"line":869},[67,6114,6115],{},"        OrderStatus::Refunded->value  => [],  \u002F\u002F terminal\n",[67,6117,6118],{"class":69,"line":880},[67,6119,6120],{},"    ];\n",[67,6122,6123],{"class":69,"line":891},[67,6124,86],{"emptyLinePlaceholder":85},[67,6126,6127],{"class":69,"line":896},[67,6128,6129],{},"    public function canTransition(Order $order, OrderStatus $to): bool\n",[67,6131,6132],{"class":69,"line":904},[67,6133,1409],{},[67,6135,6136],{"class":69,"line":915},[67,6137,6138],{},"        return in_array($to, self::TRANSITIONS[$order->status->value] ?? [], strict: true);\n",[67,6140,6141],{"class":69,"line":923},[67,6142,1439],{},[67,6144,6145],{"class":69,"line":934},[67,6146,86],{"emptyLinePlaceholder":85},[67,6148,6149],{"class":69,"line":945},[67,6150,6151],{},"    public function transition(Order $order, OrderStatus $to): void\n",[67,6153,6154],{"class":69,"line":1553},[67,6155,1409],{},[67,6157,6158],{"class":69,"line":1558},[67,6159,6160],{},"        if (!$this->canTransition($order, $to)) {\n",[67,6162,6164],{"class":69,"line":6163},46,[67,6165,6166],{},"            throw new InvalidTransitionException(\n",[67,6168,6170],{"class":69,"line":6169},47,[67,6171,6172],{},"                from: $order->status,\n",[67,6174,6176],{"class":69,"line":6175},48,[67,6177,6178],{},"                to:   $to,\n",[67,6180,6182],{"class":69,"line":6181},49,[67,6183,6184],{},"                orderId: $order->id,\n",[67,6186,6188],{"class":69,"line":6187},50,[67,6189,6190],{},"            );\n",[67,6192,6194],{"class":69,"line":6193},51,[67,6195,1889],{},[67,6197,6199],{"class":69,"line":6198},52,[67,6200,86],{"emptyLinePlaceholder":85},[67,6202,6204],{"class":69,"line":6203},53,[67,6205,6206],{},"        $previousStatus   = $order->status;\n",[67,6208,6210],{"class":69,"line":6209},54,[67,6211,6212],{},"        $order->status    = $to;\n",[67,6214,6216],{"class":69,"line":6215},55,[67,6217,6218],{},"        $order->status_changed_at = now();\n",[67,6220,6222],{"class":69,"line":6221},56,[67,6223,86],{"emptyLinePlaceholder":85},[67,6225,6227],{"class":69,"line":6226},57,[67,6228,6229],{},"        \u002F\u002F Dispatch transition event — side effects happen in listeners, not here\n",[67,6231,6233],{"class":69,"line":6232},58,[67,6234,6235],{},"        event(new OrderStatusTransitioned(\n",[67,6237,6239],{"class":69,"line":6238},59,[67,6240,6241],{},"            order:    $order,\n",[67,6243,6245],{"class":69,"line":6244},60,[67,6246,6247],{},"            from:     $previousStatus,\n",[67,6249,6251],{"class":69,"line":6250},61,[67,6252,6253],{},"            to:       $to,\n",[67,6255,6257],{"class":69,"line":6256},62,[67,6258,6259],{},"        ));\n",[67,6261,6263],{"class":69,"line":6262},63,[67,6264,1439],{},[67,6266,6268],{"class":69,"line":6267},64,[67,6269,1377],{},[12,6271,6272,6275,6276,6279],{},[37,6273,6274],{},"TRANSITIONS"," is the entire specification of your workflow. To add a new transition, you add one entry. To understand which transitions are possible from any state, you read one array. To prove that ",[37,6277,6278],{},"cancelled → refunded"," is impossible, you look at the empty array.",[22,6281,6283],{"id":6282},"side-effects-belong-in-listeners","Side effects belong in listeners",[12,6285,6286],{},"The classic mistake after adopting explicit state machines is putting side effects in the transition method:",[58,6288,6290],{"className":1348,"code":6289,"language":1350,"meta":63,"style":63},"\u002F\u002F Do not do this\npublic function transition(Order $order, OrderStatus $to): void\n{\n    \u002F\u002F validate...\n    $order->status = $to;\n\n    if ($to === OrderStatus::Paid) {\n        $this->emailService->sendPaymentConfirmation($order);\n        $this->inventoryService->reserveItems($order);\n        $this->analyticsService->trackConversion($order);\n    }\n    \u002F\u002F ...\n}\n",[37,6291,6292,6297,6302,6306,6311,6316,6320,6325,6330,6335,6340,6344,6349],{"__ignoreMap":63},[67,6293,6294],{"class":69,"line":70},[67,6295,6296],{},"\u002F\u002F Do not do this\n",[67,6298,6299],{"class":69,"line":76},[67,6300,6301],{},"public function transition(Order $order, OrderStatus $to): void\n",[67,6303,6304],{"class":69,"line":82},[67,6305,1367],{},[67,6307,6308],{"class":69,"line":89},[67,6309,6310],{},"    \u002F\u002F validate...\n",[67,6312,6313],{"class":69,"line":95},[67,6314,6315],{},"    $order->status = $to;\n",[67,6317,6318],{"class":69,"line":101},[67,6319,86],{"emptyLinePlaceholder":85},[67,6321,6322],{"class":69,"line":107},[67,6323,6324],{},"    if ($to === OrderStatus::Paid) {\n",[67,6326,6327],{"class":69,"line":113},[67,6328,6329],{},"        $this->emailService->sendPaymentConfirmation($order);\n",[67,6331,6332],{"class":69,"line":118},[67,6333,6334],{},"        $this->inventoryService->reserveItems($order);\n",[67,6336,6337],{"class":69,"line":124},[67,6338,6339],{},"        $this->analyticsService->trackConversion($order);\n",[67,6341,6342],{"class":69,"line":130},[67,6343,1439],{},[67,6345,6346],{"class":69,"line":136},[67,6347,6348],{},"    \u002F\u002F ...\n",[67,6350,6351],{"class":69,"line":142},[67,6352,1377],{},[12,6354,6355],{},"The state machine is now coupled to email, inventory, and analytics. Testing the transition requires mocking three dependencies. More importantly: if email sending fails, does the order remain unpaid? If analytics throws, does the customer not get their items?",[12,6357,6358],{},"Dispatch an event instead. Let listeners decide what to do with it:",[58,6360,6362],{"className":1348,"code":6361,"language":1350,"meta":63,"style":63},"\u002F\u002F In OrderEventSubscriber\npublic function onOrderStatusTransitioned(OrderStatusTransitioned $event): void\n{\n    if ($event->to !== OrderStatus::Paid) {\n        return;\n    }\n\n    \u002F\u002F Each listener is independently retryable, independently testable\n    $this->emailQueue->dispatch(new SendPaymentConfirmationEmail($event->order->id));\n    $this->inventoryQueue->dispatch(new ReserveOrderItems($event->order->id));\n}\n",[37,6363,6364,6369,6374,6378,6383,6388,6392,6396,6401,6406,6411],{"__ignoreMap":63},[67,6365,6366],{"class":69,"line":70},[67,6367,6368],{},"\u002F\u002F In OrderEventSubscriber\n",[67,6370,6371],{"class":69,"line":76},[67,6372,6373],{},"public function onOrderStatusTransitioned(OrderStatusTransitioned $event): void\n",[67,6375,6376],{"class":69,"line":82},[67,6377,1367],{},[67,6379,6380],{"class":69,"line":89},[67,6381,6382],{},"    if ($event->to !== OrderStatus::Paid) {\n",[67,6384,6385],{"class":69,"line":95},[67,6386,6387],{},"        return;\n",[67,6389,6390],{"class":69,"line":101},[67,6391,1439],{},[67,6393,6394],{"class":69,"line":107},[67,6395,86],{"emptyLinePlaceholder":85},[67,6397,6398],{"class":69,"line":113},[67,6399,6400],{},"    \u002F\u002F Each listener is independently retryable, independently testable\n",[67,6402,6403],{"class":69,"line":118},[67,6404,6405],{},"    $this->emailQueue->dispatch(new SendPaymentConfirmationEmail($event->order->id));\n",[67,6407,6408],{"class":69,"line":124},[67,6409,6410],{},"    $this->inventoryQueue->dispatch(new ReserveOrderItems($event->order->id));\n",[67,6412,6413],{"class":69,"line":130},[67,6414,1377],{},[12,6416,6417],{},"A failed email job does not roll back the payment status. The order is paid. The email will retry. These are separate concerns.",[22,6419,6421],{"id":6420},"persisting-state-safely","Persisting state safely",[12,6423,6424],{},"In a concurrent system — and every web application is a concurrent system — two requests can simultaneously attempt to transition the same order. Database-level protection:",[58,6426,6428],{"className":1348,"code":6427,"language":1350,"meta":63,"style":63},"public function transitionWithLock(int $orderId, OrderStatus $to): void\n{\n    DB::transaction(function () use ($orderId, $to) {\n        \u002F\u002F FOR UPDATE locks the row for the duration of this transaction\n        $order = Order::where('id', $orderId)\n                      ->lockForUpdate()\n                      ->firstOrFail();\n\n        $this->stateMachine->transition($order, $to);\n        $order->save();\n\n        \u002F\u002F Event is dispatched inside the transaction — if save() fails,\n        \u002F\u002F the event is not dispatched (assuming DB-backed event queue)\n    });\n}\n",[37,6429,6430,6435,6439,6444,6449,6454,6459,6464,6468,6473,6478,6482,6487,6492,6497],{"__ignoreMap":63},[67,6431,6432],{"class":69,"line":70},[67,6433,6434],{},"public function transitionWithLock(int $orderId, OrderStatus $to): void\n",[67,6436,6437],{"class":69,"line":76},[67,6438,1367],{},[67,6440,6441],{"class":69,"line":82},[67,6442,6443],{},"    DB::transaction(function () use ($orderId, $to) {\n",[67,6445,6446],{"class":69,"line":89},[67,6447,6448],{},"        \u002F\u002F FOR UPDATE locks the row for the duration of this transaction\n",[67,6450,6451],{"class":69,"line":95},[67,6452,6453],{},"        $order = Order::where('id', $orderId)\n",[67,6455,6456],{"class":69,"line":101},[67,6457,6458],{},"                      ->lockForUpdate()\n",[67,6460,6461],{"class":69,"line":107},[67,6462,6463],{},"                      ->firstOrFail();\n",[67,6465,6466],{"class":69,"line":113},[67,6467,86],{"emptyLinePlaceholder":85},[67,6469,6470],{"class":69,"line":118},[67,6471,6472],{},"        $this->stateMachine->transition($order, $to);\n",[67,6474,6475],{"class":69,"line":124},[67,6476,6477],{},"        $order->save();\n",[67,6479,6480],{"class":69,"line":130},[67,6481,86],{"emptyLinePlaceholder":85},[67,6483,6484],{"class":69,"line":136},[67,6485,6486],{},"        \u002F\u002F Event is dispatched inside the transaction — if save() fails,\n",[67,6488,6489],{"class":69,"line":142},[67,6490,6491],{},"        \u002F\u002F the event is not dispatched (assuming DB-backed event queue)\n",[67,6493,6494],{"class":69,"line":148},[67,6495,6496],{},"    });\n",[67,6498,6499],{"class":69,"line":154},[67,6500,1377],{},[12,6502,6503,6506,6507,6509,6510,6512,6513,6516],{},[37,6504,6505],{},"lockForUpdate()"," prevents a second concurrent request from reading the ",[37,6508,5775],{}," order until the first transaction commits. The second request then reads the ",[37,6511,5785],{}," order, finds no valid transition, and throws ",[37,6514,6515],{},"InvalidTransitionException",". No double charge.",[22,6518,6520],{"id":6519},"what-the-audit-trail-looks-like","What the audit trail looks like",[12,6522,6523,6524,6527,6528,6531],{},"Every ",[37,6525,6526],{},"OrderStatusTransitioned"," event persisted to an ",[37,6529,6530],{},"order_status_history"," table gives you a complete audit trail with almost no extra effort:",[58,6533,6535],{"className":4759,"code":6534,"language":4761,"meta":63,"style":63},"SELECT status_from, status_to, created_at, triggered_by\nFROM order_status_history\nWHERE order_id = 4821\nORDER BY created_at;\n\n-- status_from      | status_to        | created_at           | triggered_by\n-- draft            | payment_pending  | 2024-02-10 14:23:01  | user:8823\n-- payment_pending  | paid             | 2024-02-10 14:23:04  | stripe-webhook:pi_abc123\n-- paid             | shipped          | 2024-02-10 14:55:17  | fulfillment-worker\n-- shipped          | delivered        | 2024-02-11 09:14:33  | delivery-webhook\n",[37,6536,6537,6542,6547,6552,6557,6561,6566,6571,6576,6581],{"__ignoreMap":63},[67,6538,6539],{"class":69,"line":70},[67,6540,6541],{},"SELECT status_from, status_to, created_at, triggered_by\n",[67,6543,6544],{"class":69,"line":76},[67,6545,6546],{},"FROM order_status_history\n",[67,6548,6549],{"class":69,"line":82},[67,6550,6551],{},"WHERE order_id = 4821\n",[67,6553,6554],{"class":69,"line":89},[67,6555,6556],{},"ORDER BY created_at;\n",[67,6558,6559],{"class":69,"line":95},[67,6560,86],{"emptyLinePlaceholder":85},[67,6562,6563],{"class":69,"line":101},[67,6564,6565],{},"-- status_from      | status_to        | created_at           | triggered_by\n",[67,6567,6568],{"class":69,"line":107},[67,6569,6570],{},"-- draft            | payment_pending  | 2024-02-10 14:23:01  | user:8823\n",[67,6572,6573],{"class":69,"line":113},[67,6574,6575],{},"-- payment_pending  | paid             | 2024-02-10 14:23:04  | stripe-webhook:pi_abc123\n",[67,6577,6578],{"class":69,"line":118},[67,6579,6580],{},"-- paid             | shipped          | 2024-02-10 14:55:17  | fulfillment-worker\n",[67,6582,6583],{"class":69,"line":124},[67,6584,6585],{},"-- shipped          | delivered        | 2024-02-11 09:14:33  | delivery-webhook\n",[12,6587,6588],{},"When a customer calls support saying \"I paid but nothing happened,\" you read this table. The answer is one query away.",[22,6590,3258],{"id":3257},[12,6592,4726,6593,6595,6596],{},[37,6594,5801],{}," column with no corresponding state machine definition is a liability waiting to be exploited. My question: ",[32,6597,6598],{},"can the application reach an invalid state combination?",[12,6600,6601,6602,6605,6606,1990,6609,6612],{},"An order with ",[37,6603,6604],{},"status = 'shipped'"," and no shipping address — that should be impossible. An order with ",[37,6607,6608],{},"status = 'refunded'",[37,6610,6611],{},"payment_status = 'pending'"," — also impossible, if the machine is defined correctly.",[12,6614,6615],{},"If the answer to \"can this reach an invalid state\" is \"theoretically, if two things happen in the right order\", you have an implicit machine. Make it explicit. The TRANSITIONS constant is the documentation, the validation, and the test specification all at once.",[238,6617,240],{},{"title":63,"searchDepth":76,"depth":76,"links":6619},[6620,6621,6622,6623,6624,6625],{"id":5794,"depth":76,"text":5795},{"id":5944,"depth":76,"text":5945},{"id":6282,"depth":76,"text":6283},{"id":6420,"depth":76,"text":6421},{"id":6519,"depth":76,"text":6520},{"id":3257,"depth":76,"text":3258},"2024-02-10","The bug report said: \"Customer was charged twice for the same order.\" The order was in payment_pending status. A frontend timeout caused the customer to click \"Pay\" again. The second click triggered a new payment intent. Both intents succeeded within 200 milliseconds of each other. Neither the frontend nor the backend had a mechanism to prevent a second payment on an order that was mid-flight through payment collection.",{},"\u002Farticles\u002Fstate-machine",{"x":6631,"y":6632,"depth":4083,"size":252},0.82,0.4,[2637,2195],{"title":5767,"description":6627},"order-lifecycle","articles\u002Fstate-machine",[1350,2201,4890,6638,6639,6640],"fsm","ddd","order-management","FQGKsnmxwbDASvlH9WiBn9aY_iOAZSnAc2SaWZpNwAA",1779453307183]