[{"data":1,"prerenderedAt":14388},["ShallowReactive",2],{"article-php-forgets-python-remembers":3,"pl-check-php-forgets-python-remembers":928,"articles-for-sidebar":1681},{"id":4,"title":5,"articleId":6,"body":7,"category":27,"codeLang":27,"date":909,"deploys":61,"description":13,"excerpt":910,"extension":911,"lang":910,"meta":912,"navigation":51,"path":913,"pos":914,"readMin":85,"related":917,"seo":920,"service":921,"stem":922,"tags":923,"updated":910,"version":926,"__hash__":927},"articles\u002Farticles\u002Fphp-forgets-python-remembers.md","PHP forgets, Python remembers. It comes down to the execution model","php-forgets-python-remembers",{"type":8,"value":9,"toc":895},"minimark",[10,14,19,22,100,103,163,166,177,181,184,187,194,199,202,205,209,216,222,237,241,248,251,255,286,316,320,323,423,437,447,513,520,527,538,549,553,563,566,588,668,671,675,686,881,885,888,891],[11,12,13],"p",{},"The same hit counter shows 1 on every refresh in PHP and, with a single process, climbs 1, 2, 3 in Python. The reason is how long the state of the program serving your request lives: in classic PHP until the end of the request, in Python until the process ends. That decides what survives from one request to the next, and it applies to PHP in worker mode too.",[15,16,18],"h2",{"id":17},"the-counter-that-cannot-count","The counter that cannot count",[11,20,21],{},"Try a small experiment. A PHP endpoint with a hit counter in a static variable, behind nginx and PHP-FPM:",[23,24,29],"pre",{"className":25,"code":26,"language":27,"meta":28,"style":28},"language-php shiki shiki-themes github-light github-dark","\u003C?php\n\u002F\u002F public\u002Findex.php, served by nginx + PHP-FPM\n\nfunction hits(): int\n{\n    static $count = 0;\n    return ++$count;\n}\n\nheader('Content-Type: text\u002Fplain');\necho hits(); \u002F\u002F 1. Refresh: 1. Refresh a hundred times: 1.\n","php","",[30,31,32,40,46,53,59,65,71,77,83,88,94],"code",{"__ignoreMap":28},[33,34,37],"span",{"class":35,"line":36},"line",1,[33,38,39],{},"\u003C?php\n",[33,41,43],{"class":35,"line":42},2,[33,44,45],{},"\u002F\u002F public\u002Findex.php, served by nginx + PHP-FPM\n",[33,47,49],{"class":35,"line":48},3,[33,50,52],{"emptyLinePlaceholder":51},true,"\n",[33,54,56],{"class":35,"line":55},4,[33,57,58],{},"function hits(): int\n",[33,60,62],{"class":35,"line":61},5,[33,63,64],{},"{\n",[33,66,68],{"class":35,"line":67},6,[33,69,70],{},"    static $count = 0;\n",[33,72,74],{"class":35,"line":73},7,[33,75,76],{},"    return ++$count;\n",[33,78,80],{"class":35,"line":79},8,[33,81,82],{},"}\n",[33,84,86],{"class":35,"line":85},9,[33,87,52],{"emptyLinePlaceholder":51},[33,89,91],{"class":35,"line":90},10,[33,92,93],{},"header('Content-Type: text\u002Fplain');\n",[33,95,97],{"class":35,"line":96},11,[33,98,99],{},"echo hits(); \u002F\u002F 1. Refresh: 1. Refresh a hundred times: 1.\n",[11,101,102],{},"Refresh ten times. Ten ones. Now the same thing in FastAPI:",[23,104,108],{"className":105,"code":106,"language":107,"meta":28,"style":28},"language-python shiki shiki-themes github-light github-dark","# main.py, started with: uvicorn main:app\nfrom fastapi import FastAPI\n\napp = FastAPI()\ncount = 0\n\n@app.get(\"\u002F\")\ndef hits():\n    global count\n    count += 1\n    return {\"count\": count}  # 1, 2, 3... grows with every request\n","python",[30,109,110,115,120,124,129,134,138,143,148,153,158],{"__ignoreMap":28},[33,111,112],{"class":35,"line":36},[33,113,114],{},"# main.py, started with: uvicorn main:app\n",[33,116,117],{"class":35,"line":42},[33,118,119],{},"from fastapi import FastAPI\n",[33,121,122],{"class":35,"line":48},[33,123,52],{"emptyLinePlaceholder":51},[33,125,126],{"class":35,"line":55},[33,127,128],{},"app = FastAPI()\n",[33,130,131],{"class":35,"line":61},[33,132,133],{},"count = 0\n",[33,135,136],{"class":35,"line":67},[33,137,52],{"emptyLinePlaceholder":51},[33,139,140],{"class":35,"line":73},[33,141,142],{},"@app.get(\"\u002F\")\n",[33,144,145],{"class":35,"line":79},[33,146,147],{},"def hits():\n",[33,149,150],{"class":35,"line":85},[33,151,152],{},"    global count\n",[33,154,155],{"class":35,"line":90},[33,156,157],{},"    count += 1\n",[33,159,160],{"class":35,"line":96},[33,161,162],{},"    return {\"count\": count}  # 1, 2, 3... grows with every request\n",[11,164,165],{},"With a single uvicorn process, ten refreshes give you 10. The logic is the same; the difference is one level down, in what the process does between one request and the next.",[11,167,168,169,172,173,176],{},"This is a demonstration with sequential, one-at-a-time requests. As a counter under concurrent traffic it does not hold up: FastAPI runs plain ",[30,170,171],{},"def"," endpoints in a thread pool, and ",[30,174,175],{},"count += 1"," is not guaranteed to be atomic, so with several requests at once some increments can be lost.",[15,178,180],{"id":179},"classic-php-every-request-from-scratch","Classic PHP: every request from scratch",[11,182,183],{},"PHP under PHP-FPM runs in a shared-nothing model, meaning every request gets a fresh execution environment and the previous request's script state does not carry over. Variables, objects, static class properties, singletons: all of it goes in the bin the moment the script finishes.",[11,185,186],{},"Picture an employee who goes home after every task and comes back the next morning remembering nothing. They get a slip of paper (the request), do the job, hand back the result (the response) and forget. They do not remember the previous customer or that they already counted yesterday, so the counter shows 1.",[11,188,189,190,193],{},"The PHP-FPM process does not die after the request at all. An FPM worker (a child of the master process) serves hundreds of requests in a row, until ",[30,191,192],{},"pm.max_requests"," tells it to retire. The default is 0, meaning the limit is off: the worker is not recycled because of request count, though a reload, a restart or the pool shrinking will end it sooner or later. But between requests PHP runs a full request shutdown: it frees the script's memory, closes ordinary connections, wipes the symbol table.",[195,196,198],"h3",{"id":197},"so-what-does-opcache-do","So what does OPcache do?",[11,200,201],{},"OPcache keeps the compiled bytecode of your scripts (the intermediate form the engine executes instead of parsing the file's text) in shared memory, so PHP does not compile the same thing on every request. OPcache holds the compiled function together with the static variable's initial value; the current value lives in the request's memory.",[11,203,204],{},"Preloading (since PHP 7.4) goes one step further and loads chosen classes into memory once, when FPM starts. That is code too, with one side effect: FPM will not see a change to a preloaded file until the FPM master starts over, whatever the timestamp checks say.",[195,206,208],{"id":207},"where-does-state-go-then","Where does state go, then?",[11,210,211,212,215],{},"Outside the process. The session (file, Redis, database) for anything tied to a user. The database for anything that has to survive a restart. Redis or Memcached for a cache shared across servers. APCu if a local cache on a single machine is enough: it is shared memory across the FPM workers, it outlives the end of the request but disappears when FPM restarts or reloads. A counter built on ",[30,213,214],{},"apcu_inc('hits')"," will climb until FPM starts over or APCu clears its cache for lack of space: APCu creates the missing key itself, and the increment is atomic.",[11,217,218,219,221],{},"Application state does not pile up in this model. Ordinary script memory is released after every request, so a forgotten array or object lives for one request at most. That does not cover leaks in extensions and native libraries. Those stay in the process, and they are exactly why the PHP manual points you to ",[30,220,192],{},".",[11,223,224,225,228,229,232,233,236],{},"After a code change you usually do not have to restart anything either. By default OPcache checks file timestamps (every 2 seconds), so the new version comes in on its own, though not necessarily on the very next request. With ",[30,226,227],{},"opcache.validate_timestamps"," off you need to clear OPcache (for example ",[30,230,231],{},"opcache_reset()"," called inside the FPM process) or reload FPM, and for code covered by preloading you need an FPM reload (on Debian and Ubuntu ",[30,234,235],{},"systemctl reload php8.5-fpm"," sends USR2, after which the master starts over and runs the preload again). One caveat: copying files one by one onto a running application can briefly mix the old version with the new one. A fresh state per request does not make that an atomic deploy; you need a separate mechanism for switching versions.",[15,238,240],{"id":239},"python-the-process-lives-long","Python: the process lives long",[11,242,243,244,247],{},"Gunicorn and uvicorn work differently. You start a process, that process imports your module once, builds the application object once and from then on does nothing but serve requests. The line ",[30,245,246],{},"count = 0"," ran exactly once, and every subsequent request adds to the same variable in the same memory.",[11,249,250],{},"In Python the process simply stays: the database connection and every object put down \"just for a moment\" in a global variable are still in memory.",[195,252,254],{"id":253},"the-catch-there-are-several-workers","The catch: there are several workers",[11,256,257,258,261,262,265,266,269,270,273,274,277,278,281,282,285],{},"Run ",[30,259,260],{},"uvicorn main:app --workers 4"," (or ",[30,263,264],{},"gunicorn -w 4 -k asgi main:app",") and send a few requests from a terminal: ",[30,267,268],{},"for i in 1 2 3 4 5 6; do curl -s localhost:8000\u002F; echo; done",". Each ",[30,271,272],{},"curl"," call is a separate connection, so it can land on a different worker, and the counts start jumping around, for example 1, 1, 2, 1, 3, 2. Each worker is a separate process with its own copy of the module and its own ",[30,275,276],{},"count",". The workers share one listening socket and accept connections as they come, so you land here one time and there the next. And after a restart all four counters go back to zero. A browser may not show you this: if it reuses the same TCP connection to uvicorn (keep-alive; 5 seconds by default in plain uvicorn, while under gunicorn with an ASGI worker its own ",[30,279,280],{},"keepalive"," applies, 2 seconds by default; the ",[30,283,284],{},"sync"," worker closes the connection after every response), subsequent requests go to the same worker and the counter simply climbs. Behind a reverse proxy there is also the proxy's own layer of connections.",[11,287,288,289,292,293,296,297,300,301,303,304,307,308,311,312,315],{},"Without ",[30,290,291],{},"--workers",", a ",[30,294,295],{},"WEB_CONCURRENCY"," variable or a config file of your own, gunicorn starts a single synchronous worker (",[30,298,299],{},"--workers 1",", class ",[30,302,284],{},"). That worker speaks WSGI, and FastAPI is an ASGI application, which is why gunicorn needs an ASGI worker: built in as ",[30,305,306],{},"-k asgi"," since version 24, before that ",[30,309,310],{},"-k uvicorn_worker.UvicornWorker"," from the ",[30,313,314],{},"uvicorn-worker"," package. Uvicorn in the same situation runs a single process with an asyncio loop. When there are several processes, each has its own copy of the global variable.",[15,317,319],{"id":318},"the-traps-of-a-long-lived-process","The traps of a long-lived process",[11,321,322],{},"The counter hurts nobody. The same mechanism applied to user data looks like this:",[23,324,326],{"className":105,"code":325,"language":107,"meta":28,"style":28},"# auth.py: \"convenient\" access to the current user\n# (sketch: app, users and User come from the rest of the app;\n#  users.from_header() parses \"Bearer \u003Ctoken>\", returns None if invalid)\ncurrent_user = None\n\n@app.middleware(\"http\")\nasync def load_user(request, call_next):\n    global current_user\n    header = request.headers.get(\"authorization\")\n    if header:\n        current_user = users.from_header(header)  # no else branch: nobody clears it\n    return await call_next(request)\n\n@app.get(\"\u002Fme\")\ndef me():\n    return {\"email\": current_user.email}\n# Request with a token: fine. The next request WITHOUT a token on the\n# same worker gets the e-mail of the last user that worker served.\n",[30,327,328,333,338,343,348,352,357,362,367,372,377,382,388,393,399,405,411,417],{"__ignoreMap":28},[33,329,330],{"class":35,"line":36},[33,331,332],{},"# auth.py: \"convenient\" access to the current user\n",[33,334,335],{"class":35,"line":42},[33,336,337],{},"# (sketch: app, users and User come from the rest of the app;\n",[33,339,340],{"class":35,"line":48},[33,341,342],{},"#  users.from_header() parses \"Bearer \u003Ctoken>\", returns None if invalid)\n",[33,344,345],{"class":35,"line":55},[33,346,347],{},"current_user = None\n",[33,349,350],{"class":35,"line":61},[33,351,52],{"emptyLinePlaceholder":51},[33,353,354],{"class":35,"line":67},[33,355,356],{},"@app.middleware(\"http\")\n",[33,358,359],{"class":35,"line":73},[33,360,361],{},"async def load_user(request, call_next):\n",[33,363,364],{"class":35,"line":79},[33,365,366],{},"    global current_user\n",[33,368,369],{"class":35,"line":85},[33,370,371],{},"    header = request.headers.get(\"authorization\")\n",[33,373,374],{"class":35,"line":90},[33,375,376],{},"    if header:\n",[33,378,379],{"class":35,"line":96},[33,380,381],{},"        current_user = users.from_header(header)  # no else branch: nobody clears it\n",[33,383,385],{"class":35,"line":384},12,[33,386,387],{},"    return await call_next(request)\n",[33,389,391],{"class":35,"line":390},13,[33,392,52],{"emptyLinePlaceholder":51},[33,394,396],{"class":35,"line":395},14,[33,397,398],{},"@app.get(\"\u002Fme\")\n",[33,400,402],{"class":35,"line":401},15,[33,403,404],{},"def me():\n",[33,406,408],{"class":35,"line":407},16,[33,409,410],{},"    return {\"email\": current_user.email}\n",[33,412,414],{"class":35,"line":413},17,[33,415,416],{},"# Request with a token: fine. The next request WITHOUT a token on the\n",[33,418,420],{"class":35,"line":419},18,[33,421,422],{},"# same worker gets the e-mail of the last user that worker served.\n",[11,424,425,426,429,430,433,434,436],{},"In a start-from-scratch model this code would blow up on the first anonymous request: ",[30,427,428],{},"None",", exception, log entry, all visible right away. Under a long-lived process the exception only fires when ",[30,431,432],{},"current_user"," is ",[30,435,428],{},": on a fresh worker or after a request with an invalid token. Once the worker has served someone with a token, the endpoint returns that person's e-mail. The error log has no trace of it, the access log shows a plain 200.",[11,438,439,440,442,443,446],{},"One worker does not mean one request at a time, either. Async middleware interleaves with other requests on the event loop, and FastAPI runs endpoints declared with a plain ",[30,441,171],{}," in a thread pool. Two requests with different tokens arrive at once. It can happen that the first one's middleware sets user A, the second one's overwrites it with user B before the first endpoint reads it, and that endpoint returns B's e-mail. Adding ",[30,444,445],{},"else: current_user = None"," only closes the first case. The race goes away once you take the user out of the global, for example with a dependency that reads the token from the current request:",[23,448,450],{"className":105,"code":449,"language":107,"meta":28,"style":28},"# sketch, same assumptions as above\nfrom fastapi import Depends, HTTPException, Request\n\ndef get_current_user(request: Request) -> User:\n    header = request.headers.get(\"authorization\")\n    user = users.from_header(header) if header else None\n    if user is None:\n        raise HTTPException(status_code=401, headers={\"WWW-Authenticate\": \"Bearer\"})\n    return user\n\n@app.get(\"\u002Fme\")\ndef me(user: User = Depends(get_current_user)):\n    return {\"email\": user.email}  # belongs to this request only\n",[30,451,452,457,462,466,471,475,480,485,490,495,499,503,508],{"__ignoreMap":28},[33,453,454],{"class":35,"line":36},[33,455,456],{},"# sketch, same assumptions as above\n",[33,458,459],{"class":35,"line":42},[33,460,461],{},"from fastapi import Depends, HTTPException, Request\n",[33,463,464],{"class":35,"line":48},[33,465,52],{"emptyLinePlaceholder":51},[33,467,468],{"class":35,"line":55},[33,469,470],{},"def get_current_user(request: Request) -> User:\n",[33,472,473],{"class":35,"line":61},[33,474,371],{},[33,476,477],{"class":35,"line":67},[33,478,479],{},"    user = users.from_header(header) if header else None\n",[33,481,482],{"class":35,"line":73},[33,483,484],{},"    if user is None:\n",[33,486,487],{"class":35,"line":79},[33,488,489],{},"        raise HTTPException(status_code=401, headers={\"WWW-Authenticate\": \"Bearer\"})\n",[33,491,492],{"class":35,"line":85},[33,493,494],{},"    return user\n",[33,496,497],{"class":35,"line":90},[33,498,52],{"emptyLinePlaceholder":51},[33,500,501],{"class":35,"line":96},[33,502,398],{},[33,504,505],{"class":35,"line":384},[33,506,507],{},"def me(user: User = Depends(get_current_user)):\n",[33,509,510],{"class":35,"line":390},[33,511,512],{},"    return {\"email\": user.email}  # belongs to this request only\n",[11,514,515,516,519],{},"If middleware should resolve it once for every endpoint, the place for it is ",[30,517,518],{},"request.state",", not a module variable.",[11,521,522,523,526],{},"Memory is a separate problem. A list you append to \"temporarily\", a dict used as a cache with no size limit, an object held by reference in a global. CPython frees unused objects while the process runs (reference counting plus a garbage collector for cycles), but as long as a global list or dict holds the objects, they stay reachable and cannot be freed. If such a collection grows without a limit, the process's memory grows with it. That is why gunicorn has ",[30,524,525],{},"--max-requests"," (default 0, meaning off): after N requests gunicorn restarts the worker. The leak stays; the restart just returns the memory every so often.",[11,528,529,530,533,534,537],{},"Database connections behave similarly. A pool created at process start lives as long as the process, and its connections serve request after request. Meanwhile the database may restart or a firewall may close an idle session, and the request that checks out such a connection gets, with Postgres, something like \"server closed the connection unexpectedly\". When a firewall silently drops the session, the request may simply hang until a timeout. For the first case there is ",[30,531,532],{},"pool_pre_ping=True"," in SQLAlchemy: it tests the connection when it is checked out of the pool, though it does not protect against a drop in the middle of a running transaction. Against a silent firewall the ping hangs just the same; there ",[30,535,536],{},"pool_recycle"," shorter than the firewall's timeout (it replaces an old connection the next time it is checked out of the pool) or TCP keepalive helps. Other ORMs and connection pools have similar mechanisms.",[11,539,540,541,544,545,548],{},"On deploy you put new code on disk, while the process has the old code in memory. Until you send ",[30,542,543],{},"HUP"," to the gunicorn master (the master starts new workers with the new code and gracefully shuts down the old ones, unless the app runs with ",[30,546,547],{},"--preload",", in which case they inherit the old code from the master) or restart the service, production serves the version from before the deploy.",[15,550,552],{"id":551},"php-can-do-this-too-and-falls-into-the-same-holes","PHP can do this too (and falls into the same holes)",[11,554,555,556,559,560,221],{},"PHP can run long-lived processes too. In PHP you will most often meet three servers or extensions and one layer on top of them. Swoole (and its fork Open Swoole) is a PHP extension with its own HTTP server and event loop. RoadRunner is an application server written in Go that keeps a pool of PHP processes and feeds them requests over pipes. FrankenPHP is PHP embedded in the Caddy server, officially supported by the PHP Foundation since 2025, with a worker mode in which your own script runs the ",[30,557,558],{},"frankenphp_handle_request()"," loop. Laravel Octane is the layer that puts a Laravel application on any of the three with one command: ",[30,561,562],{},"php artisan octane:start --server=frankenphp",[11,564,565],{},"The mechanics are the same as in Python: the application boots once, then serves requests in a loop. Container, configuration, routing, connections, all of it stays in memory. That is usually where the speed comes from, because the cost of booting the framework on every request goes away, though how much it buys a given application has to be measured.",[11,567,568,569,572,573,576,577,580,581,584,585,221],{},"For the same reason, the same traps show up. A static property holding a counter starts climbing. A singleton you injected a ",[30,570,571],{},"Request"," object into via the constructor can hold the first user's request for the rest of the worker's life if nothing refreshes that dependency. Octane resets a fair amount of framework state between requests on its own, but the documentation notes it does not always know how to reset state created by your application, and static properties and singletons are exactly that. The documentation has a dedicated section on this and a dedicated safety valve: the worker is recycled every 500 requests (",[30,574,575],{},"--max-requests=500",", the default). RoadRunner has ",[30,578,579],{},"pool.max_jobs"," (an execution limit) and, separately, ",[30,582,583],{},"pool.supervisor.max_worker_memory"," (a memory limit), FrankenPHP's example worker script reads ",[30,586,587],{},"MAX_REQUESTS",[23,589,591],{"className":25,"code":590,"language":27,"meta":28,"style":28},"\u002F\u002F Same bug as in Python, this time in Laravel under Octane\n\u002F\u002F (sketch: User::fromToken() stands for your own token lookup)\nfinal class CurrentUser\n{\n    public static ?User $user = null;\n}\n\n\u002F\u002F middleware: sets the user when a token is present, never clears it\nif ($token = $request->bearerToken()) {\n    CurrentUser::$user = User::fromToken($token);\n}\n\n\u002F\u002F controller\nreturn ['email' => CurrentUser::$user?->email];\n\u002F\u002F PHP-FPM: a request without a token gets null.\n\u002F\u002F Octane: it gets the last user served by the same worker.\n",[30,592,593,598,603,608,612,617,621,625,630,635,640,644,648,653,658,663],{"__ignoreMap":28},[33,594,595],{"class":35,"line":36},[33,596,597],{},"\u002F\u002F Same bug as in Python, this time in Laravel under Octane\n",[33,599,600],{"class":35,"line":42},[33,601,602],{},"\u002F\u002F (sketch: User::fromToken() stands for your own token lookup)\n",[33,604,605],{"class":35,"line":48},[33,606,607],{},"final class CurrentUser\n",[33,609,610],{"class":35,"line":55},[33,611,64],{},[33,613,614],{"class":35,"line":61},[33,615,616],{},"    public static ?User $user = null;\n",[33,618,619],{"class":35,"line":67},[33,620,82],{},[33,622,623],{"class":35,"line":73},[33,624,52],{"emptyLinePlaceholder":51},[33,626,627],{"class":35,"line":79},[33,628,629],{},"\u002F\u002F middleware: sets the user when a token is present, never clears it\n",[33,631,632],{"class":35,"line":85},[33,633,634],{},"if ($token = $request->bearerToken()) {\n",[33,636,637],{"class":35,"line":90},[33,638,639],{},"    CurrentUser::$user = User::fromToken($token);\n",[33,641,642],{"class":35,"line":96},[33,643,82],{},[33,645,646],{"class":35,"line":384},[33,647,52],{"emptyLinePlaceholder":51},[33,649,650],{"class":35,"line":390},[33,651,652],{},"\u002F\u002F controller\n",[33,654,655],{"class":35,"line":395},[33,656,657],{},"return ['email' => CurrentUser::$user?->email];\n",[33,659,660],{"class":35,"line":401},[33,661,662],{},"\u002F\u002F PHP-FPM: a request without a token gets null.\n",[33,664,665],{"class":35,"line":407},[33,666,667],{},"\u002F\u002F Octane: it gets the last user served by the same worker.\n",[11,669,670],{},"Under Octane this code throws no exception, so you have to find it by review: look for static properties set in middleware.",[15,672,674],{"id":673},"from-the-server-side","From the server side",[11,676,677,678,681,682,685],{},"The nginx config shows a difference too. PHP-FPM speaks FastCGI (a protocol between the web server and the application process, older than most of your dependencies), so nginx hands it the request over a socket with ",[30,679,680],{},"fastcgi_pass",". Gunicorn, uvicorn, RoadRunner and Swoole expose plain HTTP, so nginx acts as a reverse proxy with ",[30,683,684],{},"proxy_pass",". FrankenPHP is a server in its own right (it is Caddy), so nginx in front of it is an option, not a requirement.",[687,688,689,707],"table",{},[690,691,692],"thead",{},[693,694,695,698,701,704],"tr",{},[696,697],"th",{},[696,699,700],{},"PHP-FPM",[696,702,703],{},"Python (gunicorn \u002F uvicorn)",[696,705,706],{},"PHP in worker mode (Octane, FrankenPHP, RoadRunner, Swoole)",[708,709,710,746,766,802,820,834,863],"tbody",{},[693,711,712,716,722,734],{},[713,714,715],"td",{},"Application server",[713,717,718,721],{},[30,719,720],{},"php-fpm"," (master + workers)",[713,723,724,727,728,730,731],{},[30,725,726],{},"gunicorn"," (arbiter + workers, for ASGI with ",[30,729,306],{},") or ",[30,732,733],{},"uvicorn --workers N",[713,735,736,739,740,739,743],{},[30,737,738],{},"php artisan octane:start",", ",[30,741,742],{},"frankenphp php-server --worker public\u002Fworker.php",[30,744,745],{},"rr serve",[693,747,748,751,756,761],{},[713,749,750],{},"Nginx talks over",[713,752,753],{},[30,754,755],{},"fastcgi_pass unix:\u002Frun\u002Fphp\u002Fphp8.5-fpm.sock",[713,757,758],{},[30,759,760],{},"proxy_pass http:\u002F\u002F127.0.0.1:8000",[713,762,763,765],{},[30,764,760],{}," (FrankenPHP can stand without nginx)",[693,767,768,771,781,793],{},[713,769,770],{},"Reload after deploy",[713,772,773,774,776,777,780],{},"usually unnecessary; ",[30,775,235],{}," with ",[30,778,779],{},"validate_timestamps"," off, also with preloading (service name as on Debian and Ubuntu)",[713,782,783,786,787,789,790,792],{},[30,784,785],{},"kill -HUP \u003Cmaster>"," (gunicorn without ",[30,788,547],{},", uvicorn only with ",[30,791,291],{},") or a service restart, mandatory",[713,794,795,739,798,801],{},[30,796,797],{},"php artisan octane:reload",[30,799,800],{},"rr reset",", FrankenPHP admin endpoint (with the API enabled) or a restart, mandatory",[693,803,804,807,814,817],{},[713,805,806],{},"Deploy",[713,808,809,810,813],{},"swap the files, OPcache picks up the change itself after a few seconds; with symlinked releases the realpath cache adds a delay (reload FPM or use ",[30,811,812],{},"$realpath_root"," in nginx)",[713,815,816],{},"new code on disk changes nothing until a reload",[713,818,819],{},"same as Python",[693,821,822,825,828,831],{},[713,823,824],{},"State in memory between requests",[713,826,827],{},"none (apart from APCu and persistent connections)",[713,829,830],{},"yes, separate in each process",[713,832,833],{},"yes, separate in each worker",[693,835,836,839,844,854],{},[713,837,838],{},"Safety valve for leaks",[713,840,841,843],{},[30,842,192],{}," (default 0)",[713,845,846,847,849,850,853],{},"gunicorn ",[30,848,525],{},", uvicorn ",[30,851,852],{},"--limit-max-requests"," (both off by default)",[713,855,856,857,859,860],{},"Octane ",[30,858,525],{}," (default 500), RoadRunner ",[30,861,862],{},"max_jobs",[693,864,865,868,871,874],{},[713,866,867],{},"What sits in the cache",[713,869,870],{},"bytecode (OPcache)",[713,872,873],{},"modules in process memory",[713,875,876,877,880],{},"the booted application in worker memory; bytecode only with OPcache enabled for that SAPI (on CLI ",[30,878,879],{},"opcache.enable_cli=1",")",[15,882,884],{"id":883},"what-follows-from-this","What follows from this",[11,886,887],{},"Write the application as if every request could land in a different process, on a different machine, right after a restart. User state in the session or on the current request object, application state in the database, shared cache in Redis. In globals and static properties keep what is common to every request: configuration, a compiled regular expression, an HTTP client. If you keep something that changes there, a local cache for instance, answer three questions yourself: who clears it, when it refreshes and what happens with two requests at once.",[11,889,890],{},"Under PHP-FPM you get most of that rule for free. In worker mode, data left in a global can reach the next request handled by the same worker.",[892,893,894],"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":28,"searchDepth":42,"depth":42,"links":896},[897,898,902,905,906,907,908],{"id":17,"depth":42,"text":18},{"id":179,"depth":42,"text":180,"children":899},[900,901],{"id":197,"depth":48,"text":198},{"id":207,"depth":48,"text":208},{"id":239,"depth":42,"text":240,"children":903},[904],{"id":253,"depth":48,"text":254},{"id":318,"depth":42,"text":319},{"id":551,"depth":42,"text":552},{"id":673,"depth":42,"text":674},{"id":883,"depth":42,"text":884},"2026-10-17",null,"md",{},"\u002Farticles\u002Fphp-forgets-python-remembers",{"x":915,"y":916,"depth":36,"size":911},0.46,0.37,[918,919],"singleton-pattern","php-references",{"title":5,"description":13},"execution-model","articles\u002Fphp-forgets-python-remembers",[27,107,720,924,925],"laravel-octane","deployment","v1.9.1","QakOzQpx6DZ8jPng99PbZAQL31IhEI2ePiMNQ6V3WQs",{"id":929,"title":930,"articleId":6,"body":931,"category":27,"codeLang":27,"date":909,"deploys":61,"description":935,"excerpt":910,"extension":911,"lang":1672,"meta":1673,"navigation":51,"path":1674,"pos":1675,"readMin":79,"related":1676,"seo":1677,"service":921,"stem":1678,"tags":1679,"updated":910,"version":926,"__hash__":1680},"articles_pl\u002Fpl\u002Farticles\u002Fphp-forgets-python-remembers.md","PHP zapomina, Python pamięta. Chodzi o model uruchamiania",{"type":8,"value":932,"toc":1658},[933,936,940,943,991,994,1042,1045,1054,1058,1061,1064,1070,1074,1077,1080,1084,1090,1095,1107,1111,1117,1120,1124,1148,1172,1176,1179,1255,1267,1276,1332,1338,1344,1357,1366,1370,1378,1381,1398,1466,1469,1473,1482,1646,1650,1653,1656],[11,934,935],{},"Ten sam licznik odwiedzin w PHP pokazuje 1 przy każdym odświeżeniu, a w Pythonie, przy jednym procesie, rośnie: 1, 2, 3. Powodem jest to, jak długo żyje stan programu, który obsługuje Twój request: w klasycznym PHP do końca requestu, w Pythonie do końca procesu. Od tego zależy, co przeżyje między jednym requestem a drugim, a dotyczy to także PHP w trybie worker.",[15,937,939],{"id":938},"licznik-który-nie-umie-liczyć","Licznik, który nie umie liczyć",[11,941,942],{},"Zrób mały eksperyment. Endpoint w PHP z licznikiem odwiedzin w zmiennej statycznej, za nginx i PHP-FPM:",[23,944,945],{"className":25,"code":26,"language":27,"meta":28,"style":28},[30,946,947,951,955,959,963,967,971,975,979,983,987],{"__ignoreMap":28},[33,948,949],{"class":35,"line":36},[33,950,39],{},[33,952,953],{"class":35,"line":42},[33,954,45],{},[33,956,957],{"class":35,"line":48},[33,958,52],{"emptyLinePlaceholder":51},[33,960,961],{"class":35,"line":55},[33,962,58],{},[33,964,965],{"class":35,"line":61},[33,966,64],{},[33,968,969],{"class":35,"line":67},[33,970,70],{},[33,972,973],{"class":35,"line":73},[33,974,76],{},[33,976,977],{"class":35,"line":79},[33,978,82],{},[33,980,981],{"class":35,"line":85},[33,982,52],{"emptyLinePlaceholder":51},[33,984,985],{"class":35,"line":90},[33,986,93],{},[33,988,989],{"class":35,"line":96},[33,990,99],{},[11,992,993],{},"Odśwież dziesięć razy. Dziesięć jedynek. Teraz to samo na FastAPI:",[23,995,996],{"className":105,"code":106,"language":107,"meta":28,"style":28},[30,997,998,1002,1006,1010,1014,1018,1022,1026,1030,1034,1038],{"__ignoreMap":28},[33,999,1000],{"class":35,"line":36},[33,1001,114],{},[33,1003,1004],{"class":35,"line":42},[33,1005,119],{},[33,1007,1008],{"class":35,"line":48},[33,1009,52],{"emptyLinePlaceholder":51},[33,1011,1012],{"class":35,"line":55},[33,1013,128],{},[33,1015,1016],{"class":35,"line":61},[33,1017,133],{},[33,1019,1020],{"class":35,"line":67},[33,1021,52],{"emptyLinePlaceholder":51},[33,1023,1024],{"class":35,"line":73},[33,1025,142],{},[33,1027,1028],{"class":35,"line":79},[33,1029,147],{},[33,1031,1032],{"class":35,"line":85},[33,1033,152],{},[33,1035,1036],{"class":35,"line":90},[33,1037,157],{},[33,1039,1040],{"class":35,"line":96},[33,1041,162],{},[11,1043,1044],{},"Przy jednym procesie uvicorna dziesięć odświeżeń daje 10. Logika jest ta sama, a różnica siedzi piętro niżej, w tym, co proces robi między jednym requestem a drugim.",[11,1046,1047,1048,1050,1051,1053],{},"To demonstracja na kolejnych, pojedynczych requestach. Jako licznik przy równoczesnym ruchu się nie nadaje: endpointy ze zwykłym ",[30,1049,171],{}," FastAPI uruchamia w puli wątków, a ",[30,1052,175],{}," nie ma gwarancji atomowości, więc przy kilku requestach naraz część zliczeń może przepaść.",[15,1055,1057],{"id":1056},"klasyczny-php-każdy-request-od-zera","Klasyczny PHP: każdy request od zera",[11,1059,1060],{},"PHP pod PHP-FPM pracuje w modelu shared-nothing, czyli każdy request dostaje świeże środowisko wykonania i stan skryptu z poprzedniego requestu do niego nie przechodzi. Zmienne, obiekty, statyczne właściwości klas, singletony: wszystko idzie do kosza w chwili, gdy skrypt kończy pracę.",[11,1062,1063],{},"Wyobraź sobie pracownika, który po każdym zadaniu idzie do domu i rano wraca z pustą głową. Dostaje kartkę (request), robi, co trzeba, oddaje wynik (response) i zapomina. Nie pamięta poprzedniego klienta ani tego, że wczoraj już liczył, więc licznik pokazuje 1.",[11,1065,1066,1067,1069],{},"Proces PHP-FPM wcale nie umiera po requeście. Worker FPM (proces potomny mastera) obsługuje setki requestów po kolei, aż ",[30,1068,192],{}," każe go wymienić. Domyślnie to 0, czyli limit wyłączony: worker nie jest wymieniany z powodu liczby requestów, choć reload, restart czy przycinanie puli i tak go kiedyś zakończą. Ale między requestami PHP robi pełny request shutdown: zwalnia pamięć skryptu, zamyka zwykłe połączenia, czyści tablicę symboli.",[195,1071,1073],{"id":1072},"co-w-takim-razie-robi-opcache","Co w takim razie robi OPcache?",[11,1075,1076],{},"OPcache trzyma w pamięci współdzielonej skompilowany bytecode skryptów (postać pośrednią, którą silnik wykonuje zamiast parsować tekst pliku), żeby PHP nie kompilował tego samego przy każdym requeście. W OPcache jest skompilowana funkcja razem z wartością początkową zmiennej statycznej, a bieżąca wartość żyje w pamięci requestu.",[11,1078,1079],{},"Preloading (od PHP 7.4) idzie krok dalej i ładuje wskazane klasy do pamięci raz, przy starcie FPM. To też kod, z jednym skutkiem ubocznym: zmiany w preloadowanym pliku FPM nie zobaczy, dopóki master FPM nie wystartuje od nowa, niezależnie od sprawdzania dat plików.",[195,1081,1083],{"id":1082},"gdzie-w-takim-razie-trzymać-stan","Gdzie w takim razie trzymać stan?",[11,1085,1086,1087,1089],{},"Poza procesem. Sesja (plik, Redis, baza) dla rzeczy przypiętych do użytkownika. Baza danych dla wszystkiego, co ma przeżyć restart. Redis albo Memcached dla cache'u współdzielonego między serwerami. APCu, jeśli wystarczy Ci cache lokalny na jednej maszynie: to pamięć współdzielona między workerami FPM, przeżywa koniec requestu, ale znika przy restarcie i reloadzie FPM. Licznik oparty na ",[30,1088,214],{}," będzie rósł, dopóki FPM nie wystartuje od nowa albo APCu nie wyczyści cache z braku miejsca: brakujący klucz APCu założy sam, a zwiększanie jest atomowe.",[11,1091,1092,1093,221],{},"Stan aplikacji się w tym modelu nie kumuluje. Zwykła pamięć skryptu wraca po każdym requeście, więc zapomniana tablica czy obiekt żyją najwyżej jeden request. Nie dotyczy to wycieków w rozszerzeniach i bibliotekach natywnych. Te zostają w procesie i właśnie na nie dokumentacja PHP podsuwa ",[30,1094,192],{},[11,1096,1097,1098,1100,1101,1103,1104,1106],{},"Po zmianie kodu zwykle nie musisz też niczego restartować. OPcache domyślnie sprawdza znaczniki czasu plików (co 2 sekundy), więc nowa wersja wchodzi sama, choć niekoniecznie już przy następnym requeście. Przy wyłączonym ",[30,1099,227],{}," trzeba wyczyścić OPcache (na przykład ",[30,1102,231],{}," wywołanym w procesie FPM) albo zrobić reload FPM, a przy kodzie objętym preloadingiem potrzebny jest reload FPM (w Debianie i Ubuntu ",[30,1105,235],{}," wysyła USR2, po którym master startuje od nowa i ładuje preload jeszcze raz). Jedno zastrzeżenie: kopiowanie plików po kolei na działającą aplikację potrafi przez chwilę wymieszać starą wersję z nową. Świeży stan requestu nie robi z tego atomowego deployu, do tego potrzebny jest osobny mechanizm przełączania wersji.",[15,1108,1110],{"id":1109},"python-proces-żyje-długo","Python: proces żyje długo",[11,1112,1113,1114,1116],{},"Gunicorn i uvicorn działają inaczej. Startujesz proces, ten proces importuje Twój moduł raz, buduje obiekt aplikacji raz i od tej chwili tylko obsługuje requesty. Linia ",[30,1115,246],{}," wykonała się dokładnie jeden raz, a każdy kolejny request dopisuje do tej samej zmiennej w tej samej pamięci.",[11,1118,1119],{},"W Pythonie proces po prostu zostaje: połączenie do bazy i każdy obiekt odłożony „na chwilę” w zmiennej globalnej są dalej w pamięci.",[195,1121,1123],{"id":1122},"haczyk-workerów-jest-kilku","Haczyk: workerów jest kilku",[11,1125,1126,1127,1129,1130,1132,1133,1135,1136,1138,1139,1141,1142,1144,1145,1147],{},"Uruchom ",[30,1128,260],{}," (albo ",[30,1131,264],{},") i wyślij kilka requestów z terminala: ",[30,1134,268],{},". Każde wywołanie ",[30,1137,272],{}," to osobne połączenie, więc może trafić do innego workera, a liczniki zaczynają przeskakiwać, na przykład 1, 1, 2, 1, 3, 2. Każdy worker to osobny proces z własną kopią modułu i własnym ",[30,1140,276],{},". Workery dzielą jeden socket i przyjmują połączenia, jak leci, więc trafiasz raz tu, raz tam. A po restarcie wszystkie cztery liczniki wracają do zera. Przeglądarka może tego nie pokazać. Trzyma połączenie otwarte (keep-alive), więc kolejne odświeżenia idą do tego samego workera i licznik po prostu rośnie. Uvicorn trzyma takie połączenie 5 sekund, gunicorn z workerem ASGI 2 sekundy (",[30,1143,280],{},"), a worker ",[30,1146,284],{}," zamyka je po każdej odpowiedzi. Za reverse proxy dochodzi jeszcze jego własna warstwa połączeń.",[11,1149,1150,1151,1153,1154,1156,1157,1159,1160,1162,1163,1165,1166,1168,1169,1171],{},"Bez ",[30,1152,291],{},", zmiennej ",[30,1155,295],{}," i własnego pliku konfiguracji gunicorn startuje jednego synchronicznego workera (",[30,1158,299],{},", klasa ",[30,1161,284],{},"). Ten worker obsługuje WSGI, a FastAPI jest aplikacją ASGI, dlatego przy gunicornie potrzebny jest worker ASGI: od wersji 24 wbudowany ",[30,1164,306],{},", wcześniej ",[30,1167,310],{}," z pakietu ",[30,1170,314],{},". Uvicorn w tej samej sytuacji uruchamia jeden proces z pętlą asyncio. Gdy procesów jest kilka, każdy ma własną kopię zmiennej globalnej.",[15,1173,1175],{"id":1174},"pułapki-długo-żyjącego-procesu","Pułapki długo żyjącego procesu",[11,1177,1178],{},"Licznik nikomu nie szkodzi. Ten sam mechanizm przy danych użytkownika wygląda tak:",[23,1180,1181],{"className":105,"code":325,"language":107,"meta":28,"style":28},[30,1182,1183,1187,1191,1195,1199,1203,1207,1211,1215,1219,1223,1227,1231,1235,1239,1243,1247,1251],{"__ignoreMap":28},[33,1184,1185],{"class":35,"line":36},[33,1186,332],{},[33,1188,1189],{"class":35,"line":42},[33,1190,337],{},[33,1192,1193],{"class":35,"line":48},[33,1194,342],{},[33,1196,1197],{"class":35,"line":55},[33,1198,347],{},[33,1200,1201],{"class":35,"line":61},[33,1202,52],{"emptyLinePlaceholder":51},[33,1204,1205],{"class":35,"line":67},[33,1206,356],{},[33,1208,1209],{"class":35,"line":73},[33,1210,361],{},[33,1212,1213],{"class":35,"line":79},[33,1214,366],{},[33,1216,1217],{"class":35,"line":85},[33,1218,371],{},[33,1220,1221],{"class":35,"line":90},[33,1222,376],{},[33,1224,1225],{"class":35,"line":96},[33,1226,381],{},[33,1228,1229],{"class":35,"line":384},[33,1230,387],{},[33,1232,1233],{"class":35,"line":390},[33,1234,52],{"emptyLinePlaceholder":51},[33,1236,1237],{"class":35,"line":395},[33,1238,398],{},[33,1240,1241],{"class":35,"line":401},[33,1242,404],{},[33,1244,1245],{"class":35,"line":407},[33,1246,410],{},[33,1248,1249],{"class":35,"line":413},[33,1250,416],{},[33,1252,1253],{"class":35,"line":419},[33,1254,422],{},[11,1256,1257,1258,1260,1261,1263,1264,1266],{},"W modelu „od zera” taki kod wysypałby się na pierwszym anonimowym requeście: ",[30,1259,428],{},", wyjątek, wpis w logu, wszystko widać od razu. Pod długo żyjącym procesem wyjątek poleci tylko wtedy, gdy ",[30,1262,432],{}," jest ",[30,1265,428],{},": na świeżym workerze albo po requeście z nieprawidłowym tokenem. Gdy worker obsłużył już kogoś z tokenem, endpoint zwraca e-mail tej osoby. W logu błędów nie ma śladu, w access logu zwykłe 200.",[11,1268,1269,1270,1272,1273,1275],{},"Jeden worker nie znaczy przy tym jednego requestu naraz. Middleware async przeplata się z innymi requestami na pętli zdarzeń, a endpointy zadeklarowane zwykłym ",[30,1271,171],{}," FastAPI uruchamia w puli wątków. Przychodzą dwa requesty z różnymi tokenami. Może się zdarzyć, że middleware pierwszego ustawi użytkownika A, middleware drugiego nadpisze go użytkownikiem B, zanim endpoint pierwszego go odczyta, i ten endpoint zwróci e-mail B. Dopisanie ",[30,1274,445],{}," zamyka tylko pierwszy przypadek. Wyścig znika dopiero po wyjęciu użytkownika z globala, na przykład przez zależność, która czyta token z bieżącego requestu:",[23,1277,1278],{"className":105,"code":449,"language":107,"meta":28,"style":28},[30,1279,1280,1284,1288,1292,1296,1300,1304,1308,1312,1316,1320,1324,1328],{"__ignoreMap":28},[33,1281,1282],{"class":35,"line":36},[33,1283,456],{},[33,1285,1286],{"class":35,"line":42},[33,1287,461],{},[33,1289,1290],{"class":35,"line":48},[33,1291,52],{"emptyLinePlaceholder":51},[33,1293,1294],{"class":35,"line":55},[33,1295,470],{},[33,1297,1298],{"class":35,"line":61},[33,1299,371],{},[33,1301,1302],{"class":35,"line":67},[33,1303,479],{},[33,1305,1306],{"class":35,"line":73},[33,1307,484],{},[33,1309,1310],{"class":35,"line":79},[33,1311,489],{},[33,1313,1314],{"class":35,"line":85},[33,1315,494],{},[33,1317,1318],{"class":35,"line":90},[33,1319,52],{"emptyLinePlaceholder":51},[33,1321,1322],{"class":35,"line":96},[33,1323,398],{},[33,1325,1326],{"class":35,"line":384},[33,1327,507],{},[33,1329,1330],{"class":35,"line":390},[33,1331,512],{},[11,1333,1334,1335,1337],{},"Jeśli middleware ma go policzyć raz dla wszystkich endpointów, miejscem jest ",[30,1336,518],{},", nie zmienna modułu.",[11,1339,1340,1341,1343],{},"Pamięć to osobny problem. Lista, do której dopisujesz „tymczasowo”, cache w słowniku bez limitu, obiekt trzymany przez referencję w globalu. CPython zwalnia nieużywane obiekty także w trakcie działania procesu (zliczanie referencji plus garbage collector dla cykli), ale dopóki globalna lista albo słownik trzyma obiekty, są one osiągalne i nie mogą zostać zwolnione. Jeśli taka kolekcja rośnie bez limitu, rośnie z nią pamięć procesu. Dlatego gunicorn ma ",[30,1342,525],{}," (domyślnie 0, czyli wyłączone): po N requestach gunicorn restartuje workera. Wycieku to nie usuwa, ale co jakiś czas oddaje pamięć.",[11,1345,1346,1347,1350,1351,1353,1354,1356],{},"Z połączeniami do bazy jest podobnie. Pula utworzona przy starcie procesu żyje razem z nim, a jej połączenia obsługują kolejne requesty. W tym czasie baza może się zrestartować albo firewall zamknąć bezczynną sesję, a request, który pobierze takie połączenie, dostaje przy Postgresie na przykład ",[30,1348,1349],{},"server closed the connection unexpectedly",". Gdy firewall wytnie sesję po cichu, request może po prostu wisieć do timeoutu. Na pierwszy przypadek jest ",[30,1352,532],{}," w SQLAlchemy: sprawdza połączenie przy pobraniu z puli, choć przed zerwaniem w trakcie trwającej transakcji nie chroni. Na cichy firewall ping nie pomoże, bo zawiśnie tak samo; tu pomaga ",[30,1355,536],{}," krótszy niż jego timeout (wymienia stare połączenie przy następnym pobraniu z puli) albo keepalive TCP. Podobne mechanizmy mają inne ORM-y i pule połączeń.",[11,1358,1359,1360,1362,1363,1365],{},"Przy deployu wgrywasz nowy kod na dysk, a proces ma w pamięci stary. Dopóki nie wyślesz ",[30,1361,543],{}," do mastera gunicorna (master stawia nowe workery z nowym kodem i łagodnie wygasza stare, o ile aplikacja nie startuje z ",[30,1364,547],{},", bo wtedy dziedziczą stary kod z mastera) albo nie zrestartujesz usługi, produkcja serwuje wersję sprzed deployu.",[15,1367,1369],{"id":1368},"php-też-tak-potrafi-i-wpada-w-te-same-dziury","PHP też tak potrafi (i wpada w te same dziury)",[11,1371,1372,1373,1375,1376,221],{},"Długo żyjący proces da się mieć też w PHP. W PHP najczęściej spotkasz do tego trzy serwery albo rozszerzenia i jedną warstwę nad nimi. Swoole (i jego fork Open Swoole) to rozszerzenie PHP z własnym serwerem HTTP i pętlą zdarzeń. RoadRunner to serwer aplikacji napisany w Go, który trzyma pulę procesów PHP i podaje im requesty przez pipe'y. FrankenPHP to PHP wbudowany w serwer Caddy, od 2025 roku oficjalnie wspierany przez PHP Foundation, z trybem worker, w którym Twój skrypt sam kręci pętlę ",[30,1374,558],{},". Laravel Octane to warstwa, która stawia aplikację Laravel na dowolnym z tych trzech jedną komendą: ",[30,1377,562],{},[11,1379,1380],{},"Mechanika jest taka sama jak w Pythonie: aplikacja bootuje się raz, potem obsługuje requesty w pętli. Kontener, konfiguracja, routing, połączenia, wszystko zostaje w pamięci. Stąd zwykle przyspieszenie, bo odpada koszt bootowania frameworka przy każdym requeście, choć ile to daje w konkretnej aplikacji, trzeba zmierzyć.",[11,1382,1383,1384,1386,1387,1389,1390,1392,1393,1395,1396,221],{},"Z tego samego powodu pojawiają się te same pułapki. Statyczna właściwość z licznikiem zaczyna rosnąć. Singleton, do którego wstrzyknąłeś obiekt ",[30,1385,571],{}," w konstruktorze, może trzymać request pierwszego użytkownika do końca życia workera, jeśli nikt tej zależności nie odświeża. Octane sam resetuje sporo stanu frameworka między requestami, ale dokumentacja zaznacza, że nie zawsze wie, jak wyczyścić stan zostawiony przez aplikację, a statyczne właściwości i singletony to właśnie taki stan. Dokumentacja ma na to osobny rozdział i osobny bezpiecznik: worker jest wymieniany co 500 requestów (",[30,1388,575],{},", domyślnie). RoadRunner ma ",[30,1391,579],{}," (limit wykonań) i osobno ",[30,1394,583],{}," (limit pamięci), FrankenPHP w przykładowym skrypcie czyta ",[30,1397,587],{},[23,1399,1400],{"className":25,"code":590,"language":27,"meta":28,"style":28},[30,1401,1402,1406,1410,1414,1418,1422,1426,1430,1434,1438,1442,1446,1450,1454,1458,1462],{"__ignoreMap":28},[33,1403,1404],{"class":35,"line":36},[33,1405,597],{},[33,1407,1408],{"class":35,"line":42},[33,1409,602],{},[33,1411,1412],{"class":35,"line":48},[33,1413,607],{},[33,1415,1416],{"class":35,"line":55},[33,1417,64],{},[33,1419,1420],{"class":35,"line":61},[33,1421,616],{},[33,1423,1424],{"class":35,"line":67},[33,1425,82],{},[33,1427,1428],{"class":35,"line":73},[33,1429,52],{"emptyLinePlaceholder":51},[33,1431,1432],{"class":35,"line":79},[33,1433,629],{},[33,1435,1436],{"class":35,"line":85},[33,1437,634],{},[33,1439,1440],{"class":35,"line":90},[33,1441,639],{},[33,1443,1444],{"class":35,"line":96},[33,1445,82],{},[33,1447,1448],{"class":35,"line":384},[33,1449,52],{"emptyLinePlaceholder":51},[33,1451,1452],{"class":35,"line":390},[33,1453,652],{},[33,1455,1456],{"class":35,"line":395},[33,1457,657],{},[33,1459,1460],{"class":35,"line":401},[33,1461,662],{},[33,1463,1464],{"class":35,"line":407},[33,1465,667],{},[11,1467,1468],{},"Pod Octane ten kod nie rzuca wyjątku, więc trzeba go znaleźć przeglądem: szukaj statycznych właściwości ustawianych w middleware.",[15,1470,1472],{"id":1471},"od-strony-serwera","Od strony serwera",[11,1474,1475,1476,1478,1479,1481],{},"W konfiguracji nginx też widać różnicę. PHP-FPM mówi po FastCGI (protokół między serwerem WWW a procesem aplikacji, starszy niż większość Twoich zależności), więc nginx podaje mu request przez socket dyrektywą ",[30,1477,680],{},". Gunicorn, uvicorn, RoadRunner i Swoole wystawiają zwykły HTTP, więc nginx jest dla nich reverse proxy z ",[30,1480,684],{},". FrankenPHP jest serwerem sam w sobie (to Caddy), więc nginx przed nim jest opcją, nie koniecznością.",[687,1483,1484,1497],{},[690,1485,1486],{},[693,1487,1488,1490,1492,1494],{},[696,1489],{},[696,1491,700],{},[696,1493,703],{},[696,1495,1496],{},"PHP w trybie worker (Octane, FrankenPHP, RoadRunner, Swoole)",[708,1498,1499,1527,1545,1577,1593,1607,1631],{},[693,1500,1501,1504,1509,1519],{},[713,1502,1503],{},"Serwer aplikacji",[713,1505,1506,1508],{},[30,1507,720],{}," (master + workery)",[713,1510,1511,1513,1514,1516,1517],{},[30,1512,726],{}," (arbiter + workery, dla ASGI z ",[30,1515,306],{},") albo ",[30,1518,733],{},[713,1520,1521,739,1523,739,1525],{},[30,1522,738],{},[30,1524,742],{},[30,1526,745],{},[693,1528,1529,1532,1536,1540],{},[713,1530,1531],{},"Nginx rozmawia przez",[713,1533,1534],{},[30,1535,755],{},[713,1537,1538],{},[30,1539,760],{},[713,1541,1542,1544],{},[30,1543,760],{}," (FrankenPHP może stać bez nginx)",[693,1546,1547,1550,1559,1570],{},[713,1548,1549],{},"Reload po deployu",[713,1551,1552,1553,1555,1556,1558],{},"zwykle zbędny; ",[30,1554,235],{}," przy wyłączonym ",[30,1557,779],{},", też przy preloadingu (nazwa usługi jak w Debianie i Ubuntu)",[713,1560,1561,1563,1564,1566,1567,1569],{},[30,1562,785],{}," (gunicorn bez ",[30,1565,547],{},", uvicorn tylko z ",[30,1568,291],{},") albo restart usługi, obowiązkowy",[713,1571,1572,739,1574,1576],{},[30,1573,797],{},[30,1575,800],{},", endpoint admina FrankenPHP (przy włączonym API) albo restart, obowiązkowy",[693,1578,1579,1581,1587,1590],{},[713,1580,806],{},[713,1582,1583,1584,1586],{},"podmiana plików, OPcache łapie zmianę sam po kilku sekundach; przy przełączaniu wydań symlinkiem dochodzi realpath cache (reload FPM albo ",[30,1585,812],{}," w nginx)",[713,1588,1589],{},"nowy kod na dysku nic nie zmienia do reloadu",[713,1591,1592],{},"jak w Pythonie",[693,1594,1595,1598,1601,1604],{},[713,1596,1597],{},"Stan w pamięci między requestami",[713,1599,1600],{},"nie ma (poza APCu i połączeniami trwałymi)",[713,1602,1603],{},"jest, osobny w każdym procesie",[713,1605,1606],{},"jest, osobny w każdym workerze",[693,1608,1609,1612,1617,1624],{},[713,1610,1611],{},"Bezpiecznik na wycieki",[713,1613,1614,1616],{},[30,1615,192],{}," (domyślnie 0)",[713,1618,846,1619,849,1621,1623],{},[30,1620,525],{},[30,1622,852],{}," (oba domyślnie wyłączone)",[713,1625,856,1626,1628,1629],{},[30,1627,525],{}," (domyślnie 500), RoadRunner ",[30,1630,862],{},[693,1632,1633,1636,1638,1641],{},[713,1634,1635],{},"Co siedzi w cache'u",[713,1637,870],{},[713,1639,1640],{},"moduły w pamięci procesu",[713,1642,1643,1644,880],{},"zbootowana aplikacja w pamięci workera; bytecode tylko przy włączonym OPcache dla danego SAPI (w CLI ",[30,1645,879],{},[15,1647,1649],{"id":1648},"co-z-tego-wynika","Co z tego wynika",[11,1651,1652],{},"Pisz aplikację tak, jakby każdy request mógł trafić do innego procesu, na innej maszynie, tuż po restarcie. Stan użytkownika w sesji albo w obiekcie bieżącego requestu, stan aplikacji w bazie, cache współdzielony w Redisie. W zmiennych globalnych i statycznych właściwościach trzymaj to, co jest wspólne dla wszystkich requestów: konfigurację, skompilowane wyrażenie regularne, klienta HTTP. Jeśli trzymasz tam coś, co się zmienia, na przykład lokalny cache, odpowiedz sobie na trzy pytania: kto go czyści, kiedy się odświeża i co się stanie przy dwóch requestach naraz.",[11,1654,1655],{},"Pod PHP-FPM większość tej zasady dostajesz za darmo. W trybie worker dane zostawione w globalu mogą trafić do kolejnego requestu obsługiwanego przez ten sam worker.",[892,1657,894],{},{"title":28,"searchDepth":42,"depth":42,"links":1659},[1660,1661,1665,1668,1669,1670,1671],{"id":938,"depth":42,"text":939},{"id":1056,"depth":42,"text":1057,"children":1662},[1663,1664],{"id":1072,"depth":48,"text":1073},{"id":1082,"depth":48,"text":1083},{"id":1109,"depth":42,"text":1110,"children":1666},[1667],{"id":1122,"depth":48,"text":1123},{"id":1174,"depth":42,"text":1175},{"id":1368,"depth":42,"text":1369},{"id":1471,"depth":42,"text":1472},{"id":1648,"depth":42,"text":1649},"pl",{},"\u002Fpl\u002Farticles\u002Fphp-forgets-python-remembers",{"x":915,"y":916,"depth":36,"size":911},[918,919],{"title":930,"description":935},"pl\u002Farticles\u002Fphp-forgets-python-remembers",[27,107,720,924,925],"Oo85lkYq3s_DYlNnzp220NIzLKoz8zcZCmEjgXfzA7w",[1682,2522,4092,5027,5560,5830,6602,7258,8583,9266,10163,10785,11336,12132,12771,13724],{"id":1683,"title":1684,"articleId":1685,"body":1686,"category":2500,"codeLang":107,"date":2501,"deploys":1880,"description":1690,"excerpt":910,"extension":911,"lang":910,"meta":2502,"navigation":51,"path":2503,"pos":2504,"readMin":73,"related":2509,"seo":2512,"service":2513,"stem":2514,"tags":2515,"updated":2519,"version":2520,"__hash__":2521},"articles\u002Farticles\u002Fagent-graphs.md","AI agents as state graphs in LangGraph: checkpoints, interrupts and replay","agent-graphs",{"type":8,"value":1687,"toc":2490},[1688,1691,1694,1698,1701,1704,1707,1710,1714,1919,1935,1990,1994,2001,2053,2068,2089,2093,2102,2158,2167,2170,2174,2177,2188,2261,2264,2268,2282,2286,2289,2471,2478,2481,2485,2488],[11,1689,1690],{},"The simplest agent is a loop: send the conversation to the model, execute the tool calls it requests, append the results, repeat until it stops requesting tools. It works for demos and for agents with one or two tools. Then the agent has to survive a process restart, or wait for a person, or someone has to debug a run days after it happened.",[11,1692,1693],{},"The alternative is explicit control flow. The agent becomes a state graph: nodes are steps, edges are named transitions, the state is a typed structure, and the runtime persists it after every step. The model makes its decision inside a node, and the graph picks the next step. The examples use LangGraph. Any orchestrator with the same primitives would do.",[15,1695,1697],{"id":1696},"what-the-loop-lacks","What the loop lacks",[11,1699,1700],{},"Start with retries. A failed tool call inside the loop gets retried by the model, by your code, or not at all, and the only global safeguard is a step counter. You can't say \"try retrieval up to three times, never retry the payment call\".",[11,1702,1703],{},"Intermediate state isn't persisted, because a loop keeps it in local variables. If the process dies at step 7, it's gone, and restarting from scratch repeats steps 1 to 6 together with whatever side effects they had. For the same reason there's no way to wait for a person: approval means stopping mid-run, sometimes for days, and continuing in a different process that has nothing to pick up from.",[11,1705,1706],{},"And replay. A loop leaves a log at best, and to debug a bad run you need the exact state from before the wrong decision, plus the ability to run from there again.",[11,1708,1709],{},"The loop's stop condition can fail too. I tested a ready-made agent harness with a local model: in non-interactive mode, one answer without a tool call was enough to end the loop, so the model stopped halfway through the task. The harness passed the resumed run 26,600 tokens of history, and the model still claimed it couldn't see any of it. That was a model error. With an instruction to work until the end and not ask questions, the fix finally went through, but it took 19 minutes to change one character. The tool calls themselves were correct in every run.",[15,1711,1713],{"id":1712},"the-graph","The graph",[23,1715,1717],{"className":105,"code":1716,"language":107,"meta":28,"style":28},"import operator\nfrom typing import Annotated, TypedDict\n\nfrom langgraph.graph import END, START, StateGraph\nfrom langgraph.types import RetryPolicy\n\n\nclass AgentState(TypedDict):\n    task: str\n    plan: list[str]\n    context: Annotated[list[str], operator.add]  # appended, not overwritten\n    result: str | None\n    needs_context: bool\n    done: bool\n    reflections: int\n\n\ndef route_after_plan(state: AgentState) -> str:\n    return \"retrieve\" if state[\"needs_context\"] else \"act\"\n\n\ndef route_after_act(state: AgentState) -> str:\n    if state[\"done\"] or state[\"reflections\"] >= 2:\n        return END\n    return \"reflect\"\n\n\nbuilder = StateGraph(AgentState)\nbuilder.add_node(\"plan\", planner)\nbuilder.add_node(\"retrieve\", retriever, retry_policy=RetryPolicy(max_attempts=3))\nbuilder.add_node(\"act\", tool_runner)\nbuilder.add_node(\"reflect\", critic)\n\nbuilder.add_edge(START, \"plan\")\nbuilder.add_conditional_edges(\"plan\", route_after_plan, [\"retrieve\", \"act\"])\nbuilder.add_edge(\"retrieve\", \"act\")\nbuilder.add_conditional_edges(\"act\", route_after_act, [\"reflect\", END])\nbuilder.add_edge(\"reflect\", \"plan\")\n",[30,1718,1719,1724,1729,1733,1738,1743,1747,1751,1756,1761,1766,1771,1776,1781,1786,1791,1795,1799,1804,1810,1815,1820,1826,1832,1838,1844,1849,1854,1860,1866,1872,1878,1884,1889,1895,1901,1907,1913],{"__ignoreMap":28},[33,1720,1721],{"class":35,"line":36},[33,1722,1723],{},"import operator\n",[33,1725,1726],{"class":35,"line":42},[33,1727,1728],{},"from typing import Annotated, TypedDict\n",[33,1730,1731],{"class":35,"line":48},[33,1732,52],{"emptyLinePlaceholder":51},[33,1734,1735],{"class":35,"line":55},[33,1736,1737],{},"from langgraph.graph import END, START, StateGraph\n",[33,1739,1740],{"class":35,"line":61},[33,1741,1742],{},"from langgraph.types import RetryPolicy\n",[33,1744,1745],{"class":35,"line":67},[33,1746,52],{"emptyLinePlaceholder":51},[33,1748,1749],{"class":35,"line":73},[33,1750,52],{"emptyLinePlaceholder":51},[33,1752,1753],{"class":35,"line":79},[33,1754,1755],{},"class AgentState(TypedDict):\n",[33,1757,1758],{"class":35,"line":85},[33,1759,1760],{},"    task: str\n",[33,1762,1763],{"class":35,"line":90},[33,1764,1765],{},"    plan: list[str]\n",[33,1767,1768],{"class":35,"line":96},[33,1769,1770],{},"    context: Annotated[list[str], operator.add]  # appended, not overwritten\n",[33,1772,1773],{"class":35,"line":384},[33,1774,1775],{},"    result: str | None\n",[33,1777,1778],{"class":35,"line":390},[33,1779,1780],{},"    needs_context: bool\n",[33,1782,1783],{"class":35,"line":395},[33,1784,1785],{},"    done: bool\n",[33,1787,1788],{"class":35,"line":401},[33,1789,1790],{},"    reflections: int\n",[33,1792,1793],{"class":35,"line":407},[33,1794,52],{"emptyLinePlaceholder":51},[33,1796,1797],{"class":35,"line":413},[33,1798,52],{"emptyLinePlaceholder":51},[33,1800,1801],{"class":35,"line":419},[33,1802,1803],{},"def route_after_plan(state: AgentState) -> str:\n",[33,1805,1807],{"class":35,"line":1806},19,[33,1808,1809],{},"    return \"retrieve\" if state[\"needs_context\"] else \"act\"\n",[33,1811,1813],{"class":35,"line":1812},20,[33,1814,52],{"emptyLinePlaceholder":51},[33,1816,1818],{"class":35,"line":1817},21,[33,1819,52],{"emptyLinePlaceholder":51},[33,1821,1823],{"class":35,"line":1822},22,[33,1824,1825],{},"def route_after_act(state: AgentState) -> str:\n",[33,1827,1829],{"class":35,"line":1828},23,[33,1830,1831],{},"    if state[\"done\"] or state[\"reflections\"] >= 2:\n",[33,1833,1835],{"class":35,"line":1834},24,[33,1836,1837],{},"        return END\n",[33,1839,1841],{"class":35,"line":1840},25,[33,1842,1843],{},"    return \"reflect\"\n",[33,1845,1847],{"class":35,"line":1846},26,[33,1848,52],{"emptyLinePlaceholder":51},[33,1850,1852],{"class":35,"line":1851},27,[33,1853,52],{"emptyLinePlaceholder":51},[33,1855,1857],{"class":35,"line":1856},28,[33,1858,1859],{},"builder = StateGraph(AgentState)\n",[33,1861,1863],{"class":35,"line":1862},29,[33,1864,1865],{},"builder.add_node(\"plan\", planner)\n",[33,1867,1869],{"class":35,"line":1868},30,[33,1870,1871],{},"builder.add_node(\"retrieve\", retriever, retry_policy=RetryPolicy(max_attempts=3))\n",[33,1873,1875],{"class":35,"line":1874},31,[33,1876,1877],{},"builder.add_node(\"act\", tool_runner)\n",[33,1879,1881],{"class":35,"line":1880},32,[33,1882,1883],{},"builder.add_node(\"reflect\", critic)\n",[33,1885,1887],{"class":35,"line":1886},33,[33,1888,52],{"emptyLinePlaceholder":51},[33,1890,1892],{"class":35,"line":1891},34,[33,1893,1894],{},"builder.add_edge(START, \"plan\")\n",[33,1896,1898],{"class":35,"line":1897},35,[33,1899,1900],{},"builder.add_conditional_edges(\"plan\", route_after_plan, [\"retrieve\", \"act\"])\n",[33,1902,1904],{"class":35,"line":1903},36,[33,1905,1906],{},"builder.add_edge(\"retrieve\", \"act\")\n",[33,1908,1910],{"class":35,"line":1909},37,[33,1911,1912],{},"builder.add_conditional_edges(\"act\", route_after_act, [\"reflect\", END])\n",[33,1914,1916],{"class":35,"line":1915},38,[33,1917,1918],{},"builder.add_edge(\"reflect\", \"plan\")\n",[11,1920,1921,739,1924,739,1927,1930,1931,1934],{},[30,1922,1923],{},"planner",[30,1925,1926],{},"retriever",[30,1928,1929],{},"tool_runner"," and ",[30,1932,1933],{},"critic"," are ordinary functions that take the state and return a partial update. The routing functions are plain Python and can be unit-tested without a model.",[11,1936,1937,1940,1941,1944,1945,1948,1949,1952,1953,739,1956,1959,1960,739,1963,739,1966,1969,1970,1973,1974,1977,1978,1930,1981,1984,1985,1987,1988,221],{},[30,1938,1939],{},"reflections"," is a business rule kept in the state: at most two critique rounds. ",[30,1942,1943],{},"recursion_limit"," in the run config is a separate, technical cap on the total number of steps. Go over it and you get ",[30,1946,1947],{},"GraphRecursionError"," instead of a run that never ends. The default limit in langgraph 1.2 is 10007 steps, so set your own explicitly, as in the example below. The retry policy applies to the ",[30,1950,1951],{},"retrieve"," node only, so it never repeats a tool with side effects. The search isn't retried on every error either. Unless you set your own ",[30,1954,1955],{},"retry_on",[30,1957,1958],{},"RetryPolicy"," doesn't retry ",[30,1961,1962],{},"ValueError",[30,1964,1965],{},"RuntimeError",[30,1967,1968],{},"OSError"," and a few other built-in errors, or 4xx responses raised by httpx. It does retry the built-in ",[30,1971,1972],{},"ConnectionError",", httpx 5xx and any other exception, including your own classes. With requests the default gets it backwards: an ",[30,1975,1976],{},"HTTPError"," is retried whatever the status, while ",[30,1979,1980],{},"requests.ConnectionError",[30,1982,1983],{},"Timeout"," subclass ",[30,1986,1968],{}," and are not retried. If you call HTTP through requests, pass your own ",[30,1989,1955],{},[15,1991,1993],{"id":1992},"checkpoints-in-postgres","Checkpoints in Postgres",[11,1995,1996,1997,2000],{},"A checkpointer stores the state after each step (a \"super-step\" in LangGraph terms), keyed by a thread ID. The Postgres implementation is in the ",[30,1998,1999],{},"langgraph-checkpoint-postgres"," package and uses psycopg 3.",[23,2002,2004],{"className":105,"code":2003,"language":107,"meta":28,"style":28},"from langgraph.checkpoint.postgres import PostgresSaver\n\nDB_URI = \"postgresql:\u002F\u002Fagent:secret@localhost:5432\u002Fagents\"\n\nwith PostgresSaver.from_conn_string(DB_URI) as checkpointer:\n    checkpointer.setup()  # creates the checkpoint tables; run once, like a migration\n    graph = builder.compile(checkpointer=checkpointer)\n\n    config = {\"configurable\": {\"thread_id\": \"loan-app-20931\"}, \"recursion_limit\": 30}\n    graph.invoke({\"task\": \"...\", \"context\": [], \"reflections\": 0}, config)\n",[30,2005,2006,2011,2015,2020,2024,2029,2034,2039,2043,2048],{"__ignoreMap":28},[33,2007,2008],{"class":35,"line":36},[33,2009,2010],{},"from langgraph.checkpoint.postgres import PostgresSaver\n",[33,2012,2013],{"class":35,"line":42},[33,2014,52],{"emptyLinePlaceholder":51},[33,2016,2017],{"class":35,"line":48},[33,2018,2019],{},"DB_URI = \"postgresql:\u002F\u002Fagent:secret@localhost:5432\u002Fagents\"\n",[33,2021,2022],{"class":35,"line":55},[33,2023,52],{"emptyLinePlaceholder":51},[33,2025,2026],{"class":35,"line":61},[33,2027,2028],{},"with PostgresSaver.from_conn_string(DB_URI) as checkpointer:\n",[33,2030,2031],{"class":35,"line":67},[33,2032,2033],{},"    checkpointer.setup()  # creates the checkpoint tables; run once, like a migration\n",[33,2035,2036],{"class":35,"line":73},[33,2037,2038],{},"    graph = builder.compile(checkpointer=checkpointer)\n",[33,2040,2041],{"class":35,"line":79},[33,2042,52],{"emptyLinePlaceholder":51},[33,2044,2045],{"class":35,"line":85},[33,2046,2047],{},"    config = {\"configurable\": {\"thread_id\": \"loan-app-20931\"}, \"recursion_limit\": 30}\n",[33,2049,2050],{"class":35,"line":90},[33,2051,2052],{},"    graph.invoke({\"task\": \"...\", \"context\": [], \"reflections\": 0}, config)\n",[11,2054,2055,2056,2059,2060,2063,2064,2067],{},"After a crash, ",[30,2057,2058],{},"graph.invoke(None, config)"," with the same thread ID continues from the last completed step. ",[30,2061,2062],{},"graph.get_state(config)"," returns the current state and the next node; ",[30,2065,2066],{},"graph.get_state_history(config)"," returns all previous snapshots.",[11,2069,2070,2071,1930,2074,2077,2078,2080,2081,2084,2085,2088],{},"Each checkpoint can restore the whole state. Large strings in the state (full retrieved documents, base64 files) are written into every checkpoint row, and a list extended by a reducer is written out in full on every change. Keep references in the state and the payloads somewhere else. Checkpoints also pile up per thread indefinitely unless you add a retention job. The package has ",[30,2072,2073],{},"delete_thread()",[30,2075,2076],{},"prune()"," for that, and ",[30,2079,2076],{}," with the ",[30,2082,2083],{},"keep_latest"," strategy keeps only the latest checkpoint. ",[30,2086,2087],{},"ShallowPostgresSaver"," has been deprecated since version 2.0.20 of the package. In a regulated environment the retention job has to match your data retention policy anyway.",[15,2090,2092],{"id":2091},"waiting-for-a-human","Waiting for a human",[11,2094,2095,2098,2099,221],{},[30,2096,2097],{},"interrupt()"," stops the graph inside a node, saves the state and returns the payload to the caller. The run is resumed later, from any process, with ",[30,2100,2101],{},"Command(resume=...)",[23,2103,2105],{"className":105,"code":2104,"language":107,"meta":28,"style":28},"from langgraph.types import Command, interrupt\n\n\ndef approve_refund(state: AgentState) -> dict:\n    # Assumes `amount` and `approved` are declared in `AgentState`.\n    answer = interrupt({\"question\": \"Approve refund?\", \"amount\": state[\"amount\"]})\n    return {\"approved\": answer == \"yes\"}\n\n\n# Hours later, e.g. in the HTTP handler of an approval screen:\ngraph.invoke(Command(resume=\"yes\"), {\"configurable\": {\"thread_id\": \"loan-app-20931\"}, \"recursion_limit\": 30})\n",[30,2106,2107,2112,2116,2120,2125,2130,2135,2140,2144,2148,2153],{"__ignoreMap":28},[33,2108,2109],{"class":35,"line":36},[33,2110,2111],{},"from langgraph.types import Command, interrupt\n",[33,2113,2114],{"class":35,"line":42},[33,2115,52],{"emptyLinePlaceholder":51},[33,2117,2118],{"class":35,"line":48},[33,2119,52],{"emptyLinePlaceholder":51},[33,2121,2122],{"class":35,"line":55},[33,2123,2124],{},"def approve_refund(state: AgentState) -> dict:\n",[33,2126,2127],{"class":35,"line":61},[33,2128,2129],{},"    # Assumes `amount` and `approved` are declared in `AgentState`.\n",[33,2131,2132],{"class":35,"line":67},[33,2133,2134],{},"    answer = interrupt({\"question\": \"Approve refund?\", \"amount\": state[\"amount\"]})\n",[33,2136,2137],{"class":35,"line":73},[33,2138,2139],{},"    return {\"approved\": answer == \"yes\"}\n",[33,2141,2142],{"class":35,"line":79},[33,2143,52],{"emptyLinePlaceholder":51},[33,2145,2146],{"class":35,"line":85},[33,2147,52],{"emptyLinePlaceholder":51},[33,2149,2150],{"class":35,"line":90},[33,2151,2152],{},"# Hours later, e.g. in the HTTP handler of an approval screen:\n",[33,2154,2155],{"class":35,"line":96},[33,2156,2157],{},"graph.invoke(Command(resume=\"yes\"), {\"configurable\": {\"thread_id\": \"loan-app-20931\"}, \"recursion_limit\": 30})\n",[11,2159,2160,2161,2163,2164,2166],{},"On resume, the node runs again from its first line, and ",[30,2162,2097],{}," returns the resume value instead of stopping. So any code before ",[30,2165,2097],{}," runs twice. Keep that part free of side effects, or move the side effect to its own node after the approval.",[11,2168,2169],{},"Also check how long the runtime is willing to wait. One agent orchestrator had a hard-coded 15-minute limit on the pause for human approval. After that the whole run ended with an error and none of the next steps started. Approvals often come hours later, so the pause has to be saved with the run state.",[15,2171,2173],{"id":2172},"what-a-checkpoint-does-not-protect","What a checkpoint does not protect",[11,2175,2176],{},"The checkpoint is written after a node finishes. If a node sends an email, charges a card or streams output to the user and the process dies before the checkpoint is written, resuming runs the node again. Per node, the guarantee is at-least-once.",[11,2178,2179,2180,2183,2184,2187],{},"Put one external side effect in a node, so the unit that may repeat is small and you know what it is. Derive an idempotency key from the thread ID, the node name and the step number (",[30,2181,2182],{},"langgraph_step"," in the config metadata), because in a graph with a loop the same node runs more than once per thread, and a rerun after a crash gets the same step number. Pass the key to the external system if it accepts one (Stripe, for example, takes an ",[30,2185,2186],{},"Idempotency-Key"," header). In API v1, Stripe may remove a key once it is 24 hours old, so a rerun after a pause of several days can go through as a new request. On resume the node streams again, so the client should treat streamed output as a preview and replace what it already displayed with the value the node returned into the checkpointed state, instead of appending a second copy.",[23,2189,2191],{"className":105,"code":2190,"language":107,"meta":28,"style":28},"from langchain_core.runnables import RunnableConfig\n\n\ndef send_confirmation(state: AgentState, config: RunnableConfig) -> dict:\n    # Assumes `customer_email` and `confirmation_sent` are declared in `AgentState`.\n    thread_id = config[\"configurable\"][\"thread_id\"]\n    step = config[\"metadata\"][\"langgraph_step\"]\n    # `mailer` is your client; the provider deduplicates by this key.\n    mailer.send(\n        to=state[\"customer_email\"],\n        body=state[\"result\"],\n        idempotency_key=f\"{thread_id}:send_confirmation:{step}\",\n    )\n    return {\"confirmation_sent\": True}\n",[30,2192,2193,2198,2202,2206,2211,2216,2221,2226,2231,2236,2241,2246,2251,2256],{"__ignoreMap":28},[33,2194,2195],{"class":35,"line":36},[33,2196,2197],{},"from langchain_core.runnables import RunnableConfig\n",[33,2199,2200],{"class":35,"line":42},[33,2201,52],{"emptyLinePlaceholder":51},[33,2203,2204],{"class":35,"line":48},[33,2205,52],{"emptyLinePlaceholder":51},[33,2207,2208],{"class":35,"line":55},[33,2209,2210],{},"def send_confirmation(state: AgentState, config: RunnableConfig) -> dict:\n",[33,2212,2213],{"class":35,"line":61},[33,2214,2215],{},"    # Assumes `customer_email` and `confirmation_sent` are declared in `AgentState`.\n",[33,2217,2218],{"class":35,"line":67},[33,2219,2220],{},"    thread_id = config[\"configurable\"][\"thread_id\"]\n",[33,2222,2223],{"class":35,"line":73},[33,2224,2225],{},"    step = config[\"metadata\"][\"langgraph_step\"]\n",[33,2227,2228],{"class":35,"line":79},[33,2229,2230],{},"    # `mailer` is your client; the provider deduplicates by this key.\n",[33,2232,2233],{"class":35,"line":85},[33,2234,2235],{},"    mailer.send(\n",[33,2237,2238],{"class":35,"line":90},[33,2239,2240],{},"        to=state[\"customer_email\"],\n",[33,2242,2243],{"class":35,"line":96},[33,2244,2245],{},"        body=state[\"result\"],\n",[33,2247,2248],{"class":35,"line":384},[33,2249,2250],{},"        idempotency_key=f\"{thread_id}:send_confirmation:{step}\",\n",[33,2252,2253],{"class":35,"line":390},[33,2254,2255],{},"    )\n",[33,2257,2258],{"class":35,"line":395},[33,2259,2260],{},"    return {\"confirmation_sent\": True}\n",[11,2262,2263],{},"I split work for my coding agents the same way: packages of about 250–300 lines of diff. Whole phases (550–930 lines) came back with corrections roughly one time in three.",[15,2265,2267],{"id":2266},"replay-for-debugging-and-regression","Replay for debugging and regression",[11,2269,2270,2271,2274,2275,2277,2278,2281],{},"Every snapshot from ",[30,2272,2273],{},"get_state_history"," has its own config with a checkpoint ID. Invoking the graph with ",[30,2276,428],{}," and that config runs again from that point and creates a new branch of history; the original run stays intact. If nodes receive their tools through a factory (",[30,2279,2280],{},"build_graph(tools)","), you can take the state from a failed production run, build the graph with recorded or mocked tools, and check whether a changed prompt or routing rule leads to a different decision. Production state may contain personal data. The replay environment needs the same access controls as production.",[15,2283,2285],{"id":2284},"observability","Observability",[11,2287,2288],{},"Log one structured event per node execution. A wrapper at registration time covers every node without touching its code:",[23,2290,2292],{"className":105,"code":2291,"language":107,"meta":28,"style":28},"import inspect\nimport logging\nimport time\n\nfrom langgraph.errors import GraphBubbleUp\n\nlog = logging.getLogger(\"agent\")\n\n\ndef observed(name, fn):\n    # A node may take the state alone or (state, config).\n    takes_config = len(inspect.signature(fn).parameters) > 1\n\n    # No functools.wraps: LangGraph would then read fn's signature and, for a\n    # state-only node, would not pass config to the wrapper.\n    def wrapper(state, config):\n        started = time.perf_counter()\n        error = None\n        try:\n            return fn(state, config) if takes_config else fn(state)\n        except GraphBubbleUp:  # interrupt() pauses the graph by raising it\n            raise\n        except Exception as exc:\n            error = type(exc).__name__\n            raise\n        finally:\n            log.info(\"agent_step\", extra={\n                \"thread_id\": config[\"configurable\"][\"thread_id\"],\n                \"node\": name,\n                \"latency_ms\": round((time.perf_counter() - started) * 1000),\n                \"error\": error,\n            })\n    return wrapper\n\n\n# Instead of builder.add_node(\"act\", tool_runner); a node name can be registered only once.\nbuilder.add_node(\"act\", observed(\"act\", tool_runner))\n",[30,2293,2294,2299,2304,2309,2313,2318,2322,2327,2331,2335,2340,2345,2350,2354,2359,2364,2369,2374,2379,2384,2389,2394,2399,2404,2409,2413,2418,2423,2428,2433,2438,2443,2448,2453,2457,2461,2466],{"__ignoreMap":28},[33,2295,2296],{"class":35,"line":36},[33,2297,2298],{},"import inspect\n",[33,2300,2301],{"class":35,"line":42},[33,2302,2303],{},"import logging\n",[33,2305,2306],{"class":35,"line":48},[33,2307,2308],{},"import time\n",[33,2310,2311],{"class":35,"line":55},[33,2312,52],{"emptyLinePlaceholder":51},[33,2314,2315],{"class":35,"line":61},[33,2316,2317],{},"from langgraph.errors import GraphBubbleUp\n",[33,2319,2320],{"class":35,"line":67},[33,2321,52],{"emptyLinePlaceholder":51},[33,2323,2324],{"class":35,"line":73},[33,2325,2326],{},"log = logging.getLogger(\"agent\")\n",[33,2328,2329],{"class":35,"line":79},[33,2330,52],{"emptyLinePlaceholder":51},[33,2332,2333],{"class":35,"line":85},[33,2334,52],{"emptyLinePlaceholder":51},[33,2336,2337],{"class":35,"line":90},[33,2338,2339],{},"def observed(name, fn):\n",[33,2341,2342],{"class":35,"line":96},[33,2343,2344],{},"    # A node may take the state alone or (state, config).\n",[33,2346,2347],{"class":35,"line":384},[33,2348,2349],{},"    takes_config = len(inspect.signature(fn).parameters) > 1\n",[33,2351,2352],{"class":35,"line":390},[33,2353,52],{"emptyLinePlaceholder":51},[33,2355,2356],{"class":35,"line":395},[33,2357,2358],{},"    # No functools.wraps: LangGraph would then read fn's signature and, for a\n",[33,2360,2361],{"class":35,"line":401},[33,2362,2363],{},"    # state-only node, would not pass config to the wrapper.\n",[33,2365,2366],{"class":35,"line":407},[33,2367,2368],{},"    def wrapper(state, config):\n",[33,2370,2371],{"class":35,"line":413},[33,2372,2373],{},"        started = time.perf_counter()\n",[33,2375,2376],{"class":35,"line":419},[33,2377,2378],{},"        error = None\n",[33,2380,2381],{"class":35,"line":1806},[33,2382,2383],{},"        try:\n",[33,2385,2386],{"class":35,"line":1812},[33,2387,2388],{},"            return fn(state, config) if takes_config else fn(state)\n",[33,2390,2391],{"class":35,"line":1817},[33,2392,2393],{},"        except GraphBubbleUp:  # interrupt() pauses the graph by raising it\n",[33,2395,2396],{"class":35,"line":1822},[33,2397,2398],{},"            raise\n",[33,2400,2401],{"class":35,"line":1828},[33,2402,2403],{},"        except Exception as exc:\n",[33,2405,2406],{"class":35,"line":1834},[33,2407,2408],{},"            error = type(exc).__name__\n",[33,2410,2411],{"class":35,"line":1840},[33,2412,2398],{},[33,2414,2415],{"class":35,"line":1846},[33,2416,2417],{},"        finally:\n",[33,2419,2420],{"class":35,"line":1851},[33,2421,2422],{},"            log.info(\"agent_step\", extra={\n",[33,2424,2425],{"class":35,"line":1856},[33,2426,2427],{},"                \"thread_id\": config[\"configurable\"][\"thread_id\"],\n",[33,2429,2430],{"class":35,"line":1862},[33,2431,2432],{},"                \"node\": name,\n",[33,2434,2435],{"class":35,"line":1868},[33,2436,2437],{},"                \"latency_ms\": round((time.perf_counter() - started) * 1000),\n",[33,2439,2440],{"class":35,"line":1874},[33,2441,2442],{},"                \"error\": error,\n",[33,2444,2445],{"class":35,"line":1880},[33,2446,2447],{},"            })\n",[33,2449,2450],{"class":35,"line":1886},[33,2451,2452],{},"    return wrapper\n",[33,2454,2455],{"class":35,"line":1891},[33,2456,52],{"emptyLinePlaceholder":51},[33,2458,2459],{"class":35,"line":1897},[33,2460,52],{"emptyLinePlaceholder":51},[33,2462,2463],{"class":35,"line":1903},[33,2464,2465],{},"# Instead of builder.add_node(\"act\", tool_runner); a node name can be registered only once.\n",[33,2467,2468],{"class":35,"line":1909},[33,2469,2470],{},"builder.add_node(\"act\", observed(\"act\", tool_runner))\n",[11,2472,2473,2474,2477],{},"Token counts belong in the same event. In LangChain chat models they are available on the response message as ",[30,2475,2476],{},"usage_metadata","; return them from the node or log them where the model is called. With thread ID, node, latency, tokens and error in one record, the cost of a run, a slow tool or a node that started failing after a deploy is one query away.",[11,2479,2480],{},"One step run by Codex got the wrong model name, the API rejected the request in 2 seconds, and the orchestrator running it marked the step as done. Tests and lint passed afterwards, on code that hadn't changed at all.",[15,2482,2484],{"id":2483},"when-a-graph-is-overkill","When a graph is overkill",[11,2486,2487],{},"A single model call with structured output, or a fixed sequence of steps without branching, is better served by a plain function. A short synchronous task that nobody approves and nobody will replay doesn't justify a checkpoint table and an extra database write on every step. The graph starts paying for itself when a run can take long enough to get interrupted, when someone approves something halfway through, or when a failed run has to be reproduced later.",[892,2489,894],{},{"title":28,"searchDepth":42,"depth":42,"links":2491},[2492,2493,2494,2495,2496,2497,2498,2499],{"id":1696,"depth":42,"text":1697},{"id":1712,"depth":42,"text":1713},{"id":1992,"depth":42,"text":1993},{"id":2091,"depth":42,"text":2092},{"id":2172,"depth":42,"text":2173},{"id":2266,"depth":42,"text":2267},{"id":2284,"depth":42,"text":2285},{"id":2483,"depth":42,"text":2484},"agents","2026-05-09",{},"\u002Farticles\u002Fagent-graphs",{"x":2505,"y":2506,"depth":2507,"size":2508},0.66,0.27,1.5,"xl",[2510,2511],"microservice-cost","postgres-edge",{"title":1684,"description":1690},"orchestrator-graphs","articles\u002Fagent-graphs",[2516,2517,2518,2284],"ai-agents","langgraph","state-machines","2026-10-08","v1.1.4","LCMiUX7QzIaqAEEuO4b--ZpfV74j4puHwl8t0jkzk1Y",{"id":2523,"title":2524,"articleId":2525,"body":2526,"category":4071,"codeLang":2591,"date":4072,"deploys":36,"description":2530,"excerpt":910,"extension":911,"lang":910,"meta":4073,"navigation":51,"path":4074,"pos":4075,"readMin":73,"related":4079,"seo":4080,"service":4081,"stem":4082,"tags":4083,"updated":2519,"version":4090,"__hash__":4091},"articles\u002Farticles\u002Fansible-production.md","Ansible in production: idempotency, drift detection and safe rolling changes","ansible-production",{"type":8,"value":2527,"toc":4063},[2528,2531,2535,2542,2587,2659,2665,2669,2676,2730,2736,2884,2893,2896,2918,2928,2932,2935,3579,3582,3682,3690,3694,3701,3741,3751,3766,3838,3885,3888,3892,3895,3943,3960,3986,3989,4011,4015,4060],[11,2529,2530],{},"A playbook is usually written and tested against a clean machine. In production it runs against machines in whatever state they ended up in: a config file edited by hand during an incident, a package upgraded by unattended-upgrades, a previous run canceled halfway. What matters there is whether the playbook converges a host from an unknown state to the declared one, and whether it tells you what it had to change on the way.",[15,2532,2534],{"id":2533},"idempotent-is-not-the-same-as-convergent","Idempotent is not the same as convergent",[11,2536,2537,2538,2541],{},"Idempotent means a second run changes nothing. Convergent means any starting state ends in the declared one. A task can have the first property without the second: ",[30,2539,2540],{},"lineinfile"," guarding one line won't notice another line someone added next to it by hand.",[11,2543,2544,2545,739,2548,739,2551,776,2554,739,2557,776,2560,2563,2564,2567,2568,1930,2571,2574,2575,2578,2579,2582,2583,2586],{},"Most modules get this for free because they compare current and desired state (",[30,2546,2547],{},"template",[30,2549,2550],{},"copy",[30,2552,2553],{},"service",[30,2555,2556],{},"state: started",[30,2558,2559],{},"apt",[30,2561,2562],{},"state: present","), so a second run with the same arguments reports ",[30,2565,2566],{},"ok",". It doesn't hold for ",[30,2569,2570],{},"command",[30,2572,2573],{},"shell",". Ansible can't see inside them, so they report ",[30,2576,2577],{},"changed"," on every run unless you pass ",[30,2580,2581],{},"creates"," or ",[30,2584,2585],{},"removes"," (the task then doesn't run when the given file already exists or is already gone) or tell Ansible yourself when something changed:",[23,2588,2592],{"className":2589,"code":2590,"language":2591,"meta":28,"style":28},"language-yaml shiki shiki-themes github-light github-dark","- name: Run database migrations\n  ansible.builtin.command: php artisan migrate --force\n  args:\n    chdir: \u002Fvar\u002Fwww\u002Fapp\n  register: migrate\n  changed_when: \"'Nothing to migrate' not in migrate.stdout\"\n","yaml",[30,2593,2594,2611,2621,2629,2639,2649],{"__ignoreMap":28},[33,2595,2596,2600,2604,2607],{"class":35,"line":36},[33,2597,2599],{"class":2598},"sVt8B","- ",[33,2601,2603],{"class":2602},"s9eBZ","name",[33,2605,2606],{"class":2598},": ",[33,2608,2610],{"class":2609},"sZZnC","Run database migrations\n",[33,2612,2613,2616,2618],{"class":35,"line":42},[33,2614,2615],{"class":2602},"  ansible.builtin.command",[33,2617,2606],{"class":2598},[33,2619,2620],{"class":2609},"php artisan migrate --force\n",[33,2622,2623,2626],{"class":35,"line":48},[33,2624,2625],{"class":2602},"  args",[33,2627,2628],{"class":2598},":\n",[33,2630,2631,2634,2636],{"class":35,"line":55},[33,2632,2633],{"class":2602},"    chdir",[33,2635,2606],{"class":2598},[33,2637,2638],{"class":2609},"\u002Fvar\u002Fwww\u002Fapp\n",[33,2640,2641,2644,2646],{"class":35,"line":61},[33,2642,2643],{"class":2602},"  register",[33,2645,2606],{"class":2598},[33,2647,2648],{"class":2609},"migrate\n",[33,2650,2651,2654,2656],{"class":35,"line":67},[33,2652,2653],{"class":2602},"  changed_when",[33,2655,2606],{"class":2598},[33,2657,2658],{"class":2609},"\"'Nothing to migrate' not in migrate.stdout\"\n",[11,2660,288,2661,2664],{},[30,2662,2663],{},"changed_when",", every run reports at least one change, and the change count, the cheapest drift signal you have, stops meaning anything.",[15,2666,2668],{"id":2667},"making-drift-visible","Making drift visible",[11,2670,2671,2672,2675],{},"When Ansible quietly fixes a host, the fact that someone changed it disappears. Take a service whose autostart an engineer turned off by hand (",[30,2673,2674],{},"systemctl disable",") while debugging:",[23,2677,2679],{"className":2589,"code":2678,"language":2591,"meta":28,"style":28},"- name: Ensure application service is running\n  ansible.builtin.service:\n    name: invoicer\n    state: started\n    enabled: true\n",[30,2680,2681,2692,2699,2709,2719],{"__ignoreMap":28},[33,2682,2683,2685,2687,2689],{"class":35,"line":36},[33,2684,2599],{"class":2598},[33,2686,2603],{"class":2602},[33,2688,2606],{"class":2598},[33,2690,2691],{"class":2609},"Ensure application service is running\n",[33,2693,2694,2697],{"class":35,"line":42},[33,2695,2696],{"class":2602},"  ansible.builtin.service",[33,2698,2628],{"class":2598},[33,2700,2701,2704,2706],{"class":35,"line":48},[33,2702,2703],{"class":2602},"    name",[33,2705,2606],{"class":2598},[33,2707,2708],{"class":2609},"invoicer\n",[33,2710,2711,2714,2716],{"class":35,"line":55},[33,2712,2713],{"class":2602},"    state",[33,2715,2606],{"class":2598},[33,2717,2718],{"class":2609},"started\n",[33,2720,2721,2724,2726],{"class":35,"line":61},[33,2722,2723],{"class":2602},"    enabled",[33,2725,2606],{"class":2598},[33,2727,2729],{"class":2728},"sj4cs","true\n",[11,2731,2732,2733,2735],{},"The task fixes the state and reports ",[30,2734,2577],{},". The report doesn't say whether that was a first install or a manual override being reverted. If you want to know, read the state before converging it:",[23,2737,2739],{"className":2589,"code":2738,"language":2591,"meta":28,"style":28},"- name: Read current enablement state\n  ansible.builtin.command: systemctl is-enabled invoicer\n  register: svc_enabled\n  changed_when: false\n  failed_when: false\n  check_mode: false\n\n- name: Report manual override\n  ansible.builtin.debug:\n    msg: \"invoicer is '{{ svc_enabled.stdout }}' on {{ inventory_hostname }}, expected 'enabled'\"\n  when: svc_enabled.stdout != 'enabled'\n\n- name: Converge service state\n  ansible.builtin.service:\n    name: invoicer\n    state: started\n    enabled: true\n",[30,2740,2741,2752,2761,2770,2779,2788,2797,2801,2812,2819,2829,2839,2843,2854,2860,2868,2876],{"__ignoreMap":28},[33,2742,2743,2745,2747,2749],{"class":35,"line":36},[33,2744,2599],{"class":2598},[33,2746,2603],{"class":2602},[33,2748,2606],{"class":2598},[33,2750,2751],{"class":2609},"Read current enablement state\n",[33,2753,2754,2756,2758],{"class":35,"line":42},[33,2755,2615],{"class":2602},[33,2757,2606],{"class":2598},[33,2759,2760],{"class":2609},"systemctl is-enabled invoicer\n",[33,2762,2763,2765,2767],{"class":35,"line":48},[33,2764,2643],{"class":2602},[33,2766,2606],{"class":2598},[33,2768,2769],{"class":2609},"svc_enabled\n",[33,2771,2772,2774,2776],{"class":35,"line":55},[33,2773,2653],{"class":2602},[33,2775,2606],{"class":2598},[33,2777,2778],{"class":2728},"false\n",[33,2780,2781,2784,2786],{"class":35,"line":61},[33,2782,2783],{"class":2602},"  failed_when",[33,2785,2606],{"class":2598},[33,2787,2778],{"class":2728},[33,2789,2790,2793,2795],{"class":35,"line":67},[33,2791,2792],{"class":2602},"  check_mode",[33,2794,2606],{"class":2598},[33,2796,2778],{"class":2728},[33,2798,2799],{"class":35,"line":73},[33,2800,52],{"emptyLinePlaceholder":51},[33,2802,2803,2805,2807,2809],{"class":35,"line":79},[33,2804,2599],{"class":2598},[33,2806,2603],{"class":2602},[33,2808,2606],{"class":2598},[33,2810,2811],{"class":2609},"Report manual override\n",[33,2813,2814,2817],{"class":35,"line":85},[33,2815,2816],{"class":2602},"  ansible.builtin.debug",[33,2818,2628],{"class":2598},[33,2820,2821,2824,2826],{"class":35,"line":90},[33,2822,2823],{"class":2602},"    msg",[33,2825,2606],{"class":2598},[33,2827,2828],{"class":2609},"\"invoicer is '{{ svc_enabled.stdout }}' on {{ inventory_hostname }}, expected 'enabled'\"\n",[33,2830,2831,2834,2836],{"class":35,"line":96},[33,2832,2833],{"class":2602},"  when",[33,2835,2606],{"class":2598},[33,2837,2838],{"class":2609},"svc_enabled.stdout != 'enabled'\n",[33,2840,2841],{"class":35,"line":384},[33,2842,52],{"emptyLinePlaceholder":51},[33,2844,2845,2847,2849,2851],{"class":35,"line":390},[33,2846,2599],{"class":2598},[33,2848,2603],{"class":2602},[33,2850,2606],{"class":2598},[33,2852,2853],{"class":2609},"Converge service state\n",[33,2855,2856,2858],{"class":35,"line":395},[33,2857,2696],{"class":2602},[33,2859,2628],{"class":2598},[33,2861,2862,2864,2866],{"class":35,"line":401},[33,2863,2703],{"class":2602},[33,2865,2606],{"class":2598},[33,2867,2708],{"class":2609},[33,2869,2870,2872,2874],{"class":35,"line":407},[33,2871,2713],{"class":2602},[33,2873,2606],{"class":2598},[33,2875,2718],{"class":2609},[33,2877,2878,2880,2882],{"class":35,"line":413},[33,2879,2723],{"class":2602},[33,2881,2606],{"class":2598},[33,2883,2729],{"class":2728},[11,2885,2886,2889,2890,2892],{},[30,2887,2888],{},"check_mode: false"," matters here. ",[30,2891,2570],{}," tasks are skipped in check mode by default, so without it the read wouldn't run during a dry run.",[11,2894,2895],{},"For the whole fleet, the practical drift detector is a scheduled dry run:",[23,2897,2901],{"className":2898,"code":2899,"language":2900,"meta":28,"style":28},"language-bash shiki shiki-themes github-light github-dark","ansible-playbook site.yml --check --diff\n","bash",[30,2902,2903],{"__ignoreMap":28},[33,2904,2905,2909,2912,2915],{"class":35,"line":36},[33,2906,2908],{"class":2907},"sScJk","ansible-playbook",[33,2910,2911],{"class":2609}," site.yml",[33,2913,2914],{"class":2728}," --check",[33,2916,2917],{"class":2728}," --diff\n",[11,2919,2920,2921,2923,2924,2927],{},"If the playbooks have correct ",[30,2922,2663],{}," everywhere and no unconditional restarts in tasks, any host with ",[30,2925,2926],{},"changed>0"," in the recap differs from what they declare, and the diff shows which lines. It isn't exact. Check mode can't predict tasks that depend on the result of an earlier task that would have changed something, and modules without check mode support are skipped. What you get is a list of hosts worth looking at.",[15,2929,2931],{"id":2930},"rolling-changes-with-rollback","Rolling changes with rollback",[11,2933,2934],{},"Picture a deploy job that gets interrupted while updating the nginx upstream configuration. Part of the fleet now runs the old config and the rest runs the new one. You need to converge all hosts to one version, a few at a time, and stop as soon as one host fails:",[23,2936,2938],{"className":2589,"code":2937,"language":2591,"meta":28,"style":28},"- name: Converge nginx upstream config\n  hosts: api_servers\n  become: true\n  serial: 2\n  max_fail_percentage: 0\n\n  handlers:\n    - name: Reload nginx\n      ansible.builtin.service:\n        name: nginx\n        state: reloaded\n\n  tasks:\n    - name: Deploy, verify, roll back on failure\n      block:\n        - name: Check whether upstream config exists\n          ansible.builtin.stat:\n            path: \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n          register: upstream_before\n\n        - name: Render upstream config\n          ansible.builtin.template:\n            src: templates\u002Fupstream.conf.j2\n            dest: \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n            owner: root\n            group: root\n            mode: '0644'\n            backup: true\n          register: upstream_conf\n          notify: Reload nginx\n\n        - name: Test full nginx configuration\n          ansible.builtin.command: nginx -t\n          changed_when: false\n\n        - name: Reload now instead of at the end of the play\n          ansible.builtin.meta: flush_handlers\n\n        - name: Wait for health endpoint\n          ansible.builtin.uri:\n            url: \"http:\u002F\u002F127.0.0.1:{{ nginx_port }}\u002Fhealth\"\n            status_code: 200\n            timeout: 5\n          register: health\n          until: health.status == 200\n          retries: 5\n          delay: 2\n\n      rescue:\n        - name: Restore previous config\n          ansible.builtin.copy:\n            src: \"{{ upstream_conf.backup_file }}\"\n            dest: \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n            remote_src: true\n            mode: '0644'\n          when: upstream_conf.backup_file is defined\n\n        - name: Remove config that did not exist before\n          ansible.builtin.file:\n            path: \u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n            state: absent\n          when: not upstream_before.stat.exists\n\n        - name: Reload nginx with restored config\n          ansible.builtin.service:\n            name: nginx\n            state: reloaded\n\n        - name: Mark host as failed\n          ansible.builtin.fail:\n            msg: \"upstream.conf rolled back on {{ inventory_hostname }}\"\n",[30,2939,2940,2951,2961,2970,2980,2990,2994,3001,3013,3020,3030,3040,3044,3051,3062,3069,3081,3088,3098,3108,3112,3123,3130,3140,3149,3159,3168,3178,3187,3196,3205,3209,3220,3230,3239,3243,3254,3264,3268,3280,3288,3299,3310,3321,3331,3342,3352,3362,3367,3375,3387,3395,3405,3414,3424,3433,3444,3449,3461,3469,3478,3489,3499,3504,3516,3524,3534,3543,3548,3560,3568],{"__ignoreMap":28},[33,2941,2942,2944,2946,2948],{"class":35,"line":36},[33,2943,2599],{"class":2598},[33,2945,2603],{"class":2602},[33,2947,2606],{"class":2598},[33,2949,2950],{"class":2609},"Converge nginx upstream config\n",[33,2952,2953,2956,2958],{"class":35,"line":42},[33,2954,2955],{"class":2602},"  hosts",[33,2957,2606],{"class":2598},[33,2959,2960],{"class":2609},"api_servers\n",[33,2962,2963,2966,2968],{"class":35,"line":48},[33,2964,2965],{"class":2602},"  become",[33,2967,2606],{"class":2598},[33,2969,2729],{"class":2728},[33,2971,2972,2975,2977],{"class":35,"line":55},[33,2973,2974],{"class":2602},"  serial",[33,2976,2606],{"class":2598},[33,2978,2979],{"class":2728},"2\n",[33,2981,2982,2985,2987],{"class":35,"line":61},[33,2983,2984],{"class":2602},"  max_fail_percentage",[33,2986,2606],{"class":2598},[33,2988,2989],{"class":2728},"0\n",[33,2991,2992],{"class":35,"line":67},[33,2993,52],{"emptyLinePlaceholder":51},[33,2995,2996,2999],{"class":35,"line":73},[33,2997,2998],{"class":2602},"  handlers",[33,3000,2628],{"class":2598},[33,3002,3003,3006,3008,3010],{"class":35,"line":79},[33,3004,3005],{"class":2598},"    - ",[33,3007,2603],{"class":2602},[33,3009,2606],{"class":2598},[33,3011,3012],{"class":2609},"Reload nginx\n",[33,3014,3015,3018],{"class":35,"line":85},[33,3016,3017],{"class":2602},"      ansible.builtin.service",[33,3019,2628],{"class":2598},[33,3021,3022,3025,3027],{"class":35,"line":90},[33,3023,3024],{"class":2602},"        name",[33,3026,2606],{"class":2598},[33,3028,3029],{"class":2609},"nginx\n",[33,3031,3032,3035,3037],{"class":35,"line":96},[33,3033,3034],{"class":2602},"        state",[33,3036,2606],{"class":2598},[33,3038,3039],{"class":2609},"reloaded\n",[33,3041,3042],{"class":35,"line":384},[33,3043,52],{"emptyLinePlaceholder":51},[33,3045,3046,3049],{"class":35,"line":390},[33,3047,3048],{"class":2602},"  tasks",[33,3050,2628],{"class":2598},[33,3052,3053,3055,3057,3059],{"class":35,"line":395},[33,3054,3005],{"class":2598},[33,3056,2603],{"class":2602},[33,3058,2606],{"class":2598},[33,3060,3061],{"class":2609},"Deploy, verify, roll back on failure\n",[33,3063,3064,3067],{"class":35,"line":401},[33,3065,3066],{"class":2602},"      block",[33,3068,2628],{"class":2598},[33,3070,3071,3074,3076,3078],{"class":35,"line":407},[33,3072,3073],{"class":2598},"        - ",[33,3075,2603],{"class":2602},[33,3077,2606],{"class":2598},[33,3079,3080],{"class":2609},"Check whether upstream config exists\n",[33,3082,3083,3086],{"class":35,"line":413},[33,3084,3085],{"class":2602},"          ansible.builtin.stat",[33,3087,2628],{"class":2598},[33,3089,3090,3093,3095],{"class":35,"line":419},[33,3091,3092],{"class":2602},"            path",[33,3094,2606],{"class":2598},[33,3096,3097],{"class":2609},"\u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n",[33,3099,3100,3103,3105],{"class":35,"line":1806},[33,3101,3102],{"class":2602},"          register",[33,3104,2606],{"class":2598},[33,3106,3107],{"class":2609},"upstream_before\n",[33,3109,3110],{"class":35,"line":1812},[33,3111,52],{"emptyLinePlaceholder":51},[33,3113,3114,3116,3118,3120],{"class":35,"line":1817},[33,3115,3073],{"class":2598},[33,3117,2603],{"class":2602},[33,3119,2606],{"class":2598},[33,3121,3122],{"class":2609},"Render upstream config\n",[33,3124,3125,3128],{"class":35,"line":1822},[33,3126,3127],{"class":2602},"          ansible.builtin.template",[33,3129,2628],{"class":2598},[33,3131,3132,3135,3137],{"class":35,"line":1828},[33,3133,3134],{"class":2602},"            src",[33,3136,2606],{"class":2598},[33,3138,3139],{"class":2609},"templates\u002Fupstream.conf.j2\n",[33,3141,3142,3145,3147],{"class":35,"line":1834},[33,3143,3144],{"class":2602},"            dest",[33,3146,2606],{"class":2598},[33,3148,3097],{"class":2609},[33,3150,3151,3154,3156],{"class":35,"line":1840},[33,3152,3153],{"class":2602},"            owner",[33,3155,2606],{"class":2598},[33,3157,3158],{"class":2609},"root\n",[33,3160,3161,3164,3166],{"class":35,"line":1846},[33,3162,3163],{"class":2602},"            group",[33,3165,2606],{"class":2598},[33,3167,3158],{"class":2609},[33,3169,3170,3173,3175],{"class":35,"line":1851},[33,3171,3172],{"class":2602},"            mode",[33,3174,2606],{"class":2598},[33,3176,3177],{"class":2609},"'0644'\n",[33,3179,3180,3183,3185],{"class":35,"line":1856},[33,3181,3182],{"class":2602},"            backup",[33,3184,2606],{"class":2598},[33,3186,2729],{"class":2728},[33,3188,3189,3191,3193],{"class":35,"line":1862},[33,3190,3102],{"class":2602},[33,3192,2606],{"class":2598},[33,3194,3195],{"class":2609},"upstream_conf\n",[33,3197,3198,3201,3203],{"class":35,"line":1868},[33,3199,3200],{"class":2602},"          notify",[33,3202,2606],{"class":2598},[33,3204,3012],{"class":2609},[33,3206,3207],{"class":35,"line":1874},[33,3208,52],{"emptyLinePlaceholder":51},[33,3210,3211,3213,3215,3217],{"class":35,"line":1880},[33,3212,3073],{"class":2598},[33,3214,2603],{"class":2602},[33,3216,2606],{"class":2598},[33,3218,3219],{"class":2609},"Test full nginx configuration\n",[33,3221,3222,3225,3227],{"class":35,"line":1886},[33,3223,3224],{"class":2602},"          ansible.builtin.command",[33,3226,2606],{"class":2598},[33,3228,3229],{"class":2609},"nginx -t\n",[33,3231,3232,3235,3237],{"class":35,"line":1891},[33,3233,3234],{"class":2602},"          changed_when",[33,3236,2606],{"class":2598},[33,3238,2778],{"class":2728},[33,3240,3241],{"class":35,"line":1897},[33,3242,52],{"emptyLinePlaceholder":51},[33,3244,3245,3247,3249,3251],{"class":35,"line":1903},[33,3246,3073],{"class":2598},[33,3248,2603],{"class":2602},[33,3250,2606],{"class":2598},[33,3252,3253],{"class":2609},"Reload now instead of at the end of the play\n",[33,3255,3256,3259,3261],{"class":35,"line":1909},[33,3257,3258],{"class":2602},"          ansible.builtin.meta",[33,3260,2606],{"class":2598},[33,3262,3263],{"class":2609},"flush_handlers\n",[33,3265,3266],{"class":35,"line":1915},[33,3267,52],{"emptyLinePlaceholder":51},[33,3269,3271,3273,3275,3277],{"class":35,"line":3270},39,[33,3272,3073],{"class":2598},[33,3274,2603],{"class":2602},[33,3276,2606],{"class":2598},[33,3278,3279],{"class":2609},"Wait for health endpoint\n",[33,3281,3283,3286],{"class":35,"line":3282},40,[33,3284,3285],{"class":2602},"          ansible.builtin.uri",[33,3287,2628],{"class":2598},[33,3289,3291,3294,3296],{"class":35,"line":3290},41,[33,3292,3293],{"class":2602},"            url",[33,3295,2606],{"class":2598},[33,3297,3298],{"class":2609},"\"http:\u002F\u002F127.0.0.1:{{ nginx_port }}\u002Fhealth\"\n",[33,3300,3302,3305,3307],{"class":35,"line":3301},42,[33,3303,3304],{"class":2602},"            status_code",[33,3306,2606],{"class":2598},[33,3308,3309],{"class":2728},"200\n",[33,3311,3313,3316,3318],{"class":35,"line":3312},43,[33,3314,3315],{"class":2602},"            timeout",[33,3317,2606],{"class":2598},[33,3319,3320],{"class":2728},"5\n",[33,3322,3324,3326,3328],{"class":35,"line":3323},44,[33,3325,3102],{"class":2602},[33,3327,2606],{"class":2598},[33,3329,3330],{"class":2609},"health\n",[33,3332,3334,3337,3339],{"class":35,"line":3333},45,[33,3335,3336],{"class":2602},"          until",[33,3338,2606],{"class":2598},[33,3340,3341],{"class":2609},"health.status == 200\n",[33,3343,3345,3348,3350],{"class":35,"line":3344},46,[33,3346,3347],{"class":2602},"          retries",[33,3349,2606],{"class":2598},[33,3351,3320],{"class":2728},[33,3353,3355,3358,3360],{"class":35,"line":3354},47,[33,3356,3357],{"class":2602},"          delay",[33,3359,2606],{"class":2598},[33,3361,2979],{"class":2728},[33,3363,3365],{"class":35,"line":3364},48,[33,3366,52],{"emptyLinePlaceholder":51},[33,3368,3370,3373],{"class":35,"line":3369},49,[33,3371,3372],{"class":2602},"      rescue",[33,3374,2628],{"class":2598},[33,3376,3378,3380,3382,3384],{"class":35,"line":3377},50,[33,3379,3073],{"class":2598},[33,3381,2603],{"class":2602},[33,3383,2606],{"class":2598},[33,3385,3386],{"class":2609},"Restore previous config\n",[33,3388,3390,3393],{"class":35,"line":3389},51,[33,3391,3392],{"class":2602},"          ansible.builtin.copy",[33,3394,2628],{"class":2598},[33,3396,3398,3400,3402],{"class":35,"line":3397},52,[33,3399,3134],{"class":2602},[33,3401,2606],{"class":2598},[33,3403,3404],{"class":2609},"\"{{ upstream_conf.backup_file }}\"\n",[33,3406,3408,3410,3412],{"class":35,"line":3407},53,[33,3409,3144],{"class":2602},[33,3411,2606],{"class":2598},[33,3413,3097],{"class":2609},[33,3415,3417,3420,3422],{"class":35,"line":3416},54,[33,3418,3419],{"class":2602},"            remote_src",[33,3421,2606],{"class":2598},[33,3423,2729],{"class":2728},[33,3425,3427,3429,3431],{"class":35,"line":3426},55,[33,3428,3172],{"class":2602},[33,3430,2606],{"class":2598},[33,3432,3177],{"class":2609},[33,3434,3436,3439,3441],{"class":35,"line":3435},56,[33,3437,3438],{"class":2602},"          when",[33,3440,2606],{"class":2598},[33,3442,3443],{"class":2609},"upstream_conf.backup_file is defined\n",[33,3445,3447],{"class":35,"line":3446},57,[33,3448,52],{"emptyLinePlaceholder":51},[33,3450,3452,3454,3456,3458],{"class":35,"line":3451},58,[33,3453,3073],{"class":2598},[33,3455,2603],{"class":2602},[33,3457,2606],{"class":2598},[33,3459,3460],{"class":2609},"Remove config that did not exist before\n",[33,3462,3464,3467],{"class":35,"line":3463},59,[33,3465,3466],{"class":2602},"          ansible.builtin.file",[33,3468,2628],{"class":2598},[33,3470,3472,3474,3476],{"class":35,"line":3471},60,[33,3473,3092],{"class":2602},[33,3475,2606],{"class":2598},[33,3477,3097],{"class":2609},[33,3479,3481,3484,3486],{"class":35,"line":3480},61,[33,3482,3483],{"class":2602},"            state",[33,3485,2606],{"class":2598},[33,3487,3488],{"class":2609},"absent\n",[33,3490,3492,3494,3496],{"class":35,"line":3491},62,[33,3493,3438],{"class":2602},[33,3495,2606],{"class":2598},[33,3497,3498],{"class":2609},"not upstream_before.stat.exists\n",[33,3500,3502],{"class":35,"line":3501},63,[33,3503,52],{"emptyLinePlaceholder":51},[33,3505,3507,3509,3511,3513],{"class":35,"line":3506},64,[33,3508,3073],{"class":2598},[33,3510,2603],{"class":2602},[33,3512,2606],{"class":2598},[33,3514,3515],{"class":2609},"Reload nginx with restored config\n",[33,3517,3519,3522],{"class":35,"line":3518},65,[33,3520,3521],{"class":2602},"          ansible.builtin.service",[33,3523,2628],{"class":2598},[33,3525,3527,3530,3532],{"class":35,"line":3526},66,[33,3528,3529],{"class":2602},"            name",[33,3531,2606],{"class":2598},[33,3533,3029],{"class":2609},[33,3535,3537,3539,3541],{"class":35,"line":3536},67,[33,3538,3483],{"class":2602},[33,3540,2606],{"class":2598},[33,3542,3039],{"class":2609},[33,3544,3546],{"class":35,"line":3545},68,[33,3547,52],{"emptyLinePlaceholder":51},[33,3549,3551,3553,3555,3557],{"class":35,"line":3550},69,[33,3552,3073],{"class":2598},[33,3554,2603],{"class":2602},[33,3556,2606],{"class":2598},[33,3558,3559],{"class":2609},"Mark host as failed\n",[33,3561,3563,3566],{"class":35,"line":3562},70,[33,3564,3565],{"class":2602},"          ansible.builtin.fail",[33,3567,2628],{"class":2598},[33,3569,3571,3574,3576],{"class":35,"line":3570},71,[33,3572,3573],{"class":2602},"            msg",[33,3575,2606],{"class":2598},[33,3577,3578],{"class":2609},"\"upstream.conf rolled back on {{ inventory_hostname }}\"\n",[11,3580,3581],{},"What each part does:",[3583,3584,3585,3592,3606,3634,3658,3672],"ul",{},[3586,3587,3588,3591],"li",{},[30,3589,3590],{},"serial: 2"," processes the inventory in batches of two hosts. With eight hosts, at most two are being changed at any moment.",[3586,3593,3594,3597,3598,3601,3602,3605],{},[30,3595,3596],{},"max_fail_percentage: 0"," stops the rollout after the first task on which a host counts as failed. A failure inside ",[30,3599,3600],{},"block"," is rescued, so here that only happens at the explicit ",[30,3603,3604],{},"fail",", and by then the other host in the batch has usually finished the block with the new config. The next batch never starts. Without it, Ansible only stops when every host in a batch fails, so a 1 of 2 failure would let the next batch proceed.",[3586,3607,3608,3611,3612,3615,3616,3619,3620,3623,3624,3626,3627,3629,3630,3633],{},[30,3609,3610],{},"nginx -t"," tests the whole configuration with the rendered file included. A ",[30,3613,3614],{},"conf.d"," snippet with an ",[30,3617,3618],{},"upstream"," block isn't a valid standalone config, so the ",[30,3621,3622],{},"validate"," option of ",[30,3625,2547],{}," doesn't work for it. ",[30,3628,3622],{}," is fine for files like ",[30,3631,3632],{},"nginx.conf"," that nginx can test on their own.",[3586,3635,3636,3637,739,3640,1930,3643,739,3646,3649,3650,3653,3654,3657],{},"Handlers normally run at the end of each section of the play (",[30,3638,3639],{},"pre_tasks",[30,3641,3642],{},"roles",[30,3644,3645],{},"tasks",[30,3647,3648],{},"post_tasks","), per batch. ",[30,3651,3652],{},"meta: flush_handlers"," reloads nginx before the health check, which goes through nginx on ",[30,3655,3656],{},"nginx_port",". Otherwise the check would test the old config.",[3586,3659,3660,3663,3664,3667,3668,3671],{},[30,3661,3662],{},"backup: true"," keeps a timestamped copy and returns its path in ",[30,3665,3666],{},"backup_file",". The rescue section restores it, reloads, and then fails the host explicitly, so ",[30,3669,3670],{},"max_fail_percentage"," stops the rollout. If the file didn't exist before, there is no backup, so rescue deletes the new file and nginx reloads without it. The host still ends up failed, so the rollout stops.",[3586,3673,3674,3677,3678,3681],{},[30,3675,3676],{},"reloaded"," instead of ",[30,3679,3680],{},"restarted",": new connections go to the new workers while the old ones finish the requests already in progress, so a reload doesn't interrupt them. With a broken config nginx stays on the old one.",[11,3683,3684,3685,3687,3688,221],{},"This playbook doesn't drain hosts from the load balancer. For a plain nginx reload that's usually unnecessary. If you restart the application, take the host out of the balancer in ",[30,3686,3639],{}," and put it back in ",[30,3689,3648],{},[15,3691,3693],{"id":3692},"secrets","Secrets",[11,3695,3696,3697,3700],{},"Secrets that belong to the playbook go into an encrypted vars file, referenced from a plain one. The ",[30,3698,3699],{},"vault_"," prefix convention from the Ansible documentation makes it obvious where a value comes from:",[23,3702,3704],{"className":2589,"code":3703,"language":2591,"meta":28,"style":28},"# group_vars\u002Fprod\u002Fvars.yml\ndb_password: \"{{ vault_db_password }}\"\n\n# group_vars\u002Fprod\u002Fvault.yml, encrypted with: ansible-vault encrypt group_vars\u002Fprod\u002Fvault.yml\nvault_db_password: \"...\"\n",[30,3705,3706,3712,3722,3726,3731],{"__ignoreMap":28},[33,3707,3708],{"class":35,"line":36},[33,3709,3711],{"class":3710},"sJ8bj","# group_vars\u002Fprod\u002Fvars.yml\n",[33,3713,3714,3717,3719],{"class":35,"line":42},[33,3715,3716],{"class":2602},"db_password",[33,3718,2606],{"class":2598},[33,3720,3721],{"class":2609},"\"{{ vault_db_password }}\"\n",[33,3723,3724],{"class":35,"line":48},[33,3725,52],{"emptyLinePlaceholder":51},[33,3727,3728],{"class":35,"line":55},[33,3729,3730],{"class":3710},"# group_vars\u002Fprod\u002Fvault.yml, encrypted with: ansible-vault encrypt group_vars\u002Fprod\u002Fvault.yml\n",[33,3732,3733,3736,3738],{"class":35,"line":61},[33,3734,3735],{"class":2602},"vault_db_password",[33,3737,2606],{"class":2598},[33,3739,3740],{"class":2609},"\"...\"\n",[11,3742,3743,3744,1930,3747,3750],{},"Secrets shared with other systems are better read at runtime from a secret manager through a lookup plugin (the ",[30,3745,3746],{},"amazon.aws",[30,3748,3749],{},"community.hashi_vault"," collections provide them), so the value lives in one place.",[11,3752,3753,3754,3757,3758,3761,3762,3765],{},"Any task that renders or passes a secret needs ",[30,3755,3756],{},"no_log: true",". It hides the task result in logs and in ",[30,3759,3760],{},"--diff"," output. Use ",[30,3763,3764],{},"diff: false"," if you only want to suppress the diff:",[23,3767,3769],{"className":2589,"code":3768,"language":2591,"meta":28,"style":28},"- name: Render database config\n  ansible.builtin.template:\n    src: templates\u002Fdatabase.env.j2\n    dest: \u002Fvar\u002Fwww\u002Fapp\u002F.env.database\n    owner: www-data\n    mode: '0640'\n  no_log: true\n",[30,3770,3771,3782,3789,3799,3809,3819,3829],{"__ignoreMap":28},[33,3772,3773,3775,3777,3779],{"class":35,"line":36},[33,3774,2599],{"class":2598},[33,3776,2603],{"class":2602},[33,3778,2606],{"class":2598},[33,3780,3781],{"class":2609},"Render database config\n",[33,3783,3784,3787],{"class":35,"line":42},[33,3785,3786],{"class":2602},"  ansible.builtin.template",[33,3788,2628],{"class":2598},[33,3790,3791,3794,3796],{"class":35,"line":48},[33,3792,3793],{"class":2602},"    src",[33,3795,2606],{"class":2598},[33,3797,3798],{"class":2609},"templates\u002Fdatabase.env.j2\n",[33,3800,3801,3804,3806],{"class":35,"line":55},[33,3802,3803],{"class":2602},"    dest",[33,3805,2606],{"class":2598},[33,3807,3808],{"class":2609},"\u002Fvar\u002Fwww\u002Fapp\u002F.env.database\n",[33,3810,3811,3814,3816],{"class":35,"line":61},[33,3812,3813],{"class":2602},"    owner",[33,3815,2606],{"class":2598},[33,3817,3818],{"class":2609},"www-data\n",[33,3820,3821,3824,3826],{"class":35,"line":67},[33,3822,3823],{"class":2602},"    mode",[33,3825,2606],{"class":2598},[33,3827,3828],{"class":2609},"'0640'\n",[33,3830,3831,3834,3836],{"class":35,"line":73},[33,3832,3833],{"class":2602},"  no_log",[33,3835,2606],{"class":2598},[33,3837,2729],{"class":2728},[11,3839,3840,3841,1930,3844,3847,3848,3851,3852,3855,3856,3859,3860,3863,3864,3867,3868,739,3870,3873,3874,3677,3877,3880,3881,3884],{},"Set ",[30,3842,3843],{},"owner",[30,3845,3846],{},"mode"," explicitly, even when they seem obvious. In CookieDomain, my cookie consent system, the deploy script created a config file with ",[30,3849,3850],{},"mktemp",", which gives it mode 600. ",[30,3853,3854],{},"mv"," kept that mode and ",[30,3857,3858],{},"rsync -a"," carried it to production. The web server runs as www-data and couldn't read the file, so the ",[30,3861,3862],{},"require"," in the bootstrap failed and both production and dev returned 500 on every route. The application logs had nothing, the fatal error was only in the nginx log. The fix was ",[30,3865,3866],{},"chmod 644"," before ",[30,3869,3854],{},[30,3871,3872],{},"rsync --chmod=F644,D755",", and ",[30,3875,3876],{},"is_readable()",[30,3878,3879],{},"is_file()",". For a file with secrets 644 is too open, because any user on the system can read it. There ",[30,3882,3883],{},"0640"," with the web server's group, or www-data as the owner as in the example above, is the better choice. Running the deploy again didn't fix it, because every run created the file the same way.",[11,3886,3887],{},"If a secret has already been committed in plain text, rewriting git history isn't enough, because clones and CI caches still have it. Rotate the secret first, then clean up.",[15,3889,3891],{"id":3890},"testing-before-production","Testing before production",[11,3893,3894],{},"For roles, Molecule creates a container or VM, applies the role, runs it a second time to check idempotency, and runs a verifier. Since Molecule 3.0 the default verifier is Ansible, so Testinfra has to be set explicitly. With Testinfra, the tests check the state the host actually ended up in, whatever Ansible itself reported:",[23,3896,3898],{"className":105,"code":3897,"language":107,"meta":28,"style":28},"# molecule\u002Fdefault\u002Ftests\u002Ftest_nginx.py\ndef test_nginx_running_and_enabled(host):\n    nginx = host.service(\"nginx\")\n    assert nginx.is_running\n    assert nginx.is_enabled\n\n\ndef test_nginx_config_valid(host):\n    assert host.run(\"nginx -t\").rc == 0\n",[30,3899,3900,3905,3910,3915,3920,3925,3929,3933,3938],{"__ignoreMap":28},[33,3901,3902],{"class":35,"line":36},[33,3903,3904],{},"# molecule\u002Fdefault\u002Ftests\u002Ftest_nginx.py\n",[33,3906,3907],{"class":35,"line":42},[33,3908,3909],{},"def test_nginx_running_and_enabled(host):\n",[33,3911,3912],{"class":35,"line":48},[33,3913,3914],{},"    nginx = host.service(\"nginx\")\n",[33,3916,3917],{"class":35,"line":55},[33,3918,3919],{},"    assert nginx.is_running\n",[33,3921,3922],{"class":35,"line":61},[33,3923,3924],{},"    assert nginx.is_enabled\n",[33,3926,3927],{"class":35,"line":67},[33,3928,52],{"emptyLinePlaceholder":51},[33,3930,3931],{"class":35,"line":73},[33,3932,52],{"emptyLinePlaceholder":51},[33,3934,3935],{"class":35,"line":79},[33,3936,3937],{},"def test_nginx_config_valid(host):\n",[33,3939,3940],{"class":35,"line":85},[33,3941,3942],{},"    assert host.run(\"nginx -t\").rc == 0\n",[11,3944,3945,3946,1930,3949,3952,3953,3955,3956,3959],{},"On a host with systemd, ",[30,3947,3948],{},"is_running",[30,3950,3951],{},"is_enabled"," ask systemd. In a plain container without it, Testinfra falls back to the ",[30,3954,2553],{}," command and the links in ",[30,3957,3958],{},"\u002Fetc\u002Frc?.d\u002F",", so it checks something other than the systemd you run in production. To test against systemd, you need an image with systemd or a VM.",[11,3961,3962,3963,3966,3967,3970,3971,3974,3975,3978,3979,3982,3983,221],{},"Hand-written deploy scripts and checks are easy to get wrong. In one of my deploys ",[30,3964,3965],{},"docker kill"," turned out not to test the restart policy at all; I needed ",[30,3968,3969],{},"kill -9"," on the process PID from the host. Another deploy script ran ",[30,3972,3973],{},"composer install --no-dev"," and so removed PHPUnit before the tests got to run. And in a script started with ",[30,3976,3977],{},"ssh ... bash -s",", a plain ",[30,3980,3981],{},"read"," from stdin swallowed the rest of the script, and garbage ended up in ",[30,3984,3985],{},".env",[11,3987,3988],{},"Before a production run, a dry run against one representative host is fast and shows the exact template diffs:",[23,3990,3992],{"className":2898,"code":3991,"language":2900,"meta":28,"style":28},"ansible-playbook site.yml --check --diff --limit 'api_servers[0]'\n",[30,3993,3994],{"__ignoreMap":28},[33,3995,3996,3998,4000,4002,4005,4008],{"class":35,"line":36},[33,3997,2908],{"class":2907},[33,3999,2911],{"class":2609},[33,4001,2914],{"class":2728},[33,4003,4004],{"class":2728}," --diff",[33,4006,4007],{"class":2728}," --limit",[33,4009,4010],{"class":2609}," 'api_servers[0]'\n",[15,4012,4014],{"id":4013},"before-approving-a-playbook-change","Before approving a playbook change",[3583,4016,4017,4031,4045,4051],{},[3586,4018,4019,4021,4022,1930,4024,4026,4027,4030],{},[30,4020,2663],{}," on every ",[30,4023,2570],{},[30,4025,2573],{}," task, and ",[30,4028,4029],{},"failed_when"," where the exit code means nothing.",[3586,4032,4033,4034,4037,4038,4040,4041,4044],{},"No ",[30,4035,4036],{},"ignore_errors: true"," on infrastructure tasks. A failure should stop the play so the host stays in a state you can see in the report. If you need explicit recovery, that's what ",[30,4039,3600],{},"\u002F",[30,4042,4043],{},"rescue"," is for.",[3586,4046,4047,4050],{},[30,4048,4049],{},"become: true"," only where root is needed.",[3586,4052,4053,4056,4057,4059],{},[30,4054,4055],{},"serial"," and a deliberately chosen ",[30,4058,3670],{}," on anything that touches more than one host.",[892,4061,4062],{},"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 .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}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":28,"searchDepth":42,"depth":42,"links":4064},[4065,4066,4067,4068,4069,4070],{"id":2533,"depth":42,"text":2534},{"id":2667,"depth":42,"text":2668},{"id":2930,"depth":42,"text":2931},{"id":3692,"depth":42,"text":3693},{"id":3890,"depth":42,"text":3891},{"id":4013,"depth":42,"text":4014},"backend","2024-05-06",{},"\u002Farticles\u002Fansible-production",{"x":4076,"y":4077,"depth":4078,"size":911},0.36,0.8,0.9,[2510,2511],{"title":2524,"description":2530},"infra-automation","articles\u002Fansible-production",[4084,4085,4086,4087,4088,4089],"ansible","devops","infrastructure","automation","idempotency","configuration-management","v2.1.2","FSzLz6QxSf4zexS4kyvVEMuvpANmrl8kPbqCkwFq-cI",{"id":4093,"title":4094,"articleId":4095,"body":4096,"category":27,"codeLang":27,"date":5007,"deploys":36,"description":4100,"excerpt":910,"extension":911,"lang":910,"meta":5008,"navigation":51,"path":5009,"pos":5010,"readMin":67,"related":5013,"seo":5016,"service":5017,"stem":5018,"tags":5019,"updated":2519,"version":5025,"__hash__":5026},"articles\u002Farticles\u002Fbridge-pattern.md","The Bridge pattern: separating what you send from how you send it","bridge-pattern",{"type":8,"value":4097,"toc":5000},[4098,4101,4105,4108,4116,4119,4122,4126,4129,4309,4339,4342,4536,4539,4542,4571,4574,4690,4703,4707,4710,4773,4780,4789,4792,4808,4812,4815,4971,4981,4985,4998],[11,4099,4100],{},"Bridge splits a class hierarchy along two axes that change independently and connects them through composition. Textbooks usually show it with shapes and renderers, and GoF with windows and windowing systems. A notification system is closer to everyday work, since it has exactly two axes: what the message says and how it gets delivered.",[15,4102,4104],{"id":4103},"the-problem-m-n-classes","The problem: M × N classes",[11,4106,4107],{},"Take a system that sends four notification types (payment confirmation, low inventory alert, account suspension, weekly report) through three channels: email, SMS and Slack. If each combination is its own class, you end up with:",[23,4109,4114],{"className":4110,"code":4112,"language":4113},[4111],"language-text","PaymentConfirmationEmail     PaymentConfirmationSms     PaymentConfirmationSlack\nLowInventoryAlertEmail       LowInventoryAlertSms       LowInventoryAlertSlack\nAccountSuspendedEmail        AccountSuspendedSms        AccountSuspendedSlack\nWeeklyReportEmail            WeeklyReportSms            WeeklyReportSlack\n","text",[30,4115,4112],{"__ignoreMap":28},[11,4117,4118],{},"Twelve classes. Add a fourth channel and that's four more; a fifth notification type adds three. The count is M × N, and every class duplicates the content logic of its type as well as the delivery logic of its channel, so a change in how SMS messages get shortened has to be made in four places.",[11,4120,4121],{},"Each of those classes makes two decisions: what to say and how to deliver it. Bridge splits them into two hierarchies.",[15,4123,4125],{"id":4124},"channels-on-one-side-messages-on-the-other","Channels on one side, messages on the other",[11,4127,4128],{},"The implementor side describes how a message is delivered:",[23,4130,4132],{"className":25,"code":4131,"language":27,"meta":28,"style":28},"interface 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($recipient, $subject, $body);\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 has no subject line, so it goes into the message as bold text\n        $this->slack->postMessage($recipient, \"*{$subject}*\\n{$body}\");\n    }\n}\n\nfinal class SmsChannel implements NotificationChannel\n{\n    public function __construct(\n        private readonly SmsProvider $sms,\n        private readonly int $maxLength = 70,\n    ) {}\n\n    public function send(string $recipient, string $subject, string $body): void\n    {\n        $text = \"{$subject}: \" . strip_tags($body);\n        $this->sms->send($recipient, mb_substr($text, 0, $this->maxLength));\n    }\n}\n",[30,4133,4134,4139,4143,4148,4152,4156,4161,4165,4170,4174,4179,4184,4189,4194,4198,4202,4207,4211,4216,4220,4224,4228,4233,4238,4242,4246,4250,4255,4259,4264,4269,4274,4279,4283,4287,4291,4296,4301,4305],{"__ignoreMap":28},[33,4135,4136],{"class":35,"line":36},[33,4137,4138],{},"interface NotificationChannel\n",[33,4140,4141],{"class":35,"line":42},[33,4142,64],{},[33,4144,4145],{"class":35,"line":48},[33,4146,4147],{},"    public function send(string $recipient, string $subject, string $body): void;\n",[33,4149,4150],{"class":35,"line":55},[33,4151,82],{},[33,4153,4154],{"class":35,"line":61},[33,4155,52],{"emptyLinePlaceholder":51},[33,4157,4158],{"class":35,"line":67},[33,4159,4160],{},"final class EmailChannel implements NotificationChannel\n",[33,4162,4163],{"class":35,"line":73},[33,4164,64],{},[33,4166,4167],{"class":35,"line":79},[33,4168,4169],{},"    public function __construct(private readonly Mailer $mailer) {}\n",[33,4171,4172],{"class":35,"line":85},[33,4173,52],{"emptyLinePlaceholder":51},[33,4175,4176],{"class":35,"line":90},[33,4177,4178],{},"    public function send(string $recipient, string $subject, string $body): void\n",[33,4180,4181],{"class":35,"line":96},[33,4182,4183],{},"    {\n",[33,4185,4186],{"class":35,"line":384},[33,4187,4188],{},"        $this->mailer->send($recipient, $subject, $body);\n",[33,4190,4191],{"class":35,"line":390},[33,4192,4193],{},"    }\n",[33,4195,4196],{"class":35,"line":395},[33,4197,82],{},[33,4199,4200],{"class":35,"line":401},[33,4201,52],{"emptyLinePlaceholder":51},[33,4203,4204],{"class":35,"line":407},[33,4205,4206],{},"final class SlackChannel implements NotificationChannel\n",[33,4208,4209],{"class":35,"line":413},[33,4210,64],{},[33,4212,4213],{"class":35,"line":419},[33,4214,4215],{},"    public function __construct(private readonly SlackClient $slack) {}\n",[33,4217,4218],{"class":35,"line":1806},[33,4219,52],{"emptyLinePlaceholder":51},[33,4221,4222],{"class":35,"line":1812},[33,4223,4178],{},[33,4225,4226],{"class":35,"line":1817},[33,4227,4183],{},[33,4229,4230],{"class":35,"line":1822},[33,4231,4232],{},"        \u002F\u002F Slack has no subject line, so it goes into the message as bold text\n",[33,4234,4235],{"class":35,"line":1828},[33,4236,4237],{},"        $this->slack->postMessage($recipient, \"*{$subject}*\\n{$body}\");\n",[33,4239,4240],{"class":35,"line":1834},[33,4241,4193],{},[33,4243,4244],{"class":35,"line":1840},[33,4245,82],{},[33,4247,4248],{"class":35,"line":1846},[33,4249,52],{"emptyLinePlaceholder":51},[33,4251,4252],{"class":35,"line":1851},[33,4253,4254],{},"final class SmsChannel implements NotificationChannel\n",[33,4256,4257],{"class":35,"line":1856},[33,4258,64],{},[33,4260,4261],{"class":35,"line":1862},[33,4262,4263],{},"    public function __construct(\n",[33,4265,4266],{"class":35,"line":1868},[33,4267,4268],{},"        private readonly SmsProvider $sms,\n",[33,4270,4271],{"class":35,"line":1874},[33,4272,4273],{},"        private readonly int $maxLength = 70,\n",[33,4275,4276],{"class":35,"line":1880},[33,4277,4278],{},"    ) {}\n",[33,4280,4281],{"class":35,"line":1886},[33,4282,52],{"emptyLinePlaceholder":51},[33,4284,4285],{"class":35,"line":1891},[33,4286,4178],{},[33,4288,4289],{"class":35,"line":1897},[33,4290,4183],{},[33,4292,4293],{"class":35,"line":1903},[33,4294,4295],{},"        $text = \"{$subject}: \" . strip_tags($body);\n",[33,4297,4298],{"class":35,"line":1909},[33,4299,4300],{},"        $this->sms->send($recipient, mb_substr($text, 0, $this->maxLength));\n",[33,4302,4303],{"class":35,"line":1915},[33,4304,4193],{},[33,4306,4307],{"class":35,"line":3270},[33,4308,82],{},[11,4310,4311,739,4314,1930,4317,4320,4321,4324,4325,4328,4329,4331,4332,2582,4335,4338],{},[30,4312,4313],{},"Mailer",[30,4315,4316],{},"SlackClient",[30,4318,4319],{},"SmsProvider"," stand for whatever clients the application already has. The SMS limit is a parameter on purpose. One segment holds 160 characters in the GSM-7 alphabet, but a single character outside it, Polish diacritics for instance, switches the whole message to UCS-2, where a segment holds only 70. That's also why it's ",[30,4322,4323],{},"mb_substr",": plain ",[30,4326,4327],{},"substr"," counts bytes and can cut a multibyte character in half. ",[30,4330,4323],{}," counts characters while UCS-2 counts 16-bit units, so an emoji takes two of the 70. Characters from the GSM-7 extension table, like ",[30,4333,4334],{},"€",[30,4336,4337],{},"[",", count double as well.",[11,4340,4341],{},"The abstraction side decides what to send and hands delivery over to a channel:",[23,4343,4345],{"className":25,"code":4344,"language":27,"meta":28,"style":28},"final readonly class Payment\n{\n    public function __construct(\n        public string $reference,\n        public int $amountMinor,\n        public string $currency,\n        public DateTimeImmutable $paidAt,\n    ) {}\n}\n\nabstract class Notification\n{\n    public function __construct(protected readonly NotificationChannel $channel) {}\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,\n            \"Payment confirmed: {$this->payment->reference}\",\n            sprintf(\n                \"Your payment of %s %s has been confirmed.\\nReference: %s\\nDate: %s\",\n                number_format($this->payment->amountMinor \u002F 100, 2),\n                $this->payment->currency,\n                $this->payment->reference,\n                $this->payment->paidAt->format('Y-m-d H:i'),\n            ),\n        );\n    }\n}\n",[30,4346,4347,4352,4356,4360,4365,4370,4375,4380,4384,4388,4392,4397,4401,4406,4410,4415,4419,4423,4428,4432,4436,4441,4446,4451,4456,4460,4464,4469,4473,4478,4483,4488,4493,4498,4503,4508,4513,4518,4523,4528,4532],{"__ignoreMap":28},[33,4348,4349],{"class":35,"line":36},[33,4350,4351],{},"final readonly class Payment\n",[33,4353,4354],{"class":35,"line":42},[33,4355,64],{},[33,4357,4358],{"class":35,"line":48},[33,4359,4263],{},[33,4361,4362],{"class":35,"line":55},[33,4363,4364],{},"        public string $reference,\n",[33,4366,4367],{"class":35,"line":61},[33,4368,4369],{},"        public int $amountMinor,\n",[33,4371,4372],{"class":35,"line":67},[33,4373,4374],{},"        public string $currency,\n",[33,4376,4377],{"class":35,"line":73},[33,4378,4379],{},"        public DateTimeImmutable $paidAt,\n",[33,4381,4382],{"class":35,"line":79},[33,4383,4278],{},[33,4385,4386],{"class":35,"line":85},[33,4387,82],{},[33,4389,4390],{"class":35,"line":90},[33,4391,52],{"emptyLinePlaceholder":51},[33,4393,4394],{"class":35,"line":96},[33,4395,4396],{},"abstract class Notification\n",[33,4398,4399],{"class":35,"line":384},[33,4400,64],{},[33,4402,4403],{"class":35,"line":390},[33,4404,4405],{},"    public function __construct(protected readonly NotificationChannel $channel) {}\n",[33,4407,4408],{"class":35,"line":395},[33,4409,52],{"emptyLinePlaceholder":51},[33,4411,4412],{"class":35,"line":401},[33,4413,4414],{},"    abstract public function send(string $recipient): void;\n",[33,4416,4417],{"class":35,"line":407},[33,4418,82],{},[33,4420,4421],{"class":35,"line":413},[33,4422,52],{"emptyLinePlaceholder":51},[33,4424,4425],{"class":35,"line":419},[33,4426,4427],{},"final class PaymentConfirmationNotification extends Notification\n",[33,4429,4430],{"class":35,"line":1806},[33,4431,64],{},[33,4433,4434],{"class":35,"line":1812},[33,4435,4263],{},[33,4437,4438],{"class":35,"line":1817},[33,4439,4440],{},"        NotificationChannel $channel,\n",[33,4442,4443],{"class":35,"line":1822},[33,4444,4445],{},"        private readonly Payment $payment,\n",[33,4447,4448],{"class":35,"line":1828},[33,4449,4450],{},"    ) {\n",[33,4452,4453],{"class":35,"line":1834},[33,4454,4455],{},"        parent::__construct($channel);\n",[33,4457,4458],{"class":35,"line":1840},[33,4459,4193],{},[33,4461,4462],{"class":35,"line":1846},[33,4463,52],{"emptyLinePlaceholder":51},[33,4465,4466],{"class":35,"line":1851},[33,4467,4468],{},"    public function send(string $recipient): void\n",[33,4470,4471],{"class":35,"line":1856},[33,4472,4183],{},[33,4474,4475],{"class":35,"line":1862},[33,4476,4477],{},"        $this->channel->send(\n",[33,4479,4480],{"class":35,"line":1868},[33,4481,4482],{},"            $recipient,\n",[33,4484,4485],{"class":35,"line":1874},[33,4486,4487],{},"            \"Payment confirmed: {$this->payment->reference}\",\n",[33,4489,4490],{"class":35,"line":1880},[33,4491,4492],{},"            sprintf(\n",[33,4494,4495],{"class":35,"line":1886},[33,4496,4497],{},"                \"Your payment of %s %s has been confirmed.\\nReference: %s\\nDate: %s\",\n",[33,4499,4500],{"class":35,"line":1891},[33,4501,4502],{},"                number_format($this->payment->amountMinor \u002F 100, 2),\n",[33,4504,4505],{"class":35,"line":1897},[33,4506,4507],{},"                $this->payment->currency,\n",[33,4509,4510],{"class":35,"line":1903},[33,4511,4512],{},"                $this->payment->reference,\n",[33,4514,4515],{"class":35,"line":1909},[33,4516,4517],{},"                $this->payment->paidAt->format('Y-m-d H:i'),\n",[33,4519,4520],{"class":35,"line":1915},[33,4521,4522],{},"            ),\n",[33,4524,4525],{"class":35,"line":3270},[33,4526,4527],{},"        );\n",[33,4529,4530],{"class":35,"line":3282},[33,4531,4193],{},[33,4533,4534],{"class":35,"line":3290},[33,4535,82],{},[11,4537,4538],{},"Now it's M + N, four notification classes and three channel classes. A new channel is one class. So is a new notification type, and neither of them touches the other hierarchy.",[11,4540,4541],{},"The combination of channel and notification is chosen at construction time:",[23,4543,4545],{"className":25,"code":4544,"language":27,"meta":28,"style":28},"(new PaymentConfirmationNotification(new EmailChannel($mailer), $payment))\n    ->send($user->email);\n\n(new PaymentConfirmationNotification(new SmsChannel($smsProvider), $payment))\n    ->send($user->phone);\n",[30,4546,4547,4552,4557,4561,4566],{"__ignoreMap":28},[33,4548,4549],{"class":35,"line":36},[33,4550,4551],{},"(new PaymentConfirmationNotification(new EmailChannel($mailer), $payment))\n",[33,4553,4554],{"class":35,"line":42},[33,4555,4556],{},"    ->send($user->email);\n",[33,4558,4559],{"class":35,"line":48},[33,4560,52],{"emptyLinePlaceholder":51},[33,4562,4563],{"class":35,"line":55},[33,4564,4565],{},"(new PaymentConfirmationNotification(new SmsChannel($smsProvider), $payment))\n",[33,4567,4568],{"class":35,"line":61},[33,4569,4570],{},"    ->send($user->phone);\n",[11,4572,4573],{},"In an application this should live in one place, usually a dispatcher that knows the available channels and how to build each notification type:",[23,4575,4577],{"className":25,"code":4576,"language":27,"meta":28,"style":28},"final class NotificationDispatcher\n{\n    \u002F**\n     * @param array\u003Cstring, NotificationChannel> $channels\n     * @param array\u003Cstring, Closure(NotificationChannel, array): Notification> $factories\n     *\u002F\n    public function __construct(\n        private readonly array $channels,\n        private readonly array $factories,\n    ) {}\n\n    public function dispatch(string $type, array $payload, User $user): void\n    {\n        $factory = $this->factories[$type]\n            ?? throw new InvalidArgumentException(\"Unknown notification type: {$type}\");\n\n        foreach ($user->enabledChannels() as $name) {\n            $channel = $this->channels[$name]\n                ?? throw new InvalidArgumentException(\"Unknown channel: {$name}\");\n\n            $factory($channel, $payload)->send($user->contactFor($name));\n        }\n    }\n}\n",[30,4578,4579,4584,4588,4593,4598,4603,4608,4612,4617,4622,4626,4630,4635,4639,4644,4649,4653,4658,4663,4668,4672,4677,4682,4686],{"__ignoreMap":28},[33,4580,4581],{"class":35,"line":36},[33,4582,4583],{},"final class NotificationDispatcher\n",[33,4585,4586],{"class":35,"line":42},[33,4587,64],{},[33,4589,4590],{"class":35,"line":48},[33,4591,4592],{},"    \u002F**\n",[33,4594,4595],{"class":35,"line":55},[33,4596,4597],{},"     * @param array\u003Cstring, NotificationChannel> $channels\n",[33,4599,4600],{"class":35,"line":61},[33,4601,4602],{},"     * @param array\u003Cstring, Closure(NotificationChannel, array): Notification> $factories\n",[33,4604,4605],{"class":35,"line":67},[33,4606,4607],{},"     *\u002F\n",[33,4609,4610],{"class":35,"line":73},[33,4611,4263],{},[33,4613,4614],{"class":35,"line":79},[33,4615,4616],{},"        private readonly array $channels,\n",[33,4618,4619],{"class":35,"line":85},[33,4620,4621],{},"        private readonly array $factories,\n",[33,4623,4624],{"class":35,"line":90},[33,4625,4278],{},[33,4627,4628],{"class":35,"line":96},[33,4629,52],{"emptyLinePlaceholder":51},[33,4631,4632],{"class":35,"line":384},[33,4633,4634],{},"    public function dispatch(string $type, array $payload, User $user): void\n",[33,4636,4637],{"class":35,"line":390},[33,4638,4183],{},[33,4640,4641],{"class":35,"line":395},[33,4642,4643],{},"        $factory = $this->factories[$type]\n",[33,4645,4646],{"class":35,"line":401},[33,4647,4648],{},"            ?? throw new InvalidArgumentException(\"Unknown notification type: {$type}\");\n",[33,4650,4651],{"class":35,"line":407},[33,4652,52],{"emptyLinePlaceholder":51},[33,4654,4655],{"class":35,"line":413},[33,4656,4657],{},"        foreach ($user->enabledChannels() as $name) {\n",[33,4659,4660],{"class":35,"line":419},[33,4661,4662],{},"            $channel = $this->channels[$name]\n",[33,4664,4665],{"class":35,"line":1806},[33,4666,4667],{},"                ?? throw new InvalidArgumentException(\"Unknown channel: {$name}\");\n",[33,4669,4670],{"class":35,"line":1812},[33,4671,52],{"emptyLinePlaceholder":51},[33,4673,4674],{"class":35,"line":1817},[33,4675,4676],{},"            $factory($channel, $payload)->send($user->contactFor($name));\n",[33,4678,4679],{"class":35,"line":1822},[33,4680,4681],{},"        }\n",[33,4683,4684],{"class":35,"line":1828},[33,4685,4193],{},[33,4687,4688],{"class":35,"line":1834},[33,4689,82],{},[11,4691,4692,1930,4695,4698,4699,4702],{},[30,4693,4694],{},"enabledChannels()",[30,4696,4697],{},"contactFor()"," are methods on your own ",[30,4700,4701],{},"User"," model. The dispatcher is the only class that knows both hierarchies.",[15,4704,4706],{"id":4705},"when-the-dimensions-arent-independent","When the dimensions aren't independent",[11,4708,4709],{},"Bridge assumes every notification makes sense on every channel. That breaks as soon as some type has content that fits only one channel, like a weekly report rendered as an HTML table. You'll recognize it by a type check in the abstraction:",[23,4711,4713],{"className":25,"code":4712,"language":27,"meta":28,"style":28},"final class WeeklyReportNotification extends Notification\n{\n    public function send(string $recipient): void\n    {\n        if ($this->channel instanceof SmsChannel) {\n            $this->channel->send($recipient, 'Weekly report', 'The full report was sent by email.');\n            return;\n        }\n\n        \u002F\u002F renderHtmlReport() omitted from the example\n        $this->channel->send($recipient, 'Weekly report', $this->renderHtmlReport());\n    }\n}\n",[30,4714,4715,4720,4724,4728,4732,4737,4742,4747,4751,4755,4760,4765,4769],{"__ignoreMap":28},[33,4716,4717],{"class":35,"line":36},[33,4718,4719],{},"final class WeeklyReportNotification extends Notification\n",[33,4721,4722],{"class":35,"line":42},[33,4723,64],{},[33,4725,4726],{"class":35,"line":48},[33,4727,4468],{},[33,4729,4730],{"class":35,"line":55},[33,4731,4183],{},[33,4733,4734],{"class":35,"line":61},[33,4735,4736],{},"        if ($this->channel instanceof SmsChannel) {\n",[33,4738,4739],{"class":35,"line":67},[33,4740,4741],{},"            $this->channel->send($recipient, 'Weekly report', 'The full report was sent by email.');\n",[33,4743,4744],{"class":35,"line":73},[33,4745,4746],{},"            return;\n",[33,4748,4749],{"class":35,"line":79},[33,4750,4681],{},[33,4752,4753],{"class":35,"line":85},[33,4754,52],{"emptyLinePlaceholder":51},[33,4756,4757],{"class":35,"line":90},[33,4758,4759],{},"        \u002F\u002F renderHtmlReport() omitted from the example\n",[33,4761,4762],{"class":35,"line":96},[33,4763,4764],{},"        $this->channel->send($recipient, 'Weekly report', $this->renderHtmlReport());\n",[33,4766,4767],{"class":35,"line":384},[33,4768,4193],{},[33,4770,4771],{"class":35,"line":390},[33,4772,82],{},[11,4774,4775,4776,4779],{},"With that ",[30,4777,4778],{},"instanceof"," the notification depends on a concrete channel, which is exactly what the pattern was supposed to prevent. From now on every new channel means going through all notifications to look for branches like this one.",[11,4781,4782,4783,1930,4786,221],{},"If it's one type, the simplest thing is to admit this case is M × N and write separate classes, ",[30,4784,4785],{},"WeeklyReportEmailNotification",[30,4787,4788],{},"WeeklyReportSmsSummaryNotification",[11,4790,4791],{},"When many types need channel-specific variants, it's the contract that should change. The notification builds a message object with several representations (full body, short text) and each channel picks the one it can deliver.",[11,4793,4794,4795,4798,4799,739,4802,4805,4806,221],{},"Laravel's notification system sits somewhere in between. A notification class declares its channels in ",[30,4796,4797],{},"via()"," and implements a method per channel (",[30,4800,4801],{},"toMail()",[30,4803,4804],{},"toArray()"," and so on), which gives up to M × N methods inside M classes. In exchange you write the content for each channel separately and never need ",[30,4807,4778],{},[15,4809,4811],{"id":4810},"testing","Testing",[11,4813,4814],{},"Each hierarchy can be tested without the other. You test a channel against a mocked client and a notification against a mocked channel:",[23,4816,4818],{"className":25,"code":4817,"language":27,"meta":28,"style":28},"use PHPUnit\\Framework\\TestCase;\n\nfinal class EmailChannelTest extends TestCase\n{\n    public function testDelegatesToMailer(): void\n    {\n        $mailer = $this->createMock(Mailer::class);\n        $mailer->expects($this->once())\n            ->method('send')\n            ->with('ewa.lis@example.test', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n\n        (new EmailChannel($mailer))->send('ewa.lis@example.test', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n    }\n}\n\nfinal class PaymentConfirmationNotificationTest extends TestCase\n{\n    public function testBuildsSubjectAndBody(): void\n    {\n        $channel = $this->createMock(NotificationChannel::class);\n        $channel->expects($this->once())\n            ->method('send')\n            ->with(\n                'ewa.lis@example.test',\n                $this->stringContains('PAY-2024-001'),\n                $this->stringContains('100.00'),\n            );\n\n        $payment = new Payment('PAY-2024-001', 10000, 'PLN', new DateTimeImmutable('2024-01-15 10:00'));\n\n        (new PaymentConfirmationNotification($channel, $payment))->send('ewa.lis@example.test');\n    }\n}\n",[30,4819,4820,4825,4829,4834,4838,4843,4847,4852,4857,4862,4867,4871,4876,4880,4884,4888,4893,4897,4902,4906,4911,4916,4920,4925,4930,4935,4940,4945,4949,4954,4958,4963,4967],{"__ignoreMap":28},[33,4821,4822],{"class":35,"line":36},[33,4823,4824],{},"use PHPUnit\\Framework\\TestCase;\n",[33,4826,4827],{"class":35,"line":42},[33,4828,52],{"emptyLinePlaceholder":51},[33,4830,4831],{"class":35,"line":48},[33,4832,4833],{},"final class EmailChannelTest extends TestCase\n",[33,4835,4836],{"class":35,"line":55},[33,4837,64],{},[33,4839,4840],{"class":35,"line":61},[33,4841,4842],{},"    public function testDelegatesToMailer(): void\n",[33,4844,4845],{"class":35,"line":67},[33,4846,4183],{},[33,4848,4849],{"class":35,"line":73},[33,4850,4851],{},"        $mailer = $this->createMock(Mailer::class);\n",[33,4853,4854],{"class":35,"line":79},[33,4855,4856],{},"        $mailer->expects($this->once())\n",[33,4858,4859],{"class":35,"line":85},[33,4860,4861],{},"            ->method('send')\n",[33,4863,4864],{"class":35,"line":90},[33,4865,4866],{},"            ->with('ewa.lis@example.test', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n",[33,4868,4869],{"class":35,"line":96},[33,4870,52],{"emptyLinePlaceholder":51},[33,4872,4873],{"class":35,"line":384},[33,4874,4875],{},"        (new EmailChannel($mailer))->send('ewa.lis@example.test', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n",[33,4877,4878],{"class":35,"line":390},[33,4879,4193],{},[33,4881,4882],{"class":35,"line":395},[33,4883,82],{},[33,4885,4886],{"class":35,"line":401},[33,4887,52],{"emptyLinePlaceholder":51},[33,4889,4890],{"class":35,"line":407},[33,4891,4892],{},"final class PaymentConfirmationNotificationTest extends TestCase\n",[33,4894,4895],{"class":35,"line":413},[33,4896,64],{},[33,4898,4899],{"class":35,"line":419},[33,4900,4901],{},"    public function testBuildsSubjectAndBody(): void\n",[33,4903,4904],{"class":35,"line":1806},[33,4905,4183],{},[33,4907,4908],{"class":35,"line":1812},[33,4909,4910],{},"        $channel = $this->createMock(NotificationChannel::class);\n",[33,4912,4913],{"class":35,"line":1817},[33,4914,4915],{},"        $channel->expects($this->once())\n",[33,4917,4918],{"class":35,"line":1822},[33,4919,4861],{},[33,4921,4922],{"class":35,"line":1828},[33,4923,4924],{},"            ->with(\n",[33,4926,4927],{"class":35,"line":1834},[33,4928,4929],{},"                'ewa.lis@example.test',\n",[33,4931,4932],{"class":35,"line":1840},[33,4933,4934],{},"                $this->stringContains('PAY-2024-001'),\n",[33,4936,4937],{"class":35,"line":1846},[33,4938,4939],{},"                $this->stringContains('100.00'),\n",[33,4941,4942],{"class":35,"line":1851},[33,4943,4944],{},"            );\n",[33,4946,4947],{"class":35,"line":1856},[33,4948,52],{"emptyLinePlaceholder":51},[33,4950,4951],{"class":35,"line":1862},[33,4952,4953],{},"        $payment = new Payment('PAY-2024-001', 10000, 'PLN', new DateTimeImmutable('2024-01-15 10:00'));\n",[33,4955,4956],{"class":35,"line":1868},[33,4957,52],{"emptyLinePlaceholder":51},[33,4959,4960],{"class":35,"line":1874},[33,4961,4962],{},"        (new PaymentConfirmationNotification($channel, $payment))->send('ewa.lis@example.test');\n",[33,4964,4965],{"class":35,"line":1880},[33,4966,4193],{},[33,4968,4969],{"class":35,"line":1886},[33,4970,82],{},[11,4972,4973,4974,4977,4978,4980],{},"There are as many tests as classes, and none of them needs a real mail server or the Slack API. In a project these are two files, because PHPUnit looks for a class named after the file. ",[30,4975,4976],{},"createMock(Mailer::class)"," works if ",[30,4979,4313],{}," is an interface or a non-final class.",[15,4982,4984],{"id":4983},"is-it-worth-it-here","Is it worth it here",[11,4986,4987,4988,739,4991,739,4994,4997],{},"Class names are usually the first hint. ",[30,4989,4990],{},"PaymentEmailNotification",[30,4992,4993],{},"PdfInvoiceExporter",[30,4995,4996],{},"CsvAuditLogFormatter",": each glues two concepts together. If both keep changing over time (new channels and new message types, new export formats and new report types) and almost every combination is valid, Bridge pays off. With only one dimension varying it doesn't. A single channel needs nothing more than an interface on the notification side. If many combinations need special handling, the problem is M × N, and explicit classes or per-channel methods describe it more honestly. And with two or three combinations in total, the extra indirection costs more than the duplication it removes.",[892,4999,894],{},{"title":28,"searchDepth":42,"depth":42,"links":5001},[5002,5003,5004,5005,5006],{"id":4103,"depth":42,"text":4104},{"id":4124,"depth":42,"text":4125},{"id":4705,"depth":42,"text":4706},{"id":4810,"depth":42,"text":4811},{"id":4983,"depth":42,"text":4984},"2023-10-19",{},"\u002Farticles\u002Fbridge-pattern",{"x":5011,"y":5012,"depth":4078,"size":911},0.86,0.18,[5014,5015],"factory-method","design-patterns-production",{"title":4094,"description":4100},"notification-hub","articles\u002Fbridge-pattern",[27,5020,5021,5022,5023,5024],"design-patterns","bridge","architecture","notifications","abstraction","v2.1.0","BvSDhFYGXTgHMb6yM6n6KOhOss6jaJGa-lLyhu7Nzm8",{"id":5028,"title":5029,"articleId":5030,"body":5031,"category":4071,"codeLang":2900,"date":5543,"deploys":36,"description":5035,"excerpt":910,"extension":911,"lang":910,"meta":5544,"navigation":51,"path":5545,"pos":5546,"readMin":73,"related":5549,"seo":5551,"service":5552,"stem":5553,"tags":5554,"updated":910,"version":5558,"__hash__":5559},"articles\u002Farticles\u002Fcdn-cached-fallback.md","Cloudflare cached HTML instead of CSS: notes from moving a static site","cdn-cached-fallback",{"type":8,"value":5032,"toc":5536},[5033,5036,5040,5047,5079,5082,5088,5103,5140,5144,5147,5150,5164,5167,5174,5178,5185,5282,5289,5296,5331,5345,5348,5352,5370,5377,5395,5408,5418,5438,5441,5513,5516,5520,5533],[11,5034,5035],{},"This blog is a static Nuxt site on shared hosting, behind Cloudflare. Today I moved it to a new directory on the same account: the built site is uploaded by a script over FTPS, and the domain is switched to the new directory in the control panel. After the switch the home page rendered as white text with no styles, while every check from the terminal said everything was fine.",[15,5037,5039],{"id":5038},"symptom-curl-says-200-the-browser-does-not-load-css","Symptom: curl says 200, the browser does not load CSS",[11,5041,5042,5043,5046],{},"The first check looked good. The HTML referenced ",[30,5044,5045],{},"\u002F_nuxt\u002Fentry.Czy_6vul.css"," and that file answered correctly:",[23,5048,5050],{"className":2898,"code":5049,"language":2900,"meta":28,"style":28},"curl -s -o \u002Fdev\u002Fnull -w '%{http_code} %{content_type}\\n' https:\u002F\u002Ftkulesza.eu\u002F_nuxt\u002Fentry.Czy_6vul.css\n# 200 text\u002Fcss\n",[30,5051,5052,5074],{"__ignoreMap":28},[33,5053,5054,5056,5059,5062,5065,5068,5071],{"class":35,"line":36},[33,5055,272],{"class":2907},[33,5057,5058],{"class":2728}," -s",[33,5060,5061],{"class":2728}," -o",[33,5063,5064],{"class":2609}," \u002Fdev\u002Fnull",[33,5066,5067],{"class":2728}," -w",[33,5069,5070],{"class":2609}," '%{http_code} %{content_type}\\n'",[33,5072,5073],{"class":2609}," https:\u002F\u002Ftkulesza.eu\u002F_nuxt\u002Fentry.Czy_6vul.css\n",[33,5075,5076],{"class":35,"line":42},[33,5077,5078],{"class":3710},"# 200 text\u002Fcss\n",[11,5080,5081],{},"A headless browser (puppeteer) showed what was actually happening. The console had this error:",[23,5083,5086],{"className":5084,"code":5085,"language":4113,"meta":28},[4111],"Failed to load module script: Expected a JavaScript-or-Wasm module script\nbut the server responded with a MIME type of \"text\u002Fhtml\".\n",[30,5087,5085],{"__ignoreMap":28},[11,5089,5090,5091,5094,5095,5098,5099,5102],{},"After adding a listener that logs ",[30,5092,5093],{},"_nuxt"," responses whose ",[30,5096,5097],{},"Content-Type"," contains ",[30,5100,5101],{},"html",", it turned out that the same CSS URL and several JS modules reached the browser as HTML, with status 200, so the status code alone would have shown nothing:",[23,5104,5108],{"className":5105,"code":5106,"language":5107,"meta":28,"style":28},"language-javascript shiki shiki-themes github-light github-dark","page.on('response', r => {\n  const ct = r.headers()['content-type'] || ''\n  if (r.url().includes('\u002F_nuxt\u002F') && ct.includes('html')) {\n    console.log('BAD', r.status(), ct, r.url())\n  }\n})\n","javascript",[30,5109,5110,5115,5120,5125,5130,5135],{"__ignoreMap":28},[33,5111,5112],{"class":35,"line":36},[33,5113,5114],{},"page.on('response', r => {\n",[33,5116,5117],{"class":35,"line":42},[33,5118,5119],{},"  const ct = r.headers()['content-type'] || ''\n",[33,5121,5122],{"class":35,"line":48},[33,5123,5124],{},"  if (r.url().includes('\u002F_nuxt\u002F') && ct.includes('html')) {\n",[33,5126,5127],{"class":35,"line":55},[33,5128,5129],{},"    console.log('BAD', r.status(), ct, r.url())\n",[33,5131,5132],{"class":35,"line":61},[33,5133,5134],{},"  }\n",[33,5136,5137],{"class":35,"line":67},[33,5138,5139],{},"})\n",[15,5141,5143],{"id":5142},"cause-a-200-fallback-and-caching-by-extension","Cause: a 200 fallback and caching by extension",[11,5145,5146],{},"Two things combined.",[11,5148,5149],{},"The first is the server. On this host a request for a file that does not exist did not end in a 404. It returned the home page with status 200. The setting comes from configuration outside my directory, so I had not seen it before. In a single-page app that can be intentional. Here it hurt, because every page has its own file and a missing file should be a 404.",[11,5151,5152,5153,739,5156,5159,5160,5163],{},"The second is Cloudflare. By default it caches responses based on the extension in the URL (",[30,5154,5155],{},".css",[30,5157,5158],{},".js",", images), not on the content type the origin returned. If the origin answers ",[30,5161,5162],{},"entry.Czy_6vul.css"," with HTML and status 200, and its headers don't forbid caching, Cloudflare stores that HTML under that URL and keeps serving it.",[11,5165,5166],{},"During the move there was a window in which the new HTML with new file names was already being served, while requests for those files still reached the old directory. The old directory did not have them, so it answered with the home page, and Cloudflare stored it. Hashed file names make this worse: the content does not change, so the name does not change, and the bad response stays in cache for as long as the TTL allows.",[11,5168,5169,5170,5173],{},"Why did curl get the correct file? The browser and curl sent different headers and most likely hit different cache entries. Both responses had ",[30,5171,5172],{},"cf-cache-status: HIT",". I did not dig into which header separates the entries, because purging the cache fixed the problem. The practical conclusion is simpler: a curl check does not replace a browser check.",[15,5175,5177],{"id":5176},"fix","Fix",[11,5179,5180,5181,5184],{},"The immediate fix was to purge the cache in the Cloudflare dashboard (Caching, Configuration, Purge Everything). To keep it from coming back on the next deploy, the site now ships its own ",[30,5182,5183],{},".htaccess",", in which a missing file ends in a 404:",[23,5186,5190],{"className":5187,"code":5188,"language":5189,"meta":28,"style":28},"language-apache shiki shiki-themes github-light github-dark","Options -Indexes\nDirectorySlash Off\nDirectoryIndex index.html\nErrorDocument 404 \u002F404.html\n\nRewriteEngine On\n\n# \u002Fx\u002F → \u002Fx when a prerendered page exists\nRewriteCond %{DOCUMENT_ROOT}\u002F$1\u002Findex.html -f\nRewriteRule ^(.+)\u002F$ https:\u002F\u002Ftkulesza.eu\u002F$1 [R=301,L,NE]\n\n# \u002Fx → \u002Fx\u002Findex.html without a redirect\nRewriteCond %{DOCUMENT_ROOT}\u002F$1\u002Findex.html -f\nRewriteRule ^(.+)$ $1\u002Findex.html [L]\n\n# Anything else that is not on disk → 404\nRewriteCond %{REQUEST_FILENAME} !-f\nRewriteCond %{REQUEST_FILENAME} !-d\nRewriteRule ^ - [R=404,L]\n","apache",[30,5191,5192,5197,5202,5207,5212,5216,5221,5225,5230,5235,5240,5244,5249,5253,5258,5262,5267,5272,5277],{"__ignoreMap":28},[33,5193,5194],{"class":35,"line":36},[33,5195,5196],{},"Options -Indexes\n",[33,5198,5199],{"class":35,"line":42},[33,5200,5201],{},"DirectorySlash Off\n",[33,5203,5204],{"class":35,"line":48},[33,5205,5206],{},"DirectoryIndex index.html\n",[33,5208,5209],{"class":35,"line":55},[33,5210,5211],{},"ErrorDocument 404 \u002F404.html\n",[33,5213,5214],{"class":35,"line":61},[33,5215,52],{"emptyLinePlaceholder":51},[33,5217,5218],{"class":35,"line":67},[33,5219,5220],{},"RewriteEngine On\n",[33,5222,5223],{"class":35,"line":73},[33,5224,52],{"emptyLinePlaceholder":51},[33,5226,5227],{"class":35,"line":79},[33,5228,5229],{},"# \u002Fx\u002F → \u002Fx when a prerendered page exists\n",[33,5231,5232],{"class":35,"line":85},[33,5233,5234],{},"RewriteCond %{DOCUMENT_ROOT}\u002F$1\u002Findex.html -f\n",[33,5236,5237],{"class":35,"line":90},[33,5238,5239],{},"RewriteRule ^(.+)\u002F$ https:\u002F\u002Ftkulesza.eu\u002F$1 [R=301,L,NE]\n",[33,5241,5242],{"class":35,"line":96},[33,5243,52],{"emptyLinePlaceholder":51},[33,5245,5246],{"class":35,"line":384},[33,5247,5248],{},"# \u002Fx → \u002Fx\u002Findex.html without a redirect\n",[33,5250,5251],{"class":35,"line":390},[33,5252,5234],{},[33,5254,5255],{"class":35,"line":395},[33,5256,5257],{},"RewriteRule ^(.+)$ $1\u002Findex.html [L]\n",[33,5259,5260],{"class":35,"line":401},[33,5261,52],{"emptyLinePlaceholder":51},[33,5263,5264],{"class":35,"line":407},[33,5265,5266],{},"# Anything else that is not on disk → 404\n",[33,5268,5269],{"class":35,"line":413},[33,5270,5271],{},"RewriteCond %{REQUEST_FILENAME} !-f\n",[33,5273,5274],{"class":35,"line":419},[33,5275,5276],{},"RewriteCond %{REQUEST_FILENAME} !-d\n",[33,5278,5279],{"class":35,"line":1806},[33,5280,5281],{},"RewriteRule ^ - [R=404,L]\n",[11,5283,5284,5285,5288],{},"I first tried ",[30,5286,5287],{},"FallbackResource disabled",", assuming the fallback came from that directive in a parent directory. It changed nothing, so the mechanism is something else. An explicit 404 rule at the end did the job, even though I still don't know what the host had configured.",[11,5290,5291,5292,5295],{},"Hashed files also got a header that lets browsers and the CDN keep them for a long time. The exception is ",[30,5293,5294],{},"_nuxt\u002Fbuilds\u002F",", where Nuxt keeps a file with the current build id:",[23,5297,5299],{"className":5187,"code":5298,"language":5189,"meta":28,"style":28},"\u003CIf \"%{REQUEST_URI} =~ m#^\u002F_nuxt\u002F# && %{REQUEST_URI} !~ m#^\u002F_nuxt\u002Fbuilds\u002F# && -f %{REQUEST_FILENAME}\">\n  Header always set Cache-Control \"public, max-age=31536000, immutable\"\n\u003C\u002FIf>\n\u003CElse>\n  Header always set Cache-Control \"no-cache\"\n\u003C\u002FElse>\n",[30,5300,5301,5306,5311,5316,5321,5326],{"__ignoreMap":28},[33,5302,5303],{"class":35,"line":36},[33,5304,5305],{},"\u003CIf \"%{REQUEST_URI} =~ m#^\u002F_nuxt\u002F# && %{REQUEST_URI} !~ m#^\u002F_nuxt\u002Fbuilds\u002F# && -f %{REQUEST_FILENAME}\">\n",[33,5307,5308],{"class":35,"line":42},[33,5309,5310],{},"  Header always set Cache-Control \"public, max-age=31536000, immutable\"\n",[33,5312,5313],{"class":35,"line":48},[33,5314,5315],{},"\u003C\u002FIf>\n",[33,5317,5318],{"class":35,"line":55},[33,5319,5320],{},"\u003CElse>\n",[33,5322,5323],{"class":35,"line":61},[33,5324,5325],{},"  Header always set Cache-Control \"no-cache\"\n",[33,5327,5328],{"class":35,"line":67},[33,5329,5330],{},"\u003C\u002FElse>\n",[11,5332,5333,5334,5337,5338,5341,5342,5344],{},"The ",[30,5335,5336],{},"-f"," condition matters. ",[30,5339,5340],{},"Header always"," adds the header to error responses too, so without it a missing file under ",[30,5343,5093],{}," could get a 404 with a one-year cache. Only files that exist on disk get the long cache.",[11,5346,5347],{},"Upload order matters too. The deploy script now uploads the hashed files first and the HTML that references them second, so during the upload the HTML never points at files that aren't there yet.",[15,5349,5351],{"id":5350},"second-issue-allowing-traffic-only-through-cloudflare","Second issue: allowing traffic only through Cloudflare",[11,5353,5354,5355,5358,5359,5366,5367,221],{},"While at it, I closed direct access to the origin. Anyone who knows the host's IP can bypass Cloudflare by sending a request with the domain's ",[30,5356,5357],{},"Host"," header straight to that address. The usual answer is to allow only the addresses on ",[5360,5361,5365],"a",{"href":5362,"rel":5363},"https:\u002F\u002Fwww.cloudflare.com\u002Fips\u002F",[5364],"nofollow","Cloudflare's list",", for example with ",[30,5368,5369],{},"Require ip",[11,5371,5372,5373,5376],{},"Before uploading such a rule I checked which address the server sees. A temporary PHP script printed ",[30,5374,5375],{},"REMOTE_ADDR"," for a request made through Cloudflare:",[23,5378,5380],{"className":25,"code":5379,"language":27,"meta":28,"style":28},"\u003C?php\nheader('Content-Type: text\u002Fplain');\necho $_SERVER['REMOTE_ADDR'] ?? '-', ' | ', $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '-';\n",[30,5381,5382,5386,5390],{"__ignoreMap":28},[33,5383,5384],{"class":35,"line":36},[33,5385,39],{},[33,5387,5388],{"class":35,"line":42},[33,5389,93],{},[33,5391,5392],{"class":35,"line":48},[33,5393,5394],{},"echo $_SERVER['REMOTE_ADDR'] ?? '-', ' | ', $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '-';\n",[11,5396,5397,5398,5401,5402,5404,5405,5407],{},"It printed the same address twice: mine, not Cloudflare's. The host rewrites the client address (in Apache this is ",[30,5399,5400],{},"mod_remoteip","), so ",[30,5403,5375],{}," already holds the visitor's IP. A ",[30,5406,5369],{}," rule with Cloudflare's ranges would have blocked every reader and let through only those whose own address happens to be in a Cloudflare range.",[11,5409,5410,5411,5414,5415,5417],{},"Apache exposes ",[30,5412,5413],{},"CONN_REMOTE_ADDR"," in expressions: the address of the actual TCP connection, which ",[30,5416,5400],{}," does not touch. The rule checks that variable:",[23,5419,5421],{"className":5187,"code":5420,"language":5189,"meta":28,"style":28},"# \"...\" is shortened for this post. Apache rejects it (the whole site returns 500). In the file each of the 22 ranges is a separate -ipmatch condition.\nRewriteCond expr \"!(%{CONN_REMOTE_ADDR} -ipmatch '173.245.48.0\u002F20' || %{CONN_REMOTE_ADDR} -ipmatch '103.21.244.0\u002F22' || ...)\"\nRewriteRule ^ - [F,L]\n",[30,5422,5423,5428,5433],{"__ignoreMap":28},[33,5424,5425],{"class":35,"line":36},[33,5426,5427],{},"# \"...\" is shortened for this post. Apache rejects it (the whole site returns 500). In the file each of the 22 ranges is a separate -ipmatch condition.\n",[33,5429,5430],{"class":35,"line":42},[33,5431,5432],{},"RewriteCond expr \"!(%{CONN_REMOTE_ADDR} -ipmatch '173.245.48.0\u002F20' || %{CONN_REMOTE_ADDR} -ipmatch '103.21.244.0\u002F22' || ...)\"\n",[33,5434,5435],{"class":35,"line":48},[33,5436,5437],{},"RewriteRule ^ - [F,L]\n",[11,5439,5440],{},"The list has 15 IPv4 and 7 IPv6 ranges. After uploading I checked both paths:",[23,5442,5444],{"className":2898,"code":5443,"language":2900,"meta":28,"style":28},"curl -s -o \u002Fdev\u002Fnull -w '%{http_code}\\n' https:\u002F\u002Ftkulesza.eu\u002F\n# 200\ncurl -sk --resolve tkulesza.eu:443:\u003Chost IP> -o \u002Fdev\u002Fnull -w '%{http_code}\\n' https:\u002F\u002Ftkulesza.eu\u002F\n# 403\n",[30,5445,5446,5464,5469,5508],{"__ignoreMap":28},[33,5447,5448,5450,5452,5454,5456,5458,5461],{"class":35,"line":36},[33,5449,272],{"class":2907},[33,5451,5058],{"class":2728},[33,5453,5061],{"class":2728},[33,5455,5064],{"class":2609},[33,5457,5067],{"class":2728},[33,5459,5460],{"class":2609}," '%{http_code}\\n'",[33,5462,5463],{"class":2609}," https:\u002F\u002Ftkulesza.eu\u002F\n",[33,5465,5466],{"class":35,"line":42},[33,5467,5468],{"class":3710},"# 200\n",[33,5470,5471,5473,5476,5479,5482,5486,5489,5492,5495,5498,5500,5502,5504,5506],{"class":35,"line":48},[33,5472,272],{"class":2907},[33,5474,5475],{"class":2728}," -sk",[33,5477,5478],{"class":2728}," --resolve",[33,5480,5481],{"class":2609}," tkulesza.eu:443:",[33,5483,5485],{"class":5484},"szBVR","\u003C",[33,5487,5488],{"class":2609},"host",[33,5490,5491],{"class":2609}," I",[33,5493,5494],{"class":2598},"P",[33,5496,5497],{"class":5484},">",[33,5499,5061],{"class":2728},[33,5501,5064],{"class":2609},[33,5503,5067],{"class":2728},[33,5505,5460],{"class":2609},[33,5507,5463],{"class":2609},[33,5509,5510],{"class":35,"line":55},[33,5511,5512],{"class":3710},"# 403\n",[11,5514,5515],{},"The PHP probe was deleted right after the check. Cloudflare's address list changes rarely, but it does change. If the site ever starts returning 403 through Cloudflare, an outdated list is the first suspect.",[15,5517,5519],{"id":5518},"a-note-on-scanning-your-own-site","A note on scanning your own site",[11,5521,5522,5523,2582,5526,5528,5529,5532],{},"While checking whether files such as ",[30,5524,5525],{},".git\u002Fconfig",[30,5527,3985],{}," could be fetched from outside, I also requested a few common paths, ",[30,5530,5531],{},"wp-login.php"," among them. The host's bot protection treated that as an attack and for several minutes served my address a \"One moment, please...\" page instead of the site. I did the rest of the review on the built files locally.",[892,5534,5535],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}",{"title":28,"searchDepth":42,"depth":42,"links":5537},[5538,5539,5540,5541,5542],{"id":5038,"depth":42,"text":5039},{"id":5142,"depth":42,"text":5143},{"id":5176,"depth":42,"text":5177},{"id":5350,"depth":42,"text":5351},{"id":5518,"depth":42,"text":5519},"2026-10-06",{},"\u002Farticles\u002Fcdn-cached-fallback",{"x":5547,"y":5548,"depth":36,"size":911},0.28,0.14,[2525,5550],"dday-go-live",{"title":5029,"description":5035},"edge-cache","articles\u002Fcdn-cached-fallback",[5555,5189,5556,925,5557],"cloudflare","caching","htaccess","v1.1.1","SUyVFxeHQ1sanMzoSirs9tVHFbm8JXiP-OsZAYnvtzk",{"id":5561,"title":5562,"articleId":5550,"body":5563,"category":5814,"codeLang":910,"date":5815,"deploys":36,"description":5567,"excerpt":910,"extension":911,"lang":910,"meta":5816,"navigation":51,"path":5817,"pos":5818,"readMin":67,"related":5820,"seo":5821,"service":5822,"stem":5823,"tags":5824,"updated":5828,"version":4090,"__hash__":5829},"articles\u002Farticles\u002Fdday-go-live.md","Go\u002Fno-go on 6 June 1944: what a release decision is made of",{"type":8,"value":5564,"toc":5807},[5565,5568,5572,5575,5578,5582,5585,5588,5749,5760,5767,5771,5774,5777,5784,5788,5791,5794,5798,5801,5804],[11,5566,5567],{},"The Normandy landing was planned for 5 June 1944. On the morning of 4 June it was postponed by 24 hours because of a storm over the Channel. Early on 5 June Eisenhower confirmed the landing for the 6th, based on a forecast from his chief meteorologist, Group Captain James Stagg, who predicted a short break in the weather. More than 150,000 troops landed that day.",[15,5569,5571],{"id":5570},"constraints-fixed-the-window","Constraints fixed the window",[11,5573,5574],{},"The date followed from two physical conditions. Airborne troops needed a moon close to full to see drop zones at night. The navy and engineers needed low tide around first light, so that the landing could start on the rising tide while the beach obstacles were still exposed. Both held on only a few days a month. In June 1944 that meant 5, 6 and 7 June. The next acceptable tide was around 18 to 20 June, without the moon.",[11,5576,5577],{},"Releases have the same kind of window, and it's usually set by things outside the team: a contractual date, a regulatory deadline, a peak-traffic period during which nobody deploys, the availability of the people who can roll back. So write the window down early, as a constraint, together with the next window after it. \"If we miss Thursday, the next slot is after the freeze on the 25th\" is something the decision-maker needs on the day itself.",[15,5579,5581],{"id":5580},"the-criteria-existed-before-the-forecast","The criteria existed before the forecast",[11,5583,5584],{},"The planners didn't ask on 4 June whether the weather felt good enough. Limits for wind, cloud base, visibility and sea state had been set in advance by the army, navy and air force, each for its own reasons. The meteorologists' job was to say whether the forecast fell inside those limits.",[11,5586,5587],{},"Criteria set on the morning of the release are easy to bend to the deadline. Define them when the change is planned and keep them next to the change. Something like this (an illustrative format, it doesn't belong to any particular tool):",[23,5589,5591],{"className":2589,"code":5590,"language":2591,"meta":28,"style":28},"release: payouts-2026-06\nwindow:\n  primary: 2026-06-18T07:00\u002F10:00\n  next: 2026-06-25T07:00\u002F10:00\ngo_criteria:\n  - ci_green_on_release_commit: true\n  - migration_tested_on_prod_size_copy: true\n  - migration_duration_max_minutes: 5\n  - rollback_rehearsed: true\n  - error_rate_baseline_known: true\nabort_criteria:            # evaluated during the canary\n  - error_rate_above_baseline_pct: 50\n  - p95_latency_above_ms: 800\n  - failed_payment_jobs: 1\nowner: one named person\n",[30,5592,5593,5603,5610,5620,5630,5637,5649,5660,5671,5682,5693,5704,5716,5728,5740],{"__ignoreMap":28},[33,5594,5595,5598,5600],{"class":35,"line":36},[33,5596,5597],{"class":2602},"release",[33,5599,2606],{"class":2598},[33,5601,5602],{"class":2609},"payouts-2026-06\n",[33,5604,5605,5608],{"class":35,"line":42},[33,5606,5607],{"class":2602},"window",[33,5609,2628],{"class":2598},[33,5611,5612,5615,5617],{"class":35,"line":48},[33,5613,5614],{"class":2602},"  primary",[33,5616,2606],{"class":2598},[33,5618,5619],{"class":2609},"2026-06-18T07:00\u002F10:00\n",[33,5621,5622,5625,5627],{"class":35,"line":55},[33,5623,5624],{"class":2602},"  next",[33,5626,2606],{"class":2598},[33,5628,5629],{"class":2609},"2026-06-25T07:00\u002F10:00\n",[33,5631,5632,5635],{"class":35,"line":61},[33,5633,5634],{"class":2602},"go_criteria",[33,5636,2628],{"class":2598},[33,5638,5639,5642,5645,5647],{"class":35,"line":67},[33,5640,5641],{"class":2598},"  - ",[33,5643,5644],{"class":2602},"ci_green_on_release_commit",[33,5646,2606],{"class":2598},[33,5648,2729],{"class":2728},[33,5650,5651,5653,5656,5658],{"class":35,"line":73},[33,5652,5641],{"class":2598},[33,5654,5655],{"class":2602},"migration_tested_on_prod_size_copy",[33,5657,2606],{"class":2598},[33,5659,2729],{"class":2728},[33,5661,5662,5664,5667,5669],{"class":35,"line":79},[33,5663,5641],{"class":2598},[33,5665,5666],{"class":2602},"migration_duration_max_minutes",[33,5668,2606],{"class":2598},[33,5670,3320],{"class":2728},[33,5672,5673,5675,5678,5680],{"class":35,"line":85},[33,5674,5641],{"class":2598},[33,5676,5677],{"class":2602},"rollback_rehearsed",[33,5679,2606],{"class":2598},[33,5681,2729],{"class":2728},[33,5683,5684,5686,5689,5691],{"class":35,"line":90},[33,5685,5641],{"class":2598},[33,5687,5688],{"class":2602},"error_rate_baseline_known",[33,5690,2606],{"class":2598},[33,5692,2729],{"class":2728},[33,5694,5695,5698,5701],{"class":35,"line":96},[33,5696,5697],{"class":2602},"abort_criteria",[33,5699,5700],{"class":2598},":            ",[33,5702,5703],{"class":3710},"# evaluated during the canary\n",[33,5705,5706,5708,5711,5713],{"class":35,"line":384},[33,5707,5641],{"class":2598},[33,5709,5710],{"class":2602},"error_rate_above_baseline_pct",[33,5712,2606],{"class":2598},[33,5714,5715],{"class":2728},"50\n",[33,5717,5718,5720,5723,5725],{"class":35,"line":390},[33,5719,5641],{"class":2598},[33,5721,5722],{"class":2602},"p95_latency_above_ms",[33,5724,2606],{"class":2598},[33,5726,5727],{"class":2728},"800\n",[33,5729,5730,5732,5735,5737],{"class":35,"line":395},[33,5731,5641],{"class":2598},[33,5733,5734],{"class":2602},"failed_payment_jobs",[33,5736,2606],{"class":2598},[33,5738,5739],{"class":2728},"1\n",[33,5741,5742,5744,5746],{"class":35,"line":401},[33,5743,3843],{"class":2602},[33,5745,2606],{"class":2598},[33,5747,5748],{"class":2609},"one named person\n",[11,5750,5333,5751,5753,5754,5756,5757,5759],{},[30,5752,5697],{}," block matters as much as ",[30,5755,5634],{},". The postponement on 4 June was ",[30,5758,5634],{}," at work: conditions were outside the limits, so the answer was no.",[11,5761,5762,5763,5766],{},"When I put my own SaaS on production for the first time, I wrote the go conditions down before the first deploy: off-server backups with a restore I'd actually rehearsed (it took 2 seconds), a restart policy, log rotation, TLS with a monitor and a runbook for the keys. CI had a ",[30,5764,5765],{},"DEPLOY_ENABLED"," variable, so a merge built the image but didn't deploy it until every condition was ticked off. The swap itself meant about a second of downtime. I accepted that on purpose, because there was no SLA that would justify zero-downtime work.",[15,5768,5770],{"id":5769},"blacksod-point-and-the-german-forecast","Blacksod Point and the German forecast",[11,5772,5773],{},"German forecasters saw the same storm and concluded that the stormy weather would last for about two weeks, too rough for a landing. They had fewer observations from the Atlantic than the Allies. The Allies had reports from weather ships, aircraft and stations on the western edge of Europe, including the Blacksod Point lighthouse in Ireland, and those reports showed a gap between two fronts. On the German side, senior officers acted on their forecast: Rommel left for his home near Ulm for his wife's birthday on 6 June, and several commanders were traveling to a staff exercise in Rennes.",[11,5775,5776],{},"You can't judge a canary without a baseline, so know the normal error rate and latency of the affected endpoints before the deploy. Measure the risky parts directly, for example the migration time on a production-sized copy. A quick run on staging says little about production. Business signals belong on the same screen as technical ones. A payment flow can return 200 and still create no orders.",[11,5778,5779,5780,5783],{},"I got the ordering wrong once, moving n8n to a new server. The order was strict: first switch off the old instance (clear the active workflow versions and webhooks, stop it), only then activate the workflows on the new one. Up to that step a rollback cost nothing, because the old instance was still doing the work. That's exactly where I slipped. I stopped the old instance before the A record really pointed to the new server, and for a few minutes the service returned 404. Since then, before switching off the source, I check both origins with ",[30,5781,5782],{},"curl --resolve",". That check bypasses DNS, so where the A record points has to be checked separately. After the switch I tested the whole chain: a chat with an AI answer in 6.4 s, 8 active workflows, 6 webhooks.",[15,5785,5787],{"id":5786},"conflicting-forecasts-need-one-owner","Conflicting forecasts need one owner",[11,5789,5790],{},"Stagg didn't get one forecast. Three teams (the Met Office, the Admiralty and a US Army Air Forces group) worked with different methods and disagreed during the days before the landing. His role was to reconcile them into a single statement for Eisenhower, and Eisenhower's role was to decide.",[11,5792,5793],{},"Signals before a release can disagree too. The process should say explicitly who collects and presents them, and who makes the call, by name. That name goes in the release note.",[15,5795,5797],{"id":5796},"waiting-also-has-a-cost","Waiting also has a cost",[11,5799,5800],{},"The alternative to 6 June was the next window, around 18 to 20 June. From 19 to 22 June a severe storm hit the Channel and wrecked the artificial Mulberry harbor off Omaha Beach. Had the landing been moved to that window, it would have run into the worst weather of the month.",[11,5802,5803],{},"That's hindsight. Nobody could forecast that storm on 5 June. Still, postponing carries its own risks, and they go on the list next to the risks of going. For software, delay usually means a larger batch of changes in the next release, longer-lived branches that drift from main, and a deadline that moves closer while the window narrows.",[892,5805,5806],{},"html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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":28,"searchDepth":42,"depth":42,"links":5808},[5809,5810,5811,5812,5813],{"id":5570,"depth":42,"text":5571},{"id":5580,"depth":42,"text":5581},{"id":5769,"depth":42,"text":5770},{"id":5786,"depth":42,"text":5787},{"id":5796,"depth":42,"text":5797},"arch","2026-06-06",{},"\u002Farticles\u002Fdday-go-live",{"x":4078,"y":5819,"depth":36,"size":911},0.85,[2510,5015],{"title":5562,"description":5567},"go-no-go","articles\u002Fdday-go-live",[5825,925,5826,5827,2284],"go-live","decision-making","release-management","2026-10-18","wk0brNVBArtE6baOD9fd8SCtrTVOPttl3IJnHB2LJno",{"id":5831,"title":5832,"articleId":5015,"body":5833,"category":27,"codeLang":27,"date":6587,"deploys":36,"description":6588,"excerpt":910,"extension":911,"lang":910,"meta":6589,"navigation":51,"path":6590,"pos":6591,"readMin":85,"related":6594,"seo":6595,"service":6596,"stem":6597,"tags":6598,"updated":2519,"version":4090,"__hash__":6601},"articles\u002Farticles\u002Fdesign-patterns-production.md","Design patterns in production PHP: what each one buys, what it costs, when to skip it",{"type":8,"value":5834,"toc":6571},[5835,5842,5845,5848,5852,5859,5921,5924,5928,5949,5958,5961,5964,5988,5992,5995,5998,6117,6148,6151,6154,6245,6248,6281,6284,6295,6299,6309,6312,6316,6320,6331,6398,6401,6423,6426,6429,6506,6513,6517,6532,6535,6539,6542,6545,6548,6552,6569],[11,5836,5837,5838,5841],{},"A design pattern is a name for a recurring shape of code. Its main value is communication. When a reviewer reads ",[30,5839,5840],{},"CachingUserRepository implements UserRepositoryInterface",", they recognize a decorator and know how the class is wired, what it can do and how to test it, before they read a line of the body.",[11,5843,5844],{},"The cost is indirection: almost every pattern puts at least one interface or one class between the caller and the code that does the work. It only pays off if the change the pattern was built for actually happens.",[11,5846,5847],{},"This note goes through the GoF patterns from the perspective of application-layer PHP: Laravel or Symfony, request-scoped state, a DI container available. For each one I look at the problem it addresses, the typical misuse, and whether the container or the language already does the job.",[15,5849,5851],{"id":5850},"the-test-before-applying-any-pattern","The test before applying any pattern",[11,5853,5854,5855],{},"Answer one question: ",[5856,5857,5858],"strong",{},"which future change does this make cheaper?",[687,5860,5861,5871],{},[690,5862,5863],{},[693,5864,5865,5868],{},[696,5866,5867],{},"Pattern",[696,5869,5870],{},"Change it makes cheaper",[708,5872,5873,5881,5889,5897,5905,5913],{},[693,5874,5875,5878],{},[713,5876,5877],{},"Adapter",[713,5879,5880],{},"Replacing or upgrading an external dependency",[693,5882,5883,5886],{},[713,5884,5885],{},"Decorator",[713,5887,5888],{},"Adding or removing a cross-cutting concern (cache, logging, rate limiting)",[693,5890,5891,5894],{},[713,5892,5893],{},"Strategy",[713,5895,5896],{},"Adding a new algorithm variant without touching the caller",[693,5898,5899,5902],{},[713,5900,5901],{},"Observer \u002F Event",[713,5903,5904],{},"Adding a new reaction to a state change without touching its source",[693,5906,5907,5910],{},[713,5908,5909],{},"Command",[713,5911,5912],{},"Moving work to a queue, retrying it, auditing it",[693,5914,5915,5918],{},[713,5916,5917],{},"Factory Method",[713,5919,5920],{},"Adding a new implementation selected at runtime",[11,5922,5923],{},"If you can't name the change, or it's hypothetical (\"we might switch databases\"), the pattern is speculative. Write the direct version and introduce the pattern in the commit that needs it.",[15,5925,5927],{"id":5926},"creational-patterns","Creational patterns",[11,5929,5930,5933,5934,5937,5938,5941,5942,5944,5945],{},[5856,5931,5932],{},"Singleton."," Legitimate for immutable, expensive-to-build state shared within one process. In a framework with a container, register the class as a shared binding (",[30,5935,5936],{},"$this->app->singleton(...)"," in Laravel) instead of writing a static ",[30,5939,5940],{},"getInstance()",". You can inject the container version and swap it in tests. A static ",[30,5943,5940],{}," is hard-wired into every caller. ",[5360,5946,5948],{"href":5947},"\u002Farticles\u002Fsingleton-pattern","Separate article.",[11,5950,5951,5954,5955],{},[5856,5952,5953],{},"Factory Method."," Needed when the concrete type is a runtime decision: payment gateway by country, parser by file signature, notification channel by user preference. A good factory selects the type, lets the container do the construction and returns an interface. ",[5360,5956,5948],{"href":5957},"\u002Farticles\u002Ffactory-method",[11,5959,5960],{},"Builders fit objects that are assembled in steps and then frozen. A query builder collects conditions and produces one query. Builders for test fixtures are fine too, because they make tests easier to read. It's different when production code needs a builder just to construct an entity. That usually means the entity constructor takes too many arguments, and the better fix is to split the entity or pull some of those arguments into value objects.",[11,5962,5963],{},"Abstract Factory produces families of related objects that have to match each other, like a set of UI widgets for one theme. In backend code the container's contextual binding covers most of these cases, so a hand-written one is rare.",[11,5965,5966,5967,1930,5970,5973,5974,5977,5978,5981,5982,5985,5986,221],{},"Prototype is built into PHP as ",[30,5968,5969],{},"clone",[30,5971,5972],{},"__clone()",". A ",[30,5975,5976],{},"Prototype"," interface with a ",[30,5979,5980],{},"copy()"," method that calls ",[30,5983,5984],{},"clone $this"," adds a layer and no behavior. If nested objects need deep copies, implement ",[30,5987,5972],{},[15,5989,5991],{"id":5990},"structural-patterns","Structural patterns",[195,5993,5877],{"id":5994},"adapter",[11,5996,5997],{},"Every integration with an external API is an adapter: it translates the vendor's types and errors into your domain's types and errors. An adapter should only translate. Retry policy, fee calculation and business decisions go into a service that depends on the adapter's interface.",[23,5999,6001],{"className":25,"code":6000,"language":27,"meta":28,"style":28},"final class StripePaymentGateway implements PaymentGatewayInterface\n{\n    public function __construct(private readonly \\Stripe\\StripeClient $stripe) {}\n\n    public function charge(Money $amount, string $paymentMethodId): ChargeResult\n    {\n        try {\n            $intent = $this->stripe->paymentIntents->create([\n                'amount'                       => $amount->minorUnits,  \u002F\u002F Stripe expects the smallest currency unit\n                'currency'                     => strtolower($amount->currency),\n                'payment_method'               => $paymentMethodId,\n                'allowed_payment_method_types' => ['card'],\n                'confirm'                      => true,                 \u002F\u002F without it nothing is charged and no card error is raised\n            ]);\n\n            return $intent->status === \\Stripe\\PaymentIntent::STATUS_SUCCEEDED\n                ? ChargeResult::succeeded($intent->id)\n                : ChargeResult::pending($intent->id);\n        } catch (\\Stripe\\Exception\\CardException $e) {\n            return ChargeResult::declined($e->getMessage());\n        }\n    }\n}\n",[30,6002,6003,6008,6012,6017,6021,6026,6030,6035,6040,6048,6053,6058,6063,6071,6076,6080,6085,6090,6095,6100,6105,6109,6113],{"__ignoreMap":28},[33,6004,6005],{"class":35,"line":36},[33,6006,6007],{},"final class StripePaymentGateway implements PaymentGatewayInterface\n",[33,6009,6010],{"class":35,"line":42},[33,6011,64],{},[33,6013,6014],{"class":35,"line":48},[33,6015,6016],{},"    public function __construct(private readonly \\Stripe\\StripeClient $stripe) {}\n",[33,6018,6019],{"class":35,"line":55},[33,6020,52],{"emptyLinePlaceholder":51},[33,6022,6023],{"class":35,"line":61},[33,6024,6025],{},"    public function charge(Money $amount, string $paymentMethodId): ChargeResult\n",[33,6027,6028],{"class":35,"line":67},[33,6029,4183],{},[33,6031,6032],{"class":35,"line":73},[33,6033,6034],{},"        try {\n",[33,6036,6037],{"class":35,"line":79},[33,6038,6039],{},"            $intent = $this->stripe->paymentIntents->create([\n",[33,6041,6042,6045],{"class":35,"line":85},[33,6043,6044],{},"                'amount'                       => $amount->minorUnits,",[33,6046,6047],{},"  \u002F\u002F Stripe expects the smallest currency unit\n",[33,6049,6050],{"class":35,"line":90},[33,6051,6052],{},"                'currency'                     => strtolower($amount->currency),\n",[33,6054,6055],{"class":35,"line":96},[33,6056,6057],{},"                'payment_method'               => $paymentMethodId,\n",[33,6059,6060],{"class":35,"line":384},[33,6061,6062],{},"                'allowed_payment_method_types' => ['card'],\n",[33,6064,6065,6068],{"class":35,"line":390},[33,6066,6067],{},"                'confirm'                      => true,",[33,6069,6070],{},"                 \u002F\u002F without it nothing is charged and no card error is raised\n",[33,6072,6073],{"class":35,"line":395},[33,6074,6075],{},"            ]);\n",[33,6077,6078],{"class":35,"line":401},[33,6079,52],{"emptyLinePlaceholder":51},[33,6081,6082],{"class":35,"line":407},[33,6083,6084],{},"            return $intent->status === \\Stripe\\PaymentIntent::STATUS_SUCCEEDED\n",[33,6086,6087],{"class":35,"line":413},[33,6088,6089],{},"                ? ChargeResult::succeeded($intent->id)\n",[33,6091,6092],{"class":35,"line":419},[33,6093,6094],{},"                : ChargeResult::pending($intent->id);\n",[33,6096,6097],{"class":35,"line":1806},[33,6098,6099],{},"        } catch (\\Stripe\\Exception\\CardException $e) {\n",[33,6101,6102],{"class":35,"line":1812},[33,6103,6104],{},"            return ChargeResult::declined($e->getMessage());\n",[33,6106,6107],{"class":35,"line":1817},[33,6108,4681],{},[33,6110,6111],{"class":35,"line":1822},[33,6112,4193],{},[33,6114,6115],{"class":35,"line":1828},[33,6116,82],{},[11,6118,6119,6120,6123,6124,6127,6128,6131,6132,6135,6136,6139,6140,6143,6144,6147],{},"This one maps ",[30,6121,6122],{},"Money"," to the request array, maps the response to ",[30,6125,6126],{},"ChargeResult"," and turns a card decline into a domain outcome. It's a sketch against Stripe API version ",[30,6129,6130],{},"2026-09-30.endive"," (pinned by stripe-php 22), which rejects ",[30,6133,6134],{},"payment_method_types",", so the accepted methods are narrowed with ",[30,6137,6138],{},"allowed_payment_method_types",". A real adapter would also handle ",[30,6141,6142],{},"requires_action"," (3DS) separately from ",[30,6145,6146],{},"processing"," and map the other Stripe exceptions, a connection error for example. When a class like this starts deciding things, for example whether to retry a declined card, it has grown into a service and should be split.",[195,6149,5885],{"id":6150},"decorator",[11,6152,6153],{},"A decorator wraps an object that implements the same interface and adds behavior before or after delegating. Caching, logging, metrics and rate limiting are the standard uses.",[23,6155,6157],{"className":25,"code":6156,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Cache\\Repository as Cache;\n\nfinal class CachingUserRepository implements UserRepositoryInterface\n{\n    public function __construct(\n        private readonly UserRepositoryInterface $inner,\n        private readonly Cache $cache,\n        private readonly int $ttlSeconds = 300,\n    ) {}\n\n    public function findById(int $id): ?User\n    {\n        return $this->cache->remember(\n            \"user.{$id}\",\n            $this->ttlSeconds,\n            fn () => $this->inner->findById($id),\n        );\n    }\n}\n",[30,6158,6159,6164,6168,6173,6177,6181,6186,6191,6196,6200,6204,6209,6213,6218,6223,6228,6233,6237,6241],{"__ignoreMap":28},[33,6160,6161],{"class":35,"line":36},[33,6162,6163],{},"use Illuminate\\Contracts\\Cache\\Repository as Cache;\n",[33,6165,6166],{"class":35,"line":42},[33,6167,52],{"emptyLinePlaceholder":51},[33,6169,6170],{"class":35,"line":48},[33,6171,6172],{},"final class CachingUserRepository implements UserRepositoryInterface\n",[33,6174,6175],{"class":35,"line":55},[33,6176,64],{},[33,6178,6179],{"class":35,"line":61},[33,6180,4263],{},[33,6182,6183],{"class":35,"line":67},[33,6184,6185],{},"        private readonly UserRepositoryInterface $inner,\n",[33,6187,6188],{"class":35,"line":73},[33,6189,6190],{},"        private readonly Cache $cache,\n",[33,6192,6193],{"class":35,"line":79},[33,6194,6195],{},"        private readonly int $ttlSeconds = 300,\n",[33,6197,6198],{"class":35,"line":85},[33,6199,4278],{},[33,6201,6202],{"class":35,"line":90},[33,6203,52],{"emptyLinePlaceholder":51},[33,6205,6206],{"class":35,"line":96},[33,6207,6208],{},"    public function findById(int $id): ?User\n",[33,6210,6211],{"class":35,"line":384},[33,6212,4183],{},[33,6214,6215],{"class":35,"line":390},[33,6216,6217],{},"        return $this->cache->remember(\n",[33,6219,6220],{"class":35,"line":395},[33,6221,6222],{},"            \"user.{$id}\",\n",[33,6224,6225],{"class":35,"line":401},[33,6226,6227],{},"            $this->ttlSeconds,\n",[33,6229,6230],{"class":35,"line":407},[33,6231,6232],{},"            fn () => $this->inner->findById($id),\n",[33,6234,6235],{"class":35,"line":413},[33,6236,4527],{},[33,6238,6239],{"class":35,"line":419},[33,6240,4193],{},[33,6242,6243],{"class":35,"line":1806},[33,6244,82],{},[11,6246,6247],{},"Wiring in a service provider:",[23,6249,6251],{"className":25,"code":6250,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Cache\\Repository as Cache;\n\n$this->app->bind(UserRepositoryInterface::class, fn ($app) => new CachingUserRepository(\n    $app->make(EloquentUserRepository::class),\n    $app->make(Cache::class),\n));\n",[30,6252,6253,6257,6261,6266,6271,6276],{"__ignoreMap":28},[33,6254,6255],{"class":35,"line":36},[33,6256,6163],{},[33,6258,6259],{"class":35,"line":42},[33,6260,52],{"emptyLinePlaceholder":51},[33,6262,6263],{"class":35,"line":48},[33,6264,6265],{},"$this->app->bind(UserRepositoryInterface::class, fn ($app) => new CachingUserRepository(\n",[33,6267,6268],{"class":35,"line":55},[33,6269,6270],{},"    $app->make(EloquentUserRepository::class),\n",[33,6272,6273],{"class":35,"line":61},[33,6274,6275],{},"    $app->make(Cache::class),\n",[33,6277,6278],{"class":35,"line":67},[33,6279,6280],{},"));\n",[11,6282,6283],{},"Each layer gets its own test. The decorator test covers only the miss and the hit, and data access stays in the repository test.",[11,6285,6286,6287,6290,6291,6294],{},"Where it goes wrong is long chains, assembled in a different order in different contexts. If a reader has to trace the container configuration to find out whether a call is cached, logged and retried, the stack is too deep. Two or three layers wired in one place is a reasonable limit. One detail specific to this decorator: Laravel's ",[30,6288,6289],{},"remember()"," treats a stored ",[30,6292,6293],{},"null"," as a miss, so lookups for IDs that don't exist always reach the database. If that traffic matters, cache an explicit \"not found\" marker.",[195,6296,6298],{"id":6297},"facade-and-proxy","Facade and Proxy",[11,6300,6301,6302,2582,6305,6308],{},"A facade gives one entry point to a subsystem of many classes when callers need only a few operations from it. Laravel's static facades are a different mechanism under the same name, a static proxy to a container binding. They're convenient in application code and testable through ",[30,6303,6304],{},"::fake()",[30,6306,6307],{},"::shouldReceive()",". What they hide is the dependency list, because nothing shows up in the constructor signature. In domain services I'd use constructor injection so that list stays visible.",[11,6310,6311],{},"You mostly meet proxies as classes that ORMs and containers generate for lazy loading, Doctrine entities and Symfony lazy services being the usual ones. Since PHP 8.4 both can use native lazy objects instead of generated classes. Writing your own is rarely justified. To intercept calls, a decorator does the same job explicitly and is easier to test.",[15,6313,6315],{"id":6314},"behavioral-patterns","Behavioral patterns",[195,6317,6319],{"id":6318},"observer-events","Observer \u002F events",[11,6321,6322,6323,6326,6327,6330],{},"When ",[30,6324,6325],{},"Order"," becomes paid, the code that marks it paid dispatches ",[30,6328,6329],{},"OrderPaid",". Email, inventory and analytics listeners subscribe independently, and adding a fourth listener doesn't change the order code.",[23,6332,6334],{"className":25,"code":6333,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Queue\\ShouldQueue;\n\nfinal class OrderPaid\n{\n    public function __construct(public readonly int $orderId) {}\n}\n\nfinal class ReserveInventory implements ShouldQueue\n{\n    public function handle(OrderPaid $event): void\n    {\n        \u002F\u002F load the order by id and reserve stock\n    }\n}\n",[30,6335,6336,6341,6345,6350,6354,6359,6363,6367,6372,6376,6381,6385,6390,6394],{"__ignoreMap":28},[33,6337,6338],{"class":35,"line":36},[33,6339,6340],{},"use Illuminate\\Contracts\\Queue\\ShouldQueue;\n",[33,6342,6343],{"class":35,"line":42},[33,6344,52],{"emptyLinePlaceholder":51},[33,6346,6347],{"class":35,"line":48},[33,6348,6349],{},"final class OrderPaid\n",[33,6351,6352],{"class":35,"line":55},[33,6353,64],{},[33,6355,6356],{"class":35,"line":61},[33,6357,6358],{},"    public function __construct(public readonly int $orderId) {}\n",[33,6360,6361],{"class":35,"line":67},[33,6362,82],{},[33,6364,6365],{"class":35,"line":73},[33,6366,52],{"emptyLinePlaceholder":51},[33,6368,6369],{"class":35,"line":79},[33,6370,6371],{},"final class ReserveInventory implements ShouldQueue\n",[33,6373,6374],{"class":35,"line":85},[33,6375,64],{},[33,6377,6378],{"class":35,"line":90},[33,6379,6380],{},"    public function handle(OrderPaid $event): void\n",[33,6382,6383],{"class":35,"line":96},[33,6384,4183],{},[33,6386,6387],{"class":35,"line":384},[33,6388,6389],{},"        \u002F\u002F load the order by id and reserve stock\n",[33,6391,6392],{"class":35,"line":390},[33,6393,4193],{},[33,6395,6396],{"class":35,"line":395},[33,6397,82],{},[11,6399,6400],{},"Cascading events are the risk. A listener dispatches another event, that event's listener dispatches a third one, and you get a call graph nobody ever wrote down. Synchronous listeners in such a chain all run inside the original request, so latency and query count of a simple \"mark as paid\" grow with every subscriber. What keeps it under control:",[3583,6402,6403,6409,6412],{},[3586,6404,6405,6406,221],{},"Listeners with side effects outside the database (email, HTTP calls) implement ",[30,6407,6408],{},"ShouldQueue",[3586,6410,6411],{},"Events carry identifiers, not models. The listener loads fresh state itself.",[3586,6413,6414,6415,6418,6419,6422],{},"Listeners that must not run before the transaction commits are marked that way, with ",[30,6416,6417],{},"ShouldDispatchAfterCommit"," on the event or ",[30,6420,6421],{},"$afterCommit = true"," on the queued listener.",[195,6424,5893],{"id":6425},"strategy",[11,6427,6428],{},"Strategy separates choosing an algorithm from using it. A shipping calculator with flat-rate, weight-based and zone-based pricing is the textbook case.",[23,6430,6432],{"className":25,"code":6431,"language":27,"meta":28,"style":28},"interface ShippingPricing\n{\n    public function price(Parcel $parcel): Money;\n}\n\nfinal class ShippingQuote\n{\n    \u002F** @param array\u003Cstring, ShippingPricing> $pricings *\u002F\n    public function __construct(private readonly array $pricings) {}\n\n    public function for(Carrier $carrier, Parcel $parcel): Money\n    {\n        return ($this->pricings[$carrier->value] ?? throw new UnsupportedCarrier($carrier))\n            ->price($parcel);\n    }\n}\n",[30,6433,6434,6439,6443,6448,6452,6456,6461,6465,6470,6475,6479,6484,6488,6493,6498,6502],{"__ignoreMap":28},[33,6435,6436],{"class":35,"line":36},[33,6437,6438],{},"interface ShippingPricing\n",[33,6440,6441],{"class":35,"line":42},[33,6442,64],{},[33,6444,6445],{"class":35,"line":48},[33,6446,6447],{},"    public function price(Parcel $parcel): Money;\n",[33,6449,6450],{"class":35,"line":55},[33,6451,82],{},[33,6453,6454],{"class":35,"line":61},[33,6455,52],{"emptyLinePlaceholder":51},[33,6457,6458],{"class":35,"line":67},[33,6459,6460],{},"final class ShippingQuote\n",[33,6462,6463],{"class":35,"line":73},[33,6464,64],{},[33,6466,6467],{"class":35,"line":79},[33,6468,6469],{},"    \u002F** @param array\u003Cstring, ShippingPricing> $pricings *\u002F\n",[33,6471,6472],{"class":35,"line":85},[33,6473,6474],{},"    public function __construct(private readonly array $pricings) {}\n",[33,6476,6477],{"class":35,"line":90},[33,6478,52],{"emptyLinePlaceholder":51},[33,6480,6481],{"class":35,"line":96},[33,6482,6483],{},"    public function for(Carrier $carrier, Parcel $parcel): Money\n",[33,6485,6486],{"class":35,"line":384},[33,6487,4183],{},[33,6489,6490],{"class":35,"line":390},[33,6491,6492],{},"        return ($this->pricings[$carrier->value] ?? throw new UnsupportedCarrier($carrier))\n",[33,6494,6495],{"class":35,"line":395},[33,6496,6497],{},"            ->price($parcel);\n",[33,6499,6500],{"class":35,"line":401},[33,6501,4193],{},[33,6503,6504],{"class":35,"line":407},[33,6505,82],{},[11,6507,6508,6509,6512],{},"Pick the strategy once, at the edge of the operation. When the selection happens inside a loop over items, it gets harder to follow, because the same question is asked for every item. With two variants and no third in sight, a ",[30,6510,6511],{},"match"," expression is shorter and just as clear.",[195,6514,6516],{"id":6515},"command-and-template-method","Command and Template Method",[11,6518,6519,6520,6523,6524,6527,6528,6531],{},"A command is a serializable description of an intention, ",[30,6521,6522],{},"ChargeCustomer(customerId: 42, amountMinor: 1999)",". Because it's data, it can be queued, delayed, retried, logged and replayed. A queued Laravel job is a command that usually carries its own ",[30,6525,6526],{},"handle()"," method, and a separate handler can be attached through ",[30,6529,6530],{},"Bus::map",". Most of the design work goes into the payload: keep it to identifiers and scalars, and make the handler idempotent, since a queue with retries can deliver the same command more than once.",[11,6533,6534],{},"Template Method is for two classes that share a fixed sequence of steps and differ in one, say a report built identically and exported as CSV or PDF. The base class defines the sequence and declares the varying step abstract. That inheritance is enough while only one step differs. Once more steps vary, or variants need combining, switch to composition and inject the exporter as a strategy.",[15,6536,6538],{"id":6537},"patterns-that-rarely-fit-application-code","Patterns that rarely fit application code",[11,6540,6541],{},"Interpreter means representing the grammar of your own language and evaluating its tree, and in practice you also need a parser. Check first whether an existing expression engine covers the need. Symfony ExpressionLanguage handles most business-rule cases, pricing conditions or feature-flag predicates, with far less code to maintain.",[11,6543,6544],{},"Mediator is a central object coordinating components that would otherwise reference each other. With an event dispatcher and a container in the framework, the dispatcher already plays that role most of the time. A custom one makes sense when the coordination logic itself is complex and stateful, as in a UI form where fields affect each other.",[11,6546,6547],{},"Flyweight shares immutable state between many small objects to save memory. In PHP, objects created for a request are released when it ends, and a single request rarely creates enough of them to matter. Long-running CLI jobs that create huge numbers of identical value objects, like tokens in a parser, are the exception.",[15,6549,6551],{"id":6550},"before-approving-the-pr","Before approving the PR",[3583,6553,6554,6557,6560,6563],{},[3586,6555,6556],{},"Can the author name the change the pattern makes cheaper, and is that change actually planned?",[3586,6558,6559],{},"Does the new interface have a second implementation, or at least a test double that needs it?",[3586,6561,6562],{},"Is the adapter only translating?",[3586,6564,6565,6566,6568],{},"Would a ",[30,6567,6511],{}," expression or a plain function do the same with less code?",[892,6570,894],{},{"title":28,"searchDepth":42,"depth":42,"links":6572},[6573,6574,6575,6580,6585,6586],{"id":5850,"depth":42,"text":5851},{"id":5926,"depth":42,"text":5927},{"id":5990,"depth":42,"text":5991,"children":6576},[6577,6578,6579],{"id":5994,"depth":48,"text":5877},{"id":6150,"depth":48,"text":5885},{"id":6297,"depth":48,"text":6298},{"id":6314,"depth":42,"text":6315,"children":6581},[6582,6583,6584],{"id":6318,"depth":48,"text":6319},{"id":6425,"depth":48,"text":5893},{"id":6515,"depth":48,"text":6516},{"id":6537,"depth":42,"text":6538},{"id":6550,"depth":42,"text":6551},"2023-02-10","A design pattern is a name for a recurring shape of code. Its main value is communication. When a reviewer reads CachingUserRepository implements UserRepositoryInterface, they recognize a decorator and know how the class is wired, what it can do and how to test it, before they read a line of the body.",{},"\u002Farticles\u002Fdesign-patterns-production",{"x":6592,"y":5819,"depth":36,"size":6593},0.62,"lg",[918,2510,5014],{"title":5832,"description":6588},"pattern-reference","articles\u002Fdesign-patterns-production",[5020,5022,27,6599,6600],"refactoring","code-review","Xg9JtzI0fVKF998Ut-WkrqTNLS6oq1N-fOfoBu-Fd6Q",{"id":6603,"title":6604,"articleId":5014,"body":6605,"category":27,"codeLang":27,"date":7245,"deploys":36,"description":6609,"excerpt":910,"extension":911,"lang":910,"meta":7246,"navigation":51,"path":5957,"pos":7247,"readMin":61,"related":7250,"seo":7251,"service":7252,"stem":7253,"tags":7254,"updated":2519,"version":5025,"__hash__":7257},"articles\u002Farticles\u002Ffactory-method.md","Factory Method in PHP: one place for a runtime choice of implementation",{"type":8,"value":6606,"toc":7235},[6607,6610,6614,6617,6716,6723,6727,6730,6770,6773,6777,6791,6869,6872,6958,6961,7009,7012,7046,7053,7071,7075,7082,7092,7096,7102,7106,7112,7153,7160,7214,7221,7227,7233],[11,6608,6609],{},"A factory answers one question: which implementation to create for this input. It's useful when the answer depends on runtime data, like a request field or a database column, and when the same answer is needed in more than one place. Without both, the factory only adds a layer.",[15,6611,6613],{"id":6612},"terminology","Terminology",[11,6615,6616],{},"In the GoF book, Factory Method is a specific structure: a base class calls an abstract creation method, and subclasses override it to decide the concrete product.",[23,6618,6620],{"className":25,"code":6619,"language":27,"meta":28,"style":28},"abstract class ReportExporter\n{\n    abstract protected function createWriter(): ReportWriter;\n\n    public function export(Report $report): string\n    {\n        $writer = $this->createWriter();\n        foreach ($report->rows() as $row) {\n            $writer->addRow($row);\n        }\n        return $writer->finish();\n    }\n}\n\nfinal class CsvReportExporter extends ReportExporter\n{\n    protected function createWriter(): ReportWriter\n    {\n        return new CsvWriter();\n    }\n}\n",[30,6621,6622,6627,6631,6636,6640,6645,6649,6654,6659,6664,6668,6673,6677,6681,6685,6690,6694,6699,6703,6708,6712],{"__ignoreMap":28},[33,6623,6624],{"class":35,"line":36},[33,6625,6626],{},"abstract class ReportExporter\n",[33,6628,6629],{"class":35,"line":42},[33,6630,64],{},[33,6632,6633],{"class":35,"line":48},[33,6634,6635],{},"    abstract protected function createWriter(): ReportWriter;\n",[33,6637,6638],{"class":35,"line":55},[33,6639,52],{"emptyLinePlaceholder":51},[33,6641,6642],{"class":35,"line":61},[33,6643,6644],{},"    public function export(Report $report): string\n",[33,6646,6647],{"class":35,"line":67},[33,6648,4183],{},[33,6650,6651],{"class":35,"line":73},[33,6652,6653],{},"        $writer = $this->createWriter();\n",[33,6655,6656],{"class":35,"line":79},[33,6657,6658],{},"        foreach ($report->rows() as $row) {\n",[33,6660,6661],{"class":35,"line":85},[33,6662,6663],{},"            $writer->addRow($row);\n",[33,6665,6666],{"class":35,"line":90},[33,6667,4681],{},[33,6669,6670],{"class":35,"line":96},[33,6671,6672],{},"        return $writer->finish();\n",[33,6674,6675],{"class":35,"line":384},[33,6676,4193],{},[33,6678,6679],{"class":35,"line":390},[33,6680,82],{},[33,6682,6683],{"class":35,"line":395},[33,6684,52],{"emptyLinePlaceholder":51},[33,6686,6687],{"class":35,"line":401},[33,6688,6689],{},"final class CsvReportExporter extends ReportExporter\n",[33,6691,6692],{"class":35,"line":407},[33,6693,64],{},[33,6695,6696],{"class":35,"line":413},[33,6697,6698],{},"    protected function createWriter(): ReportWriter\n",[33,6700,6701],{"class":35,"line":419},[33,6702,4183],{},[33,6704,6705],{"class":35,"line":1806},[33,6706,6707],{},"        return new CsvWriter();\n",[33,6709,6710],{"class":35,"line":1812},[33,6711,4193],{},[33,6713,6714],{"class":35,"line":1817},[33,6715,82],{},[11,6717,6718,6719,6722],{},"In PHP applications you'll more often see a parameterized factory: one object with a ",[30,6720,6721],{},"make()"," method that takes a key and returns an implementation of an interface. The rest of this note is about that form, since that's the one that replaces duplicated conditionals.",[15,6724,6726],{"id":6725},"the-problem-the-same-branch-in-several-places","The problem: the same branch in several places",[11,6728,6729],{},"Say an application accepts card payments, BLIK, bank transfers and installment financing. Every provider has its own API, its own error model and its own webhook format. Without a single decision point, the controller ends up with something like this:",[23,6731,6733],{"className":25,"code":6732,"language":27,"meta":28,"style":28},"$gateway = match ($request->input('payment_method')) {\n    'card' => new StripeGateway(config('services.stripe.secret')),\n    'blik' => new BlikGateway(config('services.blik.merchant_id'), config('services.blik.key')),\n    'transfer' => new BankTransferGateway(config('services.psp.endpoint')),\n    'financing' => new FinancingGateway(config('services.financing.api_key')),\n    default => throw new InvalidArgumentException('Unknown payment method'),\n};\n",[30,6734,6735,6740,6745,6750,6755,6760,6765],{"__ignoreMap":28},[33,6736,6737],{"class":35,"line":36},[33,6738,6739],{},"$gateway = match ($request->input('payment_method')) {\n",[33,6741,6742],{"class":35,"line":42},[33,6743,6744],{},"    'card' => new StripeGateway(config('services.stripe.secret')),\n",[33,6746,6747],{"class":35,"line":48},[33,6748,6749],{},"    'blik' => new BlikGateway(config('services.blik.merchant_id'), config('services.blik.key')),\n",[33,6751,6752],{"class":35,"line":55},[33,6753,6754],{},"    'transfer' => new BankTransferGateway(config('services.psp.endpoint')),\n",[33,6756,6757],{"class":35,"line":61},[33,6758,6759],{},"    'financing' => new FinancingGateway(config('services.financing.api_key')),\n",[33,6761,6762],{"class":35,"line":67},[33,6763,6764],{},"    default => throw new InvalidArgumentException('Unknown payment method'),\n",[33,6766,6767],{"class":35,"line":73},[33,6768,6769],{},"};\n",[11,6771,6772],{},"Then the same choice shows up again in the refund handler, the webhook endpoint and the reconciliation job, each time based on the method stored with the payment, each time with the constructor arguments copied along. Adding a provider, or a new dependency to an existing one, means hunting down every copy.",[15,6774,6776],{"id":6775},"interface-enum-factory","Interface, enum, factory",[11,6778,6779,6780,739,6782,739,6784,1930,6787,6790],{},"First comes an interface in the application's own terms. ",[30,6781,6122],{},[30,6783,6126],{},[30,6785,6786],{},"RefundResult",[30,6788,6789],{},"WebhookEvent"," are the application's value objects.",[23,6792,6794],{"className":25,"code":6793,"language":27,"meta":28,"style":28},"enum PaymentMethod: string\n{\n    case Card = 'card';\n    case Blik = 'blik';\n    case Transfer = 'transfer';\n    case Financing = 'financing';\n}\n\ninterface PaymentGateway\n{\n    public function charge(Money $amount, array $metadata): ChargeResult;\n\n    public function refund(string $chargeId, Money $amount): RefundResult;\n\n    public function parseWebhook(string $payload, array $headers): WebhookEvent;\n}\n",[30,6795,6796,6801,6805,6810,6815,6820,6825,6829,6833,6838,6842,6847,6851,6856,6860,6865],{"__ignoreMap":28},[33,6797,6798],{"class":35,"line":36},[33,6799,6800],{},"enum PaymentMethod: string\n",[33,6802,6803],{"class":35,"line":42},[33,6804,64],{},[33,6806,6807],{"class":35,"line":48},[33,6808,6809],{},"    case Card = 'card';\n",[33,6811,6812],{"class":35,"line":55},[33,6813,6814],{},"    case Blik = 'blik';\n",[33,6816,6817],{"class":35,"line":61},[33,6818,6819],{},"    case Transfer = 'transfer';\n",[33,6821,6822],{"class":35,"line":67},[33,6823,6824],{},"    case Financing = 'financing';\n",[33,6826,6827],{"class":35,"line":73},[33,6828,82],{},[33,6830,6831],{"class":35,"line":79},[33,6832,52],{"emptyLinePlaceholder":51},[33,6834,6835],{"class":35,"line":85},[33,6836,6837],{},"interface PaymentGateway\n",[33,6839,6840],{"class":35,"line":90},[33,6841,64],{},[33,6843,6844],{"class":35,"line":96},[33,6845,6846],{},"    public function charge(Money $amount, array $metadata): ChargeResult;\n",[33,6848,6849],{"class":35,"line":384},[33,6850,52],{"emptyLinePlaceholder":51},[33,6852,6853],{"class":35,"line":390},[33,6854,6855],{},"    public function refund(string $chargeId, Money $amount): RefundResult;\n",[33,6857,6858],{"class":35,"line":395},[33,6859,52],{"emptyLinePlaceholder":51},[33,6861,6862],{"class":35,"line":401},[33,6863,6864],{},"    public function parseWebhook(string $payload, array $headers): WebhookEvent;\n",[33,6866,6867],{"class":35,"line":407},[33,6868,82],{},[11,6870,6871],{},"The factory maps a method to a class and lets the container build it:",[23,6873,6875],{"className":25,"code":6874,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Container\\Container;\n\nfinal class PaymentGatewayFactory\n{\n    \u002F** @param array\u003Cstring, class-string\u003CPaymentGateway>> $gateways *\u002F\n    public function __construct(\n        private readonly Container $container,\n        private readonly array $gateways,\n    ) {}\n\n    public function make(PaymentMethod $method): PaymentGateway\n    {\n        $class = $this->gateways[$method->value]\n            ?? throw new UnsupportedPaymentMethod($method->value);\n\n        return $this->container->make($class);\n    }\n}\n",[30,6876,6877,6882,6886,6891,6895,6900,6904,6909,6914,6918,6922,6927,6931,6936,6941,6945,6950,6954],{"__ignoreMap":28},[33,6878,6879],{"class":35,"line":36},[33,6880,6881],{},"use Illuminate\\Contracts\\Container\\Container;\n",[33,6883,6884],{"class":35,"line":42},[33,6885,52],{"emptyLinePlaceholder":51},[33,6887,6888],{"class":35,"line":48},[33,6889,6890],{},"final class PaymentGatewayFactory\n",[33,6892,6893],{"class":35,"line":55},[33,6894,64],{},[33,6896,6897],{"class":35,"line":61},[33,6898,6899],{},"    \u002F** @param array\u003Cstring, class-string\u003CPaymentGateway>> $gateways *\u002F\n",[33,6901,6902],{"class":35,"line":67},[33,6903,4263],{},[33,6905,6906],{"class":35,"line":73},[33,6907,6908],{},"        private readonly Container $container,\n",[33,6910,6911],{"class":35,"line":79},[33,6912,6913],{},"        private readonly array $gateways,\n",[33,6915,6916],{"class":35,"line":85},[33,6917,4278],{},[33,6919,6920],{"class":35,"line":90},[33,6921,52],{"emptyLinePlaceholder":51},[33,6923,6924],{"class":35,"line":96},[33,6925,6926],{},"    public function make(PaymentMethod $method): PaymentGateway\n",[33,6928,6929],{"class":35,"line":384},[33,6930,4183],{},[33,6932,6933],{"class":35,"line":390},[33,6934,6935],{},"        $class = $this->gateways[$method->value]\n",[33,6937,6938],{"class":35,"line":395},[33,6939,6940],{},"            ?? throw new UnsupportedPaymentMethod($method->value);\n",[33,6942,6943],{"class":35,"line":401},[33,6944,52],{"emptyLinePlaceholder":51},[33,6946,6947],{"class":35,"line":407},[33,6948,6949],{},"        return $this->container->make($class);\n",[33,6951,6952],{"class":35,"line":413},[33,6953,4193],{},[33,6955,6956],{"class":35,"line":419},[33,6957,82],{},[11,6959,6960],{},"It's registered once, in a service provider. What each gateway needs in its own constructor (credentials, HTTP client, logger) is bound separately and the container resolves it.",[23,6962,6964],{"className":25,"code":6963,"language":27,"meta":28,"style":28},"public function register(): void\n{\n    $this->app->singleton(PaymentGatewayFactory::class, fn ($app) => new PaymentGatewayFactory($app, [\n        PaymentMethod::Card->value => StripeGateway::class,\n        PaymentMethod::Blik->value => BlikGateway::class,\n        PaymentMethod::Transfer->value => BankTransferGateway::class,\n        PaymentMethod::Financing->value => FinancingGateway::class,\n    ]));\n}\n",[30,6965,6966,6971,6975,6980,6985,6990,6995,7000,7005],{"__ignoreMap":28},[33,6967,6968],{"class":35,"line":36},[33,6969,6970],{},"public function register(): void\n",[33,6972,6973],{"class":35,"line":42},[33,6974,64],{},[33,6976,6977],{"class":35,"line":48},[33,6978,6979],{},"    $this->app->singleton(PaymentGatewayFactory::class, fn ($app) => new PaymentGatewayFactory($app, [\n",[33,6981,6982],{"class":35,"line":55},[33,6983,6984],{},"        PaymentMethod::Card->value => StripeGateway::class,\n",[33,6986,6987],{"class":35,"line":61},[33,6988,6989],{},"        PaymentMethod::Blik->value => BlikGateway::class,\n",[33,6991,6992],{"class":35,"line":67},[33,6993,6994],{},"        PaymentMethod::Transfer->value => BankTransferGateway::class,\n",[33,6996,6997],{"class":35,"line":73},[33,6998,6999],{},"        PaymentMethod::Financing->value => FinancingGateway::class,\n",[33,7001,7002],{"class":35,"line":79},[33,7003,7004],{},"    ]));\n",[33,7006,7007],{"class":35,"line":85},[33,7008,82],{},[11,7010,7011],{},"Call sites now just pass in their own input:",[23,7013,7015],{"className":25,"code":7014,"language":27,"meta":28,"style":28},"\u002F\u002F controller: a FormRequest validates the field with ['required', Rule::enum(PaymentMethod::class)]\n$gateway = $this->gateways->make(PaymentMethod::from($request->validated('payment_method')));\n$result = $gateway->charge($amount, ['order_id' => $order->id]);\n\n\u002F\u002F refund job: the input is a column cast to PaymentMethod\n$this->gateways->make($payment->method)->refund($payment->charge_id, $payment->amount);\n",[30,7016,7017,7022,7027,7032,7036,7041],{"__ignoreMap":28},[33,7018,7019],{"class":35,"line":36},[33,7020,7021],{},"\u002F\u002F controller: a FormRequest validates the field with ['required', Rule::enum(PaymentMethod::class)]\n",[33,7023,7024],{"class":35,"line":42},[33,7025,7026],{},"$gateway = $this->gateways->make(PaymentMethod::from($request->validated('payment_method')));\n",[33,7028,7029],{"class":35,"line":48},[33,7030,7031],{},"$result = $gateway->charge($amount, ['order_id' => $order->id]);\n",[33,7033,7034],{"class":35,"line":55},[33,7035,52],{"emptyLinePlaceholder":51},[33,7037,7038],{"class":35,"line":61},[33,7039,7040],{},"\u002F\u002F refund job: the input is a column cast to PaymentMethod\n",[33,7042,7043],{"class":35,"line":67},[33,7044,7045],{},"$this->gateways->make($payment->method)->refund($payment->charge_id, $payment->amount);\n",[11,7047,7048,7049,7052],{},"A new provider needs a class implementing ",[30,7050,7051],{},"PaymentGateway",", an enum case, one line in the map, and bindings for whatever dependencies the container can't build on its own.",[11,7054,7055,7056,7059,7060,739,7063,7066,7067,7070],{},"Laravel uses the same idea internally in ",[30,7057,7058],{},"Illuminate\\Support\\Manager"," (",[30,7061,7062],{},"driver()",[30,7064,7065],{},"extend()","), which backs drivers such as session, hashing and notification channels. Extending ",[30,7068,7069],{},"Manager"," is an option when implementations are picked by name in config files. For a domain choice like this one I find a dedicated factory easier to read.",[15,7072,7074],{"id":7073},"where-factories-go-wrong","Where factories go wrong",[11,7076,7077,7078,7081],{},"The most common mistake is a factory that builds objects itself. If it calls ",[30,7079,7080],{},"new StripeGateway(config(...))",", it has to change whenever a gateway's constructor changes, and the container never sees the gateway's dependencies. Let the container do the construction. Without a container, pass ready instances, or closures that build them, into the factory's constructor.",[11,7083,7084,7085,7087,7088,7091],{},"The other one is quieter. ",[30,7086,7051],{}," gets methods like ",[30,7089,7090],{},"createPaymentIntent()"," because the first integration happened to be Stripe, and from then on every other implementation has to translate its model into Stripe's. That translation is where the bugs show up. Name the operations after what the application does (charge, refund, interpret a webhook) and keep the translation to each vendor's model inside its class.",[15,7093,7095],{"id":7094},"factory-or-strategy","Factory or Strategy",[11,7097,7098,7099,7101],{},"These two often come together. Strategy is an object whose behavior can be swapped behind an interface, and each ",[30,7100,7051],{}," is a strategy for processing a payment. The factory is where the decision is made which strategy to use for a given input. If the strategy is already known when the application boots, from configuration, you don't need a factory at all. Bind the interface to the implementation in the container and inject it.",[15,7103,7105],{"id":7104},"tests","Tests",[11,7107,7108,7109,7111],{},"For the factory itself one test is enough: every enum case resolves to a ",[30,7110,7051],{},". It fails when someone adds a case and forgets the map entry, and also when the container can't build one of the gateways.",[23,7113,7115],{"className":25,"code":7114,"language":27,"meta":28,"style":28},"public function test_every_payment_method_has_a_gateway(): void\n{\n    $factory = $this->app->make(PaymentGatewayFactory::class);\n\n    foreach (PaymentMethod::cases() as $method) {\n        $this->assertInstanceOf(PaymentGateway::class, $factory->make($method));\n    }\n}\n",[30,7116,7117,7122,7126,7131,7135,7140,7145,7149],{"__ignoreMap":28},[33,7118,7119],{"class":35,"line":36},[33,7120,7121],{},"public function test_every_payment_method_has_a_gateway(): void\n",[33,7123,7124],{"class":35,"line":42},[33,7125,64],{},[33,7127,7128],{"class":35,"line":48},[33,7129,7130],{},"    $factory = $this->app->make(PaymentGatewayFactory::class);\n",[33,7132,7133],{"class":35,"line":55},[33,7134,52],{"emptyLinePlaceholder":51},[33,7136,7137],{"class":35,"line":61},[33,7138,7139],{},"    foreach (PaymentMethod::cases() as $method) {\n",[33,7141,7142],{"class":35,"line":67},[33,7143,7144],{},"        $this->assertInstanceOf(PaymentGateway::class, $factory->make($method));\n",[33,7146,7147],{"class":35,"line":73},[33,7148,4193],{},[33,7150,7151],{"class":35,"line":79},[33,7152,82],{},[11,7154,7155,7156,7159],{},"A shared contract test that each gateway's test class extends gives more. Each subclass provides its gateway with a faked transport, for instance ",[30,7157,7158],{},"Http::fake()"," with a sample provider response if the gateway uses Laravel's HTTP client.",[23,7161,7163],{"className":25,"code":7162,"language":27,"meta":28,"style":28},"abstract class PaymentGatewayContractTest extends TestCase\n{\n    abstract protected function gateway(): PaymentGateway;\n\n    public function test_charge_returns_a_charge_id(): void\n    {\n        $result = $this->gateway()->charge(new Money(1000, 'PLN'), ['order_id' => 'test-123']);\n\n        $this->assertNotSame('', $result->chargeId);\n    }\n}\n",[30,7164,7165,7170,7174,7179,7183,7188,7192,7197,7201,7206,7210],{"__ignoreMap":28},[33,7166,7167],{"class":35,"line":36},[33,7168,7169],{},"abstract class PaymentGatewayContractTest extends TestCase\n",[33,7171,7172],{"class":35,"line":42},[33,7173,64],{},[33,7175,7176],{"class":35,"line":48},[33,7177,7178],{},"    abstract protected function gateway(): PaymentGateway;\n",[33,7180,7181],{"class":35,"line":55},[33,7182,52],{"emptyLinePlaceholder":51},[33,7184,7185],{"class":35,"line":61},[33,7186,7187],{},"    public function test_charge_returns_a_charge_id(): void\n",[33,7189,7190],{"class":35,"line":67},[33,7191,4183],{},[33,7193,7194],{"class":35,"line":73},[33,7195,7196],{},"        $result = $this->gateway()->charge(new Money(1000, 'PLN'), ['order_id' => 'test-123']);\n",[33,7198,7199],{"class":35,"line":79},[33,7200,52],{"emptyLinePlaceholder":51},[33,7202,7203],{"class":35,"line":85},[33,7204,7205],{},"        $this->assertNotSame('', $result->chargeId);\n",[33,7207,7208],{"class":35,"line":90},[33,7209,4193],{},[33,7211,7212],{"class":35,"line":96},[33,7213,82],{},[15,7215,7217,7218,7220],{"id":7216},"when-a-binding-or-a-match-is-enough","When a binding or a ",[30,7219,6511],{}," is enough",[11,7222,7223,7224,7226],{},"With one implementation, or a choice that's fixed per deployment, a container binding does the job. If the choice is made in exactly one place, a ",[30,7225,6511],{}," on the enum right there is clearer than an extra class.",[11,7228,7229,7230,7232],{},"Sometimes the implementations don't share an interface that callers can use without ",[30,7231,4778],{}," checks. A factory won't help with that, and the interface needs fixing first.",[892,7234,894],{},{"title":28,"searchDepth":42,"depth":42,"links":7236},[7237,7238,7239,7240,7241,7242,7243],{"id":6612,"depth":42,"text":6613},{"id":6725,"depth":42,"text":6726},{"id":6775,"depth":42,"text":6776},{"id":7073,"depth":42,"text":7074},{"id":7094,"depth":42,"text":7095},{"id":7104,"depth":42,"text":7105},{"id":7216,"depth":42,"text":7244},"When a binding or a match is enough","2024-07-15",{},{"x":7248,"y":7249,"depth":36,"size":911},0.58,0.48,[918,2510],{"title":6604,"description":6609},"object-creation","articles\u002Ffactory-method",[27,5020,7255,7256,5022],"factory","dependency-injection","4BI-eAs64GyxSk-1wEmpVyUz-vsUkL2CwqvKfUgHA00",{"id":7259,"title":7260,"articleId":7261,"body":7262,"category":2500,"codeLang":27,"date":8566,"deploys":36,"description":7266,"excerpt":910,"extension":911,"lang":910,"meta":8567,"navigation":51,"path":8568,"pos":8569,"readMin":96,"related":8572,"seo":8573,"service":8574,"stem":8575,"tags":8576,"updated":2519,"version":5025,"__hash__":8582},"articles\u002Farticles\u002Fllm-in-php.md","LLMs in a PHP application: queues, idempotency and validation instead of a Python rewrite","llm-in-php",{"type":8,"value":7263,"toc":8555},[7264,7267,7270,7274,7277,7300,7303,7307,7454,7469,7476,7479,7483,7486,7507,7510,7720,7734,7738,7762,8025,8036,8047,8051,8062,8272,8275,8282,8285,8289,8292,8352,8456,8482,8488,8491,8495,8505,8519,8522,8526,8529,8533,8553],[11,7265,7266],{},"A hosted language model (Anthropic, OpenAI, Mistral) is an HTTPS endpoint that takes JSON and returns JSON. It doesn't care what language the caller is written in. Python does have a real advantage for training, fine-tuning and running models locally. For adding LLM features to an existing PHP system, the hard parts are elsewhere: long and unpredictable latency, output that differs between calls, validation of that output, and cost. Those are queue, database and contract problems. A PHP application already has tools for all of them.",[11,7268,7269],{},"This article walks through those problems in a Laravel application. The examples use the Anthropic Messages API over Laravel's HTTP client, so every field in the code exists in the provider's documentation.",[15,7271,7273],{"id":7272},"library-or-plain-http","Library or plain HTTP",[11,7275,7276],{},"The usual options:",[3583,7278,7279,7286,7293],{},[3586,7280,7281,7282,7285],{},"Plain HTTP through Guzzle or Laravel's ",[30,7283,7284],{},"Http"," facade. One endpoint, a few headers, no extra dependency. Enough when you call one provider for a handful of features.",[3586,7287,7288,7289,7292],{},"A client library: ",[30,7290,7291],{},"openai-php\u002Fclient"," (with a Laravel wrapper), Prism for Laravel, or LLPhant. Depending on the library, they add provider abstraction, streaming helpers or ready-made retrieval components.",[3586,7294,7295,7296,7299],{},"Symfony's AI components (the ",[30,7297,7298],{},"symfony\u002Fai"," project), if the application is built on Symfony and you want integration with its container and configuration.",[11,7301,7302],{},"Whichever you choose, check before production that you can set connect and read timeouts, you can decide which errors are retried, you get token usage from every response, and you can add your own logging around the call. Some libraries hide one of these behind their own defaults, and then you spend time working around them.",[15,7304,7306],{"id":7305},"a-thin-client","A thin client",[23,7308,7310],{"className":25,"code":7309,"language":27,"meta":28,"style":28},"use Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\Client\\RequestException;\nuse Illuminate\\Support\\Facades\\Http;\n\nfinal class AnthropicClient\n{\n    public function __construct(\n        private readonly string $apiKey,\n        private readonly string $model,\n    ) {}\n\n    \u002F** @return array\u003Cstring, mixed> *\u002F\n    public function messages(array $payload): array\n    {\n        return Http::baseUrl('https:\u002F\u002Fapi.anthropic.com\u002Fv1')\n            ->withHeaders([\n                'x-api-key' => $this->apiKey,\n                'anthropic-version' => '2023-06-01',\n            ])\n            ->connectTimeout(5)\n            ->timeout(30)\n            ->retry(2, 2000, fn (\\Throwable $e): bool =>\n                $e instanceof ConnectionException\n                || ($e instanceof RequestException\n                    && in_array($e->response->status(), [429, 500, 529], true))\n            )\n            ->post('\u002Fmessages', ['model' => $this->model, ...$payload])\n            ->json();\n    }\n}\n",[30,7311,7312,7317,7322,7327,7331,7336,7340,7344,7349,7354,7358,7362,7367,7372,7376,7381,7386,7391,7396,7401,7406,7411,7416,7421,7426,7431,7436,7441,7446,7450],{"__ignoreMap":28},[33,7313,7314],{"class":35,"line":36},[33,7315,7316],{},"use Illuminate\\Http\\Client\\ConnectionException;\n",[33,7318,7319],{"class":35,"line":42},[33,7320,7321],{},"use Illuminate\\Http\\Client\\RequestException;\n",[33,7323,7324],{"class":35,"line":48},[33,7325,7326],{},"use Illuminate\\Support\\Facades\\Http;\n",[33,7328,7329],{"class":35,"line":55},[33,7330,52],{"emptyLinePlaceholder":51},[33,7332,7333],{"class":35,"line":61},[33,7334,7335],{},"final class AnthropicClient\n",[33,7337,7338],{"class":35,"line":67},[33,7339,64],{},[33,7341,7342],{"class":35,"line":73},[33,7343,4263],{},[33,7345,7346],{"class":35,"line":79},[33,7347,7348],{},"        private readonly string $apiKey,\n",[33,7350,7351],{"class":35,"line":85},[33,7352,7353],{},"        private readonly string $model,\n",[33,7355,7356],{"class":35,"line":90},[33,7357,4278],{},[33,7359,7360],{"class":35,"line":96},[33,7361,52],{"emptyLinePlaceholder":51},[33,7363,7364],{"class":35,"line":384},[33,7365,7366],{},"    \u002F** @return array\u003Cstring, mixed> *\u002F\n",[33,7368,7369],{"class":35,"line":390},[33,7370,7371],{},"    public function messages(array $payload): array\n",[33,7373,7374],{"class":35,"line":395},[33,7375,4183],{},[33,7377,7378],{"class":35,"line":401},[33,7379,7380],{},"        return Http::baseUrl('https:\u002F\u002Fapi.anthropic.com\u002Fv1')\n",[33,7382,7383],{"class":35,"line":407},[33,7384,7385],{},"            ->withHeaders([\n",[33,7387,7388],{"class":35,"line":413},[33,7389,7390],{},"                'x-api-key' => $this->apiKey,\n",[33,7392,7393],{"class":35,"line":419},[33,7394,7395],{},"                'anthropic-version' => '2023-06-01',\n",[33,7397,7398],{"class":35,"line":1806},[33,7399,7400],{},"            ])\n",[33,7402,7403],{"class":35,"line":1812},[33,7404,7405],{},"            ->connectTimeout(5)\n",[33,7407,7408],{"class":35,"line":1817},[33,7409,7410],{},"            ->timeout(30)\n",[33,7412,7413],{"class":35,"line":1822},[33,7414,7415],{},"            ->retry(2, 2000, fn (\\Throwable $e): bool =>\n",[33,7417,7418],{"class":35,"line":1828},[33,7419,7420],{},"                $e instanceof ConnectionException\n",[33,7422,7423],{"class":35,"line":1834},[33,7424,7425],{},"                || ($e instanceof RequestException\n",[33,7427,7428],{"class":35,"line":1840},[33,7429,7430],{},"                    && in_array($e->response->status(), [429, 500, 529], true))\n",[33,7432,7433],{"class":35,"line":1846},[33,7434,7435],{},"            )\n",[33,7437,7438],{"class":35,"line":1851},[33,7439,7440],{},"            ->post('\u002Fmessages', ['model' => $this->model, ...$payload])\n",[33,7442,7443],{"class":35,"line":1856},[33,7444,7445],{},"            ->json();\n",[33,7447,7448],{"class":35,"line":1862},[33,7449,4193],{},[33,7451,7452],{"class":35,"line":1868},[33,7453,82],{},[11,7455,7456,7457,7460,7461,7464,7465,7468],{},"The model name lives in configuration. Providers retire model versions on their own schedule. Retries cover only connection errors, rate limiting (429), internal server errors (500) and overload (529). A 400 means the request itself is wrong, so the client doesn't retry it. The exception still leaves ",[30,7458,7459],{},"messages()",", though, and a job with ",[30,7462,7463],{},"$tries = 3"," will try again unless it calls ",[30,7466,7467],{},"fail()"," on a 4xx itself.",[11,7470,7471,7472,7475],{},"The read timeout must match the largest response you ask for. Time grows with the length of the response, and ",[30,7473,7474],{},"max_tokens"," sets its upper bound. A classification limited to 256 tokens finishes in seconds (on models where thinking can't be turned off, thinking tokens count toward that limit too, so it has to go up); a long summary may not fit in 30 seconds and needs either a higher timeout or streaming.",[11,7477,7478],{},"With local models the same thing gets much more visible. On my home server with Ollama, a small 4B model on CPU generated 12 tokens per second, and a full answer on a log of about 3,000 tokens took 146 seconds, with reading the log (prefill) running at 28 tok\u002Fs. With a graphics card it was 66 tok\u002Fs, and prefill ran at 1,900 to 3,400 tok\u002Fs. The first request after the model was loaded still ran at 3 tok\u002Fs, so I set the timeouts for that cold start and not for the warm average.",[15,7480,7482],{"id":7481},"model-calls-in-queue-jobs","Model calls in queue jobs",[11,7484,7485],{},"Most LLM work belongs in a queue. The user shouldn't hold an HTTP request open for several seconds, and a worker can retry. Trouble starts with how the timeouts relate to each other.",[11,7487,7488,7489,7492,7493,7496,7497,7499,7500,7503,7504,7506],{},"In Laravel, a job that runs longer than the connection's ",[30,7490,7491],{},"retry_after"," (in ",[30,7494,7495],{},"config\u002Fqueue.php",") is handed to another worker while the first one is still running it. With a slow API this produces two parallel executions of the same job, two paid calls and possibly two different results. The Laravel documentation requires the job's timeout to be shorter than ",[30,7498,7491],{},", by at least several seconds for the worker's ",[30,7501,7502],{},"--timeout",". In addition, the job's timeout must cover the entire client call including its retries. With the client above that is at most 2 × 30 s plus a 2 s pause, so a job timeout of 90 s and ",[30,7505,7491],{}," of 120 s are consistent.",[11,7508,7509],{},"Then there's non-determinism. The same input can produce a different classification on the next call, so a retry isn't really a repetition of the same operation. If the first attempt saved a result and something downstream already acted on it, a retry may overwrite it with a different answer. The fix is to make the write conditional, so only one attempt can commit:",[23,7511,7513],{"className":25,"code":7512,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Queue\\ShouldBeUnique;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Bus\\Dispatchable;\nuse Illuminate\\Foundation\\Queue\\Queueable;\nuse Illuminate\\Queue\\InteractsWithQueue;\n\nfinal class TriageTicket implements ShouldQueue, ShouldBeUnique\n{\n    use Dispatchable, InteractsWithQueue, Queueable;\n\n    public int $timeout = 90;\n    public int $tries = 3;\n    public array $backoff = [10, 60];\n    public int $uniqueFor = 600;\n\n    public function __construct(public readonly int $ticketId) {}\n\n    public function uniqueId(): string\n    {\n        return (string) $this->ticketId;\n    }\n\n    public function handle(TicketClassifier $classifier): void\n    {\n        $ticket = Ticket::findOrFail($this->ticketId);\n\n        if ($ticket->triaged_at !== null) {\n            return;\n        }\n\n        $result = $classifier->classify($ticket->body);\n\n        $updated = Ticket::whereKey($ticket->id)\n            ->whereNull('triaged_at')\n            ->update([\n                'department' => $result->department->value,\n                'priority' => $result->priority->value,\n                'triaged_at' => now(),\n            ]);\n\n        if ($updated === 1) {\n            TicketTriaged::dispatch($ticket->id);\n        }\n    }\n}\n",[30,7514,7515,7520,7524,7529,7534,7539,7543,7548,7552,7557,7561,7566,7571,7576,7581,7585,7590,7594,7599,7603,7608,7612,7616,7621,7625,7630,7634,7639,7643,7647,7651,7656,7660,7665,7670,7675,7680,7685,7690,7694,7698,7703,7708,7712,7716],{"__ignoreMap":28},[33,7516,7517],{"class":35,"line":36},[33,7518,7519],{},"use Illuminate\\Contracts\\Queue\\ShouldBeUnique;\n",[33,7521,7522],{"class":35,"line":42},[33,7523,6340],{},[33,7525,7526],{"class":35,"line":48},[33,7527,7528],{},"use Illuminate\\Foundation\\Bus\\Dispatchable;\n",[33,7530,7531],{"class":35,"line":55},[33,7532,7533],{},"use Illuminate\\Foundation\\Queue\\Queueable;\n",[33,7535,7536],{"class":35,"line":61},[33,7537,7538],{},"use Illuminate\\Queue\\InteractsWithQueue;\n",[33,7540,7541],{"class":35,"line":67},[33,7542,52],{"emptyLinePlaceholder":51},[33,7544,7545],{"class":35,"line":73},[33,7546,7547],{},"final class TriageTicket implements ShouldQueue, ShouldBeUnique\n",[33,7549,7550],{"class":35,"line":79},[33,7551,64],{},[33,7553,7554],{"class":35,"line":85},[33,7555,7556],{},"    use Dispatchable, InteractsWithQueue, Queueable;\n",[33,7558,7559],{"class":35,"line":90},[33,7560,52],{"emptyLinePlaceholder":51},[33,7562,7563],{"class":35,"line":96},[33,7564,7565],{},"    public int $timeout = 90;\n",[33,7567,7568],{"class":35,"line":384},[33,7569,7570],{},"    public int $tries = 3;\n",[33,7572,7573],{"class":35,"line":390},[33,7574,7575],{},"    public array $backoff = [10, 60];\n",[33,7577,7578],{"class":35,"line":395},[33,7579,7580],{},"    public int $uniqueFor = 600;\n",[33,7582,7583],{"class":35,"line":401},[33,7584,52],{"emptyLinePlaceholder":51},[33,7586,7587],{"class":35,"line":407},[33,7588,7589],{},"    public function __construct(public readonly int $ticketId) {}\n",[33,7591,7592],{"class":35,"line":413},[33,7593,52],{"emptyLinePlaceholder":51},[33,7595,7596],{"class":35,"line":419},[33,7597,7598],{},"    public function uniqueId(): string\n",[33,7600,7601],{"class":35,"line":1806},[33,7602,4183],{},[33,7604,7605],{"class":35,"line":1812},[33,7606,7607],{},"        return (string) $this->ticketId;\n",[33,7609,7610],{"class":35,"line":1817},[33,7611,4193],{},[33,7613,7614],{"class":35,"line":1822},[33,7615,52],{"emptyLinePlaceholder":51},[33,7617,7618],{"class":35,"line":1828},[33,7619,7620],{},"    public function handle(TicketClassifier $classifier): void\n",[33,7622,7623],{"class":35,"line":1834},[33,7624,4183],{},[33,7626,7627],{"class":35,"line":1840},[33,7628,7629],{},"        $ticket = Ticket::findOrFail($this->ticketId);\n",[33,7631,7632],{"class":35,"line":1846},[33,7633,52],{"emptyLinePlaceholder":51},[33,7635,7636],{"class":35,"line":1851},[33,7637,7638],{},"        if ($ticket->triaged_at !== null) {\n",[33,7640,7641],{"class":35,"line":1856},[33,7642,4746],{},[33,7644,7645],{"class":35,"line":1862},[33,7646,4681],{},[33,7648,7649],{"class":35,"line":1868},[33,7650,52],{"emptyLinePlaceholder":51},[33,7652,7653],{"class":35,"line":1874},[33,7654,7655],{},"        $result = $classifier->classify($ticket->body);\n",[33,7657,7658],{"class":35,"line":1880},[33,7659,52],{"emptyLinePlaceholder":51},[33,7661,7662],{"class":35,"line":1886},[33,7663,7664],{},"        $updated = Ticket::whereKey($ticket->id)\n",[33,7666,7667],{"class":35,"line":1891},[33,7668,7669],{},"            ->whereNull('triaged_at')\n",[33,7671,7672],{"class":35,"line":1897},[33,7673,7674],{},"            ->update([\n",[33,7676,7677],{"class":35,"line":1903},[33,7678,7679],{},"                'department' => $result->department->value,\n",[33,7681,7682],{"class":35,"line":1909},[33,7683,7684],{},"                'priority' => $result->priority->value,\n",[33,7686,7687],{"class":35,"line":1915},[33,7688,7689],{},"                'triaged_at' => now(),\n",[33,7691,7692],{"class":35,"line":3270},[33,7693,6075],{},[33,7695,7696],{"class":35,"line":3282},[33,7697,52],{"emptyLinePlaceholder":51},[33,7699,7700],{"class":35,"line":3290},[33,7701,7702],{},"        if ($updated === 1) {\n",[33,7704,7705],{"class":35,"line":3301},[33,7706,7707],{},"            TicketTriaged::dispatch($ticket->id);\n",[33,7709,7710],{"class":35,"line":3312},[33,7711,4681],{},[33,7713,7714],{"class":35,"line":3323},[33,7715,4193],{},[33,7717,7718],{"class":35,"line":3333},[33,7719,82],{},[11,7721,7722,7725,7726,7729,7730,7733],{},[30,7723,7724],{},"ShouldBeUnique"," prevents a second copy of the job from being queued until the first one finishes processing or fails all its attempts (or ",[30,7727,7728],{},"uniqueFor"," expires). The ",[30,7731,7732],{},"whereNull('triaged_at')"," condition decides which attempt wins if two run anyway. The follow-up event fires only for the attempt that actually wrote the row. A retry may still pay for a second API call, but the row and the event happen only once.",[15,7735,7737],{"id":7736},"structured-output-and-validation","Structured output and validation",[11,7739,7740,7741,7744,7745,7747,7748,7751,7752,7754,7755,7758,7759,7761],{},"Free text is a poor interface between a model and code. For classification and extraction, use structured outputs: pass a JSON Schema in ",[30,7742,7743],{},"output_config.format"," and the model returns a ",[30,7746,4113],{}," block containing JSON that matches it. Forcing a tool call with ",[30,7749,7750],{},"tool_choice"," is the older workaround for getting structured data, and the newest models (e.g. Claude Opus 5.5) reject a forced ",[30,7753,7750],{}," with a 400. The documentation lists cases where the output does not match the schema: a refusal (",[30,7756,7757],{},"stop_reason: refusal","), a response cut off by ",[30,7760,7474],{},", and enum values returned with different capitalization, which is why the code validates the result.",[23,7763,7765],{"className":25,"code":7764,"language":27,"meta":28,"style":28},"enum Department: string\n{\n    case Billing = 'billing';\n    case Technical = 'technical';\n    case Sales = 'sales';\n}\n\nenum Priority: string\n{\n    case Low = 'low';\n    case Normal = 'normal';\n    case Urgent = 'urgent';\n}\n\nfinal class TicketClassifier\n{\n    public function __construct(private readonly AnthropicClient $client) {}\n\n    public function classify(string $body): TicketClassification\n    {\n        $values = fn (array $cases): array => array_map(fn ($c) => $c->value, $cases);\n\n        $response = $this->client->messages([\n            'max_tokens' => 256,\n            'system' => 'Classify the customer support ticket by department and priority.',\n            'messages' => [['role' => 'user', 'content' => $body]],\n            'output_config' => [\n                'format' => [\n                    'type' => 'json_schema',\n                    'schema' => [\n                        'type' => 'object',\n                        'properties' => [\n                            'department' => ['type' => 'string', 'enum' => $values(Department::cases())],\n                            'priority' => ['type' => 'string', 'enum' => $values(Priority::cases())],\n                        ],\n                        'required' => ['department', 'priority'],\n                        'additionalProperties' => false,\n                    ],\n                ],\n            ],\n        ]);\n\n        $text = collect($response['content'])->firstWhere('type', 'text')['text'] ?? '';\n        $output = (array) json_decode($text, true);\n\n        $department = Department::tryFrom(strtolower((string) ($output['department'] ?? '')));\n        $priority = Priority::tryFrom(strtolower((string) ($output['priority'] ?? '')));\n\n        if ($department === null || $priority === null) {\n            throw new InvalidModelOutput('ticket_classification', $response);\n        }\n\n        return new TicketClassification($department, $priority, $response['usage']);\n    }\n}\n",[30,7766,7767,7772,7776,7781,7786,7791,7795,7799,7804,7808,7813,7818,7823,7827,7831,7836,7840,7845,7849,7854,7858,7863,7867,7872,7877,7882,7887,7892,7897,7902,7907,7912,7917,7922,7927,7932,7937,7942,7947,7952,7957,7962,7966,7971,7976,7980,7985,7990,7994,7999,8004,8008,8012,8017,8021],{"__ignoreMap":28},[33,7768,7769],{"class":35,"line":36},[33,7770,7771],{},"enum Department: string\n",[33,7773,7774],{"class":35,"line":42},[33,7775,64],{},[33,7777,7778],{"class":35,"line":48},[33,7779,7780],{},"    case Billing = 'billing';\n",[33,7782,7783],{"class":35,"line":55},[33,7784,7785],{},"    case Technical = 'technical';\n",[33,7787,7788],{"class":35,"line":61},[33,7789,7790],{},"    case Sales = 'sales';\n",[33,7792,7793],{"class":35,"line":67},[33,7794,82],{},[33,7796,7797],{"class":35,"line":73},[33,7798,52],{"emptyLinePlaceholder":51},[33,7800,7801],{"class":35,"line":79},[33,7802,7803],{},"enum Priority: string\n",[33,7805,7806],{"class":35,"line":85},[33,7807,64],{},[33,7809,7810],{"class":35,"line":90},[33,7811,7812],{},"    case Low = 'low';\n",[33,7814,7815],{"class":35,"line":96},[33,7816,7817],{},"    case Normal = 'normal';\n",[33,7819,7820],{"class":35,"line":384},[33,7821,7822],{},"    case Urgent = 'urgent';\n",[33,7824,7825],{"class":35,"line":390},[33,7826,82],{},[33,7828,7829],{"class":35,"line":395},[33,7830,52],{"emptyLinePlaceholder":51},[33,7832,7833],{"class":35,"line":401},[33,7834,7835],{},"final class TicketClassifier\n",[33,7837,7838],{"class":35,"line":407},[33,7839,64],{},[33,7841,7842],{"class":35,"line":413},[33,7843,7844],{},"    public function __construct(private readonly AnthropicClient $client) {}\n",[33,7846,7847],{"class":35,"line":419},[33,7848,52],{"emptyLinePlaceholder":51},[33,7850,7851],{"class":35,"line":1806},[33,7852,7853],{},"    public function classify(string $body): TicketClassification\n",[33,7855,7856],{"class":35,"line":1812},[33,7857,4183],{},[33,7859,7860],{"class":35,"line":1817},[33,7861,7862],{},"        $values = fn (array $cases): array => array_map(fn ($c) => $c->value, $cases);\n",[33,7864,7865],{"class":35,"line":1822},[33,7866,52],{"emptyLinePlaceholder":51},[33,7868,7869],{"class":35,"line":1828},[33,7870,7871],{},"        $response = $this->client->messages([\n",[33,7873,7874],{"class":35,"line":1834},[33,7875,7876],{},"            'max_tokens' => 256,\n",[33,7878,7879],{"class":35,"line":1840},[33,7880,7881],{},"            'system' => 'Classify the customer support ticket by department and priority.',\n",[33,7883,7884],{"class":35,"line":1846},[33,7885,7886],{},"            'messages' => [['role' => 'user', 'content' => $body]],\n",[33,7888,7889],{"class":35,"line":1851},[33,7890,7891],{},"            'output_config' => [\n",[33,7893,7894],{"class":35,"line":1856},[33,7895,7896],{},"                'format' => [\n",[33,7898,7899],{"class":35,"line":1862},[33,7900,7901],{},"                    'type' => 'json_schema',\n",[33,7903,7904],{"class":35,"line":1868},[33,7905,7906],{},"                    'schema' => [\n",[33,7908,7909],{"class":35,"line":1874},[33,7910,7911],{},"                        'type' => 'object',\n",[33,7913,7914],{"class":35,"line":1880},[33,7915,7916],{},"                        'properties' => [\n",[33,7918,7919],{"class":35,"line":1886},[33,7920,7921],{},"                            'department' => ['type' => 'string', 'enum' => $values(Department::cases())],\n",[33,7923,7924],{"class":35,"line":1891},[33,7925,7926],{},"                            'priority' => ['type' => 'string', 'enum' => $values(Priority::cases())],\n",[33,7928,7929],{"class":35,"line":1897},[33,7930,7931],{},"                        ],\n",[33,7933,7934],{"class":35,"line":1903},[33,7935,7936],{},"                        'required' => ['department', 'priority'],\n",[33,7938,7939],{"class":35,"line":1909},[33,7940,7941],{},"                        'additionalProperties' => false,\n",[33,7943,7944],{"class":35,"line":1915},[33,7945,7946],{},"                    ],\n",[33,7948,7949],{"class":35,"line":3270},[33,7950,7951],{},"                ],\n",[33,7953,7954],{"class":35,"line":3282},[33,7955,7956],{},"            ],\n",[33,7958,7959],{"class":35,"line":3290},[33,7960,7961],{},"        ]);\n",[33,7963,7964],{"class":35,"line":3301},[33,7965,52],{"emptyLinePlaceholder":51},[33,7967,7968],{"class":35,"line":3312},[33,7969,7970],{},"        $text = collect($response['content'])->firstWhere('type', 'text')['text'] ?? '';\n",[33,7972,7973],{"class":35,"line":3323},[33,7974,7975],{},"        $output = (array) json_decode($text, true);\n",[33,7977,7978],{"class":35,"line":3333},[33,7979,52],{"emptyLinePlaceholder":51},[33,7981,7982],{"class":35,"line":3344},[33,7983,7984],{},"        $department = Department::tryFrom(strtolower((string) ($output['department'] ?? '')));\n",[33,7986,7987],{"class":35,"line":3354},[33,7988,7989],{},"        $priority = Priority::tryFrom(strtolower((string) ($output['priority'] ?? '')));\n",[33,7991,7992],{"class":35,"line":3364},[33,7993,52],{"emptyLinePlaceholder":51},[33,7995,7996],{"class":35,"line":3369},[33,7997,7998],{},"        if ($department === null || $priority === null) {\n",[33,8000,8001],{"class":35,"line":3377},[33,8002,8003],{},"            throw new InvalidModelOutput('ticket_classification', $response);\n",[33,8005,8006],{"class":35,"line":3389},[33,8007,4681],{},[33,8009,8010],{"class":35,"line":3397},[33,8011,52],{"emptyLinePlaceholder":51},[33,8013,8014],{"class":35,"line":3407},[33,8015,8016],{},"        return new TicketClassification($department, $priority, $response['usage']);\n",[33,8018,8019],{"class":35,"line":3416},[33,8020,4193],{},[33,8022,8023],{"class":35,"line":3426},[33,8024,82],{},[11,8026,8027,8028,8031,8032,8035],{},"The enums are the single source of allowed values: they generate the schema and they validate the answer. Values are lowercased before ",[30,8029,8030],{},"tryFrom()",", because capitalization of enum values isn't guaranteed. An invalid answer raises an exception, the job retries, and after the last attempt the ticket stays unclassified and the job lands in ",[30,8033,8034],{},"failed_jobs",". Someone will see an unclassified ticket there, while a guessed value would go unnoticed.",[11,8037,8038,8039,433,8042,2582,8044,221],{},"I saw how confident a wrong answer can look when I tested small local models on PHP tasks. Both 4B models wrote an incorrect NIP (Polish tax ID) validation and presented it without any hesitation. A larger 26B model got it right, 6 out of 6 test cases, at 33 tok\u002Fs. One of the models also ignored the setting that turns \"thinking\" off and used up the whole 400-token limit on reasoning, so part of the answers came back empty with a normal HTTP status. The API reports no error then, so the code has to catch that case itself. The same goes for the tool loop below, which can return empty text when ",[30,8040,8041],{},"stop_reason",[30,8043,7474],{},[30,8045,8046],{},"refusal",[15,8048,8050],{"id":8049},"tool-calls-run-with-the-users-permissions","Tool calls run with the user's permissions",[11,8052,8053,8054,8057,8058,8061],{},"When the model may call application functions (order status, refund calculation), PHP has an advantage: the tools are existing services with existing authorization. The loop below follows the Messages API protocol. The model responds with ",[30,8055,8056],{},"stop_reason: tool_use",", the application runs the tools and sends back ",[30,8059,8060],{},"tool_result"," blocks, and this repeats until the model returns text.",[23,8063,8065],{"className":25,"code":8064,"language":27,"meta":28,"style":28},"public function answer(User $user, string $question): string\n{\n    $messages = [['role' => 'user', 'content' => $question]];\n\n    for ($step = 0; $step \u003C 5; $step++) {\n        $response = $this->client->messages([\n            'max_tokens' => 1024,\n            'tools' => $this->tools,\n            'messages' => $messages,\n        ]);\n\n        if ($response['stop_reason'] !== 'tool_use') {\n            return collect($response['content'])\n                ->where('type', 'text')\n                ->pluck('text')\n                ->implode(\"\\n\");\n        }\n\n        $messages[] = ['role' => 'assistant', 'content' => $response['content']];\n\n        $results = [];\n        foreach ($response['content'] as $block) {\n            if ($block['type'] !== 'tool_use') {\n                continue;\n            }\n            $results[] = [\n                'type' => 'tool_result',\n                'tool_use_id' => $block['id'],\n                'content' => json_encode($this->runTool($user, $block['name'], $block['input']), JSON_THROW_ON_ERROR),\n            ];\n        }\n        $messages[] = ['role' => 'user', 'content' => $results];\n    }\n\n    throw new ToolLoopLimitExceeded($step);\n}\n\nprivate function runTool(User $user, string $name, array $input): array\n{\n    return match ($name) {\n        'get_order_status' => $this->orders->statusFor($user, (string) ($input['order_id'] ?? '')),\n        default => ['error' => \"Unknown tool: {$name}\"],\n    };\n}\n",[30,8066,8067,8072,8076,8081,8085,8090,8094,8099,8104,8109,8113,8117,8122,8127,8132,8137,8142,8146,8150,8155,8159,8164,8169,8174,8179,8184,8189,8194,8199,8204,8209,8213,8218,8222,8226,8231,8235,8239,8244,8248,8253,8258,8263,8268],{"__ignoreMap":28},[33,8068,8069],{"class":35,"line":36},[33,8070,8071],{},"public function answer(User $user, string $question): string\n",[33,8073,8074],{"class":35,"line":42},[33,8075,64],{},[33,8077,8078],{"class":35,"line":48},[33,8079,8080],{},"    $messages = [['role' => 'user', 'content' => $question]];\n",[33,8082,8083],{"class":35,"line":55},[33,8084,52],{"emptyLinePlaceholder":51},[33,8086,8087],{"class":35,"line":61},[33,8088,8089],{},"    for ($step = 0; $step \u003C 5; $step++) {\n",[33,8091,8092],{"class":35,"line":67},[33,8093,7871],{},[33,8095,8096],{"class":35,"line":73},[33,8097,8098],{},"            'max_tokens' => 1024,\n",[33,8100,8101],{"class":35,"line":79},[33,8102,8103],{},"            'tools' => $this->tools,\n",[33,8105,8106],{"class":35,"line":85},[33,8107,8108],{},"            'messages' => $messages,\n",[33,8110,8111],{"class":35,"line":90},[33,8112,7961],{},[33,8114,8115],{"class":35,"line":96},[33,8116,52],{"emptyLinePlaceholder":51},[33,8118,8119],{"class":35,"line":384},[33,8120,8121],{},"        if ($response['stop_reason'] !== 'tool_use') {\n",[33,8123,8124],{"class":35,"line":390},[33,8125,8126],{},"            return collect($response['content'])\n",[33,8128,8129],{"class":35,"line":395},[33,8130,8131],{},"                ->where('type', 'text')\n",[33,8133,8134],{"class":35,"line":401},[33,8135,8136],{},"                ->pluck('text')\n",[33,8138,8139],{"class":35,"line":407},[33,8140,8141],{},"                ->implode(\"\\n\");\n",[33,8143,8144],{"class":35,"line":413},[33,8145,4681],{},[33,8147,8148],{"class":35,"line":419},[33,8149,52],{"emptyLinePlaceholder":51},[33,8151,8152],{"class":35,"line":1806},[33,8153,8154],{},"        $messages[] = ['role' => 'assistant', 'content' => $response['content']];\n",[33,8156,8157],{"class":35,"line":1812},[33,8158,52],{"emptyLinePlaceholder":51},[33,8160,8161],{"class":35,"line":1817},[33,8162,8163],{},"        $results = [];\n",[33,8165,8166],{"class":35,"line":1822},[33,8167,8168],{},"        foreach ($response['content'] as $block) {\n",[33,8170,8171],{"class":35,"line":1828},[33,8172,8173],{},"            if ($block['type'] !== 'tool_use') {\n",[33,8175,8176],{"class":35,"line":1834},[33,8177,8178],{},"                continue;\n",[33,8180,8181],{"class":35,"line":1840},[33,8182,8183],{},"            }\n",[33,8185,8186],{"class":35,"line":1846},[33,8187,8188],{},"            $results[] = [\n",[33,8190,8191],{"class":35,"line":1851},[33,8192,8193],{},"                'type' => 'tool_result',\n",[33,8195,8196],{"class":35,"line":1856},[33,8197,8198],{},"                'tool_use_id' => $block['id'],\n",[33,8200,8201],{"class":35,"line":1862},[33,8202,8203],{},"                'content' => json_encode($this->runTool($user, $block['name'], $block['input']), JSON_THROW_ON_ERROR),\n",[33,8205,8206],{"class":35,"line":1868},[33,8207,8208],{},"            ];\n",[33,8210,8211],{"class":35,"line":1874},[33,8212,4681],{},[33,8214,8215],{"class":35,"line":1880},[33,8216,8217],{},"        $messages[] = ['role' => 'user', 'content' => $results];\n",[33,8219,8220],{"class":35,"line":1886},[33,8221,4193],{},[33,8223,8224],{"class":35,"line":1891},[33,8225,52],{"emptyLinePlaceholder":51},[33,8227,8228],{"class":35,"line":1897},[33,8229,8230],{},"    throw new ToolLoopLimitExceeded($step);\n",[33,8232,8233],{"class":35,"line":1903},[33,8234,82],{},[33,8236,8237],{"class":35,"line":1909},[33,8238,52],{"emptyLinePlaceholder":51},[33,8240,8241],{"class":35,"line":1915},[33,8242,8243],{},"private function runTool(User $user, string $name, array $input): array\n",[33,8245,8246],{"class":35,"line":3270},[33,8247,64],{},[33,8249,8250],{"class":35,"line":3282},[33,8251,8252],{},"    return match ($name) {\n",[33,8254,8255],{"class":35,"line":3290},[33,8256,8257],{},"        'get_order_status' => $this->orders->statusFor($user, (string) ($input['order_id'] ?? '')),\n",[33,8259,8260],{"class":35,"line":3301},[33,8261,8262],{},"        default => ['error' => \"Unknown tool: {$name}\"],\n",[33,8264,8265],{"class":35,"line":3312},[33,8266,8267],{},"    };\n",[33,8269,8270],{"class":35,"line":3323},[33,8271,82],{},[11,8273,8274],{},"The loop needs a step limit. Without it, a model that keeps requesting tools loops until the timeout and every iteration is billed.",[11,8276,8277,8278,8281],{},"Arguments from the model are untrusted input, like a form field. ",[30,8279,8280],{},"statusFor($user, ...)"," scopes the query to the user's own orders, so this tool won't return another customer's order, even if someone in the conversation asks the model to look.",[11,8283,8284],{},"Tools that change state (refunds, cancellations) shouldn't execute straight from the model's request. Let the tool prepare a proposal and require a confirmation outside the model: a button, a second request, a person.",[15,8286,8288],{"id":8287},"retrieval-in-postgres-with-pgvector","Retrieval in Postgres with pgvector",[11,8290,8291],{},"Retrieval-augmented generation does not need a separate vector database if you already run Postgres. The expensive part, computing embeddings, happens when documents are indexed. At query time it's one embedding call and one SQL query.",[23,8293,8297],{"className":8294,"code":8295,"language":8296,"meta":28,"style":28},"language-sql shiki shiki-themes github-light github-dark","CREATE EXTENSION IF NOT EXISTS vector;\n\nCREATE TABLE document_chunks (\n    id          bigserial PRIMARY KEY,\n    document_id bigint NOT NULL REFERENCES documents (id) ON DELETE CASCADE,\n    content     text   NOT NULL,\n    embedding   vector(1536) NOT NULL\n);\n\nCREATE INDEX document_chunks_embedding_idx\n    ON document_chunks USING hnsw (embedding vector_cosine_ops);\n","sql",[30,8298,8299,8304,8308,8313,8318,8323,8328,8333,8338,8342,8347],{"__ignoreMap":28},[33,8300,8301],{"class":35,"line":36},[33,8302,8303],{},"CREATE EXTENSION IF NOT EXISTS vector;\n",[33,8305,8306],{"class":35,"line":42},[33,8307,52],{"emptyLinePlaceholder":51},[33,8309,8310],{"class":35,"line":48},[33,8311,8312],{},"CREATE TABLE document_chunks (\n",[33,8314,8315],{"class":35,"line":55},[33,8316,8317],{},"    id          bigserial PRIMARY KEY,\n",[33,8319,8320],{"class":35,"line":61},[33,8321,8322],{},"    document_id bigint NOT NULL REFERENCES documents (id) ON DELETE CASCADE,\n",[33,8324,8325],{"class":35,"line":67},[33,8326,8327],{},"    content     text   NOT NULL,\n",[33,8329,8330],{"class":35,"line":73},[33,8331,8332],{},"    embedding   vector(1536) NOT NULL\n",[33,8334,8335],{"class":35,"line":79},[33,8336,8337],{},");\n",[33,8339,8340],{"class":35,"line":85},[33,8341,52],{"emptyLinePlaceholder":51},[33,8343,8344],{"class":35,"line":90},[33,8345,8346],{},"CREATE INDEX document_chunks_embedding_idx\n",[33,8348,8349],{"class":35,"line":96},[33,8350,8351],{},"    ON document_chunks USING hnsw (embedding vector_cosine_ops);\n",[23,8353,8355],{"className":25,"code":8354,"language":27,"meta":28,"style":28},"$embedding = Http::withToken(config('services.openai.key'))\n    ->timeout(15)\n    ->post('https:\u002F\u002Fapi.openai.com\u002Fv1\u002Fembeddings', [\n        'model' => 'text-embedding-3-small', \u002F\u002F 1536 dimensions, matches the column\n        'input' => $question,\n    ])\n    ->throw()\n    ->json('data.0.embedding');\n\n$vector = '[' . implode(',', $embedding) . ']';\n\n$chunks = DB::select(\n    'SELECT id, content, embedding \u003C=> ?::vector AS distance\n       FROM document_chunks\n      ORDER BY embedding \u003C=> ?::vector\n      LIMIT 5',\n    [$vector, $vector],\n);\n\n$relevant = array_filter($chunks, fn (object $c): bool => $c->distance \u003C= $maxDistance);\n",[30,8356,8357,8362,8367,8372,8380,8385,8390,8395,8400,8404,8409,8413,8418,8423,8428,8433,8438,8443,8447,8451],{"__ignoreMap":28},[33,8358,8359],{"class":35,"line":36},[33,8360,8361],{},"$embedding = Http::withToken(config('services.openai.key'))\n",[33,8363,8364],{"class":35,"line":42},[33,8365,8366],{},"    ->timeout(15)\n",[33,8368,8369],{"class":35,"line":48},[33,8370,8371],{},"    ->post('https:\u002F\u002Fapi.openai.com\u002Fv1\u002Fembeddings', [\n",[33,8373,8374,8377],{"class":35,"line":55},[33,8375,8376],{},"        'model' => 'text-embedding-3-small',",[33,8378,8379],{}," \u002F\u002F 1536 dimensions, matches the column\n",[33,8381,8382],{"class":35,"line":61},[33,8383,8384],{},"        'input' => $question,\n",[33,8386,8387],{"class":35,"line":67},[33,8388,8389],{},"    ])\n",[33,8391,8392],{"class":35,"line":73},[33,8393,8394],{},"    ->throw()\n",[33,8396,8397],{"class":35,"line":79},[33,8398,8399],{},"    ->json('data.0.embedding');\n",[33,8401,8402],{"class":35,"line":85},[33,8403,52],{"emptyLinePlaceholder":51},[33,8405,8406],{"class":35,"line":90},[33,8407,8408],{},"$vector = '[' . implode(',', $embedding) . ']';\n",[33,8410,8411],{"class":35,"line":96},[33,8412,52],{"emptyLinePlaceholder":51},[33,8414,8415],{"class":35,"line":384},[33,8416,8417],{},"$chunks = DB::select(\n",[33,8419,8420],{"class":35,"line":390},[33,8421,8422],{},"    'SELECT id, content, embedding \u003C=> ?::vector AS distance\n",[33,8424,8425],{"class":35,"line":395},[33,8426,8427],{},"       FROM document_chunks\n",[33,8429,8430],{"class":35,"line":401},[33,8431,8432],{},"      ORDER BY embedding \u003C=> ?::vector\n",[33,8434,8435],{"class":35,"line":407},[33,8436,8437],{},"      LIMIT 5',\n",[33,8439,8440],{"class":35,"line":413},[33,8441,8442],{},"    [$vector, $vector],\n",[33,8444,8445],{"class":35,"line":419},[33,8446,8337],{},[33,8448,8449],{"class":35,"line":1806},[33,8450,52],{"emptyLinePlaceholder":51},[33,8452,8453],{"class":35,"line":1812},[33,8454,8455],{},"$relevant = array_filter($chunks, fn (object $c): bool => $c->distance \u003C= $maxDistance);\n",[11,8457,8458,8461,8462,8465,8466,8469,8470,8473,8474,8477,8478,8481],{},[30,8459,8460],{},"\u003C=>"," is cosine distance, and the ",[30,8463,8464],{},"hnsw"," index with ",[30,8467,8468],{},"vector_cosine_ops"," serves exactly this ordering. I filter the cutoff in PHP after ",[30,8471,8472],{},"LIMIT"," to see the distances of rejected chunks. A SQL ",[30,8475,8476],{},"WHERE"," on the distance, with the same ",[30,8479,8480],{},"ORDER BY … LIMIT",", would use the index too.",[11,8483,8484,8487],{},[30,8485,8486],{},"$maxDistance"," has no universal default. It depends on the embedding model, the language and the length of the chunks. You have to measure it. Prepare a few dozen questions with known relevant fragments, run them at several cutoffs and record how many relevant fragments come back and how many irrelevant ones come with them. Repeat the measurement when you change the embedding model, because distances from different models are not comparable. Changing the model also means re-indexing all documents.",[11,8489,8490],{},"When nothing passes the cutoff, tell the user. Calling the model with an empty context invites an answer from its general knowledge, which is exactly what retrieval was supposed to prevent.",[15,8492,8494],{"id":8493},"what-to-measure","What to measure",[11,8496,8497,8498,739,8501,8504],{},"Record every call in a table or a metrics system with these fields: feature name, model, ",[30,8499,8500],{},"usage.input_tokens",[30,8502,8503],{},"usage.output_tokens",", latency, and outcome (success, invalid output, API error). That covers the questions people actually ask:",[3583,8506,8507,8510,8513,8516],{},[3586,8508,8509],{},"Cost per feature. Cost scales with tokens. A long system prompt sent with every call often dominates the bill, and grouping by feature shows where.",[3586,8511,8512],{},"Latency percentiles (p50, p95). The average hides the slow calls, and those are the ones hitting timeouts.",[3586,8514,8515],{},"Invalid output rate. Alert when it moves away from its own baseline. A jump usually means a prompt change, a different input distribution or a new model version.",[3586,8517,8518],{},"Queue wait time for LLM jobs. It shows that workers are falling behind before users notice.",[11,8520,8521],{},"If prompts contain personal or financial data, log identifiers and counts. Full prompt text stays out of the logs.",[15,8523,8525],{"id":8524},"when-a-separate-python-service-makes-sense","When a separate Python service makes sense",[11,8527,8528],{},"A separate service is justified when you run your own models (fine-tuning, local inference), when you need libraries that exist only in Python, or when a team maintains ML pipelines independently of the product. Even then, keep it narrow: a defined input, a defined output, no business logic. The domain rules (who may see which order, what a refund is) should stay where they already are. Moving them to a new service so that a model can call them adds a network boundary, a second deployment and a second copy of the rules.",[15,8530,8532],{"id":8531},"before-the-first-production-deploy","Before the first production deploy",[3583,8534,8535,8544,8547,8550],{},[3586,8536,8537,8538,8541,8542,221],{},"Job ",[30,8539,8540],{},"$timeout"," covers the whole client call with retries and stays below ",[30,8543,7491],{},[3586,8545,8546],{},"Results are written conditionally, and only the attempt that wrote triggers side effects.",[3586,8548,8549],{},"The tool loop has a step limit, and state-changing tools wait for confirmation.",[3586,8551,8552],{},"The retrieval cutoff comes from a measurement on your own data.",[892,8554,894],{},{"title":28,"searchDepth":42,"depth":42,"links":8556},[8557,8558,8559,8560,8561,8562,8563,8564,8565],{"id":7272,"depth":42,"text":7273},{"id":7305,"depth":42,"text":7306},{"id":7481,"depth":42,"text":7482},{"id":7736,"depth":42,"text":7737},{"id":8049,"depth":42,"text":8050},{"id":8287,"depth":42,"text":8288},{"id":8493,"depth":42,"text":8494},{"id":8524,"depth":42,"text":8525},{"id":8531,"depth":42,"text":8532},"2024-10-28",{},"\u002Farticles\u002Fllm-in-php",{"x":8570,"y":5012,"depth":8571,"size":6593},0.47,1.3,[1685,918],{"title":7260,"description":7266},"llm-integration","articles\u002Fllm-in-php",[27,8577,8578,8579,8580,8581],"laravel","llm","anthropic-api","pgvector","queues","uI593e7gT6YuJ-xqYFNrPuXUp6-lp34D7Z-M5qEhu-Y",{"id":8584,"title":8585,"articleId":2510,"body":8586,"category":5814,"codeLang":4113,"date":9249,"deploys":36,"description":8590,"excerpt":910,"extension":911,"lang":910,"meta":9250,"navigation":51,"path":9251,"pos":9252,"readMin":79,"related":9256,"seo":9257,"service":9258,"stem":9259,"tags":9260,"updated":5828,"version":9264,"__hash__":9265},"articles\u002Farticles\u002Fmicroservice-cost.md","The hidden cost of microservice boundaries: how to price a boundary before you draw it",{"type":8,"value":8587,"toc":9241},[8588,8591,8598,8602,8605,8608,8611,8614,8617,8620,8624,8630,8633,8662,8671,8675,8680,8989,8996,8999,9003,9006,9009,9015,9018,9021,9024,9028,9031,9213,9216,9219,9222,9226,9229,9235,9238],[11,8589,8590],{},"Splitting a system into services moves complexity around without reducing it. Code that used to call a function now sends a request over the network, and every change that touches both sides of that call becomes a change to two codebases, two deploy pipelines, and one contract between them. Later on, the boundary is one of the most expensive lines in the system to move, since moving it means migrating data and rewriting the contract.",[11,8592,8593,8594,8597],{},"Before drawing one, ask: ",[5856,8595,8596],{},"what is the smallest change that will have to cross this boundary, and how often will it happen?"," If the answer is \"most features, in lockstep\", the line is in the wrong place.",[15,8599,8601],{"id":8600},"what-a-boundary-costs","What a boundary costs",[11,8603,8604],{},"Inside a monolith, changing a method signature and its callers is one commit, checked by the compiler or static analysis, deployed atomically. Across a service boundary, the same change gets a lot more expensive.",[11,8606,8607],{},"The producer has to support the old and the new contract at the same time, because the two services don't deploy at the same instant. Deploys have to happen in order: expand the producer, deploy, migrate consumers, deploy, contract the producer, deploy again. One logical change, three releases.",[11,8609,8610],{},"Static analysis no longer sees both sides, so something else (contract tests, an integration environment) has to notice that a field was renamed.",[11,8612,8613],{},"Failures get harder. A function call either returns or throws. A network call can also time out after the other side has already committed, so every cross-service write needs a decision about retries, idempotency keys and what happens to partial state.",[11,8615,8616],{},"A report that joined two tables now needs API calls (one per row in the worst case, or a batch endpoint), a replicated read model, or an event stream feeding a separate store. A request that crosses three services is much slower to debug without distributed tracing, from logs stitched together by correlation ID.",[11,8618,8619],{},"Each of these is small on its own. You pay all of them on every change that crosses the line, so the total depends on how often that happens.",[15,8621,8623],{"id":8622},"a-cost-model","A cost model",[23,8625,8628],{"className":8626,"code":8627,"language":4113,"meta":28},[4111],"cost(boundary) =\n      f_change  * cost_per_change      # coordinated changes across the line\n    + f_failure * blast_radius         # how often and how badly one side breaks the other\n    - autonomy_gained                  # independent deploys, scaling, ownership\n\n# first term dominates   -> the boundary is in the wrong place\n# second term dominates  -> the boundary may be right; invest in isolation (timeouts, queues, fallbacks)\n# third term dominates   -> the boundary pays for itself\n",[30,8629,8627],{"__ignoreMap":28},[11,8631,8632],{},"Nobody computes this to a number. The point is to estimate the terms roughly, in writing, before the decision:",[3583,8634,8635,8641,8647,8656],{},[3586,8636,8637,8640],{},[30,8638,8639],{},"f_change",": how many changes per month need both sides modified.",[3586,8642,8643,8646],{},[30,8644,8645],{},"cost_per_change",": the overhead described above, in engineer hours per coordinated change.",[3586,8648,8649,1930,8652,8655],{},[30,8650,8651],{},"f_failure",[30,8653,8654],{},"blast_radius",": how often one side being down or slow affects the other, and how far the effect spreads.",[3586,8657,8658,8661],{},[30,8659,8660],{},"autonomy_gained",": what each side can now do on its own that it couldn't before. When the same team owns both services, the ownership part is close to zero, because the team still has to coordinate with itself. What remains are the operational reasons described below.",[11,8663,8664,8665,8667,8668,8670],{},"Take two services owned by one team, with no operational differences, where most features touch both. ",[30,8666,8639],{}," is high and ",[30,8669,8660],{}," is near zero. The first term dominates: the boundary adds versioning and ordered deploys to most features.",[15,8672,8674],{"id":8673},"measuring-change-coupling-before-drawing-the-line","Measuring change coupling before drawing the line",[11,8676,8677,8679],{},[30,8678,8639],{}," can be measured from version control instead of guessed. If the system is still a monolith with directories per module, count how often commits touch both candidate sides.",[23,8681,8683],{"className":2898,"code":8682,"language":2900,"meta":28,"style":28},"#!\u002Fusr\u002Fbin\u002Fenv bash\n# Change coupling between two directories over a time window.\n# Usage: .\u002Fcoupling.sh src\u002FBilling src\u002FFraud '6 months ago'\na=\"$1\"; b=\"$2\"; since=\"${3:-6 months ago}\"\n\ntotal_a=$(git log --no-merges --since=\"$since\" --format='%H' -- \"$a\" | wc -l)\nboth=0\nfor h in $(git log --no-merges --since=\"$since\" --format='%H' -- \"$a\"); do\n  if [ -n \"$(git show --name-only --format= \"$h\" -- \"$b\")\" ]; then\n    both=$((both + 1))\n  fi\ndone\n\necho \"commits touching $a: $total_a\"\necho \"of which also touch $b: $both\"\n",[30,8684,8685,8690,8695,8700,8755,8759,8817,8826,8872,8921,8942,8947,8952,8956,8973],{"__ignoreMap":28},[33,8686,8687],{"class":35,"line":36},[33,8688,8689],{"class":3710},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[33,8691,8692],{"class":35,"line":42},[33,8693,8694],{"class":3710},"# Change coupling between two directories over a time window.\n",[33,8696,8697],{"class":35,"line":48},[33,8698,8699],{"class":3710},"# Usage: .\u002Fcoupling.sh src\u002FBilling src\u002FFraud '6 months ago'\n",[33,8701,8702,8704,8707,8710,8713,8715,8718,8720,8722,8725,8727,8730,8732,8734,8737,8740,8743,8746,8749,8752],{"class":35,"line":55},[33,8703,5360],{"class":2598},[33,8705,8706],{"class":5484},"=",[33,8708,8709],{"class":2609},"\"",[33,8711,8712],{"class":2728},"$1",[33,8714,8709],{"class":2609},[33,8716,8717],{"class":2598},"; b",[33,8719,8706],{"class":5484},[33,8721,8709],{"class":2609},[33,8723,8724],{"class":2728},"$2",[33,8726,8709],{"class":2609},[33,8728,8729],{"class":2598},"; since",[33,8731,8706],{"class":5484},[33,8733,8709],{"class":2609},[33,8735,8736],{"class":2728},"${3",[33,8738,8739],{"class":5484},":-",[33,8741,8742],{"class":2728},"6",[33,8744,8745],{"class":2598}," months",[33,8747,8748],{"class":2598}," ago",[33,8750,8751],{"class":2728},"}",[33,8753,8754],{"class":2609},"\"\n",[33,8756,8757],{"class":35,"line":61},[33,8758,52],{"emptyLinePlaceholder":51},[33,8760,8761,8764,8766,8769,8772,8775,8778,8781,8783,8786,8788,8791,8794,8797,8800,8803,8805,8808,8811,8814],{"class":35,"line":67},[33,8762,8763],{"class":2598},"total_a",[33,8765,8706],{"class":5484},[33,8767,8768],{"class":2598},"$(",[33,8770,8771],{"class":2907},"git",[33,8773,8774],{"class":2609}," log",[33,8776,8777],{"class":2728}," --no-merges",[33,8779,8780],{"class":2728}," --since=",[33,8782,8709],{"class":2609},[33,8784,8785],{"class":2598},"$since",[33,8787,8709],{"class":2609},[33,8789,8790],{"class":2728}," --format=",[33,8792,8793],{"class":2609},"'%H'",[33,8795,8796],{"class":2728}," --",[33,8798,8799],{"class":2609}," \"",[33,8801,8802],{"class":2598},"$a",[33,8804,8709],{"class":2609},[33,8806,8807],{"class":5484}," |",[33,8809,8810],{"class":2907}," wc",[33,8812,8813],{"class":2728}," -l",[33,8815,8816],{"class":2598},")\n",[33,8818,8819,8822,8824],{"class":35,"line":73},[33,8820,8821],{"class":2598},"both",[33,8823,8706],{"class":5484},[33,8825,2989],{"class":2609},[33,8827,8828,8831,8834,8837,8840,8842,8844,8846,8848,8850,8852,8854,8856,8858,8860,8862,8864,8866,8869],{"class":35,"line":79},[33,8829,8830],{"class":5484},"for",[33,8832,8833],{"class":2598}," h ",[33,8835,8836],{"class":5484},"in",[33,8838,8839],{"class":2598}," $(",[33,8841,8771],{"class":2907},[33,8843,8774],{"class":2609},[33,8845,8777],{"class":2728},[33,8847,8780],{"class":2728},[33,8849,8709],{"class":2609},[33,8851,8785],{"class":2598},[33,8853,8709],{"class":2609},[33,8855,8790],{"class":2728},[33,8857,8793],{"class":2609},[33,8859,8796],{"class":2728},[33,8861,8799],{"class":2609},[33,8863,8802],{"class":2598},[33,8865,8709],{"class":2609},[33,8867,8868],{"class":2598},"); ",[33,8870,8871],{"class":5484},"do\n",[33,8873,8874,8877,8880,8883,8886,8888,8891,8894,8896,8898,8901,8904,8907,8909,8912,8915,8918],{"class":35,"line":85},[33,8875,8876],{"class":5484},"  if",[33,8878,8879],{"class":2598}," [ ",[33,8881,8882],{"class":5484},"-n",[33,8884,8885],{"class":2609}," \"$(",[33,8887,8771],{"class":2907},[33,8889,8890],{"class":2609}," show ",[33,8892,8893],{"class":2728},"--name-only",[33,8895,8790],{"class":2728},[33,8897,8799],{"class":2609},[33,8899,8900],{"class":2598},"$h",[33,8902,8903],{"class":2609},"\" ",[33,8905,8906],{"class":2728},"--",[33,8908,8799],{"class":2609},[33,8910,8911],{"class":2598},"$b",[33,8913,8914],{"class":2609},"\")\"",[33,8916,8917],{"class":2598}," ]; ",[33,8919,8920],{"class":5484},"then\n",[33,8922,8923,8926,8928,8931,8933,8936,8939],{"class":35,"line":90},[33,8924,8925],{"class":2598},"    both",[33,8927,8706],{"class":5484},[33,8929,8930],{"class":2598},"$((",[33,8932,8821],{"class":2907},[33,8934,8935],{"class":2609}," +",[33,8937,8938],{"class":2728}," 1",[33,8940,8941],{"class":2598},"))\n",[33,8943,8944],{"class":35,"line":96},[33,8945,8946],{"class":5484},"  fi\n",[33,8948,8949],{"class":35,"line":384},[33,8950,8951],{"class":5484},"done\n",[33,8953,8954],{"class":35,"line":390},[33,8955,52],{"emptyLinePlaceholder":51},[33,8957,8958,8961,8964,8966,8968,8971],{"class":35,"line":395},[33,8959,8960],{"class":2728},"echo",[33,8962,8963],{"class":2609}," \"commits touching ",[33,8965,8802],{"class":2598},[33,8967,2606],{"class":2609},[33,8969,8970],{"class":2598},"$total_a",[33,8972,8754],{"class":2609},[33,8974,8975,8977,8980,8982,8984,8987],{"class":35,"line":401},[33,8976,8960],{"class":2728},[33,8978,8979],{"class":2609}," \"of which also touch ",[33,8981,8911],{"class":2598},[33,8983,2606],{"class":2609},[33,8985,8986],{"class":2598},"$both",[33,8988,8754],{"class":2609},[11,8990,8991,8992,8995],{},"Commit-level coupling understates the real number when one feature is spread across several commits. If commits reference a ticket ID, group by ticket. If PRs land as merge commits, group by merge commit. Squash-merged PRs are already one commit per change and need no grouping. For larger histories, code-maat and similar tools compute coupling across all file pairs from ",[30,8993,8994],{},"git log"," output.",[11,8997,8998],{},"A rough reading to start from: if more than a third of the changes on one side also touch the other, the two are one unit of change and should stay in one deployable.",[15,9000,9002],{"id":9001},"when-a-boundary-pays-for-itself","When a boundary pays for itself",[11,9004,9005],{},"A boundary earns its cost when the two sides differ in something that matters operationally.",[11,9007,9008],{},"Deploy cadence is the clearest case. A payments service ships several times a week, a fraud-scoring model ships only after a validation cycle of several weeks. Kept separate, payments can release without waiting for model validation.",[11,9010,9011,9012,9014],{},"Ownership is what makes ",[30,9013,8660],{}," real: different teams, different on-call rotations, different priorities.",[11,9016,9017],{},"Scaling profile and failure isolation are the operational reasons. A CPU-heavy document parser and a latency-sensitive API need different resources, and in separate processes each can scale and fail on its own. A component that calls an unreliable third party can sit behind a queue, so its outages don't block the request path.",[11,9019,9020],{},"Technology is sometimes enough by itself. A model served from Python next to a PHP application is a natural process boundary, because the alternative is embedding one runtime in the other.",[11,9022,9023],{},"If none of this applies, the candidate boundary is a module boundary and belongs inside one deployable.",[15,9025,9027],{"id":9026},"start-with-modules","Start with modules",[11,9029,9030],{},"A modular monolith keeps the boundaries in the code without paying the network cost. Each module owns its tables, exposes an interface, and is forbidden from reaching into another module's internals. In PHP this can be enforced in CI with deptrac:",[23,9032,9034],{"className":2589,"code":9033,"language":2591,"meta":28,"style":28},"# deptrac.yaml\ndeptrac:\n  paths:\n    - .\u002Fsrc\n  layers:\n    - name: Billing\n      collectors:\n        - type: directory\n          value: src\u002FBilling\u002F.*\n    - name: Fraud\n      collectors:\n        - type: directory\n          value: src\u002FFraud\u002F.*\n    - name: Shared\n      collectors:\n        - type: directory\n          value: src\u002FShared\u002F.*\n  ruleset:\n    Billing: [Shared]\n    Fraud: [Shared]\n",[30,9035,9036,9041,9048,9055,9062,9069,9080,9087,9099,9109,9120,9126,9136,9145,9156,9162,9172,9181,9188,9202],{"__ignoreMap":28},[33,9037,9038],{"class":35,"line":36},[33,9039,9040],{"class":3710},"# deptrac.yaml\n",[33,9042,9043,9046],{"class":35,"line":42},[33,9044,9045],{"class":2602},"deptrac",[33,9047,2628],{"class":2598},[33,9049,9050,9053],{"class":35,"line":48},[33,9051,9052],{"class":2602},"  paths",[33,9054,2628],{"class":2598},[33,9056,9057,9059],{"class":35,"line":55},[33,9058,3005],{"class":2598},[33,9060,9061],{"class":2609},".\u002Fsrc\n",[33,9063,9064,9067],{"class":35,"line":61},[33,9065,9066],{"class":2602},"  layers",[33,9068,2628],{"class":2598},[33,9070,9071,9073,9075,9077],{"class":35,"line":67},[33,9072,3005],{"class":2598},[33,9074,2603],{"class":2602},[33,9076,2606],{"class":2598},[33,9078,9079],{"class":2609},"Billing\n",[33,9081,9082,9085],{"class":35,"line":73},[33,9083,9084],{"class":2602},"      collectors",[33,9086,2628],{"class":2598},[33,9088,9089,9091,9094,9096],{"class":35,"line":79},[33,9090,3073],{"class":2598},[33,9092,9093],{"class":2602},"type",[33,9095,2606],{"class":2598},[33,9097,9098],{"class":2609},"directory\n",[33,9100,9101,9104,9106],{"class":35,"line":85},[33,9102,9103],{"class":2602},"          value",[33,9105,2606],{"class":2598},[33,9107,9108],{"class":2609},"src\u002FBilling\u002F.*\n",[33,9110,9111,9113,9115,9117],{"class":35,"line":90},[33,9112,3005],{"class":2598},[33,9114,2603],{"class":2602},[33,9116,2606],{"class":2598},[33,9118,9119],{"class":2609},"Fraud\n",[33,9121,9122,9124],{"class":35,"line":96},[33,9123,9084],{"class":2602},[33,9125,2628],{"class":2598},[33,9127,9128,9130,9132,9134],{"class":35,"line":384},[33,9129,3073],{"class":2598},[33,9131,9093],{"class":2602},[33,9133,2606],{"class":2598},[33,9135,9098],{"class":2609},[33,9137,9138,9140,9142],{"class":35,"line":390},[33,9139,9103],{"class":2602},[33,9141,2606],{"class":2598},[33,9143,9144],{"class":2609},"src\u002FFraud\u002F.*\n",[33,9146,9147,9149,9151,9153],{"class":35,"line":395},[33,9148,3005],{"class":2598},[33,9150,2603],{"class":2602},[33,9152,2606],{"class":2598},[33,9154,9155],{"class":2609},"Shared\n",[33,9157,9158,9160],{"class":35,"line":401},[33,9159,9084],{"class":2602},[33,9161,2628],{"class":2598},[33,9163,9164,9166,9168,9170],{"class":35,"line":407},[33,9165,3073],{"class":2598},[33,9167,9093],{"class":2602},[33,9169,2606],{"class":2598},[33,9171,9098],{"class":2609},[33,9173,9174,9176,9178],{"class":35,"line":413},[33,9175,9103],{"class":2602},[33,9177,2606],{"class":2598},[33,9179,9180],{"class":2609},"src\u002FShared\u002F.*\n",[33,9182,9183,9186],{"class":35,"line":419},[33,9184,9185],{"class":2602},"  ruleset",[33,9187,2628],{"class":2598},[33,9189,9190,9193,9196,9199],{"class":35,"line":1806},[33,9191,9192],{"class":2602},"    Billing",[33,9194,9195],{"class":2598},": [",[33,9197,9198],{"class":2609},"Shared",[33,9200,9201],{"class":2598},"]\n",[33,9203,9204,9207,9209,9211],{"class":35,"line":1812},[33,9205,9206],{"class":2602},"    Fraud",[33,9208,9195],{"class":2598},[33,9210,9198],{"class":2609},[33,9212,9201],{"class":2598},[11,9214,9215],{},"With this in place, Billing and Fraud can only depend on Shared, and a pull request that imports a Fraud class from Billing fails the build.",[11,9217,9218],{},"Later extraction stays cheap if modules don't join across each other's tables. A module reads another module's data only through its public interface, and when the module gets extracted, that interface becomes the API. Side effects across modules go through events. If Billing needs Fraud to react to a payment, it dispatches an event and Fraud listens. Moving that event to a message broker later is then a transport change plus handling duplicates and delay, without rewriting both modules. An in-process event usually runs synchronously, inside the same transaction, while a broker delivers it asynchronously, at least once and outside that transaction, so the consumer has to be idempotent and the producer needs an outbox.",[11,9220,9221],{},"Extraction from a well-separated module is a one-time cost. Merging two services that should never have been split is also possible, but by then each side has usually built its own version of the shared domain model, and reconciling them takes a data migration plus a rewrite of every place where the two models disagree.",[15,9223,9225],{"id":9224},"platform-before-services","Platform before services",[11,9227,9228],{},"Each new service needs a build pipeline, deploy configuration, logging, metrics, tracing, health checks, secrets, and alerting. If these are not shared and templated, every service reinvents them slightly differently, and the operational cost grows with the number of services, whatever the amount of business logic. Before splitting, have in place one CI\u002FCD template that a new service adopts without modification, structured logs with a correlation ID passed across calls, distributed tracing, and a standard for timeouts, retries and idempotency keys on every cross-service call.",[11,9230,9231,9232,221],{},"I got a small taste of this with a single extra process. In a Laravel game with online duels, WebSockets run through Reverb, which is a separate process. When it wasn't running, \"online duel\" returned 500; once it ran but the vhost had no proxy, the guest joined over HTTP while the presence channel over WebSocket got a 404. After a deploy that changed the events, Reverb kept the old code until I restarted it by hand. Debugging had its own trap: curl through Cloudflare went over HTTP\u002F2 and lost the Upgrade header, so I got a false 404 and had to use ",[30,9233,9234],{},"--http1.1",[11,9236,9237],{},"Of all the inputs to the decision, change frequency is the easiest to measure before the split, with the script above, on the last six months of history. The others (ownership, cadence, scaling, platform readiness) are largely judgment calls, and if the measured coupling says \"most features\", they rarely change the answer.",[892,9239,9240],{},"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 .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}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}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 .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":28,"searchDepth":42,"depth":42,"links":9242},[9243,9244,9245,9246,9247,9248],{"id":8600,"depth":42,"text":8601},{"id":8622,"depth":42,"text":8623},{"id":8673,"depth":42,"text":8674},{"id":9001,"depth":42,"text":9002},{"id":9026,"depth":42,"text":9027},{"id":9224,"depth":42,"text":9225},"2026-05-14",{},"\u002Farticles\u002Fmicroservice-cost",{"x":9253,"y":9254,"depth":9255,"size":6593},0.72,0.68,1.1,[1685,5015],{"title":8585,"description":8590},"service-boundaries","articles\u002Fmicroservice-cost",[5022,9261,9262,9263],"organisation","platform","devex","v6.1.2","Z_NYg9I8xL5Bk2j2QiXLjVwPHLu-d7thkGHltHshMGM",{"id":9267,"title":9268,"articleId":9269,"body":9270,"category":2500,"codeLang":5107,"date":10145,"deploys":36,"description":9274,"excerpt":910,"extension":911,"lang":910,"meta":10146,"navigation":51,"path":10147,"pos":10148,"readMin":90,"related":10151,"seo":10152,"service":10153,"stem":10154,"tags":10155,"updated":2519,"version":10161,"__hash__":10162},"articles\u002Farticles\u002Fn8n-rag-data-quality.md","RAG ingestion in n8n: document versions, deletions and freshness metadata","n8n-rag-data-quality",{"type":8,"value":9271,"toc":10134},[9272,9275,9278,9282,9285,9292,9299,9302,9305,9350,9353,9357,9360,9377,9381,9411,9422,9433,9650,9657,9660,9664,9687,9787,9790,9794,9797,9864,9867,9871,9891,9924,9927,9931,9934,9940,10067,10077,10081,10084,10119,10122,10126,10129,10132],[11,9273,9274],{},"A retrieval-augmented chatbot answers from whatever the vector store returns. If the store holds two versions of the same price list, the retriever picks the chunk with the higher similarity score, and nothing in that score reflects which version is current. The model then answers correctly with respect to its context. The answer reads well and cites a real document. It's wrong. Nothing shows up in the error log and no hallucination detector fires, because the model didn't invent anything.",[11,9276,9277],{},"The defect lives in the ingestion workflow, not in the model or the prompt. Below are the invariants an ingestion pipeline should hold and a way to implement them in n8n with Pinecone. Qdrant, Weaviate or pgvector follow the same rules.",[15,9279,9281],{"id":9280},"why-stale-chunks-survive","Why stale chunks survive",[11,9283,9284],{},"A vector store has no notion of a document version. It stores records under IDs, and an upsert replaces a record only when the ID matches. Everything else accumulates.",[11,9286,9287,9288,9291],{},"The most common source of duplicates is IDs derived from the filename or from the content. A new version saved as ",[30,9289,9290],{},"pricing-2026-v2.pdf",", or one whose first paragraph changed, gets new IDs, and the old records stay where they were.",[11,9293,9294,9295,9298],{},"A shorter new version does the same thing more quietly. Say version 1 produced 12 chunks and version 2 produces 9. An upsert by ",[30,9296,9297],{},"docId + index"," overwrites chunks 0 to 8 and leaves 9 to 11 from the old version.",[11,9300,9301],{},"Then there are deletions at the source. Moving a file to an archive folder or deleting it in Google Drive doesn't trigger anything in the vector store, so without an explicit delete the chunks stay retrievable forever.",[11,9303,9304],{},"A typical first version of the workflow (Drive trigger, text splitter, Pinecone upsert) has all of these problems at once:",[23,9306,9308],{"className":5105,"code":9307,"language":5107,"meta":28,"style":28},"\u002F\u002F n8n Code node: naive chunk preparation\nreturn $input.all().map(chunk => ({\n  json: {\n    id: chunk.json.metadata.loc.pageNumber + '_' + chunk.json.pageContent.slice(0, 32),\n    text: chunk.json.pageContent,\n    metadata: { source: chunk.json.metadata.source },\n  },\n}));\n",[30,9309,9310,9315,9320,9325,9330,9335,9340,9345],{"__ignoreMap":28},[33,9311,9312],{"class":35,"line":36},[33,9313,9314],{},"\u002F\u002F n8n Code node: naive chunk preparation\n",[33,9316,9317],{"class":35,"line":42},[33,9318,9319],{},"return $input.all().map(chunk => ({\n",[33,9321,9322],{"class":35,"line":48},[33,9323,9324],{},"  json: {\n",[33,9326,9327],{"class":35,"line":55},[33,9328,9329],{},"    id: chunk.json.metadata.loc.pageNumber + '_' + chunk.json.pageContent.slice(0, 32),\n",[33,9331,9332],{"class":35,"line":61},[33,9333,9334],{},"    text: chunk.json.pageContent,\n",[33,9336,9337],{"class":35,"line":67},[33,9338,9339],{},"    metadata: { source: chunk.json.metadata.source },\n",[33,9341,9342],{"class":35,"line":73},[33,9343,9344],{},"  },\n",[33,9346,9347],{"class":35,"line":79},[33,9348,9349],{},"}));\n",[11,9351,9352],{},"The ID depends on page content, the only metadata is the filename, and there is no delete step anywhere in the flow.",[15,9354,9356],{"id":9355},"the-invariant","The invariant",[11,9358,9359],{},"For every document in the source folder, the index contains exactly the chunks of its current version, and nothing else. The design decisions below come from that:",[3583,9361,9362,9365,9368,9371,9374],{},[3586,9363,9364],{},"the document ID comes from the source system (the Drive file ID), because the file ID doesn't change when someone renames the file or uploads a new revision;",[3586,9366,9367],{},"chunk IDs are prefixed with the document ID and a content hash, so all chunks of one document can be found by prefix and old versions can be told apart from the new one;",[3586,9369,9370],{},"replacing a document means writing the new chunks and then deleting every chunk with the same document prefix and a different hash;",[3586,9372,9373],{},"a scheduled reconciliation removes documents that no longer exist at the source;",[3586,9375,9376],{},"freshness is stored as numbers, because that is what metadata range filters operate on.",[15,9378,9380],{"id":9379},"preparing-chunks","Preparing chunks",[11,9382,9383,9384,739,9387,739,9389,739,9392,9395,9396,9398,9399,9402,9403,9406,9407,9410],{},"The node below runs in \"Run Once for All Items\" mode. It expects one item per Drive file with ",[30,9385,9386],{},"id",[30,9388,2603],{},[30,9390,9391],{},"modifiedTime",[30,9393,9394],{},"mimeType",", the extracted ",[30,9397,4113],{}," and a ",[30,9400,9401],{},"docType"," (for example derived from the subfolder). On self-hosted n8n, ",[30,9404,9405],{},"require('crypto')"," works only if the instance runs with ",[30,9408,9409],{},"NODE_FUNCTION_ALLOW_BUILTIN=crypto"," (with external task runners, the variable goes on the runner, the main instance ignores it).",[11,9412,9413,9414,9417,9418,9421],{},"Self-hosted n8n has more behaviour like this that you only find in practice. When I moved my n8n to a new server, setting ",[30,9415,9416],{},"active = false"," didn't switch the workflows off. The real switch was ",[30,9419,9420],{},"activeVersionId",", and only after clearing it the log showed \"Processed 8 draft workflows\". The Telegram Trigger takes over the webhook instead of joining it: my test copy was alive for 41 seconds and in that time it unsubscribed production from Telegram, until I restarted the production instance. The encryption key check went cleanly, 14 of 14 credentials decrypted.",[11,9423,9424,9425,9428,9429,9432],{},"I also chased a false alarm after that move. The error \"Error in sub-node Postgres Chat Memory\" looked like a migration problem, but it came from my own test, which sent a ",[30,9426,9427],{},"sessionId"," field instead of ",[30,9430,9431],{},"session_id",". Now, before I blame a migration for something, I check how it behaved before the migration.",[23,9434,9436],{"className":5105,"code":9435,"language":5107,"meta":28,"style":28},"\u002F\u002F n8n Code node, mode: Run Once for All Items\nconst crypto = require('crypto');\n\nconst CHUNK_SIZE = 1500; \u002F\u002F characters\nconst OVERLAP = 200;\n\u002F\u002F Days after the last modification when a document stops being served.\n\u002F\u002F null means the document type does not expire.\nconst TTL_DAYS = { pricing: 90, changelog: 180, adr: null, general: null };\nconst NEVER = 4102444800; \u002F\u002F 2100-01-01 as a Unix timestamp\n\nfunction split(text) {\n  const chunks = [];\n  for (let start = 0; start \u003C text.length; start += CHUNK_SIZE - OVERLAP) {\n    chunks.push(text.slice(start, start + CHUNK_SIZE));\n    if (start + CHUNK_SIZE >= text.length) break;\n  }\n  return chunks;\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n  const { id: docId, name, modifiedTime, mimeType, text } = item.json;\n  if (typeof text !== 'string' || text.trim().length \u003C 50) continue;\n\n  const docType = item.json.docType ?? 'general';\n  const body = text.trim();\n  const version = crypto.createHash('sha256').update(body).digest('hex').slice(0, 12);\n  const modifiedTs = Math.floor(Date.parse(modifiedTime) \u002F 1000);\n  const ttl = TTL_DAYS[docType] ?? null;\n  const expiresTs = ttl === null ? NEVER : modifiedTs + ttl * 86400;\n\n  split(body).forEach((chunk, i) => {\n    out.push({\n      json: {\n        id: `${docId}#${version}#${String(i).padStart(4, '0')}`,\n        docId,\n        version,\n        text: chunk,\n        metadata: { docId, version, docType, source: name, mimeType, modifiedTs, expiresTs, text: chunk },\n      },\n    });\n  });\n}\nreturn out;\n",[30,9437,9438,9443,9448,9452,9457,9462,9467,9472,9477,9482,9486,9491,9496,9501,9506,9511,9515,9520,9524,9528,9533,9538,9543,9548,9552,9557,9562,9567,9572,9577,9582,9586,9591,9596,9601,9606,9611,9616,9621,9626,9631,9636,9641,9645],{"__ignoreMap":28},[33,9439,9440],{"class":35,"line":36},[33,9441,9442],{},"\u002F\u002F n8n Code node, mode: Run Once for All Items\n",[33,9444,9445],{"class":35,"line":42},[33,9446,9447],{},"const crypto = require('crypto');\n",[33,9449,9450],{"class":35,"line":48},[33,9451,52],{"emptyLinePlaceholder":51},[33,9453,9454],{"class":35,"line":55},[33,9455,9456],{},"const CHUNK_SIZE = 1500; \u002F\u002F characters\n",[33,9458,9459],{"class":35,"line":61},[33,9460,9461],{},"const OVERLAP = 200;\n",[33,9463,9464],{"class":35,"line":67},[33,9465,9466],{},"\u002F\u002F Days after the last modification when a document stops being served.\n",[33,9468,9469],{"class":35,"line":73},[33,9470,9471],{},"\u002F\u002F null means the document type does not expire.\n",[33,9473,9474],{"class":35,"line":79},[33,9475,9476],{},"const TTL_DAYS = { pricing: 90, changelog: 180, adr: null, general: null };\n",[33,9478,9479],{"class":35,"line":85},[33,9480,9481],{},"const NEVER = 4102444800; \u002F\u002F 2100-01-01 as a Unix timestamp\n",[33,9483,9484],{"class":35,"line":90},[33,9485,52],{"emptyLinePlaceholder":51},[33,9487,9488],{"class":35,"line":96},[33,9489,9490],{},"function split(text) {\n",[33,9492,9493],{"class":35,"line":384},[33,9494,9495],{},"  const chunks = [];\n",[33,9497,9498],{"class":35,"line":390},[33,9499,9500],{},"  for (let start = 0; start \u003C text.length; start += CHUNK_SIZE - OVERLAP) {\n",[33,9502,9503],{"class":35,"line":395},[33,9504,9505],{},"    chunks.push(text.slice(start, start + CHUNK_SIZE));\n",[33,9507,9508],{"class":35,"line":401},[33,9509,9510],{},"    if (start + CHUNK_SIZE >= text.length) break;\n",[33,9512,9513],{"class":35,"line":407},[33,9514,5134],{},[33,9516,9517],{"class":35,"line":413},[33,9518,9519],{},"  return chunks;\n",[33,9521,9522],{"class":35,"line":419},[33,9523,82],{},[33,9525,9526],{"class":35,"line":1806},[33,9527,52],{"emptyLinePlaceholder":51},[33,9529,9530],{"class":35,"line":1812},[33,9531,9532],{},"const out = [];\n",[33,9534,9535],{"class":35,"line":1817},[33,9536,9537],{},"for (const item of $input.all()) {\n",[33,9539,9540],{"class":35,"line":1822},[33,9541,9542],{},"  const { id: docId, name, modifiedTime, mimeType, text } = item.json;\n",[33,9544,9545],{"class":35,"line":1828},[33,9546,9547],{},"  if (typeof text !== 'string' || text.trim().length \u003C 50) continue;\n",[33,9549,9550],{"class":35,"line":1834},[33,9551,52],{"emptyLinePlaceholder":51},[33,9553,9554],{"class":35,"line":1840},[33,9555,9556],{},"  const docType = item.json.docType ?? 'general';\n",[33,9558,9559],{"class":35,"line":1846},[33,9560,9561],{},"  const body = text.trim();\n",[33,9563,9564],{"class":35,"line":1851},[33,9565,9566],{},"  const version = crypto.createHash('sha256').update(body).digest('hex').slice(0, 12);\n",[33,9568,9569],{"class":35,"line":1856},[33,9570,9571],{},"  const modifiedTs = Math.floor(Date.parse(modifiedTime) \u002F 1000);\n",[33,9573,9574],{"class":35,"line":1862},[33,9575,9576],{},"  const ttl = TTL_DAYS[docType] ?? null;\n",[33,9578,9579],{"class":35,"line":1868},[33,9580,9581],{},"  const expiresTs = ttl === null ? NEVER : modifiedTs + ttl * 86400;\n",[33,9583,9584],{"class":35,"line":1874},[33,9585,52],{"emptyLinePlaceholder":51},[33,9587,9588],{"class":35,"line":1880},[33,9589,9590],{},"  split(body).forEach((chunk, i) => {\n",[33,9592,9593],{"class":35,"line":1886},[33,9594,9595],{},"    out.push({\n",[33,9597,9598],{"class":35,"line":1891},[33,9599,9600],{},"      json: {\n",[33,9602,9603],{"class":35,"line":1897},[33,9604,9605],{},"        id: `${docId}#${version}#${String(i).padStart(4, '0')}`,\n",[33,9607,9608],{"class":35,"line":1903},[33,9609,9610],{},"        docId,\n",[33,9612,9613],{"class":35,"line":1909},[33,9614,9615],{},"        version,\n",[33,9617,9618],{"class":35,"line":1915},[33,9619,9620],{},"        text: chunk,\n",[33,9622,9623],{"class":35,"line":3270},[33,9624,9625],{},"        metadata: { docId, version, docType, source: name, mimeType, modifiedTs, expiresTs, text: chunk },\n",[33,9627,9628],{"class":35,"line":3282},[33,9629,9630],{},"      },\n",[33,9632,9633],{"class":35,"line":3290},[33,9634,9635],{},"    });\n",[33,9637,9638],{"class":35,"line":3301},[33,9639,9640],{},"  });\n",[33,9642,9643],{"class":35,"line":3312},[33,9644,82],{},[33,9646,9647],{"class":35,"line":3323},[33,9648,9649],{},"return out;\n",[11,9651,9652,9653,9656],{},"The hash in the ID pays off whether the document changed or not. If it hasn't, the chunk IDs are identical and the workflow can skip embedding entirely (check whether any ID with the prefix ",[30,9654,9655],{},"docId#version#"," exists before calling the embedding model). If it has changed, the new chunks coexist with the old ones only for the duration of the run, and the cleanup step can identify the old ones precisely.",[11,9658,9659],{},"The chunk text is copied into metadata so that query results can be logged and audited without a second lookup. Pinecone limits metadata size per record (40 KB at the time of writing), hence the bound on chunk size.",[15,9661,9663],{"id":9662},"replacing-a-version","Replacing a version",[11,9665,9666,9667,9670,9671,9674,9675,9678,9679,9682,9683,9686],{},"After the upsert succeeds, delete the previous versions of each document. With a Pinecone serverless index this is two calls through an HTTP Request node: list record IDs by prefix (",[30,9668,9669],{},"GET \u002Fvectors\u002Flist?prefix=\u003CdocId>%23",", paginated) and delete by ID (",[30,9672,9673],{},"POST \u002Fvectors\u002Fdelete"," with an ",[30,9676,9677],{},"ids"," array). Listing by prefix is available on serverless indexes only. Pinecone also accepts delete by metadata filter (for example ",[30,9680,9681],{},"docId"," equal to the document and ",[30,9684,9685],{},"version"," different from the current one), which removes the listing step but has a much lower rate limit than delete by ID. The selection logic is the same in both cases:",[23,9688,9690],{"className":5105,"code":9689,"language":5107,"meta":28,"style":28},"\u002F\u002F n8n Code node: select IDs of superseded versions\n\u002F\u002F Input: items from \"List IDs by prefix\", each with { id }\n\u002F\u002F Current version per document comes from the \"Prepare chunks\" node\nconst current = new Map(\n  $('Prepare chunks').all().map(i => [i.json.docId, i.json.version])\n);\n\nconst stale = $input.all()\n  .map(i => i.json.id)\n  .filter(id => {\n    const [docId, version] = id.split('#');\n    return current.has(docId) && current.get(docId) !== version;\n  });\n\n\u002F\u002F Pinecone accepts up to 1000 IDs per delete call\nconst batches = [];\nfor (let i = 0; i \u003C stale.length; i += 1000) {\n  batches.push({ json: { ids: stale.slice(i, i + 1000) } });\n}\nreturn batches;\n",[30,9691,9692,9697,9702,9707,9712,9717,9721,9725,9730,9735,9740,9745,9750,9754,9758,9763,9768,9773,9778,9782],{"__ignoreMap":28},[33,9693,9694],{"class":35,"line":36},[33,9695,9696],{},"\u002F\u002F n8n Code node: select IDs of superseded versions\n",[33,9698,9699],{"class":35,"line":42},[33,9700,9701],{},"\u002F\u002F Input: items from \"List IDs by prefix\", each with { id }\n",[33,9703,9704],{"class":35,"line":48},[33,9705,9706],{},"\u002F\u002F Current version per document comes from the \"Prepare chunks\" node\n",[33,9708,9709],{"class":35,"line":55},[33,9710,9711],{},"const current = new Map(\n",[33,9713,9714],{"class":35,"line":61},[33,9715,9716],{},"  $('Prepare chunks').all().map(i => [i.json.docId, i.json.version])\n",[33,9718,9719],{"class":35,"line":67},[33,9720,8337],{},[33,9722,9723],{"class":35,"line":73},[33,9724,52],{"emptyLinePlaceholder":51},[33,9726,9727],{"class":35,"line":79},[33,9728,9729],{},"const stale = $input.all()\n",[33,9731,9732],{"class":35,"line":85},[33,9733,9734],{},"  .map(i => i.json.id)\n",[33,9736,9737],{"class":35,"line":90},[33,9738,9739],{},"  .filter(id => {\n",[33,9741,9742],{"class":35,"line":96},[33,9743,9744],{},"    const [docId, version] = id.split('#');\n",[33,9746,9747],{"class":35,"line":384},[33,9748,9749],{},"    return current.has(docId) && current.get(docId) !== version;\n",[33,9751,9752],{"class":35,"line":390},[33,9753,9640],{},[33,9755,9756],{"class":35,"line":395},[33,9757,52],{"emptyLinePlaceholder":51},[33,9759,9760],{"class":35,"line":401},[33,9761,9762],{},"\u002F\u002F Pinecone accepts up to 1000 IDs per delete call\n",[33,9764,9765],{"class":35,"line":407},[33,9766,9767],{},"const batches = [];\n",[33,9769,9770],{"class":35,"line":413},[33,9771,9772],{},"for (let i = 0; i \u003C stale.length; i += 1000) {\n",[33,9774,9775],{"class":35,"line":419},[33,9776,9777],{},"  batches.push({ json: { ids: stale.slice(i, i + 1000) } });\n",[33,9779,9780],{"class":35,"line":1806},[33,9781,82],{},[33,9783,9784],{"class":35,"line":1812},[33,9785,9786],{},"return batches;\n",[11,9788,9789],{},"The order (upsert first, delete afterwards) is deliberate. The reverse order leaves a window in which the document has no chunks at all, and a failed upsert after a successful delete removes the document from the bot's knowledge until the next run.",[15,9791,9793],{"id":9792},"reconciling-deletions","Reconciling deletions",[11,9795,9796],{},"A separate workflow, run on a schedule, compares the document IDs in the index with the files in the source folder and deletes the orphans.",[23,9798,9800],{"className":5105,"code":9799,"language":5107,"meta":28,"style":28},"\u002F\u002F n8n Code node: find documents deleted at the source\nconst live = new Set($('List Drive files').all().map(i => i.json.id));\nconst stored = new Set($('List index IDs').all().map(i => i.json.id.split('#')[0]));\n\n\u002F\u002F Guard: an empty or partial Drive listing (expired credentials, wrong folder,\n\u002F\u002F missed pagination) would otherwise delete the whole index.\nif (live.size === 0 || live.size \u003C stored.size * 0.8) {\n  throw new Error(`Drive returned ${live.size} files for ${stored.size} indexed documents, aborting`);\n}\n\nreturn [...stored]\n  .filter(docId => !live.has(docId))\n  .map(docId => ({ json: { docId } }));\n",[30,9801,9802,9807,9812,9817,9821,9826,9831,9836,9841,9845,9849,9854,9859],{"__ignoreMap":28},[33,9803,9804],{"class":35,"line":36},[33,9805,9806],{},"\u002F\u002F n8n Code node: find documents deleted at the source\n",[33,9808,9809],{"class":35,"line":42},[33,9810,9811],{},"const live = new Set($('List Drive files').all().map(i => i.json.id));\n",[33,9813,9814],{"class":35,"line":48},[33,9815,9816],{},"const stored = new Set($('List index IDs').all().map(i => i.json.id.split('#')[0]));\n",[33,9818,9819],{"class":35,"line":55},[33,9820,52],{"emptyLinePlaceholder":51},[33,9822,9823],{"class":35,"line":61},[33,9824,9825],{},"\u002F\u002F Guard: an empty or partial Drive listing (expired credentials, wrong folder,\n",[33,9827,9828],{"class":35,"line":67},[33,9829,9830],{},"\u002F\u002F missed pagination) would otherwise delete the whole index.\n",[33,9832,9833],{"class":35,"line":73},[33,9834,9835],{},"if (live.size === 0 || live.size \u003C stored.size * 0.8) {\n",[33,9837,9838],{"class":35,"line":79},[33,9839,9840],{},"  throw new Error(`Drive returned ${live.size} files for ${stored.size} indexed documents, aborting`);\n",[33,9842,9843],{"class":35,"line":85},[33,9844,82],{},[33,9846,9847],{"class":35,"line":90},[33,9848,52],{"emptyLinePlaceholder":51},[33,9850,9851],{"class":35,"line":96},[33,9852,9853],{},"return [...stored]\n",[33,9855,9856],{"class":35,"line":384},[33,9857,9858],{},"  .filter(docId => !live.has(docId))\n",[33,9860,9861],{"class":35,"line":390},[33,9862,9863],{},"  .map(docId => ({ json: { docId } }));\n",[11,9865,9866],{},"The Drive listing must exclude trashed files and must follow pagination. The 80 % threshold is arbitrary. Pick a value that matches how many documents your team realistically removes between runs.",[15,9868,9870],{"id":9869},"filtering-by-freshness-at-query-time","Filtering by freshness at query time",[11,9872,9873,9874,739,9877,739,9880,739,9883,9886,9887,9890],{},"Pinecone metadata range operators (",[30,9875,9876],{},"$gt",[30,9878,9879],{},"$gte",[30,9881,9882],{},"$lt",[30,9884,9885],{},"$lte",") only work on numbers. ISO date strings won't do, which is why the chunk carries ",[30,9888,9889],{},"expiresTs"," as a Unix timestamp. The query body in an HTTP Request node, with expressions enabled:",[23,9892,9894],{"className":5105,"code":9893,"language":5107,"meta":28,"style":28},"{\n  \"vector\": {{ JSON.stringify($json.embedding) }},\n  \"topK\": 6,\n  \"includeMetadata\": true,\n  \"filter\": { \"expiresTs\": { \"$gte\": {{ Math.floor(Date.now() \u002F 1000) }} } }\n}\n",[30,9895,9896,9900,9905,9910,9915,9920],{"__ignoreMap":28},[33,9897,9898],{"class":35,"line":36},[33,9899,64],{},[33,9901,9902],{"class":35,"line":42},[33,9903,9904],{},"  \"vector\": {{ JSON.stringify($json.embedding) }},\n",[33,9906,9907],{"class":35,"line":48},[33,9908,9909],{},"  \"topK\": 6,\n",[33,9911,9912],{"class":35,"line":55},[33,9913,9914],{},"  \"includeMetadata\": true,\n",[33,9916,9917],{"class":35,"line":61},[33,9918,9919],{},"  \"filter\": { \"expiresTs\": { \"$gte\": {{ Math.floor(Date.now() \u002F 1000) }} } }\n",[33,9921,9922],{"class":35,"line":67},[33,9923,82],{},[11,9925,9926],{},"Computing the expiry at ingestion keeps the policy per document type in one place and the query simple. The cost is that a valid pricing sheet nobody edited for 90 days also drops out. For time-sensitive data that's the better way to fail: the bot finds no context and says it doesn't know, and that's easier to detect than a wrong price. Pair the TTL with an alert on queries that return nothing, so an owner refreshes or re-confirms the document.",[15,9928,9930],{"id":9929},"logging-retrieval","Logging retrieval",[11,9932,9933],{},"Log every retrieval (all of them, sampling defeats the purpose): the query, the returned IDs, their scores, document versions and modification dates. Each of the failures described above can then be found with a query on that log.",[11,9935,9936,9937,221],{},"I know how quiet a missing record can be from my own product, CookieDomain, a system for cookie consents. For 5 days every consent change ended with a 500, and about 88 records were lost without a trace, because the widget swallowed the error in a ",[30,9938,9939],{},".catch",[23,9941,9943],{"className":5105,"code":9942,"language":5107,"meta":28,"style":28},"\u002F\u002F n8n Code node: structured retrieval log\nconst query = $('User query').first().json.text;\nconst matches = $('Pinecone query').first().json.matches ?? [];\nconst now = Date.now() \u002F 1000;\n\nreturn [{\n  json: {\n    ts: new Date().toISOString(),\n    query,\n    matches: matches.map(m => ({\n      id: m.id,\n      score: m.score,\n      docId: m.metadata.docId,\n      version: m.metadata.version,\n      ageDays: Math.round((now - m.metadata.modifiedTs) \u002F 86400),\n    })),\n    topScore: matches.length ? Math.max(...matches.map(m => m.score)) : null,\n    versionsPerDoc: Object.values(\n      matches.reduce((acc, m) => {\n        (acc[m.metadata.docId] ??= new Set()).add(m.metadata.version);\n        return acc;\n      }, {})\n    ).reduce((max, s) => Math.max(max, s.size), 0),\n  },\n}];\n",[30,9944,9945,9950,9955,9960,9965,9969,9974,9978,9983,9988,9993,9998,10003,10008,10013,10018,10023,10028,10033,10038,10043,10048,10053,10058,10062],{"__ignoreMap":28},[33,9946,9947],{"class":35,"line":36},[33,9948,9949],{},"\u002F\u002F n8n Code node: structured retrieval log\n",[33,9951,9952],{"class":35,"line":42},[33,9953,9954],{},"const query = $('User query').first().json.text;\n",[33,9956,9957],{"class":35,"line":48},[33,9958,9959],{},"const matches = $('Pinecone query').first().json.matches ?? [];\n",[33,9961,9962],{"class":35,"line":55},[33,9963,9964],{},"const now = Date.now() \u002F 1000;\n",[33,9966,9967],{"class":35,"line":61},[33,9968,52],{"emptyLinePlaceholder":51},[33,9970,9971],{"class":35,"line":67},[33,9972,9973],{},"return [{\n",[33,9975,9976],{"class":35,"line":73},[33,9977,9324],{},[33,9979,9980],{"class":35,"line":79},[33,9981,9982],{},"    ts: new Date().toISOString(),\n",[33,9984,9985],{"class":35,"line":85},[33,9986,9987],{},"    query,\n",[33,9989,9990],{"class":35,"line":90},[33,9991,9992],{},"    matches: matches.map(m => ({\n",[33,9994,9995],{"class":35,"line":96},[33,9996,9997],{},"      id: m.id,\n",[33,9999,10000],{"class":35,"line":384},[33,10001,10002],{},"      score: m.score,\n",[33,10004,10005],{"class":35,"line":390},[33,10006,10007],{},"      docId: m.metadata.docId,\n",[33,10009,10010],{"class":35,"line":395},[33,10011,10012],{},"      version: m.metadata.version,\n",[33,10014,10015],{"class":35,"line":401},[33,10016,10017],{},"      ageDays: Math.round((now - m.metadata.modifiedTs) \u002F 86400),\n",[33,10019,10020],{"class":35,"line":407},[33,10021,10022],{},"    })),\n",[33,10024,10025],{"class":35,"line":413},[33,10026,10027],{},"    topScore: matches.length ? Math.max(...matches.map(m => m.score)) : null,\n",[33,10029,10030],{"class":35,"line":419},[33,10031,10032],{},"    versionsPerDoc: Object.values(\n",[33,10034,10035],{"class":35,"line":1806},[33,10036,10037],{},"      matches.reduce((acc, m) => {\n",[33,10039,10040],{"class":35,"line":1812},[33,10041,10042],{},"        (acc[m.metadata.docId] ??= new Set()).add(m.metadata.version);\n",[33,10044,10045],{"class":35,"line":1817},[33,10046,10047],{},"        return acc;\n",[33,10049,10050],{"class":35,"line":1822},[33,10051,10052],{},"      }, {})\n",[33,10054,10055],{"class":35,"line":1828},[33,10056,10057],{},"    ).reduce((max, s) => Math.max(max, s.size), 0),\n",[33,10059,10060],{"class":35,"line":1834},[33,10061,9344],{},[33,10063,10064],{"class":35,"line":1840},[33,10065,10066],{},"}];\n",[11,10068,10069,10072,10073,10076],{},[30,10070,10071],{},"versionsPerDoc"," greater than 1 means version replacement failed for at least one document. A persistently low ",[30,10074,10075],{},"topScore"," for a class of questions means the knowledge base doesn't cover them. Score thresholds depend on the embedding model and the corpus. Derive them from the distribution in your own log; a number from someone else's setup won't transfer.",[15,10078,10080],{"id":10079},"pgvector-as-an-alternative","pgvector as an alternative",[11,10082,10083],{},"If the vector store lives in Postgres, version replacement becomes a transaction and the prefix and hash tricks are unnecessary:",[23,10085,10087],{"className":8294,"code":10086,"language":8296,"meta":28,"style":28},"BEGIN;\nDELETE FROM rag_chunks WHERE doc_id = $1;\nINSERT INTO rag_chunks (doc_id, version, chunk_no, body, embedding, modified_at, expires_at)\nVALUES ($1, $2, 0, $3, $4, $5, $6),\n       ($1, $2, 1, $7, $8, $5, $6);  -- one row per chunk\nCOMMIT;\n",[30,10088,10089,10094,10099,10104,10109,10114],{"__ignoreMap":28},[33,10090,10091],{"class":35,"line":36},[33,10092,10093],{},"BEGIN;\n",[33,10095,10096],{"class":35,"line":42},[33,10097,10098],{},"DELETE FROM rag_chunks WHERE doc_id = $1;\n",[33,10100,10101],{"class":35,"line":48},[33,10102,10103],{},"INSERT INTO rag_chunks (doc_id, version, chunk_no, body, embedding, modified_at, expires_at)\n",[33,10105,10106],{"class":35,"line":55},[33,10107,10108],{},"VALUES ($1, $2, 0, $3, $4, $5, $6),\n",[33,10110,10111],{"class":35,"line":61},[33,10112,10113],{},"       ($1, $2, 1, $7, $8, $5, $6);  -- one row per chunk\n",[33,10115,10116],{"class":35,"line":67},[33,10117,10118],{},"COMMIT;\n",[11,10120,10121],{},"Readers see the old version or the new one. There's no moment with both, or with neither. Reconciliation becomes an anti-join with a table of live document IDs. The trade-off is operational: you run and tune the database and the vector indexes yourself.",[15,10123,10125],{"id":10124},"when-this-is-unnecessary","When this is unnecessary",[11,10127,10128],{},"A corpus that's loaded once and never edited, like a fixed set of manuals for a released product, doesn't need versioning or reconciliation. If it's small enough to fit into the model's context window, it doesn't need a vector store at all. The mechanisms above are for documents that people edit, rename and archive without thinking about any index. On a shared drive that's most documents.",[11,10130,10131],{},"When reviewing an existing pipeline, one question gets you most of the way: if someone edits a document right now, what happens to the chunks of the previous version, and which node is responsible for that?",[892,10133,894],{},{"title":28,"searchDepth":42,"depth":42,"links":10135},[10136,10137,10138,10139,10140,10141,10142,10143,10144],{"id":9280,"depth":42,"text":9281},{"id":9355,"depth":42,"text":9356},{"id":9379,"depth":42,"text":9380},{"id":9662,"depth":42,"text":9663},{"id":9792,"depth":42,"text":9793},{"id":9869,"depth":42,"text":9870},{"id":9929,"depth":42,"text":9930},{"id":10079,"depth":42,"text":10080},{"id":10124,"depth":42,"text":10125},"2026-05-22",{},"\u002Farticles\u002Fn8n-rag-data-quality",{"x":10149,"y":10150,"depth":9255,"size":6593},0.4,0.6,[1685,7261],{"title":9268,"description":9274},"n8n-rag-pipeline","articles\u002Fn8n-rag-data-quality",[10156,10157,10158,10159,10160],"n8n","rag","vector-db","pinecone","data-quality","v2.0.0","hh0xlNu8PP6v95T1Co6380LoEu5UojnSR0uP-ey3jM4",{"id":4,"title":5,"articleId":6,"body":10164,"category":27,"codeLang":27,"date":909,"deploys":61,"description":13,"excerpt":910,"extension":911,"lang":910,"meta":10780,"navigation":51,"path":913,"pos":10781,"readMin":85,"related":10782,"seo":10783,"service":921,"stem":922,"tags":10784,"updated":910,"version":926,"__hash__":927},{"type":8,"value":10165,"toc":10766},[10166,10168,10170,10172,10220,10222,10270,10272,10278,10280,10282,10284,10288,10290,10292,10294,10296,10300,10304,10312,10314,10318,10320,10322,10338,10354,10356,10358,10434,10442,10448,10504,10508,10512,10518,10524,10526,10532,10534,10546,10614,10616,10618,10624,10758,10760,10762,10764],[11,10167,13],{},[15,10169,18],{"id":17},[11,10171,21],{},[23,10173,10174],{"className":25,"code":26,"language":27,"meta":28,"style":28},[30,10175,10176,10180,10184,10188,10192,10196,10200,10204,10208,10212,10216],{"__ignoreMap":28},[33,10177,10178],{"class":35,"line":36},[33,10179,39],{},[33,10181,10182],{"class":35,"line":42},[33,10183,45],{},[33,10185,10186],{"class":35,"line":48},[33,10187,52],{"emptyLinePlaceholder":51},[33,10189,10190],{"class":35,"line":55},[33,10191,58],{},[33,10193,10194],{"class":35,"line":61},[33,10195,64],{},[33,10197,10198],{"class":35,"line":67},[33,10199,70],{},[33,10201,10202],{"class":35,"line":73},[33,10203,76],{},[33,10205,10206],{"class":35,"line":79},[33,10207,82],{},[33,10209,10210],{"class":35,"line":85},[33,10211,52],{"emptyLinePlaceholder":51},[33,10213,10214],{"class":35,"line":90},[33,10215,93],{},[33,10217,10218],{"class":35,"line":96},[33,10219,99],{},[11,10221,102],{},[23,10223,10224],{"className":105,"code":106,"language":107,"meta":28,"style":28},[30,10225,10226,10230,10234,10238,10242,10246,10250,10254,10258,10262,10266],{"__ignoreMap":28},[33,10227,10228],{"class":35,"line":36},[33,10229,114],{},[33,10231,10232],{"class":35,"line":42},[33,10233,119],{},[33,10235,10236],{"class":35,"line":48},[33,10237,52],{"emptyLinePlaceholder":51},[33,10239,10240],{"class":35,"line":55},[33,10241,128],{},[33,10243,10244],{"class":35,"line":61},[33,10245,133],{},[33,10247,10248],{"class":35,"line":67},[33,10249,52],{"emptyLinePlaceholder":51},[33,10251,10252],{"class":35,"line":73},[33,10253,142],{},[33,10255,10256],{"class":35,"line":79},[33,10257,147],{},[33,10259,10260],{"class":35,"line":85},[33,10261,152],{},[33,10263,10264],{"class":35,"line":90},[33,10265,157],{},[33,10267,10268],{"class":35,"line":96},[33,10269,162],{},[11,10271,165],{},[11,10273,168,10274,172,10276,176],{},[30,10275,171],{},[30,10277,175],{},[15,10279,180],{"id":179},[11,10281,183],{},[11,10283,186],{},[11,10285,189,10286,193],{},[30,10287,192],{},[195,10289,198],{"id":197},[11,10291,201],{},[11,10293,204],{},[195,10295,208],{"id":207},[11,10297,211,10298,215],{},[30,10299,214],{},[11,10301,218,10302,221],{},[30,10303,192],{},[11,10305,224,10306,228,10308,232,10310,236],{},[30,10307,227],{},[30,10309,231],{},[30,10311,235],{},[15,10313,240],{"id":239},[11,10315,243,10316,247],{},[30,10317,246],{},[11,10319,250],{},[195,10321,254],{"id":253},[11,10323,257,10324,261,10326,265,10328,269,10330,273,10332,277,10334,281,10336,285],{},[30,10325,260],{},[30,10327,264],{},[30,10329,268],{},[30,10331,272],{},[30,10333,276],{},[30,10335,280],{},[30,10337,284],{},[11,10339,288,10340,292,10342,296,10344,300,10346,303,10348,307,10350,311,10352,315],{},[30,10341,291],{},[30,10343,295],{},[30,10345,299],{},[30,10347,284],{},[30,10349,306],{},[30,10351,310],{},[30,10353,314],{},[15,10355,319],{"id":318},[11,10357,322],{},[23,10359,10360],{"className":105,"code":325,"language":107,"meta":28,"style":28},[30,10361,10362,10366,10370,10374,10378,10382,10386,10390,10394,10398,10402,10406,10410,10414,10418,10422,10426,10430],{"__ignoreMap":28},[33,10363,10364],{"class":35,"line":36},[33,10365,332],{},[33,10367,10368],{"class":35,"line":42},[33,10369,337],{},[33,10371,10372],{"class":35,"line":48},[33,10373,342],{},[33,10375,10376],{"class":35,"line":55},[33,10377,347],{},[33,10379,10380],{"class":35,"line":61},[33,10381,52],{"emptyLinePlaceholder":51},[33,10383,10384],{"class":35,"line":67},[33,10385,356],{},[33,10387,10388],{"class":35,"line":73},[33,10389,361],{},[33,10391,10392],{"class":35,"line":79},[33,10393,366],{},[33,10395,10396],{"class":35,"line":85},[33,10397,371],{},[33,10399,10400],{"class":35,"line":90},[33,10401,376],{},[33,10403,10404],{"class":35,"line":96},[33,10405,381],{},[33,10407,10408],{"class":35,"line":384},[33,10409,387],{},[33,10411,10412],{"class":35,"line":390},[33,10413,52],{"emptyLinePlaceholder":51},[33,10415,10416],{"class":35,"line":395},[33,10417,398],{},[33,10419,10420],{"class":35,"line":401},[33,10421,404],{},[33,10423,10424],{"class":35,"line":407},[33,10425,410],{},[33,10427,10428],{"class":35,"line":413},[33,10429,416],{},[33,10431,10432],{"class":35,"line":419},[33,10433,422],{},[11,10435,425,10436,429,10438,433,10440,436],{},[30,10437,428],{},[30,10439,432],{},[30,10441,428],{},[11,10443,439,10444,442,10446,446],{},[30,10445,171],{},[30,10447,445],{},[23,10449,10450],{"className":105,"code":449,"language":107,"meta":28,"style":28},[30,10451,10452,10456,10460,10464,10468,10472,10476,10480,10484,10488,10492,10496,10500],{"__ignoreMap":28},[33,10453,10454],{"class":35,"line":36},[33,10455,456],{},[33,10457,10458],{"class":35,"line":42},[33,10459,461],{},[33,10461,10462],{"class":35,"line":48},[33,10463,52],{"emptyLinePlaceholder":51},[33,10465,10466],{"class":35,"line":55},[33,10467,470],{},[33,10469,10470],{"class":35,"line":61},[33,10471,371],{},[33,10473,10474],{"class":35,"line":67},[33,10475,479],{},[33,10477,10478],{"class":35,"line":73},[33,10479,484],{},[33,10481,10482],{"class":35,"line":79},[33,10483,489],{},[33,10485,10486],{"class":35,"line":85},[33,10487,494],{},[33,10489,10490],{"class":35,"line":90},[33,10491,52],{"emptyLinePlaceholder":51},[33,10493,10494],{"class":35,"line":96},[33,10495,398],{},[33,10497,10498],{"class":35,"line":384},[33,10499,507],{},[33,10501,10502],{"class":35,"line":390},[33,10503,512],{},[11,10505,515,10506,519],{},[30,10507,518],{},[11,10509,522,10510,526],{},[30,10511,525],{},[11,10513,529,10514,533,10516,537],{},[30,10515,532],{},[30,10517,536],{},[11,10519,540,10520,544,10522,548],{},[30,10521,543],{},[30,10523,547],{},[15,10525,552],{"id":551},[11,10527,555,10528,559,10530,221],{},[30,10529,558],{},[30,10531,562],{},[11,10533,565],{},[11,10535,568,10536,572,10538,576,10540,580,10542,584,10544,221],{},[30,10537,571],{},[30,10539,575],{},[30,10541,579],{},[30,10543,583],{},[30,10545,587],{},[23,10547,10548],{"className":25,"code":590,"language":27,"meta":28,"style":28},[30,10549,10550,10554,10558,10562,10566,10570,10574,10578,10582,10586,10590,10594,10598,10602,10606,10610],{"__ignoreMap":28},[33,10551,10552],{"class":35,"line":36},[33,10553,597],{},[33,10555,10556],{"class":35,"line":42},[33,10557,602],{},[33,10559,10560],{"class":35,"line":48},[33,10561,607],{},[33,10563,10564],{"class":35,"line":55},[33,10565,64],{},[33,10567,10568],{"class":35,"line":61},[33,10569,616],{},[33,10571,10572],{"class":35,"line":67},[33,10573,82],{},[33,10575,10576],{"class":35,"line":73},[33,10577,52],{"emptyLinePlaceholder":51},[33,10579,10580],{"class":35,"line":79},[33,10581,629],{},[33,10583,10584],{"class":35,"line":85},[33,10585,634],{},[33,10587,10588],{"class":35,"line":90},[33,10589,639],{},[33,10591,10592],{"class":35,"line":96},[33,10593,82],{},[33,10595,10596],{"class":35,"line":384},[33,10597,52],{"emptyLinePlaceholder":51},[33,10599,10600],{"class":35,"line":390},[33,10601,652],{},[33,10603,10604],{"class":35,"line":395},[33,10605,657],{},[33,10607,10608],{"class":35,"line":401},[33,10609,662],{},[33,10611,10612],{"class":35,"line":407},[33,10613,667],{},[11,10615,670],{},[15,10617,674],{"id":673},[11,10619,677,10620,681,10622,685],{},[30,10621,680],{},[30,10623,684],{},[687,10625,10626,10638],{},[690,10627,10628],{},[693,10629,10630,10632,10634,10636],{},[696,10631],{},[696,10633,700],{},[696,10635,703],{},[696,10637,706],{},[708,10639,10640,10664,10680,10704,10716,10726,10746],{},[693,10641,10642,10644,10648,10656],{},[713,10643,715],{},[713,10645,10646,721],{},[30,10647,720],{},[713,10649,10650,727,10652,730,10654],{},[30,10651,726],{},[30,10653,306],{},[30,10655,733],{},[713,10657,10658,739,10660,739,10662],{},[30,10659,738],{},[30,10661,742],{},[30,10663,745],{},[693,10665,10666,10668,10672,10676],{},[713,10667,750],{},[713,10669,10670],{},[30,10671,755],{},[713,10673,10674],{},[30,10675,760],{},[713,10677,10678,765],{},[30,10679,760],{},[693,10681,10682,10684,10690,10698],{},[713,10683,770],{},[713,10685,773,10686,776,10688,780],{},[30,10687,235],{},[30,10689,779],{},[713,10691,10692,786,10694,789,10696,792],{},[30,10693,785],{},[30,10695,547],{},[30,10697,291],{},[713,10699,10700,739,10702,801],{},[30,10701,797],{},[30,10703,800],{},[693,10705,10706,10708,10712,10714],{},[713,10707,806],{},[713,10709,809,10710,813],{},[30,10711,812],{},[713,10713,816],{},[713,10715,819],{},[693,10717,10718,10720,10722,10724],{},[713,10719,824],{},[713,10721,827],{},[713,10723,830],{},[713,10725,833],{},[693,10727,10728,10730,10734,10740],{},[713,10729,838],{},[713,10731,10732,843],{},[30,10733,192],{},[713,10735,846,10736,849,10738,853],{},[30,10737,525],{},[30,10739,852],{},[713,10741,856,10742,859,10744],{},[30,10743,525],{},[30,10745,862],{},[693,10747,10748,10750,10752,10754],{},[713,10749,867],{},[713,10751,870],{},[713,10753,873],{},[713,10755,876,10756,880],{},[30,10757,879],{},[15,10759,884],{"id":883},[11,10761,887],{},[11,10763,890],{},[892,10765,894],{},{"title":28,"searchDepth":42,"depth":42,"links":10767},[10768,10769,10773,10776,10777,10778,10779],{"id":17,"depth":42,"text":18},{"id":179,"depth":42,"text":180,"children":10770},[10771,10772],{"id":197,"depth":48,"text":198},{"id":207,"depth":48,"text":208},{"id":239,"depth":42,"text":240,"children":10774},[10775],{"id":253,"depth":48,"text":254},{"id":318,"depth":42,"text":319},{"id":551,"depth":42,"text":552},{"id":673,"depth":42,"text":674},{"id":883,"depth":42,"text":884},{},{"x":915,"y":916,"depth":36,"size":911},[918,919],{"title":5,"description":13},[27,107,720,924,925],{"id":10786,"title":10787,"articleId":919,"body":10788,"category":4071,"codeLang":27,"date":11317,"deploys":36,"description":11318,"excerpt":910,"extension":911,"lang":910,"meta":11319,"navigation":51,"path":11320,"pos":11321,"readMin":67,"related":11324,"seo":11325,"service":11326,"stem":11327,"tags":11328,"updated":5828,"version":11334,"__hash__":11335},"articles\u002Farticles\u002Fphp-references.md","PHP references: what & actually does and where it breaks code",{"type":8,"value":10789,"toc":11309},[10790,10797,10801,10808,10830,10878,10890,10894,10950,10971,10974,10996,10999,11032,11036,11039,11107,11132,11135,11139,11142,11180,11189,11193,11196,11265,11275,11279,11304,11307],[11,10791,10792,10793,10796],{},"The PHP manual says references aren't pointers, and it advises against returning by reference to gain performance. Both pieces of advice are correct and easy to forget. Here is what ",[30,10794,10795],{},"&"," does in the engine and where it breaks code.",[15,10798,10800],{"id":10799},"values-copy-on-write-and-references","Values, copy-on-write and references",[11,10802,10803,10804,10807],{},"By default PHP assigns and passes values. Arrays and strings are refcounted, so ",[30,10805,10806],{},"$b = $a"," doesn't copy anything. Both variables point at the same value and the refcount goes up. A copy (separation) happens only when one of them is written while the refcount is above one, which is what copy-on-write means.",[11,10809,10810,10811,10814,10815,1930,10817,10819,10820,739,10823,10826,10827,221],{},"A reference is different. ",[30,10812,10813],{},"$b = &$a"," wraps the value in a reference container that both names share. A write through either name changes the value both of them see, and there's no separation between ",[30,10816,8802],{},[30,10818,8911],{},". If the value is also shared with another variable, though, the first write still copies it: after ",[30,10821,10822],{},"$x = [1]; $a = $x; $b = &$a; $b[] = 2;",[30,10824,10825],{},"$x"," is still ",[30,10828,10829],{},"[1]",[23,10831,10833],{"className":25,"code":10832,"language":27,"meta":28,"style":28},"$a = 'original';\n$b = $a;           \u002F\u002F no copy, both names share one value\n$b = 'modified';   \u002F\u002F $b gets its own value\nvar_dump($a);      \u002F\u002F string(8) \"original\"\n\n$a = 'original';\n$b = &$a;          \u002F\u002F $a and $b are now one variable with two names\n$b = 'modified';\nvar_dump($a);      \u002F\u002F string(8) \"modified\"\n",[30,10834,10835,10840,10845,10850,10855,10859,10863,10868,10873],{"__ignoreMap":28},[33,10836,10837],{"class":35,"line":36},[33,10838,10839],{},"$a = 'original';\n",[33,10841,10842],{"class":35,"line":42},[33,10843,10844],{},"$b = $a;           \u002F\u002F no copy, both names share one value\n",[33,10846,10847],{"class":35,"line":48},[33,10848,10849],{},"$b = 'modified';   \u002F\u002F $b gets its own value\n",[33,10851,10852],{"class":35,"line":55},[33,10853,10854],{},"var_dump($a);      \u002F\u002F string(8) \"original\"\n",[33,10856,10857],{"class":35,"line":61},[33,10858,52],{"emptyLinePlaceholder":51},[33,10860,10861],{"class":35,"line":67},[33,10862,10839],{},[33,10864,10865],{"class":35,"line":73},[33,10866,10867],{},"$b = &$a;          \u002F\u002F $a and $b are now one variable with two names\n",[33,10869,10870],{"class":35,"line":79},[33,10871,10872],{},"$b = 'modified';\n",[33,10874,10875],{"class":35,"line":85},[33,10876,10877],{},"var_dump($a);      \u002F\u002F string(8) \"modified\"\n",[11,10879,10880,10881,10883,10884,10886,10887,10889],{},"After the assignment nothing in the code marks ",[30,10882,8911],{}," as a reference. To know how a write to ",[30,10885,8911],{}," behaves, the reader has to find the line where ",[30,10888,10795],{}," was used.",[15,10891,10893],{"id":10892},"bug-1-the-reference-left-behind-by-foreach","Bug 1: the reference left behind by foreach",[23,10895,10897],{"className":25,"code":10896,"language":27,"meta":28,"style":28},"$prices = [100, 200, 300, 400, 500];\n\nforeach ($prices as &$price) {\n    $price = $price * 0.9;\n}\n\u002F\u002F $prices is [90, 180, 270, 360, 450]\n\nforeach ($prices as $price) {\n    echo $price, ' ';\n}\n\u002F\u002F Output: 90 180 270 360 360\n",[30,10898,10899,10904,10908,10913,10918,10922,10927,10931,10936,10941,10945],{"__ignoreMap":28},[33,10900,10901],{"class":35,"line":36},[33,10902,10903],{},"$prices = [100, 200, 300, 400, 500];\n",[33,10905,10906],{"class":35,"line":42},[33,10907,52],{"emptyLinePlaceholder":51},[33,10909,10910],{"class":35,"line":48},[33,10911,10912],{},"foreach ($prices as &$price) {\n",[33,10914,10915],{"class":35,"line":55},[33,10916,10917],{},"    $price = $price * 0.9;\n",[33,10919,10920],{"class":35,"line":61},[33,10921,82],{},[33,10923,10924],{"class":35,"line":67},[33,10925,10926],{},"\u002F\u002F $prices is [90, 180, 270, 360, 450]\n",[33,10928,10929],{"class":35,"line":73},[33,10930,52],{"emptyLinePlaceholder":51},[33,10932,10933],{"class":35,"line":79},[33,10934,10935],{},"foreach ($prices as $price) {\n",[33,10937,10938],{"class":35,"line":85},[33,10939,10940],{},"    echo $price, ' ';\n",[33,10942,10943],{"class":35,"line":90},[33,10944,82],{},[33,10946,10947],{"class":35,"line":96},[33,10948,10949],{},"\u002F\u002F Output: 90 180 270 360 360\n",[11,10951,10952,10953,10956,10957,10960,10961,10963,10964,10966,10967,10970],{},"After the first loop ",[30,10954,10955],{},"$price"," is still a reference to ",[30,10958,10959],{},"$prices[4]",". The second loop assigns each element to ",[30,10962,10955],{},", which means it writes each element into ",[30,10965,10959],{},". On the fourth iteration it writes 360 there, so the fifth iteration reads 360 instead of 450. It still happens in PHP 8. The PHP 7 change to ",[30,10968,10969],{},"foreach"," semantics didn't remove it.",[11,10972,10973],{},"The minimal fix is to break the reference right after the loop:",[23,10975,10977],{"className":25,"code":10976,"language":27,"meta":28,"style":28},"foreach ($prices as &$price) {\n    $price = $price * 0.9;\n}\nunset($price); \u002F\u002F removes the name $price; $prices[4] keeps its value\n",[30,10978,10979,10983,10987,10991],{"__ignoreMap":28},[33,10980,10981],{"class":35,"line":36},[33,10982,10912],{},[33,10984,10985],{"class":35,"line":42},[33,10986,10917],{},[33,10988,10989],{"class":35,"line":48},[33,10990,82],{},[33,10992,10993],{"class":35,"line":55},[33,10994,10995],{},"unset($price); \u002F\u002F removes the name $price; $prices[4] keeps its value\n",[11,10997,10998],{},"Better still, don't use a reference at all:",[23,11000,11002],{"className":25,"code":11001,"language":27,"meta":28,"style":28},"foreach ($prices as $i => $price) {\n    $prices[$i] = $price * 0.9;\n}\n\n\u002F\u002F or, when a new array is acceptable\n$prices = array_map(fn (int|float $p): float => $p * 0.9, $prices);\n",[30,11003,11004,11009,11014,11018,11022,11027],{"__ignoreMap":28},[33,11005,11006],{"class":35,"line":36},[33,11007,11008],{},"foreach ($prices as $i => $price) {\n",[33,11010,11011],{"class":35,"line":42},[33,11012,11013],{},"    $prices[$i] = $price * 0.9;\n",[33,11015,11016],{"class":35,"line":48},[33,11017,82],{},[33,11019,11020],{"class":35,"line":55},[33,11021,52],{"emptyLinePlaceholder":51},[33,11023,11024],{"class":35,"line":61},[33,11025,11026],{},"\u002F\u002F or, when a new array is acceptable\n",[33,11028,11029],{"class":35,"line":67},[33,11030,11031],{},"$prices = array_map(fn (int|float $p): float => $p * 0.9, $prices);\n",[15,11033,11035],{"id":11034},"bug-2-a-by-reference-parameter-changes-the-contract","Bug 2: a by-reference parameter changes the contract",[11,11037,11038],{},"Say a normalization function receives large arrays, and someone switches it to a by-reference parameter \"to avoid copying\".",[23,11040,11042],{"className":25,"code":11041,"language":27,"meta":28,"style":28},"\u002F\u002F Before: pure function, the caller's array is untouched\nfunction normaliseProduct(array $product): array\n{\n    $product['title'] = trim(strtolower($product['title']));\n    $product['price'] = round($product['price'], 2);\n    return $product;\n}\n\n\u002F\u002F After: mutates the argument and returns nothing\nfunction normaliseProduct(array &$product): void\n{\n    $product['title'] = trim(strtolower($product['title']));\n    $product['price'] = round($product['price'], 2);\n}\n",[30,11043,11044,11049,11054,11058,11063,11068,11073,11077,11081,11086,11091,11095,11099,11103],{"__ignoreMap":28},[33,11045,11046],{"class":35,"line":36},[33,11047,11048],{},"\u002F\u002F Before: pure function, the caller's array is untouched\n",[33,11050,11051],{"class":35,"line":42},[33,11052,11053],{},"function normaliseProduct(array $product): array\n",[33,11055,11056],{"class":35,"line":48},[33,11057,64],{},[33,11059,11060],{"class":35,"line":55},[33,11061,11062],{},"    $product['title'] = trim(strtolower($product['title']));\n",[33,11064,11065],{"class":35,"line":61},[33,11066,11067],{},"    $product['price'] = round($product['price'], 2);\n",[33,11069,11070],{"class":35,"line":67},[33,11071,11072],{},"    return $product;\n",[33,11074,11075],{"class":35,"line":73},[33,11076,82],{},[33,11078,11079],{"class":35,"line":79},[33,11080,52],{"emptyLinePlaceholder":51},[33,11082,11083],{"class":35,"line":85},[33,11084,11085],{},"\u002F\u002F After: mutates the argument and returns nothing\n",[33,11087,11088],{"class":35,"line":90},[33,11089,11090],{},"function normaliseProduct(array &$product): void\n",[33,11092,11093],{"class":35,"line":96},[33,11094,64],{},[33,11096,11097],{"class":35,"line":384},[33,11098,11062],{},[33,11100,11101],{"class":35,"line":390},[33,11102,11067],{},[33,11104,11105],{"class":35,"line":395},[33,11106,82],{},[11,11108,11109,11110,11113,11114,11116,11117,11120,11121,11124,11125,739,11128,11131],{},"Every existing call of the form ",[30,11111,11112],{},"$normalised = normaliseProduct($product)"," now stores ",[30,11115,6293],{}," and modifies ",[30,11118,11119],{},"$product"," as a side effect. PHP itself doesn't report using the result of a ",[30,11122,11123],{},"void"," function. PHPStan (from level 0) or Psalm (at errorLevel 1 through 7, so every level except the loosest, 8) will, if they run in CI. And if a function is meant to mutate its argument, its name should say so (",[30,11126,11127],{},"sortInPlace",[30,11129,11130],{},"applyDefaults",").",[11,11133,11134],{},"The optimization also gains less than it seems. The by-value version copies the array once, when it writes the first key, and that copy is shallow: nested arrays stay shared until something writes to them. The by-reference version avoids that copy only when the caller's array isn't shared. If it's shared with another variable (refcount above one), the first write inside the function separates it anyway.",[15,11136,11138],{"id":11137},"bug-3-references-survive-inside-arrays","Bug 3: references survive inside arrays",[11,11140,11141],{},"A reference to an array element stays attached to that element. Copy the array and the reference goes along, so the element stays shared.",[23,11143,11145],{"className":25,"code":11144,"language":27,"meta":28,"style":28},"$a = [1, 2];\n$ref = &$a[0];\n\n$b = $a;        \u002F\u002F looks like a copy\n$b[0] = 99;\n\nvar_dump($a[0]); \u002F\u002F int(99)\n",[30,11146,11147,11152,11157,11161,11166,11171,11175],{"__ignoreMap":28},[33,11148,11149],{"class":35,"line":36},[33,11150,11151],{},"$a = [1, 2];\n",[33,11153,11154],{"class":35,"line":42},[33,11155,11156],{},"$ref = &$a[0];\n",[33,11158,11159],{"class":35,"line":48},[33,11160,52],{"emptyLinePlaceholder":51},[33,11162,11163],{"class":35,"line":55},[33,11164,11165],{},"$b = $a;        \u002F\u002F looks like a copy\n",[33,11167,11168],{"class":35,"line":61},[33,11169,11170],{},"$b[0] = 99;\n",[33,11172,11173],{"class":35,"line":67},[33,11174,52],{"emptyLinePlaceholder":51},[33,11176,11177],{"class":35,"line":73},[33,11178,11179],{},"var_dump($a[0]); \u002F\u002F int(99)\n",[11,11181,11182,11183,11185,11186,11188],{},"The manual documents this. Of the three bugs it's the hardest to spot, because the ",[30,11184,10795],{}," can be far away from the copy. It can be left behind, for example, by a by-reference ",[30,11187,10969],{}," whose loop variable was never unset.",[15,11190,11192],{"id":11191},"objects-are-not-passed-by-reference","Objects are not passed by reference",[11,11194,11195],{},"Objects are passed by value, and the value is an object handle. Through the handle a function can change the object's state. Assigning a new object to the parameter changes only the local copy of the handle.",[23,11197,11199],{"className":25,"code":11198,"language":27,"meta":28,"style":28},"final class Counter\n{\n    public int $count = 0;\n}\n\nfunction increment(Counter $counter): void\n{\n    $counter->count++;       \u002F\u002F visible to the caller\n    $counter = new Counter(); \u002F\u002F not visible to the caller\n}\n\n$c = new Counter();\nincrement($c);\nvar_dump($c->count); \u002F\u002F int(1)\n",[30,11200,11201,11206,11210,11215,11219,11223,11228,11232,11237,11242,11246,11250,11255,11260],{"__ignoreMap":28},[33,11202,11203],{"class":35,"line":36},[33,11204,11205],{},"final class Counter\n",[33,11207,11208],{"class":35,"line":42},[33,11209,64],{},[33,11211,11212],{"class":35,"line":48},[33,11213,11214],{},"    public int $count = 0;\n",[33,11216,11217],{"class":35,"line":55},[33,11218,82],{},[33,11220,11221],{"class":35,"line":61},[33,11222,52],{"emptyLinePlaceholder":51},[33,11224,11225],{"class":35,"line":67},[33,11226,11227],{},"function increment(Counter $counter): void\n",[33,11229,11230],{"class":35,"line":73},[33,11231,64],{},[33,11233,11234],{"class":35,"line":79},[33,11235,11236],{},"    $counter->count++;       \u002F\u002F visible to the caller\n",[33,11238,11239],{"class":35,"line":85},[33,11240,11241],{},"    $counter = new Counter(); \u002F\u002F not visible to the caller\n",[33,11243,11244],{"class":35,"line":90},[33,11245,82],{},[33,11247,11248],{"class":35,"line":96},[33,11249,52],{"emptyLinePlaceholder":51},[33,11251,11252],{"class":35,"line":384},[33,11253,11254],{},"$c = new Counter();\n",[33,11256,11257],{"class":35,"line":390},[33,11258,11259],{},"increment($c);\n",[33,11261,11262],{"class":35,"line":395},[33,11263,11264],{},"var_dump($c->count); \u002F\u002F int(1)\n",[11,11266,11267,11268,11270,11271,11274],{},"You only need ",[30,11269,10795],{}," on an object parameter (",[30,11272,11273],{},"Counter &$counter",") when the function must replace the caller's object. Returning a new object is usually better.",[15,11276,11278],{"id":11277},"when-a-reference-is-justified","When a reference is justified",[3583,11280,11281,11294,11301],{},[3586,11282,11283,11284,739,11287,739,11290,11293],{},"Output parameters of built-in functions: ",[30,11285,11286],{},"preg_match($pattern, $subject, $matches)",[30,11288,11289],{},"sort($array)",[30,11291,11292],{},"array_push()"," and others take their argument by reference by design.",[3586,11295,11296,11297,11300],{},"In-place modification of a large nested structure, for example a recursive walk over a tree of arrays that updates leaves. Passing by value separates each modified level on the way down and requires the caller to reassign the result. Measure with ",[30,11298,11299],{},"memory_get_peak_usage()"," before deciding. For most request-sized data the difference is negligible.",[3586,11302,11303],{},"Building a nested structure through a reference to the current node, a common pattern when turning a flat list with parent ids into a tree. Keep it inside one function and unset the reference before returning.",[11,11305,11306],{},"In application code that works on objects, the same effect is usually available without references: pass an object and mutate its state, or return a new value.",[892,11308,894],{},{"title":28,"searchDepth":42,"depth":42,"links":11310},[11311,11312,11313,11314,11315,11316],{"id":10799,"depth":42,"text":10800},{"id":10892,"depth":42,"text":10893},{"id":11034,"depth":42,"text":11035},{"id":11137,"depth":42,"text":11138},{"id":11191,"depth":42,"text":11192},{"id":11277,"depth":42,"text":11278},"2023-09-30","The PHP manual says references aren't pointers, and it advises against returning by reference to gain performance. Both pieces of advice are correct and easy to forget. Here is what & does in the engine and where it breaks code.",{},"\u002Farticles\u002Fphp-references",{"x":11322,"y":11323,"depth":4077,"size":911},0.12,0.5,[918,5014],{"title":10787,"description":11318},"memory-management","articles\u002Fphp-references",[27,11329,11330,11331,11332,11333],"memory","debugging","references","performance","footguns","v2.1.1","vCeRrSowV5DPp9wWXcii3y2vYJVIYlgSx14gtrGcyd0",{"id":11337,"title":11338,"articleId":2511,"body":11339,"category":4071,"codeLang":910,"date":12119,"deploys":79,"description":12120,"excerpt":910,"extension":911,"lang":910,"meta":12121,"navigation":51,"path":12122,"pos":910,"readMin":73,"related":910,"seo":12123,"service":12124,"stem":12125,"tags":12126,"updated":5828,"version":12130,"__hash__":12131},"articles\u002Farticles\u002Fpostgres-edge.md","Postgres with more than one writer: primary keys and how to migrate them",{"type":8,"value":11340,"toc":12114},[11341,11351,11355,11358,11361,11372,11379,11383,11389,11414,11417,11423,11433,11488,11524,11527,11538,11542,11556,11562,11582,11585,11595,11680,11699,11708,11742,11752,11765,11860,11889,11898,11908,12018,12029,12053,12056,12071,12086,12112],[11,11342,11343,11344,2582,11347,11350],{},"A ",[30,11345,11346],{},"bigserial",[30,11348,11349],{},"identity"," column gets its values from a sequence. A sequence is local to one Postgres instance, so it only produces unique values if every insert goes through that instance. With a single primary that's already true, so uniqueness alone is no reason to change the key type. That changes when more than one node accepts writes, or when IDs are created outside the database.",[15,11352,11354],{"id":11353},"when-the-key-type-matters","When the key type matters",[11,11356,11357],{},"Changing the key type doesn't reduce write latency in a single-primary setup. A client in another region still sends the whole transaction to the primary. The sequence call is a negligible part of that round trip.",[11,11359,11360],{},"The key type matters when:",[3583,11362,11363,11366,11369],{},[3586,11364,11365],{},"several nodes accept writes for the same table (bidirectional logical replication, multi-master extensions, sharding by region),",[3586,11367,11368],{},"the identifier has to exist before the row reaches the database (offline clients, idempotency keys generated by the client, events published before the insert),",[3586,11370,11371],{},"data from several independent databases is merged into one place.",[11,11373,11374,11375,11378],{},"In each of these cases two writers can produce the same ",[30,11376,11377],{},"bigint"," if they don't coordinate.",[15,11380,11382],{"id":11381},"the-options","The options",[11,11384,11385,11388],{},[5856,11386,11387],{},"Per-node sequences."," Each node gets its own sequence with a different start and a shared step:",[23,11390,11392],{"className":8294,"code":11391,"language":8296,"meta":28,"style":28},"-- node 1\ncreate sequence orders_id_seq start with 1 increment by 16;\n-- node 2\ncreate sequence orders_id_seq start with 2 increment by 16;\n",[30,11393,11394,11399,11404,11409],{"__ignoreMap":28},[33,11395,11396],{"class":35,"line":36},[33,11397,11398],{},"-- node 1\n",[33,11400,11401],{"class":35,"line":42},[33,11402,11403],{},"create sequence orders_id_seq start with 1 increment by 16;\n",[33,11405,11406],{"class":35,"line":48},[33,11407,11408],{},"-- node 2\n",[33,11410,11411],{"class":35,"line":55},[33,11412,11413],{},"create sequence orders_id_seq start with 2 increment by 16;\n",[11,11415,11416],{},"Keys stay 8-byte integers and existing foreign keys keep working. The cost is operational: the step caps the number of nodes, every new node needs a free offset, and a misconfigured node produces collisions that show up only at replication time.",[11,11418,11419,11422],{},[5856,11420,11421],{},"UUIDv4."," Unique without coordination, and random. Each insert lands on a random leaf page of the B-tree index. The working set of the index becomes the whole index, page splits are spread across it, and after a checkpoint more pages are written in full to WAL. This is usually tolerable while the index fits in memory, but on a large one inserts read and write noticeably more pages.",[11,11424,11425,11428,11429,11432],{},[5856,11426,11427],{},"UUIDv7"," (RFC 9562). The first 48 bits are a Unix timestamp in milliseconds; apart from the version and variant bits, the rest is random (Postgres 18 uses 12 of those bits for a sub-millisecond fraction). Values generated at roughly the same time are close in the index, so inserts go mostly to the rightmost pages, as with a sequence. Postgres 18 has a built-in ",[30,11430,11431],{},"uuidv7()"," function. For Postgres 13–17 it can be written in SQL:",[23,11434,11436],{"className":8294,"code":11435,"language":8296,"meta":28,"style":28},"create function uuidv7_at(ts timestamptz) returns uuid\nlanguage sql volatile as $$\n  select encode(\n    set_bit(set_bit(\n      overlay(uuid_send(gen_random_uuid())\n              placing substring(int8send(floor(extract(epoch from ts) * 1000)::bigint) from 3)\n              from 1 for 6),\n      52, 1), 53, 1),\n    'hex')::uuid;\n$$;\n",[30,11437,11438,11443,11448,11453,11458,11463,11468,11473,11478,11483],{"__ignoreMap":28},[33,11439,11440],{"class":35,"line":36},[33,11441,11442],{},"create function uuidv7_at(ts timestamptz) returns uuid\n",[33,11444,11445],{"class":35,"line":42},[33,11446,11447],{},"language sql volatile as $$\n",[33,11449,11450],{"class":35,"line":48},[33,11451,11452],{},"  select encode(\n",[33,11454,11455],{"class":35,"line":55},[33,11456,11457],{},"    set_bit(set_bit(\n",[33,11459,11460],{"class":35,"line":61},[33,11461,11462],{},"      overlay(uuid_send(gen_random_uuid())\n",[33,11464,11465],{"class":35,"line":67},[33,11466,11467],{},"              placing substring(int8send(floor(extract(epoch from ts) * 1000)::bigint) from 3)\n",[33,11469,11470],{"class":35,"line":73},[33,11471,11472],{},"              from 1 for 6),\n",[33,11474,11475],{"class":35,"line":79},[33,11476,11477],{},"      52, 1), 53, 1),\n",[33,11479,11480],{"class":35,"line":85},[33,11481,11482],{},"    'hex')::uuid;\n",[33,11484,11485],{"class":35,"line":90},[33,11486,11487],{},"$$;\n",[11,11489,11490,11491,11494,11495,11498,11499,11502,11503,11505,11506,11508,11509,11512,11513,11516,11517,11519,11520,11523],{},"It takes a random v4 UUID, overwrites the first 6 bytes with the millisecond timestamp and changes the version bits from 4 to 7. The ",[30,11492,11493],{},"ts"," parameter also makes it usable for backfilling old rows from ",[30,11496,11497],{},"created_at",". On Postgres 18 ",[30,11500,11501],{},"uuidv7((extract(epoch from created_at) - extract(epoch from clock_timestamp())) * interval '1 second')"," gives a UUID whose timestamp matches ",[30,11504,11497],{}," to within microseconds, because ",[30,11507,11431],{}," accepts an interval that shifts the timestamp; those few microseconds occasionally push the stored millisecond up by one. The shorter ",[30,11510,11511],{},"uuidv7(created_at - clock_timestamp())"," is only safe in a time zone without daylight saving, such as UTC: the difference of two ",[30,11514,11515],{},"timestamptz"," values carries days that ",[30,11518,11431],{}," adds in the session time zone, and across a daylight-saving change in ",[30,11521,11522],{},"Europe\u002FWarsaw"," the timestamp came out shifted by an hour.",[11,11525,11526],{},"For new tables with more than one writer I'd choose UUIDv7, and accept the costs:",[3583,11528,11529,11532,11535],{},[3586,11530,11531],{},"16 bytes instead of 8 in the primary key and in every foreign key and index that contains it,",[3586,11533,11534],{},"the identifier reveals when the row was created, which matters if IDs appear in public URLs,",[3586,11536,11537],{},"ordering by ID is only approximately chronological across nodes, because it depends on their clocks.",[15,11539,11541],{"id":11540},"migrating-an-existing-table-expand-backfill-swap","Migrating an existing table: expand, backfill, swap",[11,11543,11544,11545,11548,11549,11551,11552,11555],{},"Take an ",[30,11546,11547],{},"orders"," table with a ",[30,11550,11377],{}," primary key and an ",[30,11553,11554],{},"order_items"," table that references it. The goal is a UUIDv7 primary key without locking either table for longer than a few seconds.",[11,11557,11558,11561],{},[5856,11559,11560],{},"1. Add the column and a default for new rows."," Adding a nullable column without a default only changes the catalog. Setting the default afterwards affects new rows only.",[23,11563,11565],{"className":8294,"code":11564,"language":8296,"meta":28,"style":28},"alter table orders add column id_v2 uuid;\nalter table orders alter column id_v2 set default uuidv7();            -- Postgres 18\n-- alter table orders alter column id_v2 set default uuidv7_at(clock_timestamp());  -- Postgres 13–17\n",[30,11566,11567,11572,11577],{"__ignoreMap":28},[33,11568,11569],{"class":35,"line":36},[33,11570,11571],{},"alter table orders add column id_v2 uuid;\n",[33,11573,11574],{"class":35,"line":42},[33,11575,11576],{},"alter table orders alter column id_v2 set default uuidv7();            -- Postgres 18\n",[33,11578,11579],{"class":35,"line":48},[33,11580,11581],{},"-- alter table orders alter column id_v2 set default uuidv7_at(clock_timestamp());  -- Postgres 13–17\n",[11,11583,11584],{},"With several nodes, add the new columns (here and in step 4) on all of them before any of them starts filling them. A write to the table on a node that already has the new column stops replication to the nodes that don't have it yet, even when the column is empty. It resumes on its own once they add it. Set the default only once the empty column exists on every node. In the opposite order, a row inserted on a node without the column gets its own UUID from the default on the node that has it, but stays NULL on the node where it was inserted. Run the backfill on that node: its UUID reaches the other node through replication and replaces the one there. On the node that already has values the loop skips these rows, so the NULLs stay (checked on Postgres 18.6).",[11,11586,11587,11590,11591,11594],{},[5856,11588,11589],{},"2. Backfill in batches."," A single ",[30,11592,11593],{},"update"," on the whole table holds row locks for its entire duration and leaves a large number of dead tuples at once. Batches with a commit in between keep locks short and give autovacuum time to clean up between batches:",[23,11596,11598],{"className":8294,"code":11597,"language":8296,"meta":28,"style":28},"do $$\ndeclare\n  batch_size constant bigint := 10000;\n  from_id bigint := 0;\n  max_id bigint;\nbegin\n  select max(id) into max_id from orders;\n  while from_id \u003C= max_id loop\n    update orders\n       set id_v2 = uuidv7_at(created_at)\n     where id >= from_id and id \u003C from_id + batch_size\n       and id_v2 is null;\n    commit;\n    from_id := from_id + batch_size;\n  end loop;\nend $$;\n",[30,11599,11600,11605,11610,11615,11620,11625,11630,11635,11640,11645,11650,11655,11660,11665,11670,11675],{"__ignoreMap":28},[33,11601,11602],{"class":35,"line":36},[33,11603,11604],{},"do $$\n",[33,11606,11607],{"class":35,"line":42},[33,11608,11609],{},"declare\n",[33,11611,11612],{"class":35,"line":48},[33,11613,11614],{},"  batch_size constant bigint := 10000;\n",[33,11616,11617],{"class":35,"line":55},[33,11618,11619],{},"  from_id bigint := 0;\n",[33,11621,11622],{"class":35,"line":61},[33,11623,11624],{},"  max_id bigint;\n",[33,11626,11627],{"class":35,"line":67},[33,11628,11629],{},"begin\n",[33,11631,11632],{"class":35,"line":73},[33,11633,11634],{},"  select max(id) into max_id from orders;\n",[33,11636,11637],{"class":35,"line":79},[33,11638,11639],{},"  while from_id \u003C= max_id loop\n",[33,11641,11642],{"class":35,"line":85},[33,11643,11644],{},"    update orders\n",[33,11646,11647],{"class":35,"line":90},[33,11648,11649],{},"       set id_v2 = uuidv7_at(created_at)\n",[33,11651,11652],{"class":35,"line":96},[33,11653,11654],{},"     where id >= from_id and id \u003C from_id + batch_size\n",[33,11656,11657],{"class":35,"line":384},[33,11658,11659],{},"       and id_v2 is null;\n",[33,11661,11662],{"class":35,"line":390},[33,11663,11664],{},"    commit;\n",[33,11666,11667],{"class":35,"line":395},[33,11668,11669],{},"    from_id := from_id + batch_size;\n",[33,11671,11672],{"class":35,"line":401},[33,11673,11674],{},"  end loop;\n",[33,11676,11677],{"class":35,"line":407},[33,11678,11679],{},"end $$;\n",[11,11681,11682,11683,11686,11687,11690,11691,11694,11695,11698],{},"The loop calls ",[30,11684,11685],{},"uuidv7_at",", so on Postgres 18 either create that function too or use the epoch-based ",[30,11688,11689],{},"uuidv7(...)"," expression above instead. ",[30,11692,11693],{},"commit"," inside a ",[30,11696,11697],{},"do"," block works when the block isn't run inside an explicit transaction. In production it's easier to run the same loop from a script that can pause when replication lag grows.",[11,11700,11701],{},[5856,11702,11703,11704,11707],{},"3. Index and ",[30,11705,11706],{},"not null"," without a long lock.",[23,11709,11711],{"className":8294,"code":11710,"language":8296,"meta":28,"style":28},"create unique index concurrently orders_id_v2_uq on orders (id_v2);\n\nalter table orders add constraint orders_id_v2_nn check (id_v2 is not null) not valid;\nalter table orders validate constraint orders_id_v2_nn;\nalter table orders alter column id_v2 set not null;   -- uses the validated check, no full scan\nalter table orders drop constraint orders_id_v2_nn;\n",[30,11712,11713,11718,11722,11727,11732,11737],{"__ignoreMap":28},[33,11714,11715],{"class":35,"line":36},[33,11716,11717],{},"create unique index concurrently orders_id_v2_uq on orders (id_v2);\n",[33,11719,11720],{"class":35,"line":42},[33,11721,52],{"emptyLinePlaceholder":51},[33,11723,11724],{"class":35,"line":48},[33,11725,11726],{},"alter table orders add constraint orders_id_v2_nn check (id_v2 is not null) not valid;\n",[33,11728,11729],{"class":35,"line":55},[33,11730,11731],{},"alter table orders validate constraint orders_id_v2_nn;\n",[33,11733,11734],{"class":35,"line":61},[33,11735,11736],{},"alter table orders alter column id_v2 set not null;   -- uses the validated check, no full scan\n",[33,11738,11739],{"class":35,"line":67},[33,11740,11741],{},"alter table orders drop constraint orders_id_v2_nn;\n",[11,11743,11744,11747,11748,11751],{},[30,11745,11746],{},"set not null"," skips the table scan when a validated check constraint already proves the condition (Postgres 12 and newer). ",[30,11749,11750],{},"validate constraint"," takes a lock that doesn't block reads or writes.",[11,11753,11754,11757,11758,11761,11762,11764],{},[5856,11755,11756],{},"4. The same for child tables."," Add ",[30,11759,11760],{},"order_id_v2",", keep it filled for new rows with a trigger, backfill old rows in batches by joining to ",[30,11763,11547],{},":",[23,11766,11768],{"className":8294,"code":11767,"language":8296,"meta":28,"style":28},"alter table order_items add column order_id_v2 uuid;\n\ncreate function order_items_sync_order_id_v2() returns trigger\nlanguage plpgsql as $$\nbegin\n  select o.id_v2 into new.order_id_v2 from orders o where o.id = new.order_id;\n  return new;\nend $$;\n\ncreate trigger order_items_sync_order_id_v2\n  before insert or update of order_id on order_items\n  for each row execute function order_items_sync_order_id_v2();\n\nupdate order_items i\n   set order_id_v2 = o.id_v2\n  from orders o\n where o.id = i.order_id\n   and i.order_id_v2 is null\n   and i.id >= 0 and i.id \u003C 10000;   -- repeated per batch\n",[30,11769,11770,11775,11779,11784,11789,11793,11798,11803,11807,11811,11816,11821,11826,11830,11835,11840,11845,11850,11855],{"__ignoreMap":28},[33,11771,11772],{"class":35,"line":36},[33,11773,11774],{},"alter table order_items add column order_id_v2 uuid;\n",[33,11776,11777],{"class":35,"line":42},[33,11778,52],{"emptyLinePlaceholder":51},[33,11780,11781],{"class":35,"line":48},[33,11782,11783],{},"create function order_items_sync_order_id_v2() returns trigger\n",[33,11785,11786],{"class":35,"line":55},[33,11787,11788],{},"language plpgsql as $$\n",[33,11790,11791],{"class":35,"line":61},[33,11792,11629],{},[33,11794,11795],{"class":35,"line":67},[33,11796,11797],{},"  select o.id_v2 into new.order_id_v2 from orders o where o.id = new.order_id;\n",[33,11799,11800],{"class":35,"line":73},[33,11801,11802],{},"  return new;\n",[33,11804,11805],{"class":35,"line":79},[33,11806,11679],{},[33,11808,11809],{"class":35,"line":85},[33,11810,52],{"emptyLinePlaceholder":51},[33,11812,11813],{"class":35,"line":90},[33,11814,11815],{},"create trigger order_items_sync_order_id_v2\n",[33,11817,11818],{"class":35,"line":96},[33,11819,11820],{},"  before insert or update of order_id on order_items\n",[33,11822,11823],{"class":35,"line":384},[33,11824,11825],{},"  for each row execute function order_items_sync_order_id_v2();\n",[33,11827,11828],{"class":35,"line":390},[33,11829,52],{"emptyLinePlaceholder":51},[33,11831,11832],{"class":35,"line":395},[33,11833,11834],{},"update order_items i\n",[33,11836,11837],{"class":35,"line":401},[33,11838,11839],{},"   set order_id_v2 = o.id_v2\n",[33,11841,11842],{"class":35,"line":407},[33,11843,11844],{},"  from orders o\n",[33,11846,11847],{"class":35,"line":413},[33,11848,11849],{}," where o.id = i.order_id\n",[33,11851,11852],{"class":35,"line":419},[33,11853,11854],{},"   and i.order_id_v2 is null\n",[33,11856,11857],{"class":35,"line":1806},[33,11858,11859],{},"   and i.id >= 0 and i.id \u003C 10000;   -- repeated per batch\n",[11,11861,11862,11863,11866,11867,11869,11870,11872,11873,11876,11877,11872,11880,11882,11883,11886,11887,11764],{},"Then the same ",[30,11864,11865],{},"not valid"," \u002F ",[30,11868,3622],{}," sequence for ",[30,11871,11706],{}," on ",[30,11874,11875],{},"order_items.order_id_v2",", plus an index for the future foreign key. A foreign key doesn't create one by itself. Without it, every ",[30,11878,11879],{},"delete",[30,11881,11547],{},", every update of ",[30,11884,11885],{},"orders.id"," and every lookup of an order's items scans all of ",[30,11888,11554],{},[23,11890,11892],{"className":8294,"code":11891,"language":8296,"meta":28,"style":28},"create index concurrently order_items_order_id_v2_idx on order_items (order_id_v2);\n",[30,11893,11894],{"__ignoreMap":28},[33,11895,11896],{"class":35,"line":36},[33,11897,11891],{},[11,11899,11900,11903,11904,11907],{},[5856,11901,11902],{},"5. Swap in one short transaction."," If anything still looks rows up by the old numeric ID, create a unique index on ",[30,11905,11906],{},"orders (id)"," concurrently before this step, because dropping the primary key removes the old one.",[23,11909,11911],{"className":8294,"code":11910,"language":8296,"meta":28,"style":28},"begin;\nset local lock_timeout = '5s';\n\ndrop trigger order_items_sync_order_id_v2 on order_items;\nalter table order_items drop constraint order_items_order_id_fkey;\n\nalter table orders drop constraint orders_pkey;\nalter table orders add constraint orders_pkey primary key using index orders_id_v2_uq;\n\nalter table orders rename column id to legacy_id;\nalter table orders rename column id_v2 to id;\nalter table order_items rename column order_id to legacy_order_id;\nalter table order_items rename column order_id_v2 to order_id;\nalter table order_items alter column legacy_order_id drop not null;  -- new rows no longer fill it\n-- if the old id had no default (no bigserial\u002Fidentity), also:\n-- alter table orders alter column legacy_id drop not null;\n\nalter table order_items add constraint order_items_order_id_fkey\n  foreign key (order_id) references orders (id) not valid;\ncommit;\n\nalter table order_items validate constraint order_items_order_id_fkey;\n",[30,11912,11913,11918,11923,11927,11932,11937,11941,11946,11951,11955,11960,11965,11970,11975,11980,11985,11990,11994,11999,12004,12009,12013],{"__ignoreMap":28},[33,11914,11915],{"class":35,"line":36},[33,11916,11917],{},"begin;\n",[33,11919,11920],{"class":35,"line":42},[33,11921,11922],{},"set local lock_timeout = '5s';\n",[33,11924,11925],{"class":35,"line":48},[33,11926,52],{"emptyLinePlaceholder":51},[33,11928,11929],{"class":35,"line":55},[33,11930,11931],{},"drop trigger order_items_sync_order_id_v2 on order_items;\n",[33,11933,11934],{"class":35,"line":61},[33,11935,11936],{},"alter table order_items drop constraint order_items_order_id_fkey;\n",[33,11938,11939],{"class":35,"line":67},[33,11940,52],{"emptyLinePlaceholder":51},[33,11942,11943],{"class":35,"line":73},[33,11944,11945],{},"alter table orders drop constraint orders_pkey;\n",[33,11947,11948],{"class":35,"line":79},[33,11949,11950],{},"alter table orders add constraint orders_pkey primary key using index orders_id_v2_uq;\n",[33,11952,11953],{"class":35,"line":85},[33,11954,52],{"emptyLinePlaceholder":51},[33,11956,11957],{"class":35,"line":90},[33,11958,11959],{},"alter table orders rename column id to legacy_id;\n",[33,11961,11962],{"class":35,"line":96},[33,11963,11964],{},"alter table orders rename column id_v2 to id;\n",[33,11966,11967],{"class":35,"line":384},[33,11968,11969],{},"alter table order_items rename column order_id to legacy_order_id;\n",[33,11971,11972],{"class":35,"line":390},[33,11973,11974],{},"alter table order_items rename column order_id_v2 to order_id;\n",[33,11976,11977],{"class":35,"line":395},[33,11978,11979],{},"alter table order_items alter column legacy_order_id drop not null;  -- new rows no longer fill it\n",[33,11981,11982],{"class":35,"line":401},[33,11983,11984],{},"-- if the old id had no default (no bigserial\u002Fidentity), also:\n",[33,11986,11987],{"class":35,"line":407},[33,11988,11989],{},"-- alter table orders alter column legacy_id drop not null;\n",[33,11991,11992],{"class":35,"line":413},[33,11993,52],{"emptyLinePlaceholder":51},[33,11995,11996],{"class":35,"line":419},[33,11997,11998],{},"alter table order_items add constraint order_items_order_id_fkey\n",[33,12000,12001],{"class":35,"line":1806},[33,12002,12003],{},"  foreign key (order_id) references orders (id) not valid;\n",[33,12005,12006],{"class":35,"line":1812},[33,12007,12008],{},"commit;\n",[33,12010,12011],{"class":35,"line":1817},[33,12012,52],{"emptyLinePlaceholder":51},[33,12014,12015],{"class":35,"line":1822},[33,12016,12017],{},"alter table order_items validate constraint order_items_order_id_fkey;\n",[11,12019,12020,12021,12024,12025,12028],{},"Every statement in the transaction changes only the catalog, so the ",[30,12022,12023],{},"access exclusive"," lock is held briefly. ",[30,12026,12027],{},"lock_timeout"," caps the wait: if the lock isn't granted within 5 s, the transaction rolls back instead of queueing behind a long-running query. The limit applies to each lock separately, and the transaction takes its locks one after another, so the wait can add up to several 5 s stretches. Queries that line up behind it wait all that time. The trigger is dropped in the same transaction because its body refers to the column names that are being renamed.",[11,12030,12031,12032,12034,12035,12038,12039,12042,12043,12045,12046,1930,12049,12052],{},"After the rename, ",[30,12033,11885],{}," is a ",[30,12036,12037],{},"uuid",". The rename breaks any code that still uses ",[30,12040,12041],{},"id_v2"," or treats ",[30,12044,9386],{}," as a number, so the release that knows the new names has to go out together with step 5. Until it's live, anything in the old code that uses these IDs fails, reads included, so either pause traffic to these tables for those few seconds or accept the errors. A second, cleanup release removes ",[30,12047,12048],{},"legacy_id",[30,12050,12051],{},"legacy_order_id"," after the swap.",[11,12054,12055],{},"Before step 1, list everything that stores the old ID: tables, views, materialized views, report queries, external systems. Child tables are often larger than the parent, and their new column has to copy the parent's UUID, which is why their backfill goes through the join in step 4. Replication lag and autovacuum need watching while the backfill runs.",[11,12057,12058,12059,1930,12062,12065,12066,2582,12068,12070],{},"With several nodes on logical replication, schema changes (DDL) aren't replicated, so every ",[30,12060,12061],{},"alter",[30,12063,12064],{},"create"," has to run on each node separately. The swap in step 5 is the hard part. Logical replication matches columns by name, so between the swap on the first node and the swap on the last one, as soon as a change to ",[30,12067,11547],{},[30,12069,11554],{}," arrives, the subscription that carries them stops with a \"missing replicated column\" error, along with every other table in it, and WAL piles up on the publisher's slot. For the swap, stop writes to these tables on all nodes, wait until replication has caught up (zero lag on every subscription), and only then swap everywhere. Resume writes after the swap on the last node. Disabling the subscriptions is not enough on its own: changes written before the swap arrive with the old column names.",[11,12072,12073,12074,12076,12077,12079,12080,12082,12083,12085],{},"The backfill, being ordinary DML, does replicate. Run it on one node only and let replication carry it to the others. ",[30,12075,11685],{}," has a random part, so running it on every node would give the same row a different UUID on each. Replication sends the whole row, so if another node updates the same row before the backfill reaches it, the nodes diverge without any error: in a test on 18.6 the backfilled ",[30,12078,12041],{}," came back as NULL on one node and the user's edit was lost on the other. While the backfill runs, pause updates to these tables on the other nodes, then compare the tables across nodes and rerun the loop for rows where ",[30,12081,12041],{}," is still NULL. The same applies to ",[30,12084,11554],{}," in step 4. The legacy columns stay until every consumer has switched, and dropping them is a separate change.",[11,12087,12088,12089,12092,12093,12096,12097,12100,12101,12104,12105,739,12108,12111],{},"I'd also rehearse restoring a backup on a copy first, because that's the way back if the swap goes wrong. When I did that on a copy of my SaaS database, it turned out that ",[30,12090,12091],{},"pg_restore --clean --if-exists"," drops only the objects that are in the dump. A table added by a newer migration stayed in place, the restored ",[30,12094,12095],{},"migrations"," table didn't know about it, and ",[30,12098,12099],{},"migrate --force"," ended with ",[30,12102,12103],{},"SQLSTATE[42P07]: Duplicate table ... already exists",". What worked was a fresh database: ",[30,12106,12107],{},"dropdb",[30,12109,12110],{},"createdb",", restore, migrate, 1.5 s in total. I only did this on the copy, never on production. The copy you rehearse on shouldn't run an older major version than production, because the docs don't guarantee that a dump from 17 loads into 16. That matters for me: one of my production servers is a VPS on Postgres 17 (the distribution on it doesn't package 16), while my development machine runs 16.",[892,12113,894],{},{"title":28,"searchDepth":42,"depth":42,"links":12115},[12116,12117,12118],{"id":11353,"depth":42,"text":11354},{"id":11381,"depth":42,"text":11382},{"id":11540,"depth":42,"text":11541},"2026-03-30","A bigserial or identity column gets its values from a sequence. A sequence is local to one Postgres instance, so it only produces unique values if every insert goes through that instance. With a single primary that's already true, so uniqueness alone is no reason to change the key type. That changes when more than one node accepts writes, or when IDs are created outside the database.",{},"\u002Farticles\u002Fpostgres-edge",{"title":11338,"description":12120},"pg-primary-keys","articles\u002Fpostgres-edge",[12127,12128,12129,12095],"postgres","distributed-systems","uuidv7","v2.1.9","qXgCjPwkfba0hsnbIwVerN3c14NpXNAjUlYQ9ZYLx7E",{"id":12133,"title":12134,"articleId":918,"body":12135,"category":27,"codeLang":27,"date":12757,"deploys":48,"description":12758,"excerpt":910,"extension":911,"lang":910,"meta":12759,"navigation":51,"path":5947,"pos":12760,"readMin":67,"related":12764,"seo":12765,"service":12766,"stem":12767,"tags":12768,"updated":2519,"version":12769,"__hash__":12770},"articles\u002Farticles\u002Fsingleton-pattern.md","Singleton in PHP: what \"one instance\" means under PHP-FPM, Octane and PHPUnit",{"type":8,"value":12136,"toc":12750},[12137,12143,12146,12150,12202,12206,12209,12304,12314,12337,12357,12361,12364,12371,12374,12426,12434,12451,12455,12462,12473,12540,12604,12608,12611,12625,12628,12709,12738,12748],[11,12138,12139,12140,12142],{},"The classic Singleton stores an instance in a static property and hands it out through ",[30,12141,5940],{},". It bundles lazy construction with global access. Lazy construction is useful. The problems described below come from the global access.",[11,12144,12145],{},"In PHP there's a more important question, though: how long does \"one\" last? The lifetime of a static property depends on the runtime. Under PHP-FPM the engine resets it at the end of every request, even though the process keeps running. In a process that handles many requests or jobs in turn, it survives until that process ends.",[15,12147,12149],{"id":12148},"what-the-static-property-actually-survives","What the static property actually survives",[687,12151,12152,12165],{},[690,12153,12154],{},[693,12155,12156,12159],{},[696,12157,12158],{},"Runtime",[696,12160,12161,12162],{},"Lifetime of ",[30,12163,12164],{},"self::$instance",[708,12166,12167,12175,12186,12194],{},[693,12168,12169,12172],{},[713,12170,12171],{},"PHP-FPM, mod_php",[713,12173,12174],{},"One request. The engine discards all static state at the end of the request.",[693,12176,12177,12183],{},[713,12178,12179,12180],{},"Laravel Octane (Swoole, RoadRunner, FrankenPHP), ",[30,12181,12182],{},"queue:work",[713,12184,12185],{},"One worker process, across many requests or jobs.",[693,12187,12188,12191],{},[713,12189,12190],{},"CLI script, cron command",[713,12192,12193],{},"One execution.",[693,12195,12196,12199],{},[713,12197,12198],{},"PHPUnit",[713,12200,12201],{},"The whole test run in that process, across all test cases.",[15,12203,12205],{"id":12204},"php-fpm-one-instance-per-request","PHP-FPM: one instance per request",[11,12207,12208],{},"Here's a rate limiter written as a Singleton:",[23,12210,12212],{"className":25,"code":12211,"language":27,"meta":28,"style":28},"final class RateLimiter\n{\n    private static ?self $instance = null;\n\n    \u002F** @var array\u003Cstring, int> *\u002F\n    private array $hits = [];\n\n    public static function getInstance(): self\n    {\n        return self::$instance ??= new self();\n    }\n\n    public function allow(string $ip, int $limitPerMinute): bool\n    {\n        $key = $ip . ':' . intdiv(time(), 60);\n        $this->hits[$key] = ($this->hits[$key] ?? 0) + 1;\n\n        return $this->hits[$key] \u003C= $limitPerMinute;\n    }\n}\n",[30,12213,12214,12219,12223,12228,12232,12237,12242,12246,12251,12255,12260,12264,12268,12273,12277,12282,12287,12291,12296,12300],{"__ignoreMap":28},[33,12215,12216],{"class":35,"line":36},[33,12217,12218],{},"final class RateLimiter\n",[33,12220,12221],{"class":35,"line":42},[33,12222,64],{},[33,12224,12225],{"class":35,"line":48},[33,12226,12227],{},"    private static ?self $instance = null;\n",[33,12229,12230],{"class":35,"line":55},[33,12231,52],{"emptyLinePlaceholder":51},[33,12233,12234],{"class":35,"line":61},[33,12235,12236],{},"    \u002F** @var array\u003Cstring, int> *\u002F\n",[33,12238,12239],{"class":35,"line":67},[33,12240,12241],{},"    private array $hits = [];\n",[33,12243,12244],{"class":35,"line":73},[33,12245,52],{"emptyLinePlaceholder":51},[33,12247,12248],{"class":35,"line":79},[33,12249,12250],{},"    public static function getInstance(): self\n",[33,12252,12253],{"class":35,"line":85},[33,12254,4183],{},[33,12256,12257],{"class":35,"line":90},[33,12258,12259],{},"        return self::$instance ??= new self();\n",[33,12261,12262],{"class":35,"line":96},[33,12263,4193],{},[33,12265,12266],{"class":35,"line":384},[33,12267,52],{"emptyLinePlaceholder":51},[33,12269,12270],{"class":35,"line":390},[33,12271,12272],{},"    public function allow(string $ip, int $limitPerMinute): bool\n",[33,12274,12275],{"class":35,"line":395},[33,12276,4183],{},[33,12278,12279],{"class":35,"line":401},[33,12280,12281],{},"        $key = $ip . ':' . intdiv(time(), 60);\n",[33,12283,12284],{"class":35,"line":407},[33,12285,12286],{},"        $this->hits[$key] = ($this->hits[$key] ?? 0) + 1;\n",[33,12288,12289],{"class":35,"line":413},[33,12290,52],{"emptyLinePlaceholder":51},[33,12292,12293],{"class":35,"line":419},[33,12294,12295],{},"        return $this->hits[$key] \u003C= $limitPerMinute;\n",[33,12297,12298],{"class":35,"line":1806},[33,12299,4193],{},[33,12301,12302],{"class":35,"line":1812},[33,12303,82],{},[11,12305,12306,12307,12310,12311,12313],{},"Under PHP-FPM every request starts with an empty ",[30,12308,12309],{},"$hits"," array. The counter reaches 1, and the limit is never enforced. Under Octane it would count per worker, so the effective limit could be up to as many times higher as there are workers (and servers), with requests from one IP landing on workers in no predictable order. On top of that the ",[30,12312,12309],{}," array would grow in the worker forever, because keys from past minutes are never removed.",[11,12315,12316,12317,12320,12321,12324,12325,12328,12329,12332,12333,12336],{},"\"100 requests per minute per client across the application\" is a requirement about state shared between processes and servers, and no in-process object can meet it. That state has to live in a shared cache store, e.g. Redis, Memcached or the database. Laravel already has this: the ",[30,12318,12319],{},"throttle"," middleware and the ",[30,12322,12323],{},"RateLimiter"," facade keep their counters in the cache store. The limit is shared across the application only if every server sees the same store. An ",[30,12326,12327],{},"array"," store or a separate ",[30,12330,12331],{},"file"," store on each server won't do that. The ",[30,12334,12335],{},"throttle:100,1"," below counts logged-in users by user id and guests by IP. The middleware checks the counter first and increments it afterwards, so under concurrent requests a few may get through over the limit.",[23,12338,12340],{"className":25,"code":12339,"language":27,"meta":28,"style":28},"Route::middleware('throttle:100,1')->group(function () {\n    Route::post('\u002Fapi\u002Fscoring', ScoringController::class);\n});\n",[30,12341,12342,12347,12352],{"__ignoreMap":28},[33,12343,12344],{"class":35,"line":36},[33,12345,12346],{},"Route::middleware('throttle:100,1')->group(function () {\n",[33,12348,12349],{"class":35,"line":42},[33,12350,12351],{},"    Route::post('\u002Fapi\u002Fscoring', ScoringController::class);\n",[33,12353,12354],{"class":35,"line":48},[33,12355,12356],{},"});\n",[15,12358,12360],{"id":12359},"long-running-workers-state-leaks-between-requests","Long-running workers: state leaks between requests",[11,12362,12363],{},"In Octane or a queue worker the static instance survives the request, and the problem flips. Whatever request-specific data a Singleton captures is visible to the next request handled by the same worker.",[11,12365,12366,12367,12370],{},"Take a ",[30,12368,12369],{},"TenantContext"," singleton that stores the current tenant on the first request. Under PHP-FPM it works, because the instance dies with the request. Under Octane the second request on that worker reads the first tenant's context, unless something resets it. The Octane documentation warns about the same thing for container singletons that receive the request or the container in their constructor.",[11,12372,12373],{},"Laravel's container lets you declare lifetimes explicitly:",[23,12375,12377],{"className":25,"code":12376,"language":27,"meta":28,"style":28},"\u002F\u002F AppServiceProvider::register()\n\n\u002F\u002F One instance per application instance. Safe only for immutable, request-independent objects.\n$this->app->singleton(ExchangeRateTable::class, fn () => ExchangeRateTable::fromConfig(config('rates')));\n\n\u002F\u002F One instance per request or job; Octane and the queue worker flush scoped instances.\n$this->app->scoped(TenantContext::class, fn ($app) => TenantContext::fromRequest($app['request']));\n\n\u002F\u002F New instance on every resolve.\n$this->app->bind(PaymentGateway::class, StripeGateway::class);\n",[30,12378,12379,12384,12388,12393,12398,12402,12407,12412,12416,12421],{"__ignoreMap":28},[33,12380,12381],{"class":35,"line":36},[33,12382,12383],{},"\u002F\u002F AppServiceProvider::register()\n",[33,12385,12386],{"class":35,"line":42},[33,12387,52],{"emptyLinePlaceholder":51},[33,12389,12390],{"class":35,"line":48},[33,12391,12392],{},"\u002F\u002F One instance per application instance. Safe only for immutable, request-independent objects.\n",[33,12394,12395],{"class":35,"line":55},[33,12396,12397],{},"$this->app->singleton(ExchangeRateTable::class, fn () => ExchangeRateTable::fromConfig(config('rates')));\n",[33,12399,12400],{"class":35,"line":61},[33,12401,52],{"emptyLinePlaceholder":51},[33,12403,12404],{"class":35,"line":67},[33,12405,12406],{},"\u002F\u002F One instance per request or job; Octane and the queue worker flush scoped instances.\n",[33,12408,12409],{"class":35,"line":73},[33,12410,12411],{},"$this->app->scoped(TenantContext::class, fn ($app) => TenantContext::fromRequest($app['request']));\n",[33,12413,12414],{"class":35,"line":79},[33,12415,52],{"emptyLinePlaceholder":51},[33,12417,12418],{"class":35,"line":85},[33,12419,12420],{},"\u002F\u002F New instance on every resolve.\n",[33,12422,12423],{"class":35,"line":90},[33,12424,12425],{},"$this->app->bind(PaymentGateway::class, StripeGateway::class);\n",[11,12427,12428,739,12431,12433],{},[30,12429,12430],{},"ExchangeRateTable",[30,12432,12369],{}," and the gateway classes are placeholders. What matters is that the lifetime sits in one place, and you can change it without touching the classes that use the dependency.",[11,12435,12436,12437,12440,12441,12444,12445,12448,12449,221],{},"In my own SaaS (multi-tenant, Laravel) I made the company context fail-closed: a company context object, a trait on the models and two explicit entry points. One sets the context when the logged-in user is resolved, the other is a separate system context for console commands. The two exclude each other, and setting both at once throws an exception. I didn't get it right the first time. Two code reviews gave 12 findings, and two of them were critical and invisible to tests and linters: the companies table, the root of the tenant boundary, was itself outside the boundary, so a logged-in user could see all companies. The other has nothing to do with object lifetimes, but it came out of the same review: ",[30,12438,12439],{},"trustProxies(at: '*')",", which let anyone bypass the login rate limit with a forged header. I changed it to ",[30,12442,12443],{},"at: ['REMOTE_ADDR']",". That only protects you when the app accepts connections exclusively through a single reverse proxy that sets the X-Forwarded-For header itself. If the app can be reached around the proxy, or there are several proxy layers, list the actual proxy addresses. The version matters too: since Laravel 13.20.0 (July 2026) ",[30,12446,12447],{},"'*'"," trusts every address, while in 11, 12 and 13 up to 13.19 it behaved exactly like ",[30,12450,5375],{},[15,12452,12454],{"id":12453},"phpunit-one-instance-for-the-whole-suite","PHPUnit: one instance for the whole suite",[11,12456,12457,12458,12461],{},"By default PHPUnit runs test cases in one process. Laravel's ",[30,12459,12460],{},"TestCase"," builds a fresh application for every test, so container singletons get reset. Plain static properties don't. A Singleton modified in one test carries that state into the next one, and the result starts depending on execution order.",[11,12463,12464,12465,12468,12469,12472],{},"The usual workaround is a ",[30,12466,12467],{},"resetInstance()"," method called in ",[30,12470,12471],{},"tearDown()",", which works until someone forgets it in one test class. It's safer to stop reaching for the global and inject the dependency:",[23,12474,12476],{"className":25,"code":12475,"language":27,"meta":28,"style":28},"interface Clock\n{\n    public function now(): DateTimeImmutable;\n}\n\nfinal class InvoiceDueDate\n{\n    public function __construct(private readonly Clock $clock) {}\n\n    public function forTermDays(int $days): DateTimeImmutable\n    {\n        return $this->clock->now()->modify(\"+{$days} days\");\n    }\n}\n",[30,12477,12478,12483,12487,12492,12496,12500,12505,12509,12514,12518,12523,12527,12532,12536],{"__ignoreMap":28},[33,12479,12480],{"class":35,"line":36},[33,12481,12482],{},"interface Clock\n",[33,12484,12485],{"class":35,"line":42},[33,12486,64],{},[33,12488,12489],{"class":35,"line":48},[33,12490,12491],{},"    public function now(): DateTimeImmutable;\n",[33,12493,12494],{"class":35,"line":55},[33,12495,82],{},[33,12497,12498],{"class":35,"line":61},[33,12499,52],{"emptyLinePlaceholder":51},[33,12501,12502],{"class":35,"line":67},[33,12503,12504],{},"final class InvoiceDueDate\n",[33,12506,12507],{"class":35,"line":73},[33,12508,64],{},[33,12510,12511],{"class":35,"line":79},[33,12512,12513],{},"    public function __construct(private readonly Clock $clock) {}\n",[33,12515,12516],{"class":35,"line":85},[33,12517,52],{"emptyLinePlaceholder":51},[33,12519,12520],{"class":35,"line":90},[33,12521,12522],{},"    public function forTermDays(int $days): DateTimeImmutable\n",[33,12524,12525],{"class":35,"line":96},[33,12526,4183],{},[33,12528,12529],{"class":35,"line":384},[33,12530,12531],{},"        return $this->clock->now()->modify(\"+{$days} days\");\n",[33,12533,12534],{"class":35,"line":390},[33,12535,4193],{},[33,12537,12538],{"class":35,"line":395},[33,12539,82],{},[23,12541,12543],{"className":25,"code":12542,"language":27,"meta":28,"style":28},"public function test_due_date_is_counted_from_today(): void\n{\n    $clock = new class implements Clock {\n        public function now(): DateTimeImmutable\n        {\n            return new DateTimeImmutable('2024-10-15');\n        }\n    };\n\n    $dueDate = (new InvoiceDueDate($clock))->forTermDays(14);\n\n    $this->assertSame('2024-10-29', $dueDate->format('Y-m-d'));\n}\n",[30,12544,12545,12550,12554,12559,12564,12569,12574,12578,12582,12586,12591,12595,12600],{"__ignoreMap":28},[33,12546,12547],{"class":35,"line":36},[33,12548,12549],{},"public function test_due_date_is_counted_from_today(): void\n",[33,12551,12552],{"class":35,"line":42},[33,12553,64],{},[33,12555,12556],{"class":35,"line":48},[33,12557,12558],{},"    $clock = new class implements Clock {\n",[33,12560,12561],{"class":35,"line":55},[33,12562,12563],{},"        public function now(): DateTimeImmutable\n",[33,12565,12566],{"class":35,"line":61},[33,12567,12568],{},"        {\n",[33,12570,12571],{"class":35,"line":67},[33,12572,12573],{},"            return new DateTimeImmutable('2024-10-15');\n",[33,12575,12576],{"class":35,"line":73},[33,12577,4681],{},[33,12579,12580],{"class":35,"line":79},[33,12581,8267],{},[33,12583,12584],{"class":35,"line":85},[33,12585,52],{"emptyLinePlaceholder":51},[33,12587,12588],{"class":35,"line":90},[33,12589,12590],{},"    $dueDate = (new InvoiceDueDate($clock))->forTermDays(14);\n",[33,12592,12593],{"class":35,"line":96},[33,12594,52],{"emptyLinePlaceholder":51},[33,12596,12597],{"class":35,"line":384},[33,12598,12599],{},"    $this->assertSame('2024-10-29', $dueDate->format('Y-m-d'));\n",[33,12601,12602],{"class":35,"line":390},[33,12603,82],{},[15,12605,12607],{"id":12606},"when-a-static-singleton-is-defensible","When a static Singleton is defensible",[11,12609,12610],{},"It holds up when all of the following are true:",[3583,12612,12613,12616,12619,12622],{},[3586,12614,12615],{},"all state is set in the constructor and never modified afterwards,",[3586,12617,12618],{},"the state does not depend on the request, the user or the tenant,",[3586,12620,12621],{},"construction is expensive enough that doing it once per request or once per worker process matters,",[3586,12623,12624],{},"tests never need a different instance.",[11,12626,12627],{},"Immutable configuration read from the environment fits:",[23,12629,12631],{"className":25,"code":12630,"language":27,"meta":28,"style":28},"final class AppConfig\n{\n    private static ?self $instance = null;\n\n    private function __construct(\n        public readonly string $appEnv,\n        public readonly string $databaseUrl,\n    ) {}\n\n    public static function get(): self\n    {\n        return self::$instance ??= new self(\n            appEnv: getenv('APP_ENV') ?: 'production',\n            databaseUrl: getenv('DATABASE_URL') ?: throw new RuntimeException('DATABASE_URL is not set'),\n        );\n    }\n}\n",[30,12632,12633,12638,12642,12646,12650,12655,12660,12665,12669,12673,12678,12682,12687,12692,12697,12701,12705],{"__ignoreMap":28},[33,12634,12635],{"class":35,"line":36},[33,12636,12637],{},"final class AppConfig\n",[33,12639,12640],{"class":35,"line":42},[33,12641,64],{},[33,12643,12644],{"class":35,"line":48},[33,12645,12227],{},[33,12647,12648],{"class":35,"line":55},[33,12649,52],{"emptyLinePlaceholder":51},[33,12651,12652],{"class":35,"line":61},[33,12653,12654],{},"    private function __construct(\n",[33,12656,12657],{"class":35,"line":67},[33,12658,12659],{},"        public readonly string $appEnv,\n",[33,12661,12662],{"class":35,"line":73},[33,12663,12664],{},"        public readonly string $databaseUrl,\n",[33,12666,12667],{"class":35,"line":79},[33,12668,4278],{},[33,12670,12671],{"class":35,"line":85},[33,12672,52],{"emptyLinePlaceholder":51},[33,12674,12675],{"class":35,"line":90},[33,12676,12677],{},"    public static function get(): self\n",[33,12679,12680],{"class":35,"line":96},[33,12681,4183],{},[33,12683,12684],{"class":35,"line":384},[33,12685,12686],{},"        return self::$instance ??= new self(\n",[33,12688,12689],{"class":35,"line":390},[33,12690,12691],{},"            appEnv: getenv('APP_ENV') ?: 'production',\n",[33,12693,12694],{"class":35,"line":395},[33,12695,12696],{},"            databaseUrl: getenv('DATABASE_URL') ?: throw new RuntimeException('DATABASE_URL is not set'),\n",[33,12698,12699],{"class":35,"line":401},[33,12700,4527],{},[33,12702,12703],{"class":35,"line":407},[33,12704,4193],{},[33,12706,12707],{"class":35,"line":413},[33,12708,82],{},[11,12710,12711,12714,12715,12718,12719,12722,12723,12726,12727,12730,12731,12733,12734,12737],{},[30,12712,12713],{},"readonly"," properties prevent mutation, and a missing variable fails at the first call rather than somewhere later in unrelated code. In a Laravel application I'd still register the same object with ",[30,12716,12717],{},"$this->app->singleton()",", because then a test can swap it with ",[30,12720,12721],{},"$this->app->instance()",". I'd also take the values from ",[30,12724,12725],{},"config()"," there, because after ",[30,12728,12729],{},"php artisan config:cache"," Laravel doesn't load the ",[30,12732,3985],{}," file and ",[30,12735,12736],{},"getenv()"," only sees variables set in the system.",[11,12739,12740,12741,12744,12745,12747],{},"For memoizing an expensive computation, Laravel 11 and newer have the ",[30,12742,12743],{},"once()"," helper. It caches a closure's result per object and call site, and Octane flushes it between requests. A queue worker doesn't flush it between jobs. Inside a job's own method that's harmless, because every job is a new object, but ",[30,12746,12743],{}," in a static method, a plain function or a container singleton keeps its result for the life of the process and the problem described above comes back.",[892,12749,894],{},{"title":28,"searchDepth":42,"depth":42,"links":12751},[12752,12753,12754,12755,12756],{"id":12148,"depth":42,"text":12149},{"id":12204,"depth":42,"text":12205},{"id":12359,"depth":42,"text":12360},{"id":12453,"depth":42,"text":12454},{"id":12606,"depth":42,"text":12607},"2024-10-15","The classic Singleton stores an instance in a static property and hands it out through getInstance(). It bundles lazy construction with global access. Lazy construction is useful. The problems described below come from the global access.",{},{"x":12761,"y":12762,"depth":12763,"size":6593},0.22,0.34,1.2,[2510,1685],{"title":12134,"description":12758},"global-state-mgmt","articles\u002Fsingleton-pattern",[27,5020,7256,8577,4810,720],"v5.1.2","EgsfD-330D8OjPZwCkk-6BVX6pN2vb6wcfyBgp5mMeE",{"id":12772,"title":12773,"articleId":12774,"body":12775,"category":27,"codeLang":27,"date":13709,"deploys":36,"description":13710,"excerpt":910,"extension":911,"lang":910,"meta":13711,"navigation":51,"path":13712,"pos":13713,"readMin":73,"related":13715,"seo":13716,"service":13717,"stem":13718,"tags":13719,"updated":2519,"version":13722,"__hash__":13723},"articles\u002Farticles\u002Fstate-machine.md","State machines in PHP: an explicit order lifecycle with enums, locks and events","state-machine",{"type":8,"value":12776,"toc":13700},[12777,12788,12792,12877,12890,12894,12897,13056,13073,13080,13084,13095,13229,13243,13257,13272,13310,13313,13317,13331,13350,13375,13395,13399,13402,13408,13533,13554,13558,13561,13666,13669,13673,13680,13687,13698],[11,12778,12779,12780,12783,12784,12787],{},"An order, a subscription and a loan application all have a lifecycle, so each can be described as a state machine. In most codebases that machine is implicit. It's just a ",[30,12781,12782],{},"status"," column plus ",[30,12785,12786],{},"if"," checks in whatever services change it. Below I show how to make it explicit in PHP 8.3 and Laravel, how to apply transitions safely under concurrency, and where side effects should go.",[15,12789,12791],{"id":12790},"the-implicit-version","The implicit version",[23,12793,12795],{"className":25,"code":12794,"language":27,"meta":28,"style":28},"class OrderService\n{\n    public function markPaid(Order $order): void\n    {\n        if ($order->status !== 'payment_pending') {\n            throw new \\LogicException(\"Cannot mark order {$order->id} as paid\");\n        }\n        \u002F\u002F ...\n    }\n\n    public function cancel(Order $order): void\n    {\n        if (in_array($order->status, ['shipped', 'delivered', 'refunded'], true)) {\n            throw new \\LogicException(\"Cannot cancel order {$order->id}\");\n        }\n        \u002F\u002F ...\n    }\n}\n",[30,12796,12797,12802,12806,12811,12815,12820,12825,12829,12834,12838,12842,12847,12851,12856,12861,12865,12869,12873],{"__ignoreMap":28},[33,12798,12799],{"class":35,"line":36},[33,12800,12801],{},"class OrderService\n",[33,12803,12804],{"class":35,"line":42},[33,12805,64],{},[33,12807,12808],{"class":35,"line":48},[33,12809,12810],{},"    public function markPaid(Order $order): void\n",[33,12812,12813],{"class":35,"line":55},[33,12814,4183],{},[33,12816,12817],{"class":35,"line":61},[33,12818,12819],{},"        if ($order->status !== 'payment_pending') {\n",[33,12821,12822],{"class":35,"line":67},[33,12823,12824],{},"            throw new \\LogicException(\"Cannot mark order {$order->id} as paid\");\n",[33,12826,12827],{"class":35,"line":73},[33,12828,4681],{},[33,12830,12831],{"class":35,"line":79},[33,12832,12833],{},"        \u002F\u002F ...\n",[33,12835,12836],{"class":35,"line":85},[33,12837,4193],{},[33,12839,12840],{"class":35,"line":90},[33,12841,52],{"emptyLinePlaceholder":51},[33,12843,12844],{"class":35,"line":96},[33,12845,12846],{},"    public function cancel(Order $order): void\n",[33,12848,12849],{"class":35,"line":384},[33,12850,4183],{},[33,12852,12853],{"class":35,"line":390},[33,12854,12855],{},"        if (in_array($order->status, ['shipped', 'delivered', 'refunded'], true)) {\n",[33,12857,12858],{"class":35,"line":395},[33,12859,12860],{},"            throw new \\LogicException(\"Cannot cancel order {$order->id}\");\n",[33,12862,12863],{"class":35,"line":401},[33,12864,4681],{},[33,12866,12867],{"class":35,"line":407},[33,12868,12833],{},[33,12870,12871],{"class":35,"line":413},[33,12872,4193],{},[33,12874,12875],{"class":35,"line":419},[33,12876,82],{},[11,12878,12879,12880,12882,12883,12885,12886,12889],{},"Each method checks whatever it considers forbidden. The full set of allowed transitions isn't written down anywhere. You can only reconstruct it by reading every method that touches ",[30,12881,12782],{},". So a new code path that changes ",[30,12884,12782],{},", be it a controller, an import command or an admin panel, has no rule to follow unless its author finds and copies the right check. On top of that, ",[30,12887,12888],{},"cancel()"," works from a deny list. Any status added later is cancellable by default, and the business probably doesn't want that.",[15,12891,12893],{"id":12892},"transitions-in-one-place","Transitions in one place",[11,12895,12896],{},"A backed enum can hold both the states and the transition graph:",[23,12898,12900],{"className":25,"code":12899,"language":27,"meta":28,"style":28},"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    \u002F** @return list\u003Cself> *\u002F\n    public function allowedTransitions(): array\n    {\n        return match ($this) {\n            self::Draft => [self::PaymentPending, self::Cancelled],\n            self::PaymentPending => [self::Paid, self::Cancelled],\n            self::Paid => [self::Shipped, self::Refunded],\n            self::Shipped => [self::Delivered],\n            self::Delivered => [self::Refunded],\n            self::Cancelled, self::Refunded => [],\n        };\n    }\n\n    public function canTransitionTo(self $to): bool\n    {\n        return in_array($to, $this->allowedTransitions(), true);\n    }\n\n    public function isFinal(): bool\n    {\n        return $this->allowedTransitions() === [];\n    }\n}\n",[30,12901,12902,12907,12911,12916,12921,12926,12931,12936,12941,12946,12950,12955,12960,12964,12969,12974,12979,12984,12989,12994,12999,13004,13008,13012,13017,13021,13026,13030,13034,13039,13043,13048,13052],{"__ignoreMap":28},[33,12903,12904],{"class":35,"line":36},[33,12905,12906],{},"enum OrderStatus: string\n",[33,12908,12909],{"class":35,"line":42},[33,12910,64],{},[33,12912,12913],{"class":35,"line":48},[33,12914,12915],{},"    case Draft = 'draft';\n",[33,12917,12918],{"class":35,"line":55},[33,12919,12920],{},"    case PaymentPending = 'payment_pending';\n",[33,12922,12923],{"class":35,"line":61},[33,12924,12925],{},"    case Paid = 'paid';\n",[33,12927,12928],{"class":35,"line":67},[33,12929,12930],{},"    case Shipped = 'shipped';\n",[33,12932,12933],{"class":35,"line":73},[33,12934,12935],{},"    case Delivered = 'delivered';\n",[33,12937,12938],{"class":35,"line":79},[33,12939,12940],{},"    case Cancelled = 'cancelled';\n",[33,12942,12943],{"class":35,"line":85},[33,12944,12945],{},"    case Refunded = 'refunded';\n",[33,12947,12948],{"class":35,"line":90},[33,12949,52],{"emptyLinePlaceholder":51},[33,12951,12952],{"class":35,"line":96},[33,12953,12954],{},"    \u002F** @return list\u003Cself> *\u002F\n",[33,12956,12957],{"class":35,"line":384},[33,12958,12959],{},"    public function allowedTransitions(): array\n",[33,12961,12962],{"class":35,"line":390},[33,12963,4183],{},[33,12965,12966],{"class":35,"line":395},[33,12967,12968],{},"        return match ($this) {\n",[33,12970,12971],{"class":35,"line":401},[33,12972,12973],{},"            self::Draft => [self::PaymentPending, self::Cancelled],\n",[33,12975,12976],{"class":35,"line":407},[33,12977,12978],{},"            self::PaymentPending => [self::Paid, self::Cancelled],\n",[33,12980,12981],{"class":35,"line":413},[33,12982,12983],{},"            self::Paid => [self::Shipped, self::Refunded],\n",[33,12985,12986],{"class":35,"line":419},[33,12987,12988],{},"            self::Shipped => [self::Delivered],\n",[33,12990,12991],{"class":35,"line":1806},[33,12992,12993],{},"            self::Delivered => [self::Refunded],\n",[33,12995,12996],{"class":35,"line":1812},[33,12997,12998],{},"            self::Cancelled, self::Refunded => [],\n",[33,13000,13001],{"class":35,"line":1817},[33,13002,13003],{},"        };\n",[33,13005,13006],{"class":35,"line":1822},[33,13007,4193],{},[33,13009,13010],{"class":35,"line":1828},[33,13011,52],{"emptyLinePlaceholder":51},[33,13013,13014],{"class":35,"line":1834},[33,13015,13016],{},"    public function canTransitionTo(self $to): bool\n",[33,13018,13019],{"class":35,"line":1840},[33,13020,4183],{},[33,13022,13023],{"class":35,"line":1846},[33,13024,13025],{},"        return in_array($to, $this->allowedTransitions(), true);\n",[33,13027,13028],{"class":35,"line":1851},[33,13029,4193],{},[33,13031,13032],{"class":35,"line":1856},[33,13033,52],{"emptyLinePlaceholder":51},[33,13035,13036],{"class":35,"line":1862},[33,13037,13038],{},"    public function isFinal(): bool\n",[33,13040,13041],{"class":35,"line":1868},[33,13042,4183],{},[33,13044,13045],{"class":35,"line":1874},[33,13046,13047],{},"        return $this->allowedTransitions() === [];\n",[33,13049,13050],{"class":35,"line":1880},[33,13051,4193],{},[33,13053,13054],{"class":35,"line":1886},[33,13055,82],{},[11,13057,13058,13059,13061,13062,13065,13066,13069,13070,13072],{},"Now the rules are an allow list. A new case without its own ",[30,13060,6511],{}," arm throws ",[30,13063,13064],{},"UnhandledMatchError"," the first time ",[30,13067,13068],{},"allowedTransitions()"," runs for it, and PHPStan (level 4 and up) or Psalm flag the non-exhaustive ",[30,13071,6511],{}," before that.",[11,13074,13075,13076,13079],{},"On the model, cast the column to the enum (",[30,13077,13078],{},"protected $casts = ['status' => OrderStatus::class];",") so the rest of the code never compares raw strings.",[15,13081,13083],{"id":13082},"applying-a-transition","Applying a transition",[11,13085,13086,13087,13090,13091,13094],{},"Checking isn't enough on its own. Two requests can read the same order in ",[30,13088,13089],{},"payment_pending",", both pass ",[30,13092,13093],{},"canTransitionTo(Paid)",", both write. So the transition reads and writes the row under a lock:",[23,13096,13098],{"className":25,"code":13097,"language":27,"meta":28,"style":28},"final class OrderTransitions\n{\n    public function apply(int $orderId, OrderStatus $to, ?string $reason = null): Order\n    {\n        return DB::transaction(function () use ($orderId, $to, $reason): Order {\n            $order = Order::query()->lockForUpdate()->findOrFail($orderId);\n            $from = $order->status;\n\n            if (! $from->canTransitionTo($to)) {\n                throw InvalidTransition::between($order->id, $from, $to);\n            }\n\n            $order->status = $to;\n            $order->status_changed_at = now();\n            $order->save();\n\n            $order->statusHistory()->create([\n                'from' => $from->value,\n                'to' => $to->value,\n                'reason' => $reason,\n            ]);\n\n            OrderStatusChanged::dispatch($order->id, $from, $to);\n\n            return $order;\n        });\n    }\n}\n",[30,13099,13100,13105,13109,13114,13118,13123,13128,13133,13137,13142,13147,13151,13155,13160,13165,13170,13174,13179,13184,13189,13194,13198,13202,13207,13211,13216,13221,13225],{"__ignoreMap":28},[33,13101,13102],{"class":35,"line":36},[33,13103,13104],{},"final class OrderTransitions\n",[33,13106,13107],{"class":35,"line":42},[33,13108,64],{},[33,13110,13111],{"class":35,"line":48},[33,13112,13113],{},"    public function apply(int $orderId, OrderStatus $to, ?string $reason = null): Order\n",[33,13115,13116],{"class":35,"line":55},[33,13117,4183],{},[33,13119,13120],{"class":35,"line":61},[33,13121,13122],{},"        return DB::transaction(function () use ($orderId, $to, $reason): Order {\n",[33,13124,13125],{"class":35,"line":67},[33,13126,13127],{},"            $order = Order::query()->lockForUpdate()->findOrFail($orderId);\n",[33,13129,13130],{"class":35,"line":73},[33,13131,13132],{},"            $from = $order->status;\n",[33,13134,13135],{"class":35,"line":79},[33,13136,52],{"emptyLinePlaceholder":51},[33,13138,13139],{"class":35,"line":85},[33,13140,13141],{},"            if (! $from->canTransitionTo($to)) {\n",[33,13143,13144],{"class":35,"line":90},[33,13145,13146],{},"                throw InvalidTransition::between($order->id, $from, $to);\n",[33,13148,13149],{"class":35,"line":96},[33,13150,8183],{},[33,13152,13153],{"class":35,"line":384},[33,13154,52],{"emptyLinePlaceholder":51},[33,13156,13157],{"class":35,"line":390},[33,13158,13159],{},"            $order->status = $to;\n",[33,13161,13162],{"class":35,"line":395},[33,13163,13164],{},"            $order->status_changed_at = now();\n",[33,13166,13167],{"class":35,"line":401},[33,13168,13169],{},"            $order->save();\n",[33,13171,13172],{"class":35,"line":407},[33,13173,52],{"emptyLinePlaceholder":51},[33,13175,13176],{"class":35,"line":413},[33,13177,13178],{},"            $order->statusHistory()->create([\n",[33,13180,13181],{"class":35,"line":419},[33,13182,13183],{},"                'from' => $from->value,\n",[33,13185,13186],{"class":35,"line":1806},[33,13187,13188],{},"                'to' => $to->value,\n",[33,13190,13191],{"class":35,"line":1812},[33,13192,13193],{},"                'reason' => $reason,\n",[33,13195,13196],{"class":35,"line":1817},[33,13197,6075],{},[33,13199,13200],{"class":35,"line":1822},[33,13201,52],{"emptyLinePlaceholder":51},[33,13203,13204],{"class":35,"line":1828},[33,13205,13206],{},"            OrderStatusChanged::dispatch($order->id, $from, $to);\n",[33,13208,13209],{"class":35,"line":1834},[33,13210,52],{"emptyLinePlaceholder":51},[33,13212,13213],{"class":35,"line":1840},[33,13214,13215],{},"            return $order;\n",[33,13217,13218],{"class":35,"line":1846},[33,13219,13220],{},"        });\n",[33,13222,13223],{"class":35,"line":1851},[33,13224,4193],{},[33,13226,13227],{"class":35,"line":1856},[33,13228,82],{},[11,13230,13231,13234,13235,13238,13239,13242],{},[30,13232,13233],{},"lockForUpdate()"," adds ",[30,13236,13237],{},"FOR UPDATE"," to the ",[30,13240,13241],{},"SELECT",". A second transaction wanting the same row waits for the first one to commit, then reads the new status, and the check rejects the transition. That's how it works in MySQL and PostgreSQL at the default isolation level. SQLite ignores the lock, and PostgreSQL under REPEATABLE READ gives the second transaction a serialization error instead of the new status. The history table keeps what changed, when and why (add the user's id if you need to know who).",[11,13244,13245,13246,13249,13250,13253,13254,221],{},"The history table should be append-only, and its rows need their own ids. In CookieDomain, my cookie consent system, the consent log was append-only, but the controller used the ",[30,13247,13248],{},"consent_id"," sent back by the cookie, which is the id of the previous consent, as the primary key of the new row. So every change of consent after the first one failed with ",[30,13251,13252],{},"1062 Duplicate entry",". The fix: each row gets a fresh UUID, and the previous consent's id goes to ",[30,13255,13256],{},"prev_consent_id",[11,13258,13259,13260,13263,13264,13267,13268,13271],{},"If you'd rather not hold a lock, a conditional update also guarantees the transition is applied once. It bypasses ",[30,13261,13262],{},"apply()",", though, so it writes no history and dispatches no ",[30,13265,13266],{},"OrderStatusChanged",", and a query-level ",[30,13269,13270],{},"update()"," skips model events too:",[23,13273,13275],{"className":25,"code":13274,"language":27,"meta":28,"style":28},"$affected = Order::whereKey($orderId)\n    ->where('status', OrderStatus::PaymentPending->value)\n    ->update(['status' => OrderStatus::Paid->value, 'status_changed_at' => now()]);\n\nif ($affected === 0) {\n    \u002F\u002F no such order, or another request changed the status first\n}\n",[30,13276,13277,13282,13287,13292,13296,13301,13306],{"__ignoreMap":28},[33,13278,13279],{"class":35,"line":36},[33,13280,13281],{},"$affected = Order::whereKey($orderId)\n",[33,13283,13284],{"class":35,"line":42},[33,13285,13286],{},"    ->where('status', OrderStatus::PaymentPending->value)\n",[33,13288,13289],{"class":35,"line":48},[33,13290,13291],{},"    ->update(['status' => OrderStatus::Paid->value, 'status_changed_at' => now()]);\n",[33,13293,13294],{"class":35,"line":55},[33,13295,52],{"emptyLinePlaceholder":51},[33,13297,13298],{"class":35,"line":61},[33,13299,13300],{},"if ($affected === 0) {\n",[33,13302,13303],{"class":35,"line":67},[33,13304,13305],{},"    \u002F\u002F no such order, or another request changed the status first\n",[33,13307,13308],{"class":35,"line":73},[33,13309,82],{},[11,13311,13312],{},"It works well on hot paths like payment webhooks. The locked version is easier to extend once the transition also has to read other data or write several rows.",[15,13314,13316],{"id":13315},"repeated-calls-from-external-systems","Repeated calls from external systems",[11,13318,13319,13320,13323,13324,13327,13328,13330],{},"Payment providers retry webhooks, and Stripe's docs say an endpoint might occasionally receive the same event more than once. So the same \"payment succeeded\" event can show up twice. The second delivery finds the order already ",[30,13321,13322],{},"paid",". There's no transition from ",[30,13325,13326],{},"Paid"," to ",[30,13329,13326],{},". For callers like that, \"already in the target state\" should count as success:",[23,13332,13334],{"className":25,"code":13333,"language":27,"meta":28,"style":28},"if ($from === $to) {\n    return $order; \u002F\u002F idempotent repeat, nothing to do\n}\n",[30,13335,13336,13341,13346],{"__ignoreMap":28},[33,13337,13338],{"class":35,"line":36},[33,13339,13340],{},"if ($from === $to) {\n",[33,13342,13343],{"class":35,"line":42},[33,13344,13345],{},"    return $order; \u002F\u002F idempotent repeat, nothing to do\n",[33,13347,13348],{"class":35,"line":48},[33,13349,82],{},[11,13351,13352,13353,13356,13357,13360,13361,13363,13364,13367,13368,13370,13371,13374],{},"This check goes right after ",[30,13354,13355],{},"$from = $order->status;",", before ",[30,13358,13359],{},"canTransitionTo()",", in a separate variant of ",[30,13362,13262],{}," for idempotent callers (webhooks, retried jobs), for example ",[30,13365,13366],{},"applyIdempotent()",". The generic ",[30,13369,13262],{}," stays strict: a user who clicks \"Cancel\" on an order that's already cancelled should get an error message. Comparing states isn't enough when the repeated event arrives after the order has already moved on to ",[30,13372,13373],{},"shipped",". That's why Stripe suggests logging the ids of processed events and skipping those already logged.",[11,13376,13377,13378,13380,13381,13383,13384,13387,13388,13391,13392,13394],{},"As long as every status write goes through ",[30,13379,13262],{}," or the conditional update, the order changes to ",[30,13382,13322],{}," once, but the provider can still charge twice: when the customer clicks \"Pay\" twice, two requests can each create a payment at the provider. That has to be stopped where the payment gets created. Create it only in the ",[30,13385,13386],{},"Draft → PaymentPending"," transition, under the same lock (the row stays locked for the whole HTTP call, so give that call a short timeout), and let the second request find ",[30,13389,13390],{},"PaymentPending"," and return the existing payment instead of creating another. The lock covers only your database, so the call to the provider needs an idempotency key derived from the order id (Stripe accepts it in the ",[30,13393,2186],{}," header): if the process dies after the provider created the payment but before the commit, a retry with the same key and the same parameters gets the same payment back. Stripe may remove the key after 24 hours, and with different parameters (a changed amount, say) it returns an error.",[15,13396,13398],{"id":13397},"side-effects-go-to-events","Side effects go to events",[11,13400,13401],{},"Sending an email, reserving stock or notifying analytics inside the transition ties the state machine to those systems. And a failed email must not roll back the payment status, because the payment already happened and a missing email doesn't change that.",[11,13403,13404,13405,13407],{},"So the transition only dispatches an event. That event implements ",[30,13406,6417],{},", which means listeners don't run if the transaction rolls back and don't see the old row state when they read through another connection:",[23,13409,13411],{"className":25,"code":13410,"language":27,"meta":28,"style":28},"use Illuminate\\Contracts\\Events\\ShouldDispatchAfterCommit;\nuse Illuminate\\Contracts\\Queue\\ShouldQueue;\nuse Illuminate\\Foundation\\Events\\Dispatchable;\n\nfinal class OrderStatusChanged implements ShouldDispatchAfterCommit\n{\n    use Dispatchable;\n\n    public function __construct(\n        public readonly int $orderId,\n        public readonly OrderStatus $from,\n        public readonly OrderStatus $to,\n    ) {}\n}\n\nfinal class SendPaymentConfirmation implements ShouldQueue\n{\n    public function handle(OrderStatusChanged $event): void\n    {\n        if ($event->to !== OrderStatus::Paid) {\n            return;\n        }\n\n        $order = Order::findOrFail($event->orderId);\n        Mail::to($order->customer_email)->send(new PaymentConfirmed($order));\n    }\n}\n",[30,13412,13413,13418,13422,13427,13431,13436,13440,13445,13449,13453,13458,13463,13468,13472,13476,13480,13485,13489,13494,13498,13503,13507,13511,13515,13520,13525,13529],{"__ignoreMap":28},[33,13414,13415],{"class":35,"line":36},[33,13416,13417],{},"use Illuminate\\Contracts\\Events\\ShouldDispatchAfterCommit;\n",[33,13419,13420],{"class":35,"line":42},[33,13421,6340],{},[33,13423,13424],{"class":35,"line":48},[33,13425,13426],{},"use Illuminate\\Foundation\\Events\\Dispatchable;\n",[33,13428,13429],{"class":35,"line":55},[33,13430,52],{"emptyLinePlaceholder":51},[33,13432,13433],{"class":35,"line":61},[33,13434,13435],{},"final class OrderStatusChanged implements ShouldDispatchAfterCommit\n",[33,13437,13438],{"class":35,"line":67},[33,13439,64],{},[33,13441,13442],{"class":35,"line":73},[33,13443,13444],{},"    use Dispatchable;\n",[33,13446,13447],{"class":35,"line":79},[33,13448,52],{"emptyLinePlaceholder":51},[33,13450,13451],{"class":35,"line":85},[33,13452,4263],{},[33,13454,13455],{"class":35,"line":90},[33,13456,13457],{},"        public readonly int $orderId,\n",[33,13459,13460],{"class":35,"line":96},[33,13461,13462],{},"        public readonly OrderStatus $from,\n",[33,13464,13465],{"class":35,"line":384},[33,13466,13467],{},"        public readonly OrderStatus $to,\n",[33,13469,13470],{"class":35,"line":390},[33,13471,4278],{},[33,13473,13474],{"class":35,"line":395},[33,13475,82],{},[33,13477,13478],{"class":35,"line":401},[33,13479,52],{"emptyLinePlaceholder":51},[33,13481,13482],{"class":35,"line":407},[33,13483,13484],{},"final class SendPaymentConfirmation implements ShouldQueue\n",[33,13486,13487],{"class":35,"line":413},[33,13488,64],{},[33,13490,13491],{"class":35,"line":419},[33,13492,13493],{},"    public function handle(OrderStatusChanged $event): void\n",[33,13495,13496],{"class":35,"line":1806},[33,13497,4183],{},[33,13499,13500],{"class":35,"line":1812},[33,13501,13502],{},"        if ($event->to !== OrderStatus::Paid) {\n",[33,13504,13505],{"class":35,"line":1817},[33,13506,4746],{},[33,13508,13509],{"class":35,"line":1822},[33,13510,4681],{},[33,13512,13513],{"class":35,"line":1828},[33,13514,52],{"emptyLinePlaceholder":51},[33,13516,13517],{"class":35,"line":1834},[33,13518,13519],{},"        $order = Order::findOrFail($event->orderId);\n",[33,13521,13522],{"class":35,"line":1840},[33,13523,13524],{},"        Mail::to($order->customer_email)->send(new PaymentConfirmed($order));\n",[33,13526,13527],{"class":35,"line":1846},[33,13528,4193],{},[33,13530,13531],{"class":35,"line":1851},[33,13532,82],{},[11,13534,13535,13536,13539,13540,13542,13543,13546,13547,1930,13550,13553],{},"Each queued listener runs as a separate job with its own attempts (Laravel attempts a job once unless you raise the limit on the listener or with ",[30,13537,13538],{},"queue:work --tries","). With one attempt, a job that hits a dead mail server lands in ",[30,13541,8034],{},", and the email doesn't go out until someone runs ",[30,13544,13545],{},"queue:retry",". If the listener has several attempts with a delay between them (",[30,13548,13549],{},"$tries",[30,13551,13552],{},"$backoff","), the email goes out on a later attempt if the server is back by then, and in neither case does the order status change.",[15,13555,13557],{"id":13556},"tests-that-check-the-graph","Tests that check the graph",[11,13559,13560],{},"Testing every pair of states against a copy of the same table just duplicates the definition. It's more useful to test properties of the graph, plus the business rules that matter:",[23,13562,13564],{"className":25,"code":13563,"language":27,"meta":28,"style":28},"public function test_every_status_is_reachable_from_draft(): void\n{\n    $seen = [OrderStatus::Draft];\n    $queue = [OrderStatus::Draft];\n\n    while ($queue !== []) {\n        foreach (array_shift($queue)->allowedTransitions() as $next) {\n            if (! in_array($next, $seen, true)) {\n                $seen[] = $next;\n                $queue[] = $next;\n            }\n        }\n    }\n\n    $values = fn (array $states): array => array_map(fn (OrderStatus $s) => $s->value, $states);\n    $this->assertEqualsCanonicalizing($values(OrderStatus::cases()), $values($seen));\n}\n\npublic function test_shipped_order_cannot_be_cancelled(): void\n{\n    $this->assertFalse(OrderStatus::Shipped->canTransitionTo(OrderStatus::Cancelled));\n}\n",[30,13565,13566,13571,13575,13580,13585,13589,13594,13599,13604,13609,13614,13618,13622,13626,13630,13635,13640,13644,13648,13653,13657,13662],{"__ignoreMap":28},[33,13567,13568],{"class":35,"line":36},[33,13569,13570],{},"public function test_every_status_is_reachable_from_draft(): void\n",[33,13572,13573],{"class":35,"line":42},[33,13574,64],{},[33,13576,13577],{"class":35,"line":48},[33,13578,13579],{},"    $seen = [OrderStatus::Draft];\n",[33,13581,13582],{"class":35,"line":55},[33,13583,13584],{},"    $queue = [OrderStatus::Draft];\n",[33,13586,13587],{"class":35,"line":61},[33,13588,52],{"emptyLinePlaceholder":51},[33,13590,13591],{"class":35,"line":67},[33,13592,13593],{},"    while ($queue !== []) {\n",[33,13595,13596],{"class":35,"line":73},[33,13597,13598],{},"        foreach (array_shift($queue)->allowedTransitions() as $next) {\n",[33,13600,13601],{"class":35,"line":79},[33,13602,13603],{},"            if (! in_array($next, $seen, true)) {\n",[33,13605,13606],{"class":35,"line":85},[33,13607,13608],{},"                $seen[] = $next;\n",[33,13610,13611],{"class":35,"line":90},[33,13612,13613],{},"                $queue[] = $next;\n",[33,13615,13616],{"class":35,"line":96},[33,13617,8183],{},[33,13619,13620],{"class":35,"line":384},[33,13621,4681],{},[33,13623,13624],{"class":35,"line":390},[33,13625,4193],{},[33,13627,13628],{"class":35,"line":395},[33,13629,52],{"emptyLinePlaceholder":51},[33,13631,13632],{"class":35,"line":401},[33,13633,13634],{},"    $values = fn (array $states): array => array_map(fn (OrderStatus $s) => $s->value, $states);\n",[33,13636,13637],{"class":35,"line":407},[33,13638,13639],{},"    $this->assertEqualsCanonicalizing($values(OrderStatus::cases()), $values($seen));\n",[33,13641,13642],{"class":35,"line":413},[33,13643,82],{},[33,13645,13646],{"class":35,"line":419},[33,13647,52],{"emptyLinePlaceholder":51},[33,13649,13650],{"class":35,"line":1806},[33,13651,13652],{},"public function test_shipped_order_cannot_be_cancelled(): void\n",[33,13654,13655],{"class":35,"line":1812},[33,13656,64],{},[33,13658,13659],{"class":35,"line":1817},[33,13660,13661],{},"    $this->assertFalse(OrderStatus::Shipped->canTransitionTo(OrderStatus::Cancelled));\n",[33,13663,13664],{"class":35,"line":1822},[33,13665,82],{},[11,13667,13668],{},"The reachability test fails when someone adds a status with no way into it. The Shipped\u002FCancelled test exists because that rule came from the business, so a change to the graph there should have to go through someone.",[15,13670,13672],{"id":13671},"limits-and-alternatives","Limits and alternatives",[11,13674,13675,13676,13679],{},"Several independent dimensions, like payment, fulfillment and invoicing, are better modeled as several small machines than as one status holding every combination. Four fulfillment states (new, shipped, delivered, cancelled) times four payment states (none, pending, paid, refunded) is already 16 combinations in a single status. A boolean flag with no rules attached (",[30,13677,13678],{},"is_archived",", say) doesn't need a state machine at all.",[11,13681,13682,13683,13686],{},"Database ",[30,13684,13685],{},"CHECK"," constraints can restrict the set of values, but not transitions. You can enforce transitions in a trigger, though that moves business rules to a place most PHP teams don't review.",[11,13688,13689,13690,13693,13694,13697],{},"There are ready-made components: the Symfony Workflow component, which has a ",[30,13691,13692],{},"state_machine"," type, and ",[30,13695,13696],{},"spatie\u002Flaravel-model-states"," for Laravel. Symfony Workflow adds guards, transition events and a command that dumps the graph. The Spatie package gives you state classes, custom transition classes and transition events. The enum above is enough until transitions need their own guards or logic; that's where those libraries start to pay off.",[892,13699,894],{},{"title":28,"searchDepth":42,"depth":42,"links":13701},[13702,13703,13704,13705,13706,13707,13708],{"id":12790,"depth":42,"text":12791},{"id":12892,"depth":42,"text":12893},{"id":13082,"depth":42,"text":13083},{"id":13315,"depth":42,"text":13316},{"id":13397,"depth":42,"text":13398},{"id":13556,"depth":42,"text":13557},{"id":13671,"depth":42,"text":13672},"2024-02-10","An order, a subscription and a loan application all have a lifecycle, so each can be described as a state machine. In most codebases that machine is implicit. It's just a status column plus if checks in whatever services change it. Below I show how to make it explicit in PHP 8.3 and Laravel, how to apply transitions safely under concurrency, and where side effects should go.",{},"\u002Farticles\u002Fstate-machine",{"x":13714,"y":10149,"depth":9255,"size":911},0.82,[918,5014],{"title":12773,"description":13710},"order-lifecycle","articles\u002Fstate-machine",[27,8577,5020,12774,13720,13721],"fsm","order-management","v2.1.3","c9mEsf5LgebcLny_ySAd-JQ6yE0vorsJSZdm79CsGEw",{"id":13725,"title":13726,"articleId":13727,"body":13728,"category":14372,"codeLang":27,"date":14373,"deploys":36,"description":14374,"excerpt":910,"extension":911,"lang":910,"meta":14375,"navigation":51,"path":14376,"pos":14377,"readMin":67,"related":14380,"seo":14381,"service":14382,"stem":14383,"tags":14384,"updated":2519,"version":5025,"__hash__":14387},"articles\u002Farticles\u002Fzero-is-not-missing-data.md","Zero is not missing data: modelling \"measured\", \"not provided\" and \"unreadable\"","zero-is-not-missing-data",{"type":8,"value":13729,"toc":14366},[13730,13737,13740,13744,13747,13767,13770,13806,13809,13815,13819,13822,13843,13849,13983,13986,14067,14076,14145,14156,14159,14163,14166,14248,14251,14255,14258,14358,14361,14364],[11,13731,13732,13733,13736],{},"In a scoring pipeline, an exception from a parser is a visible failure. The job fails and, if monitoring is set up, someone gets an alert. A parser that returns ",[30,13734,13735],{},"0"," because it couldn't read the input fails invisibly. The value has the right type and passes validation, so downstream rules treat it as a fact, and in credit scoring that fact can be favorable to the applicant: zero entries in a debtor registry, zero arrears, zero inquiries.",[11,13738,13739],{},"I ran into this while working on scoring. An external engine returned a summary saying the applicant had no entries in a debtor registry, while the raw registry response attached to the same call contained an active entry. The summary was wrong because the parser hadn't matched the response structure and fell back to defaults. Nothing in the logs pointed to a problem.",[15,13741,13743],{"id":13742},"how-the-zero-gets-in","How the zero gets in",[11,13745,13746],{},"Mostly through code that's reasonable line by line. PHP makes it easy to replace \"no value\" with zero:",[23,13748,13750],{"className":25,"code":13749,"language":27,"meta":28,"style":28},"$debts  = $response['summary']['count'] ?? 0;\n$amount = (int) $row['amount'];   \u002F\u002F null → 0, \"\" → 0, \"abc\" → 0\n$total  = array_sum($items);      \u002F\u002F [] → 0\n",[30,13751,13752,13757,13762],{"__ignoreMap":28},[33,13753,13754],{"class":35,"line":36},[33,13755,13756],{},"$debts  = $response['summary']['count'] ?? 0;\n",[33,13758,13759],{"class":35,"line":42},[33,13760,13761],{},"$amount = (int) $row['amount'];   \u002F\u002F null → 0, \"\" → 0, \"abc\" → 0\n",[33,13763,13764],{"class":35,"line":48},[33,13765,13766],{},"$total  = array_sum($items);      \u002F\u002F [] → 0\n",[11,13768,13769],{},"And through constructors with defaults:",[23,13771,13773],{"className":25,"code":13772,"language":27,"meta":28,"style":28},"final class RegistryResult\n{\n    public function __construct(\n        public int $negativeCount = 0,\n        public float $negativeAmount = 0.0,\n    ) {}\n}\n",[30,13774,13775,13780,13784,13788,13793,13798,13802],{"__ignoreMap":28},[33,13776,13777],{"class":35,"line":36},[33,13778,13779],{},"final class RegistryResult\n",[33,13781,13782],{"class":35,"line":42},[33,13783,64],{},[33,13785,13786],{"class":35,"line":48},[33,13787,4263],{},[33,13789,13790],{"class":35,"line":55},[33,13791,13792],{},"        public int $negativeCount = 0,\n",[33,13794,13795],{"class":35,"line":61},[33,13796,13797],{},"        public float $negativeAmount = 0.0,\n",[33,13799,13800],{"class":35,"line":67},[33,13801,4278],{},[33,13803,13804],{"class":35,"line":73},[33,13805,82],{},[11,13807,13808],{},"Combine them, and a parser that misses the structure of a response (a new schema version, a different XML namespace, a renamed key) doesn't fail. It finds nothing. \"Nothing\" becomes the default value. Tests built on empty or minimal responses still pass, because for this parser every response looks empty.",[11,13810,13811,13812,13814],{},"The habit comes from treating ",[30,13813,6293],{}," as a crash risk. Tony Hoare called the null reference his \"billion-dollar mistake\", and a lot of defensive code since then aims to make nulls disappear. Often you get a wrong number instead of an exception.",[15,13816,13818],{"id":13817},"what-an-empty-result-can-mean","What an empty result can mean",[11,13820,13821],{},"For data describing a person or a company, \"nothing\" covers different situations:",[13823,13824,13825,13831,13837],"ol",{},[3586,13826,13827,13830],{},[5856,13828,13829],{},"Measured, result is zero."," The registry answered and has no entries for this person. You can score that.",[3586,13832,13833,13836],{},[5856,13834,13835],{},"Not provided."," The source was never requested or never arrived, so there's nothing to score yet.",[3586,13838,13839,13842],{},[5856,13840,13841],{},"Unreadable."," The source arrived and the parser couldn't interpret it. Something in the code or in the provider's format has changed, or the data arrived corrupted.",[11,13844,13845,13846,13848],{},"Only the first may become ",[30,13847,13735],{},". The other two must reach the decision layer as distinct states, and a rule that depends on them should return \"cannot assess\" instead of a score.",[23,13850,13852],{"className":25,"code":13851,"language":27,"meta":28,"style":28},"enum Presence\n{\n    case Measured;\n    case NotProvided;\n    case Unreadable;\n}\n\nfinal readonly class Feature\n{\n    private function __construct(\n        public Presence $presence,\n        public ?float $value,\n    ) {}\n\n    public static function measured(float $value): self\n    {\n        return new self(Presence::Measured, $value);\n    }\n\n    public static function notProvided(): self\n    {\n        return new self(Presence::NotProvided, null);\n    }\n\n    public static function unreadable(): self\n    {\n        return new self(Presence::Unreadable, null);\n    }\n}\n",[30,13853,13854,13859,13863,13868,13873,13878,13882,13886,13891,13895,13899,13904,13909,13913,13917,13922,13926,13931,13935,13939,13944,13948,13953,13957,13961,13966,13970,13975,13979],{"__ignoreMap":28},[33,13855,13856],{"class":35,"line":36},[33,13857,13858],{},"enum Presence\n",[33,13860,13861],{"class":35,"line":42},[33,13862,64],{},[33,13864,13865],{"class":35,"line":48},[33,13866,13867],{},"    case Measured;\n",[33,13869,13870],{"class":35,"line":55},[33,13871,13872],{},"    case NotProvided;\n",[33,13874,13875],{"class":35,"line":61},[33,13876,13877],{},"    case Unreadable;\n",[33,13879,13880],{"class":35,"line":67},[33,13881,82],{},[33,13883,13884],{"class":35,"line":73},[33,13885,52],{"emptyLinePlaceholder":51},[33,13887,13888],{"class":35,"line":79},[33,13889,13890],{},"final readonly class Feature\n",[33,13892,13893],{"class":35,"line":85},[33,13894,64],{},[33,13896,13897],{"class":35,"line":90},[33,13898,12654],{},[33,13900,13901],{"class":35,"line":96},[33,13902,13903],{},"        public Presence $presence,\n",[33,13905,13906],{"class":35,"line":384},[33,13907,13908],{},"        public ?float $value,\n",[33,13910,13911],{"class":35,"line":390},[33,13912,4278],{},[33,13914,13915],{"class":35,"line":395},[33,13916,52],{"emptyLinePlaceholder":51},[33,13918,13919],{"class":35,"line":401},[33,13920,13921],{},"    public static function measured(float $value): self\n",[33,13923,13924],{"class":35,"line":407},[33,13925,4183],{},[33,13927,13928],{"class":35,"line":413},[33,13929,13930],{},"        return new self(Presence::Measured, $value);\n",[33,13932,13933],{"class":35,"line":419},[33,13934,4193],{},[33,13936,13937],{"class":35,"line":1806},[33,13938,52],{"emptyLinePlaceholder":51},[33,13940,13941],{"class":35,"line":1812},[33,13942,13943],{},"    public static function notProvided(): self\n",[33,13945,13946],{"class":35,"line":1817},[33,13947,4183],{},[33,13949,13950],{"class":35,"line":1822},[33,13951,13952],{},"        return new self(Presence::NotProvided, null);\n",[33,13954,13955],{"class":35,"line":1828},[33,13956,4193],{},[33,13958,13959],{"class":35,"line":1834},[33,13960,52],{"emptyLinePlaceholder":51},[33,13962,13963],{"class":35,"line":1840},[33,13964,13965],{},"    public static function unreadable(): self\n",[33,13967,13968],{"class":35,"line":1846},[33,13969,4183],{},[33,13971,13972],{"class":35,"line":1851},[33,13973,13974],{},"        return new self(Presence::Unreadable, null);\n",[33,13976,13977],{"class":35,"line":1856},[33,13978,4193],{},[33,13980,13981],{"class":35,"line":1862},[33,13982,82],{},[11,13984,13985],{},"The parser decides which state applies. It doesn't guess:",[23,13987,13989],{"className":25,"code":13988,"language":27,"meta":28,"style":28},"final class RegistryParser\n{\n    public function negativeCount(?array $response): Feature\n    {\n        if ($response === null) {\n            return Feature::notProvided();\n        }\n\n        $count = $response['summary']['count'] ?? null;\n\n        if (!is_int($count) || $count \u003C 0) {\n            return Feature::unreadable();\n        }\n\n        return Feature::measured($count);\n    }\n}\n",[30,13990,13991,13996,14000,14005,14009,14014,14019,14023,14027,14032,14036,14041,14046,14050,14054,14059,14063],{"__ignoreMap":28},[33,13992,13993],{"class":35,"line":36},[33,13994,13995],{},"final class RegistryParser\n",[33,13997,13998],{"class":35,"line":42},[33,13999,64],{},[33,14001,14002],{"class":35,"line":48},[33,14003,14004],{},"    public function negativeCount(?array $response): Feature\n",[33,14006,14007],{"class":35,"line":55},[33,14008,4183],{},[33,14010,14011],{"class":35,"line":61},[33,14012,14013],{},"        if ($response === null) {\n",[33,14015,14016],{"class":35,"line":67},[33,14017,14018],{},"            return Feature::notProvided();\n",[33,14020,14021],{"class":35,"line":73},[33,14022,4681],{},[33,14024,14025],{"class":35,"line":79},[33,14026,52],{"emptyLinePlaceholder":51},[33,14028,14029],{"class":35,"line":85},[33,14030,14031],{},"        $count = $response['summary']['count'] ?? null;\n",[33,14033,14034],{"class":35,"line":90},[33,14035,52],{"emptyLinePlaceholder":51},[33,14037,14038],{"class":35,"line":96},[33,14039,14040],{},"        if (!is_int($count) || $count \u003C 0) {\n",[33,14042,14043],{"class":35,"line":384},[33,14044,14045],{},"            return Feature::unreadable();\n",[33,14047,14048],{"class":35,"line":390},[33,14049,4681],{},[33,14051,14052],{"class":35,"line":395},[33,14053,52],{"emptyLinePlaceholder":51},[33,14055,14056],{"class":35,"line":401},[33,14057,14058],{},"        return Feature::measured($count);\n",[33,14060,14061],{"class":35,"line":407},[33,14062,4193],{},[33,14064,14065],{"class":35,"line":413},[33,14066,82],{},[11,14068,14069,14070,14072,14073,14075],{},"The rule handles every state explicitly. With ",[30,14071,6511],{}," over an enum, a forgotten case raises ",[30,14074,13064],{}," at runtime instead of falling through to a default:",[23,14077,14079],{"className":25,"code":14078,"language":27,"meta":28,"style":28},"enum Decision\n{\n    case Pass;\n    case Reject;\n    case CannotAssess;\n}\n\nfunction debtorRegistryRule(Feature $negatives): Decision\n{\n    return match ($negatives->presence) {\n        Presence::Measured => $negatives->value > 0 ? Decision::Reject : Decision::Pass,\n        Presence::NotProvided, Presence::Unreadable => Decision::CannotAssess,\n    };\n}\n",[30,14080,14081,14086,14090,14095,14100,14105,14109,14113,14118,14122,14127,14132,14137,14141],{"__ignoreMap":28},[33,14082,14083],{"class":35,"line":36},[33,14084,14085],{},"enum Decision\n",[33,14087,14088],{"class":35,"line":42},[33,14089,64],{},[33,14091,14092],{"class":35,"line":48},[33,14093,14094],{},"    case Pass;\n",[33,14096,14097],{"class":35,"line":55},[33,14098,14099],{},"    case Reject;\n",[33,14101,14102],{"class":35,"line":61},[33,14103,14104],{},"    case CannotAssess;\n",[33,14106,14107],{"class":35,"line":67},[33,14108,82],{},[33,14110,14111],{"class":35,"line":73},[33,14112,52],{"emptyLinePlaceholder":51},[33,14114,14115],{"class":35,"line":79},[33,14116,14117],{},"function debtorRegistryRule(Feature $negatives): Decision\n",[33,14119,14120],{"class":35,"line":85},[33,14121,64],{},[33,14123,14124],{"class":35,"line":90},[33,14125,14126],{},"    return match ($negatives->presence) {\n",[33,14128,14129],{"class":35,"line":96},[33,14130,14131],{},"        Presence::Measured => $negatives->value > 0 ? Decision::Reject : Decision::Pass,\n",[33,14133,14134],{"class":35,"line":384},[33,14135,14136],{},"        Presence::NotProvided, Presence::Unreadable => Decision::CannotAssess,\n",[33,14138,14139],{"class":35,"line":390},[33,14140,8267],{},[33,14142,14143],{"class":35,"line":395},[33,14144,82],{},[11,14146,14147,14148,14151,14152,14155],{},"The same distinction belongs in storage. A nullable column alone does not separate \"not provided\" from \"unreadable\"; store the state next to the value (for example ",[30,14149,14150],{},"negative_count integer null"," plus ",[30,14153,14154],{},"negative_count_status text not null","), so reports and later analyses can filter on it.",[11,14157,14158],{},"This has a cost. Every consumer now has to handle three states, and the product needs a defined path for \"cannot assess\" (manual review, a request for documents, a retry). What you get for it is a business rule about missing data that someone actually wrote down. For fields where a missing value really is equivalent to zero, such as an optional discount, this machinery isn't needed.",[15,14160,14162],{"id":14161},"tests-that-catch-a-parser-returning-zeros","Tests that catch a parser returning zeros",[11,14164,14165],{},"A test with an empty response proves nothing, since a broken parser passes it too. Use a real, anonymized response that contains a positive result, with an assertion that the result is found:",[23,14167,14169],{"className":25,"code":14168,"language":27,"meta":28,"style":28},"public function test_detects_entry_in_registry_response(): void\n{\n    $json = file_get_contents(__DIR__ . '\u002Ffixtures\u002Fregistry_with_active_entry.json');\n    $response = json_decode($json, true, flags: JSON_THROW_ON_ERROR);\n\n    $feature = (new RegistryParser())->negativeCount($response);\n\n    $this->assertSame(Presence::Measured, $feature->presence);\n    $this->assertGreaterThan(0, $feature->value);\n}\n\npublic function test_unknown_structure_is_unreadable_not_zero(): void\n{\n    $feature = (new RegistryParser())->negativeCount(['v2' => ['items' => []]]);\n\n    $this->assertSame(Presence::Unreadable, $feature->presence);\n}\n",[30,14170,14171,14176,14180,14185,14190,14194,14199,14203,14208,14213,14217,14221,14226,14230,14235,14239,14244],{"__ignoreMap":28},[33,14172,14173],{"class":35,"line":36},[33,14174,14175],{},"public function test_detects_entry_in_registry_response(): void\n",[33,14177,14178],{"class":35,"line":42},[33,14179,64],{},[33,14181,14182],{"class":35,"line":48},[33,14183,14184],{},"    $json = file_get_contents(__DIR__ . '\u002Ffixtures\u002Fregistry_with_active_entry.json');\n",[33,14186,14187],{"class":35,"line":55},[33,14188,14189],{},"    $response = json_decode($json, true, flags: JSON_THROW_ON_ERROR);\n",[33,14191,14192],{"class":35,"line":61},[33,14193,52],{"emptyLinePlaceholder":51},[33,14195,14196],{"class":35,"line":67},[33,14197,14198],{},"    $feature = (new RegistryParser())->negativeCount($response);\n",[33,14200,14201],{"class":35,"line":73},[33,14202,52],{"emptyLinePlaceholder":51},[33,14204,14205],{"class":35,"line":79},[33,14206,14207],{},"    $this->assertSame(Presence::Measured, $feature->presence);\n",[33,14209,14210],{"class":35,"line":85},[33,14211,14212],{},"    $this->assertGreaterThan(0, $feature->value);\n",[33,14214,14215],{"class":35,"line":90},[33,14216,82],{},[33,14218,14219],{"class":35,"line":96},[33,14220,52],{"emptyLinePlaceholder":51},[33,14222,14223],{"class":35,"line":384},[33,14224,14225],{},"public function test_unknown_structure_is_unreadable_not_zero(): void\n",[33,14227,14228],{"class":35,"line":390},[33,14229,64],{},[33,14231,14232],{"class":35,"line":395},[33,14233,14234],{},"    $feature = (new RegistryParser())->negativeCount(['v2' => ['items' => []]]);\n",[33,14236,14237],{"class":35,"line":401},[33,14238,52],{"emptyLinePlaceholder":51},[33,14240,14241],{"class":35,"line":407},[33,14242,14243],{},"    $this->assertSame(Presence::Unreadable, $feature->presence);\n",[33,14245,14246],{"class":35,"line":413},[33,14247,82],{},[11,14249,14250],{},"Add a new fixture whenever the provider changes its schema. One non-empty sample per format is the kind of test that fails when a parser starts returning zeros. Happy-path tests on synthetic data usually don't, however many of them there are.",[15,14252,14254],{"id":14253},"checking-fields-across-many-cases","Checking fields across many cases",[11,14256,14257],{},"Another check needs no knowledge of the parser at all. Compare the same field across several reports about different subjects. If a field has the same value in every report, it's probably padding or a broken mapping. Typical candidates: a risk flag that is always set, a \"has a registered business\" marker that is always false, a category that appears in every report regardless of input.",[23,14259,14261],{"className":25,"code":14260,"language":27,"meta":28,"style":28},"\u002F**\n * @param list\u003Carray\u003Cstring, mixed>> $reports\n * @return list\u003Cstring> fields with a single value across all reports (a missing field counts as null)\n *\u002F\nfunction constantFields(array $reports): array\n{\n    if (count($reports) \u003C 2) {\n        return [];\n    }\n\n    $fields = array_keys(array_replace(...$reports));\n\n    return array_values(array_filter(\n        $fields,\n        static fn (string $field): bool => count(array_unique(array_map(\n            static fn (array $report): string => json_encode($report[$field] ?? null, JSON_THROW_ON_ERROR),\n            $reports,\n        ))) === 1,\n    ));\n}\n",[30,14262,14263,14268,14273,14278,14283,14288,14292,14297,14302,14306,14310,14315,14319,14324,14329,14334,14339,14344,14349,14354],{"__ignoreMap":28},[33,14264,14265],{"class":35,"line":36},[33,14266,14267],{},"\u002F**\n",[33,14269,14270],{"class":35,"line":42},[33,14271,14272],{}," * @param list\u003Carray\u003Cstring, mixed>> $reports\n",[33,14274,14275],{"class":35,"line":48},[33,14276,14277],{}," * @return list\u003Cstring> fields with a single value across all reports (a missing field counts as null)\n",[33,14279,14280],{"class":35,"line":55},[33,14281,14282],{}," *\u002F\n",[33,14284,14285],{"class":35,"line":61},[33,14286,14287],{},"function constantFields(array $reports): array\n",[33,14289,14290],{"class":35,"line":67},[33,14291,64],{},[33,14293,14294],{"class":35,"line":73},[33,14295,14296],{},"    if (count($reports) \u003C 2) {\n",[33,14298,14299],{"class":35,"line":79},[33,14300,14301],{},"        return [];\n",[33,14303,14304],{"class":35,"line":85},[33,14305,4193],{},[33,14307,14308],{"class":35,"line":90},[33,14309,52],{"emptyLinePlaceholder":51},[33,14311,14312],{"class":35,"line":96},[33,14313,14314],{},"    $fields = array_keys(array_replace(...$reports));\n",[33,14316,14317],{"class":35,"line":384},[33,14318,52],{"emptyLinePlaceholder":51},[33,14320,14321],{"class":35,"line":390},[33,14322,14323],{},"    return array_values(array_filter(\n",[33,14325,14326],{"class":35,"line":395},[33,14327,14328],{},"        $fields,\n",[33,14330,14331],{"class":35,"line":401},[33,14332,14333],{},"        static fn (string $field): bool => count(array_unique(array_map(\n",[33,14335,14336],{"class":35,"line":407},[33,14337,14338],{},"            static fn (array $report): string => json_encode($report[$field] ?? null, JSON_THROW_ON_ERROR),\n",[33,14340,14341],{"class":35,"line":413},[33,14342,14343],{},"            $reports,\n",[33,14345,14346],{"class":35,"line":419},[33,14347,14348],{},"        ))) === 1,\n",[33,14350,14351],{"class":35,"line":1806},[33,14352,14353],{},"    ));\n",[33,14355,14356],{"class":35,"line":1812},[33,14357,82],{},[11,14359,14360],{},"Run it on five to ten real cases before trusting a new data source.",[11,14362,14363],{},"Third-party extractions in general deserve the same treatment. Structured JSON with readable field names is someone's interpretation of the source, with their defaults and their mapping errors included. Before building conclusions on it (for example, \"all obligations are non-bank\"), compare a few cases with the raw source document.",[892,14365,894],{},{"title":28,"searchDepth":42,"depth":42,"links":14367},[14368,14369,14370,14371],{"id":13742,"depth":42,"text":13743},{"id":13817,"depth":42,"text":13818},{"id":14161,"depth":42,"text":14162},{"id":14253,"depth":42,"text":14254},"fintech","2026-09-24","In a scoring pipeline, an exception from a parser is a visible failure. The job fails and, if monitoring is set up, someone gets an alert. A parser that returns 0 because it couldn't read the input fails invisibly. The value has the right type and passes validation, so downstream rules treat it as a fact, and in credit scoring that fact can be favorable to the applicant: zero entries in a debtor registry, zero arrears, zero inquiries.",{},"\u002Farticles\u002Fzero-is-not-missing-data",{"x":14378,"y":14379,"depth":36,"size":911},0.88,0.56,[9269,919],{"title":13726,"description":14374},"missing-vs-zero","articles\u002Fzero-is-not-missing-data",[27,10160,14385,14386,4810],"null-handling","credit-scoring","UVxdiNBDNpG_3q3srtbfsxKJg3EQM30fojh1dO8i2JE",1792306064028]