[{"data":1,"prerenderedAt":13047},["ShallowReactive",2],{"article-zero-is-not-missing-data":3,"pl-check-zero-is-not-missing-data":762,"articles-for-sidebar":1379},{"id":4,"title":5,"articleId":6,"body":7,"category":739,"codeLang":35,"date":740,"deploys":43,"description":741,"excerpt":742,"extension":743,"lang":742,"meta":744,"navigation":189,"path":745,"pos":746,"readMin":96,"related":749,"seo":752,"service":753,"stem":754,"tags":755,"version":760,"__hash__":761},"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":9,"toc":732},"minimark",[10,19,22,27,30,59,62,106,109,116,120,123,146,152,313,316,398,409,479,490,493,497,500,582,585,589,592,692,695,698,702,728],[11,12,13,14,18],"p",{},"In a scoring pipeline, an exception from a parser is a visible failure: the job fails, someone gets an alert. A parser that returns ",[15,16,17],"code",{},"0"," when it could not read the input is an invisible one. The value has the right type, passes validation, and downstream rules treat it as a fact. In credit scoring that fact is often favourable: zero entries in a debtor registry, zero arrears, zero inquiries.",[11,20,21],{},"Example: an external engine returns a summary saying the applicant has no entries in a debtor registry, while the raw registry response attached to the same call contains an active entry. The summary is wrong because the parser did not match the response structure and fell back to defaults. Nothing in the logs indicates a problem.",[23,24,26],"h2",{"id":25},"how-the-zero-gets-in","How the zero gets in",[11,28,29],{},"Mostly through code that is reasonable line by line. PHP makes it easy to replace \"no value\" with zero:",[31,32,37],"pre",{"className":33,"code":34,"language":35,"meta":36,"style":36},"language-php shiki shiki-themes github-light github-dark","$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","php","",[15,38,39,47,53],{"__ignoreMap":36},[40,41,44],"span",{"class":42,"line":43},"line",1,[40,45,46],{},"$debts  = $response['summary']['count'] ?? 0;\n",[40,48,50],{"class":42,"line":49},2,[40,51,52],{},"$amount = (int) $row['amount'];   \u002F\u002F null → 0, \"\" → 0, \"abc\" → 0\n",[40,54,56],{"class":42,"line":55},3,[40,57,58],{},"$total  = array_sum($items);      \u002F\u002F [] → 0\n",[11,60,61],{},"And through constructors with defaults:",[31,63,65],{"className":33,"code":64,"language":35,"meta":36,"style":36},"final class RegistryResult\n{\n    public function __construct(\n        public int $negativeCount = 0,\n        public float $negativeAmount = 0.0,\n    ) {}\n}\n",[15,66,67,72,77,82,88,94,100],{"__ignoreMap":36},[40,68,69],{"class":42,"line":43},[40,70,71],{},"final class RegistryResult\n",[40,73,74],{"class":42,"line":49},[40,75,76],{},"{\n",[40,78,79],{"class":42,"line":55},[40,80,81],{},"    public function __construct(\n",[40,83,85],{"class":42,"line":84},4,[40,86,87],{},"        public int $negativeCount = 0,\n",[40,89,91],{"class":42,"line":90},5,[40,92,93],{},"        public float $negativeAmount = 0.0,\n",[40,95,97],{"class":42,"line":96},6,[40,98,99],{},"    ) {}\n",[40,101,103],{"class":42,"line":102},7,[40,104,105],{},"}\n",[11,107,108],{},"Combine them and a parser that misses the structure of a response (a new schema version, a different XML namespace, a renamed key) does not fail. It finds nothing, and \"nothing\" becomes the default value. Tests built on empty or minimal responses pass, because for this parser every response looks empty.",[11,110,111,112,115],{},"The habit comes from treating ",[15,113,114],{},"null"," 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. The result is often worse: a loud failure replaced with a quiet wrong answer.",[23,117,119],{"id":118},"three-meanings-of-nothing","Three meanings of \"nothing\"",[11,121,122],{},"For data describing a person or a company, an empty result can mean three different things:",[124,125,126,134,140],"ol",{},[127,128,129,133],"li",{},[130,131,132],"strong",{},"Measured, result is zero."," The registry answered and there are no entries. This is information.",[127,135,136,139],{},[130,137,138],{},"Not provided."," The source was never requested or never delivered. This is a gap.",[127,141,142,145],{},[130,143,144],{},"Unreadable."," The source arrived, but the parser could not interpret it. This is a failure.",[11,147,148,149,151],{},"Only the first may become ",[15,150,17],{},". 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.",[31,153,155],{"className":33,"code":154,"language":35,"meta":36,"style":36},"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",[15,156,157,162,166,171,176,181,185,191,197,202,208,214,220,225,230,236,242,248,254,259,265,270,276,281,286,292,297,303,308],{"__ignoreMap":36},[40,158,159],{"class":42,"line":43},[40,160,161],{},"enum Presence\n",[40,163,164],{"class":42,"line":49},[40,165,76],{},[40,167,168],{"class":42,"line":55},[40,169,170],{},"    case Measured;\n",[40,172,173],{"class":42,"line":84},[40,174,175],{},"    case NotProvided;\n",[40,177,178],{"class":42,"line":90},[40,179,180],{},"    case Unreadable;\n",[40,182,183],{"class":42,"line":96},[40,184,105],{},[40,186,187],{"class":42,"line":102},[40,188,190],{"emptyLinePlaceholder":189},true,"\n",[40,192,194],{"class":42,"line":193},8,[40,195,196],{},"final readonly class Feature\n",[40,198,200],{"class":42,"line":199},9,[40,201,76],{},[40,203,205],{"class":42,"line":204},10,[40,206,207],{},"    private function __construct(\n",[40,209,211],{"class":42,"line":210},11,[40,212,213],{},"        public Presence $presence,\n",[40,215,217],{"class":42,"line":216},12,[40,218,219],{},"        public ?float $value,\n",[40,221,223],{"class":42,"line":222},13,[40,224,99],{},[40,226,228],{"class":42,"line":227},14,[40,229,190],{"emptyLinePlaceholder":189},[40,231,233],{"class":42,"line":232},15,[40,234,235],{},"    public static function measured(float $value): self\n",[40,237,239],{"class":42,"line":238},16,[40,240,241],{},"    {\n",[40,243,245],{"class":42,"line":244},17,[40,246,247],{},"        return new self(Presence::Measured, $value);\n",[40,249,251],{"class":42,"line":250},18,[40,252,253],{},"    }\n",[40,255,257],{"class":42,"line":256},19,[40,258,190],{"emptyLinePlaceholder":189},[40,260,262],{"class":42,"line":261},20,[40,263,264],{},"    public static function notProvided(): self\n",[40,266,268],{"class":42,"line":267},21,[40,269,241],{},[40,271,273],{"class":42,"line":272},22,[40,274,275],{},"        return new self(Presence::NotProvided, null);\n",[40,277,279],{"class":42,"line":278},23,[40,280,253],{},[40,282,284],{"class":42,"line":283},24,[40,285,190],{"emptyLinePlaceholder":189},[40,287,289],{"class":42,"line":288},25,[40,290,291],{},"    public static function unreadable(): self\n",[40,293,295],{"class":42,"line":294},26,[40,296,241],{},[40,298,300],{"class":42,"line":299},27,[40,301,302],{},"        return new self(Presence::Unreadable, null);\n",[40,304,306],{"class":42,"line":305},28,[40,307,253],{},[40,309,311],{"class":42,"line":310},29,[40,312,105],{},[11,314,315],{},"The parser decides which state applies and does not guess:",[31,317,319],{"className":33,"code":318,"language":35,"meta":36,"style":36},"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",[15,320,321,326,330,335,339,344,349,354,358,363,367,372,377,381,385,390,394],{"__ignoreMap":36},[40,322,323],{"class":42,"line":43},[40,324,325],{},"final class RegistryParser\n",[40,327,328],{"class":42,"line":49},[40,329,76],{},[40,331,332],{"class":42,"line":55},[40,333,334],{},"    public function negativeCount(?array $response): Feature\n",[40,336,337],{"class":42,"line":84},[40,338,241],{},[40,340,341],{"class":42,"line":90},[40,342,343],{},"        if ($response === null) {\n",[40,345,346],{"class":42,"line":96},[40,347,348],{},"            return Feature::notProvided();\n",[40,350,351],{"class":42,"line":102},[40,352,353],{},"        }\n",[40,355,356],{"class":42,"line":193},[40,357,190],{"emptyLinePlaceholder":189},[40,359,360],{"class":42,"line":199},[40,361,362],{},"        $count = $response['summary']['count'] ?? null;\n",[40,364,365],{"class":42,"line":204},[40,366,190],{"emptyLinePlaceholder":189},[40,368,369],{"class":42,"line":210},[40,370,371],{},"        if (!is_int($count) || $count \u003C 0) {\n",[40,373,374],{"class":42,"line":216},[40,375,376],{},"            return Feature::unreadable();\n",[40,378,379],{"class":42,"line":222},[40,380,353],{},[40,382,383],{"class":42,"line":227},[40,384,190],{"emptyLinePlaceholder":189},[40,386,387],{"class":42,"line":232},[40,388,389],{},"        return Feature::measured($count);\n",[40,391,392],{"class":42,"line":238},[40,393,253],{},[40,395,396],{"class":42,"line":244},[40,397,105],{},[11,399,400,401,404,405,408],{},"The rule handles every state explicitly. With ",[15,402,403],{},"match"," over an enum, a forgotten case raises ",[15,406,407],{},"UnhandledMatchError"," at runtime instead of falling through to a default:",[31,410,412],{"className":33,"code":411,"language":35,"meta":36,"style":36},"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",[15,413,414,419,423,428,433,438,442,446,451,455,460,465,470,475],{"__ignoreMap":36},[40,415,416],{"class":42,"line":43},[40,417,418],{},"enum Decision\n",[40,420,421],{"class":42,"line":49},[40,422,76],{},[40,424,425],{"class":42,"line":55},[40,426,427],{},"    case Pass;\n",[40,429,430],{"class":42,"line":84},[40,431,432],{},"    case Reject;\n",[40,434,435],{"class":42,"line":90},[40,436,437],{},"    case CannotAssess;\n",[40,439,440],{"class":42,"line":96},[40,441,105],{},[40,443,444],{"class":42,"line":102},[40,445,190],{"emptyLinePlaceholder":189},[40,447,448],{"class":42,"line":193},[40,449,450],{},"function debtorRegistryRule(Feature $negatives): Decision\n",[40,452,453],{"class":42,"line":199},[40,454,76],{},[40,456,457],{"class":42,"line":204},[40,458,459],{},"    return match ($negatives->presence) {\n",[40,461,462],{"class":42,"line":210},[40,463,464],{},"        Presence::Measured => $negatives->value > 0 ? Decision::Reject : Decision::Pass,\n",[40,466,467],{"class":42,"line":216},[40,468,469],{},"        Presence::NotProvided, Presence::Unreadable => Decision::CannotAssess,\n",[40,471,472],{"class":42,"line":222},[40,473,474],{},"    };\n",[40,476,477],{"class":42,"line":227},[40,478,105],{},[11,480,481,482,485,486,489],{},"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 ",[15,483,484],{},"negative_count integer null"," plus ",[15,487,488],{},"negative_count_status text not null","), so reports and later analyses can filter on it.",[11,491,492],{},"Trade-offs: 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). That is extra work, and it is the point: the decision about missing data moves from an accidental default to an explicit business rule. For fields where a missing value really is equivalent to zero, such as an optional discount, this machinery is unnecessary.",[23,494,496],{"id":495},"tests-that-catch-a-parser-returning-zeros","Tests that catch a parser returning zeros",[11,498,499],{},"A test with an empty response proves nothing, because a broken parser passes it. The useful fixture is a real, anonymised response that contains a positive result, with an assertion that the result is found:",[31,501,503],{"className":33,"code":502,"language":35,"meta":36,"style":36},"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",[15,504,505,510,514,519,524,528,533,537,542,547,551,555,560,564,569,573,578],{"__ignoreMap":36},[40,506,507],{"class":42,"line":43},[40,508,509],{},"public function test_detects_entry_in_registry_response(): void\n",[40,511,512],{"class":42,"line":49},[40,513,76],{},[40,515,516],{"class":42,"line":55},[40,517,518],{},"    $json = file_get_contents(__DIR__ . '\u002Ffixtures\u002Fregistry_with_active_entry.json');\n",[40,520,521],{"class":42,"line":84},[40,522,523],{},"    $response = json_decode($json, true, flags: JSON_THROW_ON_ERROR);\n",[40,525,526],{"class":42,"line":90},[40,527,190],{"emptyLinePlaceholder":189},[40,529,530],{"class":42,"line":96},[40,531,532],{},"    $feature = (new RegistryParser())->negativeCount($response);\n",[40,534,535],{"class":42,"line":102},[40,536,190],{"emptyLinePlaceholder":189},[40,538,539],{"class":42,"line":193},[40,540,541],{},"    $this->assertSame(Presence::Measured, $feature->presence);\n",[40,543,544],{"class":42,"line":199},[40,545,546],{},"    $this->assertGreaterThan(0, $feature->value);\n",[40,548,549],{"class":42,"line":204},[40,550,105],{},[40,552,553],{"class":42,"line":210},[40,554,190],{"emptyLinePlaceholder":189},[40,556,557],{"class":42,"line":216},[40,558,559],{},"public function test_unknown_structure_is_unreadable_not_zero(): void\n",[40,561,562],{"class":42,"line":222},[40,563,76],{},[40,565,566],{"class":42,"line":227},[40,567,568],{},"    $feature = (new RegistryParser())->negativeCount(['v2' => ['items' => []]]);\n",[40,570,571],{"class":42,"line":232},[40,572,190],{"emptyLinePlaceholder":189},[40,574,575],{"class":42,"line":238},[40,576,577],{},"    $this->assertSame(Presence::Unreadable, $feature->presence);\n",[40,579,580],{"class":42,"line":244},[40,581,105],{},[11,583,584],{},"Add a new fixture whenever the provider changes its schema. One non-empty sample per format catches more than many happy-path tests on synthetic data.",[23,586,588],{"id":587},"checking-fields-across-many-cases","Checking fields across many cases",[11,590,591],{},"The second check needs no knowledge of the parser. Compare the same field across several reports about different subjects. A field that has the same value in every report is probably padding or a broken mapping, not a measurement. 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.",[31,593,595],{"className":33,"code":594,"language":35,"meta":36,"style":36},"\u002F**\n * @param list\u003Carray\u003Cstring, mixed>> $reports\n * @return list\u003Cstring> fields with a single value across all reports\n *\u002F\nfunction constantFields(array $reports): array\n{\n    if (count($reports) \u003C 2) {\n        return [];\n    }\n\n    $fields = array_keys($reports[0]);\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),\n            $reports,\n        ))) === 1,\n    ));\n}\n",[15,596,597,602,607,612,617,622,626,631,636,640,644,649,653,658,663,668,673,678,683,688],{"__ignoreMap":36},[40,598,599],{"class":42,"line":43},[40,600,601],{},"\u002F**\n",[40,603,604],{"class":42,"line":49},[40,605,606],{}," * @param list\u003Carray\u003Cstring, mixed>> $reports\n",[40,608,609],{"class":42,"line":55},[40,610,611],{}," * @return list\u003Cstring> fields with a single value across all reports\n",[40,613,614],{"class":42,"line":84},[40,615,616],{}," *\u002F\n",[40,618,619],{"class":42,"line":90},[40,620,621],{},"function constantFields(array $reports): array\n",[40,623,624],{"class":42,"line":96},[40,625,76],{},[40,627,628],{"class":42,"line":102},[40,629,630],{},"    if (count($reports) \u003C 2) {\n",[40,632,633],{"class":42,"line":193},[40,634,635],{},"        return [];\n",[40,637,638],{"class":42,"line":199},[40,639,253],{},[40,641,642],{"class":42,"line":204},[40,643,190],{"emptyLinePlaceholder":189},[40,645,646],{"class":42,"line":210},[40,647,648],{},"    $fields = array_keys($reports[0]);\n",[40,650,651],{"class":42,"line":216},[40,652,190],{"emptyLinePlaceholder":189},[40,654,655],{"class":42,"line":222},[40,656,657],{},"    return array_values(array_filter(\n",[40,659,660],{"class":42,"line":227},[40,661,662],{},"        $fields,\n",[40,664,665],{"class":42,"line":232},[40,666,667],{},"        static fn (string $field): bool => count(array_unique(array_map(\n",[40,669,670],{"class":42,"line":238},[40,671,672],{},"            static fn (array $report): string => json_encode($report[$field] ?? null),\n",[40,674,675],{"class":42,"line":244},[40,676,677],{},"            $reports,\n",[40,679,680],{"class":42,"line":250},[40,681,682],{},"        ))) === 1,\n",[40,684,685],{"class":42,"line":256},[40,686,687],{},"    ));\n",[40,689,690],{"class":42,"line":261},[40,691,105],{},[11,693,694],{},"Run it on five to ten real cases before trusting a new data source. The distribution of a field's values tells you more about its quality than its name.",[11,696,697],{},"The same applies to third-party extractions in general. Structured JSON with readable field names is someone's interpretation of the source, including their defaults and mapping errors. Before building conclusions on it (for example, \"all obligations are non-bank\"), compare a few cases with the raw source document.",[23,699,701],{"id":700},"checklist","Checklist",[703,704,705,716,719,722,725],"ul",{},[127,706,707,708,711,712,715],{},"Search for ",[15,709,710],{},"?? 0",", ",[15,713,714],{},"(int)"," casts and numeric constructor defaults in parsers and DTOs.",[127,717,718],{},"Model \"measured\", \"not provided\" and \"unreadable\" as separate states, in code and in storage.",[127,720,721],{},"Make rules return \"cannot assess\" for the last two, and define what happens next.",[127,723,724],{},"Keep at least one non-empty, anonymised fixture per source format.",[127,726,727],{},"Check value distributions across real cases before relying on a field.",[729,730,731],"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":36,"searchDepth":49,"depth":49,"links":733},[734,735,736,737,738],{"id":25,"depth":49,"text":26},{"id":118,"depth":49,"text":119},{"id":495,"depth":49,"text":496},{"id":587,"depth":49,"text":588},{"id":700,"depth":49,"text":701},"fintech","2026-09-24","In a scoring pipeline, an exception from a parser is a visible failure: the job fails, someone gets an alert. A parser that returns 0 when it could not read the input is an invisible one. The value has the right type, passes validation, and downstream rules treat it as a fact. In credit scoring that fact is often favourable: zero entries in a debtor registry, zero arrears, zero inquiries.",null,"md",{},"\u002Farticles\u002Fzero-is-not-missing-data",{"x":747,"y":748,"depth":43,"size":743},0.88,0.56,[750,751],"n8n-rag-data-quality","php-references",{"title":5,"description":741},"missing-vs-zero","articles\u002Fzero-is-not-missing-data",[35,756,757,758,759],"data-quality","null-handling","credit-scoring","testing","v2.0.0","FcltT_bCDDphLHNBrRQu9dv_42VxH4bUnXWkB4TEnGw",{"id":763,"title":764,"articleId":6,"body":765,"category":739,"codeLang":35,"date":740,"deploys":43,"description":1369,"excerpt":742,"extension":743,"lang":1370,"meta":1371,"navigation":189,"path":1372,"pos":1373,"readMin":90,"related":1374,"seo":1375,"service":753,"stem":1376,"tags":1377,"version":760,"__hash__":1378},"articles_pl\u002Fpl\u002Farticles\u002Fzero-is-not-missing-data.md","Zero to nie brak danych: stany „zmierzono”, „nie dostarczono” i „nie odczytano”",{"type":8,"value":766,"toc":1362},[767,773,776,780,783,799,802,834,837,843,847,850,870,876,996,999,1071,1080,1140,1149,1152,1156,1159,1231,1234,1238,1241,1327,1330,1333,1337,1360],[11,768,769,770,772],{},"W pipeline scoringowym wyjątek z parsera to awaria widoczna: job pada, ktoś dostaje alert. Parser, który zwraca ",[15,771,17],{},", bo nie umiał odczytać danych, to awaria niewidoczna. Wartość ma właściwy typ, przechodzi walidację, a reguły dalej traktują ją jak fakt. W ocenie kredytowej ten fakt bywa korzystny dla wnioskodawcy: zero wpisów w rejestrze dłużników, zero opóźnień, zero zapytań.",[11,774,775],{},"Przykład: zewnętrzny silnik zwraca podsumowanie, że wnioskodawca nie ma wpisów w rejestrze dłużników, a surowa odpowiedź rejestru dołączona do tego samego wywołania zawiera aktywny wpis. Podsumowanie jest błędne, bo parser nie trafił w strukturę odpowiedzi i użył wartości domyślnych. W logach nic nie wskazuje na problem.",[23,777,779],{"id":778},"skąd-się-bierze-zero","Skąd się bierze zero",[11,781,782],{},"Najczęściej z kodu, który linijka po linijce jest rozsądny. PHP ułatwia zamianę „braku wartości” na zero:",[31,784,785],{"className":33,"code":34,"language":35,"meta":36,"style":36},[15,786,787,791,795],{"__ignoreMap":36},[40,788,789],{"class":42,"line":43},[40,790,46],{},[40,792,793],{"class":42,"line":49},[40,794,52],{},[40,796,797],{"class":42,"line":55},[40,798,58],{},[11,800,801],{},"Do tego konstruktory z wartościami domyślnymi:",[31,803,804],{"className":33,"code":64,"language":35,"meta":36,"style":36},[15,805,806,810,814,818,822,826,830],{"__ignoreMap":36},[40,807,808],{"class":42,"line":43},[40,809,71],{},[40,811,812],{"class":42,"line":49},[40,813,76],{},[40,815,816],{"class":42,"line":55},[40,817,81],{},[40,819,820],{"class":42,"line":84},[40,821,87],{},[40,823,824],{"class":42,"line":90},[40,825,93],{},[40,827,828],{"class":42,"line":96},[40,829,99],{},[40,831,832],{"class":42,"line":102},[40,833,105],{},[11,835,836],{},"Po złożeniu tych elementów parser, który nie trafi w strukturę odpowiedzi (nowa wersja schematu, inny namespace XML, zmieniona nazwa klucza), nie zgłasza błędu. Nic nie znajduje, a „nic” dostaje wartość domyślną. Testy oparte na pustych albo minimalnych odpowiedziach przechodzą, bo dla takiego parsera każda odpowiedź wygląda na pustą.",[11,838,839,840,842],{},"Ten nawyk bierze się z traktowania ",[15,841,114],{}," jako ryzyka wywrotki. Tony Hoare nazwał referencję null swoim „błędem za miliard dolarów” i sporo kodu defensywnego od tamtej pory ma po prostu sprawić, żeby null zniknął. Efekt bywa gorszy: głośną awarię zastępuje cicha, błędna odpowiedź.",[23,844,846],{"id":845},"trzy-znaczenia-słowa-brak","Trzy znaczenia słowa „brak”",[11,848,849],{},"W danych opisujących osobę albo firmę pusty wynik może znaczyć trzy różne rzeczy:",[124,851,852,858,864],{},[127,853,854,857],{},[130,855,856],{},"Zmierzono, wynik to zero."," Rejestr odpowiedział i wpisów nie ma. To jest informacja.",[127,859,860,863],{},[130,861,862],{},"Nie dostarczono."," O źródło nie zapytano albo nie przyszło. To jest luka.",[127,865,866,869],{},[130,867,868],{},"Nie odczytano."," Źródło przyszło, ale parser go nie zinterpretował. To jest awaria.",[11,871,872,873,875],{},"Tylko pierwszy przypadek może stać się ",[15,874,17],{},". Dwa pozostałe muszą dojść do warstwy decyzyjnej jako osobne stany, a reguła, która od nich zależy, powinna zwrócić „nie da się ocenić” zamiast wyniku.",[31,877,878],{"className":33,"code":154,"language":35,"meta":36,"style":36},[15,879,880,884,888,892,896,900,904,908,912,916,920,924,928,932,936,940,944,948,952,956,960,964,968,972,976,980,984,988,992],{"__ignoreMap":36},[40,881,882],{"class":42,"line":43},[40,883,161],{},[40,885,886],{"class":42,"line":49},[40,887,76],{},[40,889,890],{"class":42,"line":55},[40,891,170],{},[40,893,894],{"class":42,"line":84},[40,895,175],{},[40,897,898],{"class":42,"line":90},[40,899,180],{},[40,901,902],{"class":42,"line":96},[40,903,105],{},[40,905,906],{"class":42,"line":102},[40,907,190],{"emptyLinePlaceholder":189},[40,909,910],{"class":42,"line":193},[40,911,196],{},[40,913,914],{"class":42,"line":199},[40,915,76],{},[40,917,918],{"class":42,"line":204},[40,919,207],{},[40,921,922],{"class":42,"line":210},[40,923,213],{},[40,925,926],{"class":42,"line":216},[40,927,219],{},[40,929,930],{"class":42,"line":222},[40,931,99],{},[40,933,934],{"class":42,"line":227},[40,935,190],{"emptyLinePlaceholder":189},[40,937,938],{"class":42,"line":232},[40,939,235],{},[40,941,942],{"class":42,"line":238},[40,943,241],{},[40,945,946],{"class":42,"line":244},[40,947,247],{},[40,949,950],{"class":42,"line":250},[40,951,253],{},[40,953,954],{"class":42,"line":256},[40,955,190],{"emptyLinePlaceholder":189},[40,957,958],{"class":42,"line":261},[40,959,264],{},[40,961,962],{"class":42,"line":267},[40,963,241],{},[40,965,966],{"class":42,"line":272},[40,967,275],{},[40,969,970],{"class":42,"line":278},[40,971,253],{},[40,973,974],{"class":42,"line":283},[40,975,190],{"emptyLinePlaceholder":189},[40,977,978],{"class":42,"line":288},[40,979,291],{},[40,981,982],{"class":42,"line":294},[40,983,241],{},[40,985,986],{"class":42,"line":299},[40,987,302],{},[40,989,990],{"class":42,"line":305},[40,991,253],{},[40,993,994],{"class":42,"line":310},[40,995,105],{},[11,997,998],{},"Parser rozstrzyga, który stan zachodzi, i nie zgaduje:",[31,1000,1001],{"className":33,"code":318,"language":35,"meta":36,"style":36},[15,1002,1003,1007,1011,1015,1019,1023,1027,1031,1035,1039,1043,1047,1051,1055,1059,1063,1067],{"__ignoreMap":36},[40,1004,1005],{"class":42,"line":43},[40,1006,325],{},[40,1008,1009],{"class":42,"line":49},[40,1010,76],{},[40,1012,1013],{"class":42,"line":55},[40,1014,334],{},[40,1016,1017],{"class":42,"line":84},[40,1018,241],{},[40,1020,1021],{"class":42,"line":90},[40,1022,343],{},[40,1024,1025],{"class":42,"line":96},[40,1026,348],{},[40,1028,1029],{"class":42,"line":102},[40,1030,353],{},[40,1032,1033],{"class":42,"line":193},[40,1034,190],{"emptyLinePlaceholder":189},[40,1036,1037],{"class":42,"line":199},[40,1038,362],{},[40,1040,1041],{"class":42,"line":204},[40,1042,190],{"emptyLinePlaceholder":189},[40,1044,1045],{"class":42,"line":210},[40,1046,371],{},[40,1048,1049],{"class":42,"line":216},[40,1050,376],{},[40,1052,1053],{"class":42,"line":222},[40,1054,353],{},[40,1056,1057],{"class":42,"line":227},[40,1058,190],{"emptyLinePlaceholder":189},[40,1060,1061],{"class":42,"line":232},[40,1062,389],{},[40,1064,1065],{"class":42,"line":238},[40,1066,253],{},[40,1068,1069],{"class":42,"line":244},[40,1070,105],{},[11,1072,1073,1074,1076,1077,1079],{},"Reguła obsługuje każdy stan jawnie. ",[15,1075,403],{}," po enumie przy pominiętym przypadku rzuci w trakcie działania ",[15,1078,407],{},", zamiast przejść do wartości domyślnej:",[31,1081,1082],{"className":33,"code":411,"language":35,"meta":36,"style":36},[15,1083,1084,1088,1092,1096,1100,1104,1108,1112,1116,1120,1124,1128,1132,1136],{"__ignoreMap":36},[40,1085,1086],{"class":42,"line":43},[40,1087,418],{},[40,1089,1090],{"class":42,"line":49},[40,1091,76],{},[40,1093,1094],{"class":42,"line":55},[40,1095,427],{},[40,1097,1098],{"class":42,"line":84},[40,1099,432],{},[40,1101,1102],{"class":42,"line":90},[40,1103,437],{},[40,1105,1106],{"class":42,"line":96},[40,1107,105],{},[40,1109,1110],{"class":42,"line":102},[40,1111,190],{"emptyLinePlaceholder":189},[40,1113,1114],{"class":42,"line":193},[40,1115,450],{},[40,1117,1118],{"class":42,"line":199},[40,1119,76],{},[40,1121,1122],{"class":42,"line":204},[40,1123,459],{},[40,1125,1126],{"class":42,"line":210},[40,1127,464],{},[40,1129,1130],{"class":42,"line":216},[40,1131,469],{},[40,1133,1134],{"class":42,"line":222},[40,1135,474],{},[40,1137,1138],{"class":42,"line":227},[40,1139,105],{},[11,1141,1142,1143,1145,1146,1148],{},"To samo rozróżnienie powinno trafić do bazy. Sama kolumna nullable nie odróżni „nie dostarczono” od „nie odczytano”. Zapisuj stan obok wartości (np. ",[15,1144,484],{}," i ",[15,1147,488],{},"), żeby raporty i późniejsze analizy mogły po nim filtrować.",[11,1150,1151],{},"Koszty: każdy konsument musi teraz obsłużyć trzy stany, a produkt potrzebuje ustalonej ścieżki dla „nie da się ocenić” (ręczna weryfikacja, prośba o dokumenty, ponowienie zapytania). To dodatkowa praca i o to chodzi: decyzja o brakujących danych przestaje być przypadkową wartością domyślną i staje się jawną regułą biznesową. Dla pól, w których brak naprawdę oznacza zero, np. opcjonalnego rabatu, ta konstrukcja jest zbędna.",[23,1153,1155],{"id":1154},"testy-które-wyłapią-parser-zwracający-zera","Testy, które wyłapią parser zwracający zera",[11,1157,1158],{},"Test na pustej odpowiedzi niczego nie dowodzi, bo zepsuty parser go przejdzie. Przydatny fixture to prawdziwa, zanonimizowana odpowiedź z pozytywnym wynikiem i asercja, że ten wynik został znaleziony:",[31,1160,1161],{"className":33,"code":502,"language":35,"meta":36,"style":36},[15,1162,1163,1167,1171,1175,1179,1183,1187,1191,1195,1199,1203,1207,1211,1215,1219,1223,1227],{"__ignoreMap":36},[40,1164,1165],{"class":42,"line":43},[40,1166,509],{},[40,1168,1169],{"class":42,"line":49},[40,1170,76],{},[40,1172,1173],{"class":42,"line":55},[40,1174,518],{},[40,1176,1177],{"class":42,"line":84},[40,1178,523],{},[40,1180,1181],{"class":42,"line":90},[40,1182,190],{"emptyLinePlaceholder":189},[40,1184,1185],{"class":42,"line":96},[40,1186,532],{},[40,1188,1189],{"class":42,"line":102},[40,1190,190],{"emptyLinePlaceholder":189},[40,1192,1193],{"class":42,"line":193},[40,1194,541],{},[40,1196,1197],{"class":42,"line":199},[40,1198,546],{},[40,1200,1201],{"class":42,"line":204},[40,1202,105],{},[40,1204,1205],{"class":42,"line":210},[40,1206,190],{"emptyLinePlaceholder":189},[40,1208,1209],{"class":42,"line":216},[40,1210,559],{},[40,1212,1213],{"class":42,"line":222},[40,1214,76],{},[40,1216,1217],{"class":42,"line":227},[40,1218,568],{},[40,1220,1221],{"class":42,"line":232},[40,1222,190],{"emptyLinePlaceholder":189},[40,1224,1225],{"class":42,"line":238},[40,1226,577],{},[40,1228,1229],{"class":42,"line":244},[40,1230,105],{},[11,1232,1233],{},"Przy każdej zmianie schematu u dostawcy dodaj nowy fixture. Jedna niepusta próbka na format wyłapie więcej niż wiele testów happy path na syntetycznych danych.",[23,1235,1237],{"id":1236},"sprawdzanie-pól-na-wielu-przypadkach","Sprawdzanie pól na wielu przypadkach",[11,1239,1240],{},"Drugi sposób nie wymaga znajomości parsera. Porównaj to samo pole w kilku raportach o różnych osobach. Pole, które w każdym raporcie ma tę samą wartość, to najpewniej wypełniacz albo zepsute mapowanie, a nie pomiar. Typowi kandydaci: flaga ryzyka zawsze ustawiona, znacznik „prowadzi działalność gospodarczą” zawsze na false, kategoria obecna w każdym raporcie niezależnie od danych wejściowych.",[31,1242,1244],{"className":33,"code":1243,"language":35,"meta":36,"style":36},"\u002F**\n * @param list\u003Carray\u003Cstring, mixed>> $reports\n * @return list\u003Cstring> pola z jedną wartością we wszystkich raportach\n *\u002F\nfunction constantFields(array $reports): array\n{\n    if (count($reports) \u003C 2) {\n        return [];\n    }\n\n    $fields = array_keys($reports[0]);\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),\n            $reports,\n        ))) === 1,\n    ));\n}\n",[15,1245,1246,1250,1254,1259,1263,1267,1271,1275,1279,1283,1287,1291,1295,1299,1303,1307,1311,1315,1319,1323],{"__ignoreMap":36},[40,1247,1248],{"class":42,"line":43},[40,1249,601],{},[40,1251,1252],{"class":42,"line":49},[40,1253,606],{},[40,1255,1256],{"class":42,"line":55},[40,1257,1258],{}," * @return list\u003Cstring> pola z jedną wartością we wszystkich raportach\n",[40,1260,1261],{"class":42,"line":84},[40,1262,616],{},[40,1264,1265],{"class":42,"line":90},[40,1266,621],{},[40,1268,1269],{"class":42,"line":96},[40,1270,76],{},[40,1272,1273],{"class":42,"line":102},[40,1274,630],{},[40,1276,1277],{"class":42,"line":193},[40,1278,635],{},[40,1280,1281],{"class":42,"line":199},[40,1282,253],{},[40,1284,1285],{"class":42,"line":204},[40,1286,190],{"emptyLinePlaceholder":189},[40,1288,1289],{"class":42,"line":210},[40,1290,648],{},[40,1292,1293],{"class":42,"line":216},[40,1294,190],{"emptyLinePlaceholder":189},[40,1296,1297],{"class":42,"line":222},[40,1298,657],{},[40,1300,1301],{"class":42,"line":227},[40,1302,662],{},[40,1304,1305],{"class":42,"line":232},[40,1306,667],{},[40,1308,1309],{"class":42,"line":238},[40,1310,672],{},[40,1312,1313],{"class":42,"line":244},[40,1314,677],{},[40,1316,1317],{"class":42,"line":250},[40,1318,682],{},[40,1320,1321],{"class":42,"line":256},[40,1322,687],{},[40,1324,1325],{"class":42,"line":261},[40,1326,105],{},[11,1328,1329],{},"Uruchom to na pięciu do dziesięciu prawdziwych przypadkach, zanim zaufasz nowemu źródłu danych. Rozkład wartości pola mówi o jego jakości więcej niż jego nazwa.",[11,1331,1332],{},"To samo dotyczy cudzych ekstrakcji w ogóle. Uporządkowany JSON z czytelnymi nazwami pól to czyjaś interpretacja źródła, razem z cudzymi wartościami domyślnymi i błędami mapowania. Zanim zbudujesz na nim wnioski (np. „wszystkie zobowiązania są pozabankowe”), porównaj kilka przypadków z surowym dokumentem źródłowym.",[23,1334,1336],{"id":1335},"lista-kontrolna","Lista kontrolna",[703,1338,1339,1348,1351,1354,1357],{},[127,1340,1341,1342,1344,1345,1347],{},"Wyszukaj ",[15,1343,710],{},", rzutowania ",[15,1346,714],{}," i liczbowe wartości domyślne w konstruktorach parserów i DTO.",[127,1349,1350],{},"Modeluj „zmierzono”, „nie dostarczono” i „nie odczytano” jako osobne stany, w kodzie i w bazie.",[127,1352,1353],{},"Reguły dla dwóch ostatnich stanów mają zwracać „nie da się ocenić”, a proces musi określać, co dalej.",[127,1355,1356],{},"Trzymaj co najmniej jeden niepusty, zanonimizowany fixture na każdy format źródła.",[127,1358,1359],{},"Sprawdź rozkład wartości na prawdziwych przypadkach, zanim oprzesz się na polu.",[729,1361,731],{},{"title":36,"searchDepth":49,"depth":49,"links":1363},[1364,1365,1366,1367,1368],{"id":778,"depth":49,"text":779},{"id":845,"depth":49,"text":846},{"id":1154,"depth":49,"text":1155},{"id":1236,"depth":49,"text":1237},{"id":1335,"depth":49,"text":1336},"W pipeline scoringowym wyjątek z parsera to awaria widoczna: job pada, ktoś dostaje alert. Parser, który zwraca 0, bo nie umiał odczytać danych, to awaria niewidoczna. Wartość ma właściwy typ, przechodzi walidację, a reguły dalej traktują ją jak fakt. W ocenie kredytowej ten fakt bywa korzystny dla wnioskodawcy: zero wpisów w rejestrze dłużników, zero opóźnień, zero zapytań.","pl",{},"\u002Fpl\u002Farticles\u002Fzero-is-not-missing-data",{"x":747,"y":748,"depth":43,"size":743},[750,751],{"title":764,"description":1369},"pl\u002Farticles\u002Fzero-is-not-missing-data",[35,756,757,758,759],"h4Skkk8XfuQJUFghJzwc8wWFIU3PPu9YFoxc4S9Uz-U",[1380,2163,3513,4453,5005,5295,6051,6730,8027,8789,9688,10259,10955,11580,12486],{"id":1381,"title":1382,"articleId":1383,"body":1384,"category":2142,"codeLang":1430,"date":2143,"deploys":1583,"description":1388,"excerpt":742,"extension":743,"lang":742,"meta":2144,"navigation":189,"path":2145,"pos":2146,"readMin":102,"related":2151,"seo":2154,"service":2155,"stem":2156,"tags":2157,"version":2161,"__hash__":2162},"articles\u002Farticles\u002Fagent-graphs.md","AI agents as state graphs in LangGraph: checkpoints, interrupts and replay","agent-graphs",{"type":8,"value":1385,"toc":2131},[1386,1389,1392,1396,1422,1426,1622,1638,1657,1661,1668,1720,1735,1738,1742,1752,1808,1817,1821,1824,1827,1842,1905,1909,1924,1928,1931,2081,2088,2092,2095,2097,2129],[11,1387,1388],{},"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. This works for demos and for agents with one or two tools. As soon as the agent has to survive a process restart, wait for a person, or be debugged after the fact, the loop has no structure to attach those requirements to.",[11,1390,1391],{},"The alternative is to make control flow explicit. 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 decides inside a node; the graph decides what happens next. The examples use LangGraph, but the argument applies to any orchestrator with the same primitives.",[23,1393,1395],{"id":1394},"what-the-loop-lacks","What the loop lacks",[703,1397,1398,1404,1410,1416],{},[127,1399,1400,1403],{},[130,1401,1402],{},"Boundaries for retries and limits."," A failed tool call inside the loop is retried by the model, by your code, or not at all, and the only global safeguard is a step counter. There is no way to say \"retry retrieval three times, never retry the payment call\".",[127,1405,1406,1409],{},[130,1407,1408],{},"Persisted intermediate state."," If the process dies at step 7, everything is lost. Restarting from scratch repeats steps 1 to 6, including any side effects they had.",[127,1411,1412,1415],{},[130,1413,1414],{},"A place to wait."," Human approval requires stopping mid-run, possibly for days, and continuing in a different process. A loop holds its state in local variables, so it cannot do that.",[127,1417,1418,1421],{},[130,1419,1420],{},"Replay."," To debug a bad run, you want the exact state before the wrong decision and the ability to run from there again. A loop gives you a log at best.",[23,1423,1425],{"id":1424},"the-graph","The graph",[31,1427,1431],{"className":1428,"code":1429,"language":1430,"meta":36,"style":36},"language-python shiki shiki-themes github-light github-dark","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","python",[15,1432,1433,1438,1443,1447,1452,1457,1461,1465,1470,1475,1480,1485,1490,1495,1500,1505,1509,1513,1518,1523,1527,1531,1536,1541,1546,1551,1555,1559,1564,1569,1575,1581,1587,1592,1598,1604,1610,1616],{"__ignoreMap":36},[40,1434,1435],{"class":42,"line":43},[40,1436,1437],{},"import operator\n",[40,1439,1440],{"class":42,"line":49},[40,1441,1442],{},"from typing import Annotated, TypedDict\n",[40,1444,1445],{"class":42,"line":55},[40,1446,190],{"emptyLinePlaceholder":189},[40,1448,1449],{"class":42,"line":84},[40,1450,1451],{},"from langgraph.graph import END, START, StateGraph\n",[40,1453,1454],{"class":42,"line":90},[40,1455,1456],{},"from langgraph.types import RetryPolicy\n",[40,1458,1459],{"class":42,"line":96},[40,1460,190],{"emptyLinePlaceholder":189},[40,1462,1463],{"class":42,"line":102},[40,1464,190],{"emptyLinePlaceholder":189},[40,1466,1467],{"class":42,"line":193},[40,1468,1469],{},"class AgentState(TypedDict):\n",[40,1471,1472],{"class":42,"line":199},[40,1473,1474],{},"    task: str\n",[40,1476,1477],{"class":42,"line":204},[40,1478,1479],{},"    plan: list[str]\n",[40,1481,1482],{"class":42,"line":210},[40,1483,1484],{},"    context: Annotated[list[str], operator.add]  # appended, not overwritten\n",[40,1486,1487],{"class":42,"line":216},[40,1488,1489],{},"    result: str | None\n",[40,1491,1492],{"class":42,"line":222},[40,1493,1494],{},"    needs_context: bool\n",[40,1496,1497],{"class":42,"line":227},[40,1498,1499],{},"    done: bool\n",[40,1501,1502],{"class":42,"line":232},[40,1503,1504],{},"    reflections: int\n",[40,1506,1507],{"class":42,"line":238},[40,1508,190],{"emptyLinePlaceholder":189},[40,1510,1511],{"class":42,"line":244},[40,1512,190],{"emptyLinePlaceholder":189},[40,1514,1515],{"class":42,"line":250},[40,1516,1517],{},"def route_after_plan(state: AgentState) -> str:\n",[40,1519,1520],{"class":42,"line":256},[40,1521,1522],{},"    return \"retrieve\" if state[\"needs_context\"] else \"act\"\n",[40,1524,1525],{"class":42,"line":261},[40,1526,190],{"emptyLinePlaceholder":189},[40,1528,1529],{"class":42,"line":267},[40,1530,190],{"emptyLinePlaceholder":189},[40,1532,1533],{"class":42,"line":272},[40,1534,1535],{},"def route_after_act(state: AgentState) -> str:\n",[40,1537,1538],{"class":42,"line":278},[40,1539,1540],{},"    if state[\"done\"] or state[\"reflections\"] >= 2:\n",[40,1542,1543],{"class":42,"line":283},[40,1544,1545],{},"        return END\n",[40,1547,1548],{"class":42,"line":288},[40,1549,1550],{},"    return \"reflect\"\n",[40,1552,1553],{"class":42,"line":294},[40,1554,190],{"emptyLinePlaceholder":189},[40,1556,1557],{"class":42,"line":299},[40,1558,190],{"emptyLinePlaceholder":189},[40,1560,1561],{"class":42,"line":305},[40,1562,1563],{},"builder = StateGraph(AgentState)\n",[40,1565,1566],{"class":42,"line":310},[40,1567,1568],{},"builder.add_node(\"plan\", planner)\n",[40,1570,1572],{"class":42,"line":1571},30,[40,1573,1574],{},"builder.add_node(\"retrieve\", retriever, retry_policy=RetryPolicy(max_attempts=3))\n",[40,1576,1578],{"class":42,"line":1577},31,[40,1579,1580],{},"builder.add_node(\"act\", tool_runner)\n",[40,1582,1584],{"class":42,"line":1583},32,[40,1585,1586],{},"builder.add_node(\"reflect\", critic)\n",[40,1588,1590],{"class":42,"line":1589},33,[40,1591,190],{"emptyLinePlaceholder":189},[40,1593,1595],{"class":42,"line":1594},34,[40,1596,1597],{},"builder.add_edge(START, \"plan\")\n",[40,1599,1601],{"class":42,"line":1600},35,[40,1602,1603],{},"builder.add_conditional_edges(\"plan\", route_after_plan, [\"retrieve\", \"act\"])\n",[40,1605,1607],{"class":42,"line":1606},36,[40,1608,1609],{},"builder.add_edge(\"retrieve\", \"act\")\n",[40,1611,1613],{"class":42,"line":1612},37,[40,1614,1615],{},"builder.add_conditional_edges(\"act\", route_after_act, [\"reflect\", END])\n",[40,1617,1619],{"class":42,"line":1618},38,[40,1620,1621],{},"builder.add_edge(\"reflect\", \"plan\")\n",[11,1623,1624,711,1627,711,1630,1633,1634,1637],{},[15,1625,1626],{},"planner",[15,1628,1629],{},"retriever",[15,1631,1632],{},"tool_runner"," and ",[15,1635,1636],{},"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,1639,1640,1641,1644,1645,1648,1649,1652,1653,1656],{},"There are two separate limits. ",[15,1642,1643],{},"reflections"," is a business rule in the state: at most two critique rounds. ",[15,1646,1647],{},"recursion_limit"," in the run config is a technical cap on the total number of steps; exceeding it raises ",[15,1650,1651],{},"GraphRecursionError"," instead of running indefinitely. The retry policy applies to the ",[15,1654,1655],{},"retrieve"," node only, so a flaky search is retried and a tool with side effects is not.",[23,1658,1660],{"id":1659},"checkpoints-in-postgres","Checkpoints in Postgres",[11,1662,1663,1664,1667],{},"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 ",[15,1665,1666],{},"langgraph-checkpoint-postgres"," package and uses psycopg 3.",[31,1669,1671],{"className":1428,"code":1670,"language":1430,"meta":36,"style":36},"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\": \"ticket-4812\"}, \"recursion_limit\": 30}\n    graph.invoke({\"task\": \"...\", \"context\": [], \"reflections\": 0}, config)\n",[15,1672,1673,1678,1682,1687,1691,1696,1701,1706,1710,1715],{"__ignoreMap":36},[40,1674,1675],{"class":42,"line":43},[40,1676,1677],{},"from langgraph.checkpoint.postgres import PostgresSaver\n",[40,1679,1680],{"class":42,"line":49},[40,1681,190],{"emptyLinePlaceholder":189},[40,1683,1684],{"class":42,"line":55},[40,1685,1686],{},"DB_URI = \"postgresql:\u002F\u002Fagent:secret@localhost:5432\u002Fagents\"\n",[40,1688,1689],{"class":42,"line":84},[40,1690,190],{"emptyLinePlaceholder":189},[40,1692,1693],{"class":42,"line":90},[40,1694,1695],{},"with PostgresSaver.from_conn_string(DB_URI) as checkpointer:\n",[40,1697,1698],{"class":42,"line":96},[40,1699,1700],{},"    checkpointer.setup()  # creates the checkpoint tables; run once, like a migration\n",[40,1702,1703],{"class":42,"line":102},[40,1704,1705],{},"    graph = builder.compile(checkpointer=checkpointer)\n",[40,1707,1708],{"class":42,"line":193},[40,1709,190],{"emptyLinePlaceholder":189},[40,1711,1712],{"class":42,"line":199},[40,1713,1714],{},"    config = {\"configurable\": {\"thread_id\": \"ticket-4812\"}, \"recursion_limit\": 30}\n",[40,1716,1717],{"class":42,"line":204},[40,1718,1719],{},"    graph.invoke({\"task\": \"...\", \"context\": [], \"reflections\": 0}, config)\n",[11,1721,1722,1723,1726,1727,1730,1731,1734],{},"After a crash, ",[15,1724,1725],{},"graph.invoke(None, config)"," with the same thread ID continues from the last completed step. ",[15,1728,1729],{},"graph.get_state(config)"," returns the current state and the next node; ",[15,1732,1733],{},"graph.get_state_history(config)"," returns all previous snapshots.",[11,1736,1737],{},"Two operational notes. Each checkpoint contains the state, so large values (full retrieved documents, base64 files) multiply storage across steps; keep references in the state and the payloads elsewhere. Checkpoints also accumulate per thread indefinitely unless you add a retention job, which in a regulated environment has to match your data retention policy anyway.",[23,1739,1741],{"id":1740},"waiting-for-a-human","Waiting for a human",[11,1743,1744,1747,1748,1751],{},[15,1745,1746],{},"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 ",[15,1749,1750],{},"Command(resume=...)",".",[31,1753,1755],{"className":1428,"code":1754,"language":1430,"meta":36,"style":36},"from langgraph.types import Command, interrupt\n\n\ndef approve_refund(state: AgentState) -> dict:\n    # Assumes `amount` and `approved` fields in the state.\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\": \"ticket-4812\"}})\n",[15,1756,1757,1762,1766,1770,1775,1780,1785,1790,1794,1798,1803],{"__ignoreMap":36},[40,1758,1759],{"class":42,"line":43},[40,1760,1761],{},"from langgraph.types import Command, interrupt\n",[40,1763,1764],{"class":42,"line":49},[40,1765,190],{"emptyLinePlaceholder":189},[40,1767,1768],{"class":42,"line":55},[40,1769,190],{"emptyLinePlaceholder":189},[40,1771,1772],{"class":42,"line":84},[40,1773,1774],{},"def approve_refund(state: AgentState) -> dict:\n",[40,1776,1777],{"class":42,"line":90},[40,1778,1779],{},"    # Assumes `amount` and `approved` fields in the state.\n",[40,1781,1782],{"class":42,"line":96},[40,1783,1784],{},"    answer = interrupt({\"question\": \"Approve refund?\", \"amount\": state[\"amount\"]})\n",[40,1786,1787],{"class":42,"line":102},[40,1788,1789],{},"    return {\"approved\": answer == \"yes\"}\n",[40,1791,1792],{"class":42,"line":193},[40,1793,190],{"emptyLinePlaceholder":189},[40,1795,1796],{"class":42,"line":199},[40,1797,190],{"emptyLinePlaceholder":189},[40,1799,1800],{"class":42,"line":204},[40,1801,1802],{},"# Hours later, e.g. in the HTTP handler of an approval screen:\n",[40,1804,1805],{"class":42,"line":210},[40,1806,1807],{},"graph.invoke(Command(resume=\"yes\"), {\"configurable\": {\"thread_id\": \"ticket-4812\"}})\n",[11,1809,1810,1811,1813,1814,1816],{},"On resume, the node runs again from its first line, and ",[15,1812,1746],{}," returns the resume value instead of stopping. Any code before ",[15,1815,1746],{}," therefore executes twice. Keep that part free of side effects, or move the side effect to a separate node after the approval.",[23,1818,1820],{"id":1819},"what-a-checkpoint-does-not-protect","What a checkpoint does not protect",[11,1822,1823],{},"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,1825,1826],{},"The consequences for design:",[703,1828,1829,1832,1839],{},[127,1830,1831],{},"one external side effect per node, so the unit of repetition is small and known;",[127,1833,1834,1835,1838],{},"an idempotency key derived from the thread ID and node name, passed to the external system where it supports one (Stripe, for example, accepts an ",[15,1836,1837],{},"Idempotency-Key"," header);",[127,1840,1841],{},"streamed output treated as presentation; the source of truth is the value the node returns into the state, and a resumed run does not re-stream what the client already received.",[31,1843,1845],{"className":1428,"code":1844,"language":1430,"meta":36,"style":36},"from langchain_core.runnables import RunnableConfig\n\n\ndef send_confirmation(state: AgentState, config: RunnableConfig) -> dict:\n    thread_id = config[\"configurable\"][\"thread_id\"]\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\",\n    )\n    return {\"confirmation_sent\": True}\n",[15,1846,1847,1852,1856,1860,1865,1870,1875,1880,1885,1890,1895,1900],{"__ignoreMap":36},[40,1848,1849],{"class":42,"line":43},[40,1850,1851],{},"from langchain_core.runnables import RunnableConfig\n",[40,1853,1854],{"class":42,"line":49},[40,1855,190],{"emptyLinePlaceholder":189},[40,1857,1858],{"class":42,"line":55},[40,1859,190],{"emptyLinePlaceholder":189},[40,1861,1862],{"class":42,"line":84},[40,1863,1864],{},"def send_confirmation(state: AgentState, config: RunnableConfig) -> dict:\n",[40,1866,1867],{"class":42,"line":90},[40,1868,1869],{},"    thread_id = config[\"configurable\"][\"thread_id\"]\n",[40,1871,1872],{"class":42,"line":96},[40,1873,1874],{},"    # `mailer` is your client; the provider deduplicates by this key.\n",[40,1876,1877],{"class":42,"line":102},[40,1878,1879],{},"    mailer.send(\n",[40,1881,1882],{"class":42,"line":193},[40,1883,1884],{},"        to=state[\"customer_email\"],\n",[40,1886,1887],{"class":42,"line":199},[40,1888,1889],{},"        body=state[\"result\"],\n",[40,1891,1892],{"class":42,"line":204},[40,1893,1894],{},"        idempotency_key=f\"{thread_id}:send_confirmation\",\n",[40,1896,1897],{"class":42,"line":210},[40,1898,1899],{},"    )\n",[40,1901,1902],{"class":42,"line":216},[40,1903,1904],{},"    return {\"confirmation_sent\": True}\n",[23,1906,1908],{"id":1907},"replay-for-debugging-and-regression","Replay for debugging and regression",[11,1910,1911,1912,1915,1916,1919,1920,1923],{},"Every snapshot from ",[15,1913,1914],{},"get_state_history"," has its own config with a checkpoint ID. Invoking the graph with ",[15,1917,1918],{},"None"," and that config runs again from that point and creates a new branch of history; the original run stays intact. Combined with nodes that receive their tools through a factory (",[15,1921,1922],{},"build_graph(tools)","), this allows taking the state from a failed production run, building the graph with recorded or mocked tools, and checking whether a changed prompt or routing rule leads to a different decision. The state from production may contain personal data, so the replay environment needs the same access controls as production.",[23,1925,1927],{"id":1926},"observability","Observability",[11,1929,1930],{},"Log one structured event per node execution. A wrapper at registration time covers every node without touching its code:",[31,1932,1934],{"className":1428,"code":1933,"language":1430,"meta":36,"style":36},"import functools\nimport logging\nimport time\n\nlog = logging.getLogger(\"agent\")\n\n\ndef observed(name, fn):\n    # Assumes node functions accept (state, config).\n    @functools.wraps(fn)\n    def wrapper(state, config):\n        started = time.perf_counter()\n        error = None\n        try:\n            return fn(state, config)\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",[15,1935,1936,1941,1946,1951,1955,1960,1964,1968,1973,1978,1983,1988,1993,1998,2003,2008,2013,2018,2023,2028,2033,2038,2043,2048,2053,2058,2063,2067,2071,2076],{"__ignoreMap":36},[40,1937,1938],{"class":42,"line":43},[40,1939,1940],{},"import functools\n",[40,1942,1943],{"class":42,"line":49},[40,1944,1945],{},"import logging\n",[40,1947,1948],{"class":42,"line":55},[40,1949,1950],{},"import time\n",[40,1952,1953],{"class":42,"line":84},[40,1954,190],{"emptyLinePlaceholder":189},[40,1956,1957],{"class":42,"line":90},[40,1958,1959],{},"log = logging.getLogger(\"agent\")\n",[40,1961,1962],{"class":42,"line":96},[40,1963,190],{"emptyLinePlaceholder":189},[40,1965,1966],{"class":42,"line":102},[40,1967,190],{"emptyLinePlaceholder":189},[40,1969,1970],{"class":42,"line":193},[40,1971,1972],{},"def observed(name, fn):\n",[40,1974,1975],{"class":42,"line":199},[40,1976,1977],{},"    # Assumes node functions accept (state, config).\n",[40,1979,1980],{"class":42,"line":204},[40,1981,1982],{},"    @functools.wraps(fn)\n",[40,1984,1985],{"class":42,"line":210},[40,1986,1987],{},"    def wrapper(state, config):\n",[40,1989,1990],{"class":42,"line":216},[40,1991,1992],{},"        started = time.perf_counter()\n",[40,1994,1995],{"class":42,"line":222},[40,1996,1997],{},"        error = None\n",[40,1999,2000],{"class":42,"line":227},[40,2001,2002],{},"        try:\n",[40,2004,2005],{"class":42,"line":232},[40,2006,2007],{},"            return fn(state, config)\n",[40,2009,2010],{"class":42,"line":238},[40,2011,2012],{},"        except Exception as exc:\n",[40,2014,2015],{"class":42,"line":244},[40,2016,2017],{},"            error = type(exc).__name__\n",[40,2019,2020],{"class":42,"line":250},[40,2021,2022],{},"            raise\n",[40,2024,2025],{"class":42,"line":256},[40,2026,2027],{},"        finally:\n",[40,2029,2030],{"class":42,"line":261},[40,2031,2032],{},"            log.info(\"agent_step\", extra={\n",[40,2034,2035],{"class":42,"line":267},[40,2036,2037],{},"                \"thread_id\": config[\"configurable\"][\"thread_id\"],\n",[40,2039,2040],{"class":42,"line":272},[40,2041,2042],{},"                \"node\": name,\n",[40,2044,2045],{"class":42,"line":278},[40,2046,2047],{},"                \"latency_ms\": round((time.perf_counter() - started) * 1000),\n",[40,2049,2050],{"class":42,"line":283},[40,2051,2052],{},"                \"error\": error,\n",[40,2054,2055],{"class":42,"line":288},[40,2056,2057],{},"            })\n",[40,2059,2060],{"class":42,"line":294},[40,2061,2062],{},"    return wrapper\n",[40,2064,2065],{"class":42,"line":299},[40,2066,190],{"emptyLinePlaceholder":189},[40,2068,2069],{"class":42,"line":305},[40,2070,190],{"emptyLinePlaceholder":189},[40,2072,2073],{"class":42,"line":310},[40,2074,2075],{},"# Instead of builder.add_node(\"act\", tool_runner); a node name can be registered only once.\n",[40,2077,2078],{"class":42,"line":1571},[40,2079,2080],{},"builder.add_node(\"act\", observed(\"act\", tool_runner))\n",[11,2082,2083,2084,2087],{},"Token counts belong in the same event. In LangChain chat models they are available on the response message as ",[15,2085,2086],{},"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, a cost per run, a slow tool or a node that started failing after a deploy are queries rather than investigations.",[23,2089,2091],{"id":2090},"when-a-graph-is-overkill","When a graph is overkill",[11,2093,2094],{},"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 needs to replay does not justify a checkpoint table and an extra database write per step. The graph pays off when at least one of these is true: the run takes long enough to be interrupted, a person approves something mid-run, or failed runs must be reproducible.",[23,2096,701],{"id":700},[703,2098,2099,2102,2108,2111,2114,2120,2123,2126],{},[127,2100,2101],{},"Control flow lives in edges and routing functions, not in the prompt.",[127,2103,2104,2105,2107],{},"There is a business limit in the state and a technical ",[15,2106,1647],{}," in the config.",[127,2109,2110],{},"Retry policies are set per node, and nodes with side effects are not retried automatically.",[127,2112,2113],{},"The checkpointer writes to a persistent store, and the thread ID maps to a business identifier.",[127,2115,2116,2117,2119],{},"Code before ",[15,2118,1746],{}," has no side effects.",[127,2121,2122],{},"Every external side effect has an idempotency key.",[127,2124,2125],{},"Each node execution produces one structured event with tokens and latency.",[127,2127,2128],{},"Checkpoints have a retention policy.",[729,2130,731],{},{"title":36,"searchDepth":49,"depth":49,"links":2132},[2133,2134,2135,2136,2137,2138,2139,2140,2141],{"id":1394,"depth":49,"text":1395},{"id":1424,"depth":49,"text":1425},{"id":1659,"depth":49,"text":1660},{"id":1740,"depth":49,"text":1741},{"id":1819,"depth":49,"text":1820},{"id":1907,"depth":49,"text":1908},{"id":1926,"depth":49,"text":1927},{"id":2090,"depth":49,"text":2091},{"id":700,"depth":49,"text":701},"agents","2026-05-09",{},"\u002Farticles\u002Fagent-graphs",{"x":2147,"y":2148,"depth":2149,"size":2150},0.66,0.27,1.5,"xl",[2152,2153],"microservice-cost","postgres-edge",{"title":1382,"description":1388},"orchestrator-graphs","articles\u002Fagent-graphs",[2158,2159,2160,1926],"ai-agents","langgraph","state-machines","v1.0.0","qdW6-hkPszZQjTx5Fef-PJmFaWmq5WtLjYBNG2n7RMk",{"id":2164,"title":2165,"articleId":2166,"body":2167,"category":3493,"codeLang":2211,"date":3494,"deploys":43,"description":2171,"excerpt":742,"extension":743,"lang":742,"meta":3495,"navigation":189,"path":3496,"pos":3497,"readMin":102,"related":3501,"seo":3502,"service":3503,"stem":3504,"tags":3505,"version":760,"__hash__":3512},"articles\u002Farticles\u002Fansible-production.md","Ansible in production: idempotency, drift detection and safe rolling changes","ansible-production",{"type":8,"value":2168,"toc":3485},[2169,2172,2176,2207,2279,2286,2290,2293,2347,2353,2501,2510,2513,2535,2545,2549,2552,3097,3100,3173,3186,3190,3197,3237,3247,3262,3334,3337,3341,3344,3392,3395,3417,3421,3482],[11,2170,2171],{},"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 cancelled halfway. The useful question is not whether the playbook works, but whether it converges a host from an unknown state to the declared one, and whether it tells you what it had to change on the way.",[23,2173,2175],{"id":2174},"idempotent-is-not-the-same-as-convergent","Idempotent is not the same as convergent",[11,2177,2178,2179,2182,2183,711,2186,711,2189,711,2192,2195,2196,1633,2199,2202,2203,2206],{},"Ansible modules are idempotent in a narrow sense: running a task twice with the same arguments leaves the system in the same state, and the second run reports ",[15,2180,2181],{},"ok",". That property holds for modules that compare current and desired state (",[15,2184,2185],{},"template",[15,2187,2188],{},"copy",[15,2190,2191],{},"service",[15,2193,2194],{},"apt","). It does not hold for ",[15,2197,2198],{},"command",[15,2200,2201],{},"shell",", which Ansible cannot inspect. They report ",[15,2204,2205],{},"changed"," on every run unless you say otherwise:",[31,2208,2212],{"className":2209,"code":2210,"language":2211,"meta":36,"style":36},"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",[15,2213,2214,2231,2241,2249,2259,2269],{"__ignoreMap":36},[40,2215,2216,2220,2224,2227],{"class":42,"line":43},[40,2217,2219],{"class":2218},"sVt8B","- ",[40,2221,2223],{"class":2222},"s9eBZ","name",[40,2225,2226],{"class":2218},": ",[40,2228,2230],{"class":2229},"sZZnC","Run database migrations\n",[40,2232,2233,2236,2238],{"class":42,"line":49},[40,2234,2235],{"class":2222},"  ansible.builtin.command",[40,2237,2226],{"class":2218},[40,2239,2240],{"class":2229},"php artisan migrate --force\n",[40,2242,2243,2246],{"class":42,"line":55},[40,2244,2245],{"class":2222},"  args",[40,2247,2248],{"class":2218},":\n",[40,2250,2251,2254,2256],{"class":42,"line":84},[40,2252,2253],{"class":2222},"    chdir",[40,2255,2226],{"class":2218},[40,2257,2258],{"class":2229},"\u002Fvar\u002Fwww\u002Fapp\n",[40,2260,2261,2264,2266],{"class":42,"line":90},[40,2262,2263],{"class":2222},"  register",[40,2265,2226],{"class":2218},[40,2267,2268],{"class":2229},"migrate\n",[40,2270,2271,2274,2276],{"class":42,"line":96},[40,2272,2273],{"class":2222},"  changed_when",[40,2275,2226],{"class":2218},[40,2277,2278],{"class":2229},"\"'Nothing to migrate' not in migrate.stdout\"\n",[11,2280,2281,2282,2285],{},"Without ",[15,2283,2284],{},"changed_when",", every run reports at least one change, and the change count stops carrying information. That matters because the change count is the cheapest drift signal you have.",[23,2287,2289],{"id":2288},"making-drift-visible","Making drift visible",[11,2291,2292],{},"Converging a host silently hides the fact that someone changed it. Take a service that an engineer disabled by hand while debugging:",[31,2294,2296],{"className":2209,"code":2295,"language":2211,"meta":36,"style":36},"- name: Ensure application service is running\n  ansible.builtin.service:\n    name: myapp\n    state: started\n    enabled: true\n",[15,2297,2298,2309,2316,2326,2336],{"__ignoreMap":36},[40,2299,2300,2302,2304,2306],{"class":42,"line":43},[40,2301,2219],{"class":2218},[40,2303,2223],{"class":2222},[40,2305,2226],{"class":2218},[40,2307,2308],{"class":2229},"Ensure application service is running\n",[40,2310,2311,2314],{"class":42,"line":49},[40,2312,2313],{"class":2222},"  ansible.builtin.service",[40,2315,2248],{"class":2218},[40,2317,2318,2321,2323],{"class":42,"line":55},[40,2319,2320],{"class":2222},"    name",[40,2322,2226],{"class":2218},[40,2324,2325],{"class":2229},"myapp\n",[40,2327,2328,2331,2333],{"class":42,"line":84},[40,2329,2330],{"class":2222},"    state",[40,2332,2226],{"class":2218},[40,2334,2335],{"class":2229},"started\n",[40,2337,2338,2341,2343],{"class":42,"line":90},[40,2339,2340],{"class":2222},"    enabled",[40,2342,2226],{"class":2218},[40,2344,2346],{"class":2345},"sj4cs","true\n",[11,2348,2349,2350,2352],{},"The task fixes the state and reports ",[15,2351,2205],{},", but the report does not say whether the change came from a first install or from a manual override. If you want that distinction, read the state before converging it:",[31,2354,2356],{"className":2209,"code":2355,"language":2211,"meta":36,"style":36},"- name: Read current enablement state\n  ansible.builtin.command: systemctl is-enabled myapp\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: \"myapp 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: myapp\n    state: started\n    enabled: true\n",[15,2357,2358,2369,2378,2387,2396,2405,2414,2418,2429,2436,2446,2456,2460,2471,2477,2485,2493],{"__ignoreMap":36},[40,2359,2360,2362,2364,2366],{"class":42,"line":43},[40,2361,2219],{"class":2218},[40,2363,2223],{"class":2222},[40,2365,2226],{"class":2218},[40,2367,2368],{"class":2229},"Read current enablement state\n",[40,2370,2371,2373,2375],{"class":42,"line":49},[40,2372,2235],{"class":2222},[40,2374,2226],{"class":2218},[40,2376,2377],{"class":2229},"systemctl is-enabled myapp\n",[40,2379,2380,2382,2384],{"class":42,"line":55},[40,2381,2263],{"class":2222},[40,2383,2226],{"class":2218},[40,2385,2386],{"class":2229},"svc_enabled\n",[40,2388,2389,2391,2393],{"class":42,"line":84},[40,2390,2273],{"class":2222},[40,2392,2226],{"class":2218},[40,2394,2395],{"class":2345},"false\n",[40,2397,2398,2401,2403],{"class":42,"line":90},[40,2399,2400],{"class":2222},"  failed_when",[40,2402,2226],{"class":2218},[40,2404,2395],{"class":2345},[40,2406,2407,2410,2412],{"class":42,"line":96},[40,2408,2409],{"class":2222},"  check_mode",[40,2411,2226],{"class":2218},[40,2413,2395],{"class":2345},[40,2415,2416],{"class":42,"line":102},[40,2417,190],{"emptyLinePlaceholder":189},[40,2419,2420,2422,2424,2426],{"class":42,"line":193},[40,2421,2219],{"class":2218},[40,2423,2223],{"class":2222},[40,2425,2226],{"class":2218},[40,2427,2428],{"class":2229},"Report manual override\n",[40,2430,2431,2434],{"class":42,"line":199},[40,2432,2433],{"class":2222},"  ansible.builtin.debug",[40,2435,2248],{"class":2218},[40,2437,2438,2441,2443],{"class":42,"line":204},[40,2439,2440],{"class":2222},"    msg",[40,2442,2226],{"class":2218},[40,2444,2445],{"class":2229},"\"myapp is '{{ svc_enabled.stdout }}' on {{ inventory_hostname }}, expected 'enabled'\"\n",[40,2447,2448,2451,2453],{"class":42,"line":210},[40,2449,2450],{"class":2222},"  when",[40,2452,2226],{"class":2218},[40,2454,2455],{"class":2229},"svc_enabled.stdout != 'enabled'\n",[40,2457,2458],{"class":42,"line":216},[40,2459,190],{"emptyLinePlaceholder":189},[40,2461,2462,2464,2466,2468],{"class":42,"line":222},[40,2463,2219],{"class":2218},[40,2465,2223],{"class":2222},[40,2467,2226],{"class":2218},[40,2469,2470],{"class":2229},"Converge service state\n",[40,2472,2473,2475],{"class":42,"line":227},[40,2474,2313],{"class":2222},[40,2476,2248],{"class":2218},[40,2478,2479,2481,2483],{"class":42,"line":232},[40,2480,2320],{"class":2222},[40,2482,2226],{"class":2218},[40,2484,2325],{"class":2229},[40,2486,2487,2489,2491],{"class":42,"line":238},[40,2488,2330],{"class":2222},[40,2490,2226],{"class":2218},[40,2492,2335],{"class":2229},[40,2494,2495,2497,2499],{"class":42,"line":244},[40,2496,2340],{"class":2222},[40,2498,2226],{"class":2218},[40,2500,2346],{"class":2345},[11,2502,2503,2506,2507,2509],{},[15,2504,2505],{},"check_mode: false"," matters here: ",[15,2508,2198],{}," tasks are skipped in check mode by default, so without it the read would not run during a dry run.",[11,2511,2512],{},"For the whole fleet, the practical drift detector is a scheduled dry run:",[31,2514,2518],{"className":2515,"code":2516,"language":2517,"meta":36,"style":36},"language-bash shiki shiki-themes github-light github-dark","ansible-playbook site.yml --check --diff\n","bash",[15,2519,2520],{"__ignoreMap":36},[40,2521,2522,2526,2529,2532],{"class":42,"line":43},[40,2523,2525],{"class":2524},"sScJk","ansible-playbook",[40,2527,2528],{"class":2229}," site.yml",[40,2530,2531],{"class":2345}," --check",[40,2533,2534],{"class":2345}," --diff\n",[11,2536,2537,2538,2540,2541,2544],{},"If the playbooks have correct ",[15,2539,2284],{}," everywhere, any host with ",[15,2542,2543],{},"changed>0"," in the recap has drifted, and the diff shows which lines. Two limits: check mode cannot predict tasks that depend on the result of an earlier task that would have changed something, and modules without check mode support are skipped. Treat the result as a list of hosts to look at, not as a precise report.",[23,2546,2548],{"id":2547},"rolling-changes-with-rollback","Rolling changes with rollback",[11,2550,2551],{},"Example scenario: a deploy job is interrupted while updating the nginx upstream configuration, and part of the fleet runs the old config while the rest runs the new one. The fix is to converge all hosts to one version, a few at a time, and to stop as soon as one host fails. A playbook for that should be written and tested before it is needed:",[31,2553,2555],{"className":2209,"code":2554,"language":2211,"meta":36,"style":36},"- 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: 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:{{ app_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: 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",[15,2556,2557,2568,2578,2587,2597,2607,2611,2618,2630,2637,2647,2657,2661,2668,2679,2686,2698,2705,2715,2725,2735,2744,2754,2763,2773,2782,2786,2797,2807,2816,2820,2831,2841,2845,2856,2863,2873,2883,2893,2903,2914,2924,2934,2939,2947,2959,2967,2977,2986,2996,3005,3016,3021,3033,3041,3051,3061,3066,3078,3086],{"__ignoreMap":36},[40,2558,2559,2561,2563,2565],{"class":42,"line":43},[40,2560,2219],{"class":2218},[40,2562,2223],{"class":2222},[40,2564,2226],{"class":2218},[40,2566,2567],{"class":2229},"Converge nginx upstream config\n",[40,2569,2570,2573,2575],{"class":42,"line":49},[40,2571,2572],{"class":2222},"  hosts",[40,2574,2226],{"class":2218},[40,2576,2577],{"class":2229},"api_servers\n",[40,2579,2580,2583,2585],{"class":42,"line":55},[40,2581,2582],{"class":2222},"  become",[40,2584,2226],{"class":2218},[40,2586,2346],{"class":2345},[40,2588,2589,2592,2594],{"class":42,"line":84},[40,2590,2591],{"class":2222},"  serial",[40,2593,2226],{"class":2218},[40,2595,2596],{"class":2345},"2\n",[40,2598,2599,2602,2604],{"class":42,"line":90},[40,2600,2601],{"class":2222},"  max_fail_percentage",[40,2603,2226],{"class":2218},[40,2605,2606],{"class":2345},"0\n",[40,2608,2609],{"class":42,"line":96},[40,2610,190],{"emptyLinePlaceholder":189},[40,2612,2613,2616],{"class":42,"line":102},[40,2614,2615],{"class":2222},"  handlers",[40,2617,2248],{"class":2218},[40,2619,2620,2623,2625,2627],{"class":42,"line":193},[40,2621,2622],{"class":2218},"    - ",[40,2624,2223],{"class":2222},[40,2626,2226],{"class":2218},[40,2628,2629],{"class":2229},"Reload nginx\n",[40,2631,2632,2635],{"class":42,"line":199},[40,2633,2634],{"class":2222},"      ansible.builtin.service",[40,2636,2248],{"class":2218},[40,2638,2639,2642,2644],{"class":42,"line":204},[40,2640,2641],{"class":2222},"        name",[40,2643,2226],{"class":2218},[40,2645,2646],{"class":2229},"nginx\n",[40,2648,2649,2652,2654],{"class":42,"line":210},[40,2650,2651],{"class":2222},"        state",[40,2653,2226],{"class":2218},[40,2655,2656],{"class":2229},"reloaded\n",[40,2658,2659],{"class":42,"line":216},[40,2660,190],{"emptyLinePlaceholder":189},[40,2662,2663,2666],{"class":42,"line":222},[40,2664,2665],{"class":2222},"  tasks",[40,2667,2248],{"class":2218},[40,2669,2670,2672,2674,2676],{"class":42,"line":227},[40,2671,2622],{"class":2218},[40,2673,2223],{"class":2222},[40,2675,2226],{"class":2218},[40,2677,2678],{"class":2229},"Deploy, verify, roll back on failure\n",[40,2680,2681,2684],{"class":42,"line":232},[40,2682,2683],{"class":2222},"      block",[40,2685,2248],{"class":2218},[40,2687,2688,2691,2693,2695],{"class":42,"line":238},[40,2689,2690],{"class":2218},"        - ",[40,2692,2223],{"class":2222},[40,2694,2226],{"class":2218},[40,2696,2697],{"class":2229},"Render upstream config\n",[40,2699,2700,2703],{"class":42,"line":244},[40,2701,2702],{"class":2222},"          ansible.builtin.template",[40,2704,2248],{"class":2218},[40,2706,2707,2710,2712],{"class":42,"line":250},[40,2708,2709],{"class":2222},"            src",[40,2711,2226],{"class":2218},[40,2713,2714],{"class":2229},"templates\u002Fupstream.conf.j2\n",[40,2716,2717,2720,2722],{"class":42,"line":256},[40,2718,2719],{"class":2222},"            dest",[40,2721,2226],{"class":2218},[40,2723,2724],{"class":2229},"\u002Fetc\u002Fnginx\u002Fconf.d\u002Fupstream.conf\n",[40,2726,2727,2730,2732],{"class":42,"line":261},[40,2728,2729],{"class":2222},"            owner",[40,2731,2226],{"class":2218},[40,2733,2734],{"class":2229},"root\n",[40,2736,2737,2740,2742],{"class":42,"line":267},[40,2738,2739],{"class":2222},"            group",[40,2741,2226],{"class":2218},[40,2743,2734],{"class":2229},[40,2745,2746,2749,2751],{"class":42,"line":272},[40,2747,2748],{"class":2222},"            mode",[40,2750,2226],{"class":2218},[40,2752,2753],{"class":2229},"'0644'\n",[40,2755,2756,2759,2761],{"class":42,"line":278},[40,2757,2758],{"class":2222},"            backup",[40,2760,2226],{"class":2218},[40,2762,2346],{"class":2345},[40,2764,2765,2768,2770],{"class":42,"line":283},[40,2766,2767],{"class":2222},"          register",[40,2769,2226],{"class":2218},[40,2771,2772],{"class":2229},"upstream_conf\n",[40,2774,2775,2778,2780],{"class":42,"line":288},[40,2776,2777],{"class":2222},"          notify",[40,2779,2226],{"class":2218},[40,2781,2629],{"class":2229},[40,2783,2784],{"class":42,"line":294},[40,2785,190],{"emptyLinePlaceholder":189},[40,2787,2788,2790,2792,2794],{"class":42,"line":299},[40,2789,2690],{"class":2218},[40,2791,2223],{"class":2222},[40,2793,2226],{"class":2218},[40,2795,2796],{"class":2229},"Test full nginx configuration\n",[40,2798,2799,2802,2804],{"class":42,"line":305},[40,2800,2801],{"class":2222},"          ansible.builtin.command",[40,2803,2226],{"class":2218},[40,2805,2806],{"class":2229},"nginx -t\n",[40,2808,2809,2812,2814],{"class":42,"line":310},[40,2810,2811],{"class":2222},"          changed_when",[40,2813,2226],{"class":2218},[40,2815,2395],{"class":2345},[40,2817,2818],{"class":42,"line":1571},[40,2819,190],{"emptyLinePlaceholder":189},[40,2821,2822,2824,2826,2828],{"class":42,"line":1577},[40,2823,2690],{"class":2218},[40,2825,2223],{"class":2222},[40,2827,2226],{"class":2218},[40,2829,2830],{"class":2229},"Reload now instead of at the end of the play\n",[40,2832,2833,2836,2838],{"class":42,"line":1583},[40,2834,2835],{"class":2222},"          ansible.builtin.meta",[40,2837,2226],{"class":2218},[40,2839,2840],{"class":2229},"flush_handlers\n",[40,2842,2843],{"class":42,"line":1589},[40,2844,190],{"emptyLinePlaceholder":189},[40,2846,2847,2849,2851,2853],{"class":42,"line":1594},[40,2848,2690],{"class":2218},[40,2850,2223],{"class":2222},[40,2852,2226],{"class":2218},[40,2854,2855],{"class":2229},"Wait for health endpoint\n",[40,2857,2858,2861],{"class":42,"line":1600},[40,2859,2860],{"class":2222},"          ansible.builtin.uri",[40,2862,2248],{"class":2218},[40,2864,2865,2868,2870],{"class":42,"line":1606},[40,2866,2867],{"class":2222},"            url",[40,2869,2226],{"class":2218},[40,2871,2872],{"class":2229},"\"http:\u002F\u002F127.0.0.1:{{ app_port }}\u002Fhealth\"\n",[40,2874,2875,2878,2880],{"class":42,"line":1612},[40,2876,2877],{"class":2222},"            status_code",[40,2879,2226],{"class":2218},[40,2881,2882],{"class":2345},"200\n",[40,2884,2885,2888,2890],{"class":42,"line":1618},[40,2886,2887],{"class":2222},"            timeout",[40,2889,2226],{"class":2218},[40,2891,2892],{"class":2345},"5\n",[40,2894,2896,2898,2900],{"class":42,"line":2895},39,[40,2897,2767],{"class":2222},[40,2899,2226],{"class":2218},[40,2901,2902],{"class":2229},"health\n",[40,2904,2906,2909,2911],{"class":42,"line":2905},40,[40,2907,2908],{"class":2222},"          until",[40,2910,2226],{"class":2218},[40,2912,2913],{"class":2229},"health.status == 200\n",[40,2915,2917,2920,2922],{"class":42,"line":2916},41,[40,2918,2919],{"class":2222},"          retries",[40,2921,2226],{"class":2218},[40,2923,2892],{"class":2345},[40,2925,2927,2930,2932],{"class":42,"line":2926},42,[40,2928,2929],{"class":2222},"          delay",[40,2931,2226],{"class":2218},[40,2933,2596],{"class":2345},[40,2935,2937],{"class":42,"line":2936},43,[40,2938,190],{"emptyLinePlaceholder":189},[40,2940,2942,2945],{"class":42,"line":2941},44,[40,2943,2944],{"class":2222},"      rescue",[40,2946,2248],{"class":2218},[40,2948,2950,2952,2954,2956],{"class":42,"line":2949},45,[40,2951,2690],{"class":2218},[40,2953,2223],{"class":2222},[40,2955,2226],{"class":2218},[40,2957,2958],{"class":2229},"Restore previous config\n",[40,2960,2962,2965],{"class":42,"line":2961},46,[40,2963,2964],{"class":2222},"          ansible.builtin.copy",[40,2966,2248],{"class":2218},[40,2968,2970,2972,2974],{"class":42,"line":2969},47,[40,2971,2709],{"class":2222},[40,2973,2226],{"class":2218},[40,2975,2976],{"class":2229},"\"{{ upstream_conf.backup_file }}\"\n",[40,2978,2980,2982,2984],{"class":42,"line":2979},48,[40,2981,2719],{"class":2222},[40,2983,2226],{"class":2218},[40,2985,2724],{"class":2229},[40,2987,2989,2992,2994],{"class":42,"line":2988},49,[40,2990,2991],{"class":2222},"            remote_src",[40,2993,2226],{"class":2218},[40,2995,2346],{"class":2345},[40,2997,2999,3001,3003],{"class":42,"line":2998},50,[40,3000,2748],{"class":2222},[40,3002,2226],{"class":2218},[40,3004,2753],{"class":2229},[40,3006,3008,3011,3013],{"class":42,"line":3007},51,[40,3009,3010],{"class":2222},"          when",[40,3012,2226],{"class":2218},[40,3014,3015],{"class":2229},"upstream_conf.backup_file is defined\n",[40,3017,3019],{"class":42,"line":3018},52,[40,3020,190],{"emptyLinePlaceholder":189},[40,3022,3024,3026,3028,3030],{"class":42,"line":3023},53,[40,3025,2690],{"class":2218},[40,3027,2223],{"class":2222},[40,3029,2226],{"class":2218},[40,3031,3032],{"class":2229},"Reload nginx with restored config\n",[40,3034,3036,3039],{"class":42,"line":3035},54,[40,3037,3038],{"class":2222},"          ansible.builtin.service",[40,3040,2248],{"class":2218},[40,3042,3044,3047,3049],{"class":42,"line":3043},55,[40,3045,3046],{"class":2222},"            name",[40,3048,2226],{"class":2218},[40,3050,2646],{"class":2229},[40,3052,3054,3057,3059],{"class":42,"line":3053},56,[40,3055,3056],{"class":2222},"            state",[40,3058,2226],{"class":2218},[40,3060,2656],{"class":2229},[40,3062,3064],{"class":42,"line":3063},57,[40,3065,190],{"emptyLinePlaceholder":189},[40,3067,3069,3071,3073,3075],{"class":42,"line":3068},58,[40,3070,2690],{"class":2218},[40,3072,2223],{"class":2222},[40,3074,2226],{"class":2218},[40,3076,3077],{"class":2229},"Mark host as failed\n",[40,3079,3081,3084],{"class":42,"line":3080},59,[40,3082,3083],{"class":2222},"          ansible.builtin.fail",[40,3085,2248],{"class":2218},[40,3087,3089,3092,3094],{"class":42,"line":3088},60,[40,3090,3091],{"class":2222},"            msg",[40,3093,2226],{"class":2218},[40,3095,3096],{"class":2229},"\"upstream.conf rolled back on {{ inventory_hostname }}\"\n",[11,3098,3099],{},"What each part does:",[703,3101,3102,3108,3114,3142,3149,3163],{},[127,3103,3104,3107],{},[15,3105,3106],{},"serial: 2"," processes the inventory in batches of two hosts. With eight hosts, at most two are being changed at any moment.",[127,3109,3110,3113],{},[15,3111,3112],{},"max_fail_percentage: 0"," stops the rollout after the first batch with a failed host. Without it, Ansible only stops when every host in a batch fails, so a 1 of 2 failure would let the next batch proceed.",[127,3115,3116,3119,3120,3123,3124,3127,3128,3131,3132,3134,3135,3137,3138,3141],{},[15,3117,3118],{},"nginx -t"," tests the full configuration, not the rendered file alone. A ",[15,3121,3122],{},"conf.d"," snippet with an ",[15,3125,3126],{},"upstream"," block is not a valid standalone config, so the ",[15,3129,3130],{},"validate"," option of ",[15,3133,2185],{}," does not work for it; ",[15,3136,3130],{}," is fine for files like ",[15,3139,3140],{},"nginx.conf"," that nginx can test on their own.",[127,3143,3144,3145,3148],{},"Handlers normally run at the end of the play (per batch). ",[15,3146,3147],{},"meta: flush_handlers"," reloads nginx before the health check, otherwise the check would test the old config.",[127,3150,3151,3154,3155,3158,3159,3162],{},[15,3152,3153],{},"backup: true"," keeps a timestamped copy and returns its path in ",[15,3156,3157],{},"backup_file",". The rescue section restores it, reloads, and then fails the host explicitly, so ",[15,3160,3161],{},"max_fail_percentage"," stops the rollout.",[127,3164,3165,3168,3169,3172],{},[15,3166,3167],{},"reloaded"," instead of ",[15,3170,3171],{},"restarted",": nginx keeps serving with the old workers until the new ones start, so a reload does not drop connections.",[11,3174,3175,3176,3179,3180,1633,3183,1751],{},"This playbook does not drain hosts from the load balancer. With ",[15,3177,3178],{},"reload"," that is usually unnecessary; for application restarts it is, and the drain\u002Fundrain steps belong in ",[15,3181,3182],{},"pre_tasks",[15,3184,3185],{},"post_tasks",[23,3187,3189],{"id":3188},"secrets","Secrets",[11,3191,3192,3193,3196],{},"Secrets that belong to the playbook go into an encrypted vars file, referenced from a plain one. The ",[15,3194,3195],{},"vault_"," prefix convention from the Ansible documentation makes it obvious where a value comes from:",[31,3198,3200],{"className":2209,"code":3199,"language":2211,"meta":36,"style":36},"# 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",[15,3201,3202,3208,3218,3222,3227],{"__ignoreMap":36},[40,3203,3204],{"class":42,"line":43},[40,3205,3207],{"class":3206},"sJ8bj","# group_vars\u002Fprod\u002Fvars.yml\n",[40,3209,3210,3213,3215],{"class":42,"line":49},[40,3211,3212],{"class":2222},"db_password",[40,3214,2226],{"class":2218},[40,3216,3217],{"class":2229},"\"{{ vault_db_password }}\"\n",[40,3219,3220],{"class":42,"line":55},[40,3221,190],{"emptyLinePlaceholder":189},[40,3223,3224],{"class":42,"line":84},[40,3225,3226],{"class":3206},"# group_vars\u002Fprod\u002Fvault.yml, encrypted with: ansible-vault encrypt group_vars\u002Fprod\u002Fvault.yml\n",[40,3228,3229,3232,3234],{"class":42,"line":90},[40,3230,3231],{"class":2222},"vault_db_password",[40,3233,2226],{"class":2218},[40,3235,3236],{"class":2229},"\"...\"\n",[11,3238,3239,3240,1633,3243,3246],{},"Secrets shared with other systems are better read at runtime from a secret manager through a lookup plugin (the ",[15,3241,3242],{},"amazon.aws",[15,3244,3245],{},"community.hashi_vault"," collections provide them), so there is one source of truth.",[11,3248,3249,3250,3253,3254,3257,3258,3261],{},"Any task that renders or passes a secret needs ",[15,3251,3252],{},"no_log: true",". It hides the task result in logs and in ",[15,3255,3256],{},"--diff"," output. Use ",[15,3259,3260],{},"diff: false"," if you only want to suppress the diff:",[31,3263,3265],{"className":2209,"code":3264,"language":2211,"meta":36,"style":36},"- 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",[15,3266,3267,3278,3285,3295,3305,3315,3325],{"__ignoreMap":36},[40,3268,3269,3271,3273,3275],{"class":42,"line":43},[40,3270,2219],{"class":2218},[40,3272,2223],{"class":2222},[40,3274,2226],{"class":2218},[40,3276,3277],{"class":2229},"Render database config\n",[40,3279,3280,3283],{"class":42,"line":49},[40,3281,3282],{"class":2222},"  ansible.builtin.template",[40,3284,2248],{"class":2218},[40,3286,3287,3290,3292],{"class":42,"line":55},[40,3288,3289],{"class":2222},"    src",[40,3291,2226],{"class":2218},[40,3293,3294],{"class":2229},"templates\u002Fdatabase.env.j2\n",[40,3296,3297,3300,3302],{"class":42,"line":84},[40,3298,3299],{"class":2222},"    dest",[40,3301,2226],{"class":2218},[40,3303,3304],{"class":2229},"\u002Fvar\u002Fwww\u002Fapp\u002F.env.database\n",[40,3306,3307,3310,3312],{"class":42,"line":90},[40,3308,3309],{"class":2222},"    owner",[40,3311,2226],{"class":2218},[40,3313,3314],{"class":2229},"www-data\n",[40,3316,3317,3320,3322],{"class":42,"line":96},[40,3318,3319],{"class":2222},"    mode",[40,3321,2226],{"class":2218},[40,3323,3324],{"class":2229},"'0640'\n",[40,3326,3327,3330,3332],{"class":42,"line":102},[40,3328,3329],{"class":2222},"  no_log",[40,3331,2226],{"class":2218},[40,3333,2346],{"class":2345},[11,3335,3336],{},"If a secret has already been committed in plain text, rewriting git history is not enough: clones and CI caches still have it. Rotate the secret first, then clean up.",[23,3338,3340],{"id":3339},"testing-before-production","Testing before production",[11,3342,3343],{},"For roles, Molecule creates a container or VM, applies the role, runs it a second time to check idempotency, and runs a verifier. With Testinfra as the verifier, tests assert the resulting state rather than Ansible's own report:",[31,3345,3347],{"className":1428,"code":3346,"language":1430,"meta":36,"style":36},"# 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",[15,3348,3349,3354,3359,3364,3369,3374,3378,3382,3387],{"__ignoreMap":36},[40,3350,3351],{"class":42,"line":43},[40,3352,3353],{},"# molecule\u002Fdefault\u002Ftests\u002Ftest_nginx.py\n",[40,3355,3356],{"class":42,"line":49},[40,3357,3358],{},"def test_nginx_running_and_enabled(host):\n",[40,3360,3361],{"class":42,"line":55},[40,3362,3363],{},"    nginx = host.service(\"nginx\")\n",[40,3365,3366],{"class":42,"line":84},[40,3367,3368],{},"    assert nginx.is_running\n",[40,3370,3371],{"class":42,"line":90},[40,3372,3373],{},"    assert nginx.is_enabled\n",[40,3375,3376],{"class":42,"line":96},[40,3377,190],{"emptyLinePlaceholder":189},[40,3379,3380],{"class":42,"line":102},[40,3381,190],{"emptyLinePlaceholder":189},[40,3383,3384],{"class":42,"line":193},[40,3385,3386],{},"def test_nginx_config_valid(host):\n",[40,3388,3389],{"class":42,"line":199},[40,3390,3391],{},"    assert host.run(\"nginx -t\").rc == 0\n",[11,3393,3394],{},"Before a production run, a dry run against one representative host is fast and shows the exact template diffs:",[31,3396,3398],{"className":2515,"code":3397,"language":2517,"meta":36,"style":36},"ansible-playbook site.yml --check --diff --limit 'api_servers[0]'\n",[15,3399,3400],{"__ignoreMap":36},[40,3401,3402,3404,3406,3408,3411,3414],{"class":42,"line":43},[40,3403,2525],{"class":2524},[40,3405,2528],{"class":2229},[40,3407,2531],{"class":2345},[40,3409,3410],{"class":2345}," --diff",[40,3412,3413],{"class":2345}," --limit",[40,3415,3416],{"class":2229}," 'api_servers[0]'\n",[23,3418,3420],{"id":3419},"review-checklist","Review checklist",[703,3422,3423,3438,3443,3458,3464,3469,3479],{},[127,3424,3425,3426,1633,3428,3430,3431,3433,3434,3437],{},"Every ",[15,3427,2198],{},[15,3429,2201],{}," task has ",[15,3432,2284],{}," (and ",[15,3435,3436],{},"failed_when"," where the exit code is not meaningful).",[127,3439,3440,3441,1751],{},"Read-only tasks that must run in dry runs have ",[15,3442,2505],{},[127,3444,3445,3446,3449,3450,3453,3454,3457],{},"No ",[15,3447,3448],{},"ignore_errors: true"," on infrastructure tasks. A failure should stop the play and keep the host out of rotation; use ",[15,3451,3452],{},"block","\u002F",[15,3455,3456],{},"rescue"," when you need explicit recovery.",[127,3459,3460,3463],{},[15,3461,3462],{},"become: true"," only on plays or tasks that need root.",[127,3465,3466,3467,1751],{},"Every task that touches a secret has ",[15,3468,3252],{},[127,3470,3471,3472,3475,3476,3478],{},"Changes to more than one host at a time use ",[15,3473,3474],{},"serial"," and a ",[15,3477,3161],{}," chosen on purpose.",[127,3480,3481],{},"Handlers that later tasks depend on are flushed explicitly.",[729,3483,3484],{},"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 .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 .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 pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}",{"title":36,"searchDepth":49,"depth":49,"links":3486},[3487,3488,3489,3490,3491,3492],{"id":2174,"depth":49,"text":2175},{"id":2288,"depth":49,"text":2289},{"id":2547,"depth":49,"text":2548},{"id":3188,"depth":49,"text":3189},{"id":3339,"depth":49,"text":3340},{"id":3419,"depth":49,"text":3420},"backend","2024-05-06",{},"\u002Farticles\u002Fansible-production",{"x":3498,"y":3499,"depth":3500,"size":743},0.36,0.8,0.9,[2152,2153],{"title":2165,"description":2171},"infra-automation","articles\u002Fansible-production",[3506,3507,3508,3509,3510,3511],"ansible","devops","infrastructure","automation","idempotency","configuration-management","AJvABOrXB-fEkL_LQcYHCWucW6fKWk6uV_CCLD_QGS0",{"id":3514,"title":3515,"articleId":3516,"body":3517,"category":35,"codeLang":35,"date":4434,"deploys":43,"description":3521,"excerpt":742,"extension":743,"lang":742,"meta":4435,"navigation":189,"path":4436,"pos":4437,"readMin":96,"related":4440,"seo":4443,"service":4444,"stem":4445,"tags":4446,"version":760,"__hash__":4452},"articles\u002Farticles\u002Fbridge-pattern.md","The Bridge pattern: separating what you send from how you send it","bridge-pattern",{"type":8,"value":3518,"toc":4426},[3519,3522,3526,3529,3537,3540,3543,3547,3550,3726,3746,3749,3943,3946,3950,3953,3982,3985,4100,4113,4117,4120,4178,4185,4188,4203,4217,4220,4223,4379,4382,4386,4389,4410,4413,4424],[11,3520,3521],{},"Bridge splits a class hierarchy along two axes that change independently and connects them through composition. Textbooks illustrate it with shapes and renderers. A notification system is a more practical example, because it has exactly two axes: what the message says and how it is delivered.",[23,3523,3525],{"id":3524},"the-problem-m-n-classes","The problem: M × N classes",[11,3527,3528],{},"Example: a system sends four notification types (payment confirmation, low inventory alert, account suspension, weekly report) through three channels (email, SMS, Slack). If each combination is its own class, you get:",[31,3530,3535],{"className":3531,"code":3533,"language":3534},[3532],"language-text","PaymentConfirmationEmail     PaymentConfirmationSms     PaymentConfirmationSlack\nLowInventoryAlertEmail       LowInventoryAlertSms       LowInventoryAlertSlack\nAccountSuspendedEmail        AccountSuspendedSms        AccountSuspendedSlack\nWeeklyReportEmail            WeeklyReportSms            WeeklyReportSlack\n","text",[15,3536,3533],{"__ignoreMap":36},[11,3538,3539],{},"Twelve classes. A fourth channel adds four, a fifth notification type adds three. The count is M × N, and each class duplicates both the content logic of its type and the delivery logic of its channel. A change in how SMS messages are shortened has to be made in four places.",[11,3541,3542],{},"The cause is that inheritance (or copy-paste) fuses two independent decisions into one class. Bridge keeps them in separate hierarchies.",[23,3544,3546],{"id":3545},"two-hierarchies","Two hierarchies",[11,3548,3549],{},"The implementor side describes how to deliver a message:",[31,3551,3553],{"className":33,"code":3552,"language":35,"meta":36,"style":36},"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",[15,3554,3555,3560,3564,3569,3573,3577,3582,3586,3591,3595,3600,3604,3609,3613,3617,3621,3626,3630,3635,3639,3643,3647,3652,3657,3661,3665,3669,3674,3678,3682,3687,3692,3696,3700,3704,3708,3713,3718,3722],{"__ignoreMap":36},[40,3556,3557],{"class":42,"line":43},[40,3558,3559],{},"interface NotificationChannel\n",[40,3561,3562],{"class":42,"line":49},[40,3563,76],{},[40,3565,3566],{"class":42,"line":55},[40,3567,3568],{},"    public function send(string $recipient, string $subject, string $body): void;\n",[40,3570,3571],{"class":42,"line":84},[40,3572,105],{},[40,3574,3575],{"class":42,"line":90},[40,3576,190],{"emptyLinePlaceholder":189},[40,3578,3579],{"class":42,"line":96},[40,3580,3581],{},"final class EmailChannel implements NotificationChannel\n",[40,3583,3584],{"class":42,"line":102},[40,3585,76],{},[40,3587,3588],{"class":42,"line":193},[40,3589,3590],{},"    public function __construct(private readonly Mailer $mailer) {}\n",[40,3592,3593],{"class":42,"line":199},[40,3594,190],{"emptyLinePlaceholder":189},[40,3596,3597],{"class":42,"line":204},[40,3598,3599],{},"    public function send(string $recipient, string $subject, string $body): void\n",[40,3601,3602],{"class":42,"line":210},[40,3603,241],{},[40,3605,3606],{"class":42,"line":216},[40,3607,3608],{},"        $this->mailer->send($recipient, $subject, $body);\n",[40,3610,3611],{"class":42,"line":222},[40,3612,253],{},[40,3614,3615],{"class":42,"line":227},[40,3616,105],{},[40,3618,3619],{"class":42,"line":232},[40,3620,190],{"emptyLinePlaceholder":189},[40,3622,3623],{"class":42,"line":238},[40,3624,3625],{},"final class SlackChannel implements NotificationChannel\n",[40,3627,3628],{"class":42,"line":244},[40,3629,76],{},[40,3631,3632],{"class":42,"line":250},[40,3633,3634],{},"    public function __construct(private readonly SlackClient $slack) {}\n",[40,3636,3637],{"class":42,"line":256},[40,3638,190],{"emptyLinePlaceholder":189},[40,3640,3641],{"class":42,"line":261},[40,3642,3599],{},[40,3644,3645],{"class":42,"line":267},[40,3646,241],{},[40,3648,3649],{"class":42,"line":272},[40,3650,3651],{},"        \u002F\u002F Slack has no subject line, so it goes into the message as bold text\n",[40,3653,3654],{"class":42,"line":278},[40,3655,3656],{},"        $this->slack->postMessage($recipient, \"*{$subject}*\\n{$body}\");\n",[40,3658,3659],{"class":42,"line":283},[40,3660,253],{},[40,3662,3663],{"class":42,"line":288},[40,3664,105],{},[40,3666,3667],{"class":42,"line":294},[40,3668,190],{"emptyLinePlaceholder":189},[40,3670,3671],{"class":42,"line":299},[40,3672,3673],{},"final class SmsChannel implements NotificationChannel\n",[40,3675,3676],{"class":42,"line":305},[40,3677,76],{},[40,3679,3680],{"class":42,"line":310},[40,3681,81],{},[40,3683,3684],{"class":42,"line":1571},[40,3685,3686],{},"        private readonly SmsProvider $sms,\n",[40,3688,3689],{"class":42,"line":1577},[40,3690,3691],{},"        private readonly int $maxLength = 70,\n",[40,3693,3694],{"class":42,"line":1583},[40,3695,99],{},[40,3697,3698],{"class":42,"line":1589},[40,3699,190],{"emptyLinePlaceholder":189},[40,3701,3702],{"class":42,"line":1594},[40,3703,3599],{},[40,3705,3706],{"class":42,"line":1600},[40,3707,241],{},[40,3709,3710],{"class":42,"line":1606},[40,3711,3712],{},"        $text = \"{$subject}: \" . strip_tags($body);\n",[40,3714,3715],{"class":42,"line":1612},[40,3716,3717],{},"        $this->sms->send($recipient, mb_substr($text, 0, $this->maxLength));\n",[40,3719,3720],{"class":42,"line":1618},[40,3721,253],{},[40,3723,3724],{"class":42,"line":2895},[40,3725,105],{},[11,3727,3728,711,3731,1633,3734,3737,3738,3741,3742,3745],{},[15,3729,3730],{},"Mailer",[15,3732,3733],{},"SlackClient",[15,3735,3736],{},"SmsProvider"," stand for whatever clients the application already has. The SMS limit is a parameter for a reason: one segment holds 160 characters in the GSM-7 alphabet, but a single character outside it (Polish diacritics, for instance) switches the message to UCS-2, where a segment holds 70. ",[15,3739,3740],{},"mb_substr"," is needed for the same reason; ",[15,3743,3744],{},"substr"," counts bytes and can cut a multibyte character in half.",[11,3747,3748],{},"The abstraction side describes what to send and delegates delivery to a channel:",[31,3750,3752],{"className":33,"code":3751,"language":35,"meta":36,"style":36},"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",[15,3753,3754,3759,3763,3767,3772,3777,3782,3787,3791,3795,3799,3804,3808,3813,3817,3822,3826,3830,3835,3839,3843,3848,3853,3858,3863,3867,3871,3876,3880,3885,3890,3895,3900,3905,3910,3915,3920,3925,3930,3935,3939],{"__ignoreMap":36},[40,3755,3756],{"class":42,"line":43},[40,3757,3758],{},"final readonly class Payment\n",[40,3760,3761],{"class":42,"line":49},[40,3762,76],{},[40,3764,3765],{"class":42,"line":55},[40,3766,81],{},[40,3768,3769],{"class":42,"line":84},[40,3770,3771],{},"        public string $reference,\n",[40,3773,3774],{"class":42,"line":90},[40,3775,3776],{},"        public int $amountMinor,\n",[40,3778,3779],{"class":42,"line":96},[40,3780,3781],{},"        public string $currency,\n",[40,3783,3784],{"class":42,"line":102},[40,3785,3786],{},"        public DateTimeImmutable $paidAt,\n",[40,3788,3789],{"class":42,"line":193},[40,3790,99],{},[40,3792,3793],{"class":42,"line":199},[40,3794,105],{},[40,3796,3797],{"class":42,"line":204},[40,3798,190],{"emptyLinePlaceholder":189},[40,3800,3801],{"class":42,"line":210},[40,3802,3803],{},"abstract class Notification\n",[40,3805,3806],{"class":42,"line":216},[40,3807,76],{},[40,3809,3810],{"class":42,"line":222},[40,3811,3812],{},"    public function __construct(protected readonly NotificationChannel $channel) {}\n",[40,3814,3815],{"class":42,"line":227},[40,3816,190],{"emptyLinePlaceholder":189},[40,3818,3819],{"class":42,"line":232},[40,3820,3821],{},"    abstract public function send(string $recipient): void;\n",[40,3823,3824],{"class":42,"line":238},[40,3825,105],{},[40,3827,3828],{"class":42,"line":244},[40,3829,190],{"emptyLinePlaceholder":189},[40,3831,3832],{"class":42,"line":250},[40,3833,3834],{},"final class PaymentConfirmationNotification extends Notification\n",[40,3836,3837],{"class":42,"line":256},[40,3838,76],{},[40,3840,3841],{"class":42,"line":261},[40,3842,81],{},[40,3844,3845],{"class":42,"line":267},[40,3846,3847],{},"        NotificationChannel $channel,\n",[40,3849,3850],{"class":42,"line":272},[40,3851,3852],{},"        private readonly Payment $payment,\n",[40,3854,3855],{"class":42,"line":278},[40,3856,3857],{},"    ) {\n",[40,3859,3860],{"class":42,"line":283},[40,3861,3862],{},"        parent::__construct($channel);\n",[40,3864,3865],{"class":42,"line":288},[40,3866,253],{},[40,3868,3869],{"class":42,"line":294},[40,3870,190],{"emptyLinePlaceholder":189},[40,3872,3873],{"class":42,"line":299},[40,3874,3875],{},"    public function send(string $recipient): void\n",[40,3877,3878],{"class":42,"line":305},[40,3879,241],{},[40,3881,3882],{"class":42,"line":310},[40,3883,3884],{},"        $this->channel->send(\n",[40,3886,3887],{"class":42,"line":1571},[40,3888,3889],{},"            $recipient,\n",[40,3891,3892],{"class":42,"line":1577},[40,3893,3894],{},"            \"Payment confirmed: {$this->payment->reference}\",\n",[40,3896,3897],{"class":42,"line":1583},[40,3898,3899],{},"            sprintf(\n",[40,3901,3902],{"class":42,"line":1589},[40,3903,3904],{},"                \"Your payment of %s %s has been confirmed.\\nReference: %s\\nDate: %s\",\n",[40,3906,3907],{"class":42,"line":1594},[40,3908,3909],{},"                number_format($this->payment->amountMinor \u002F 100, 2),\n",[40,3911,3912],{"class":42,"line":1600},[40,3913,3914],{},"                $this->payment->currency,\n",[40,3916,3917],{"class":42,"line":1606},[40,3918,3919],{},"                $this->payment->reference,\n",[40,3921,3922],{"class":42,"line":1612},[40,3923,3924],{},"                $this->payment->paidAt->format('Y-m-d H:i'),\n",[40,3926,3927],{"class":42,"line":1618},[40,3928,3929],{},"            ),\n",[40,3931,3932],{"class":42,"line":2895},[40,3933,3934],{},"        );\n",[40,3936,3937],{"class":42,"line":2905},[40,3938,253],{},[40,3940,3941],{"class":42,"line":2916},[40,3942,105],{},[11,3944,3945],{},"The count is now M + N: four notification classes and three channel classes. A new channel is one class, a new notification type is one class, and neither touches the other hierarchy.",[23,3947,3949],{"id":3948},"where-the-two-sides-are-connected","Where the two sides are connected",[11,3951,3952],{},"The combination is chosen at construction time:",[31,3954,3956],{"className":33,"code":3955,"language":35,"meta":36,"style":36},"(new PaymentConfirmationNotification(new EmailChannel($mailer), $payment))\n    ->send($user->email);\n\n(new PaymentConfirmationNotification(new SmsChannel($smsProvider), $payment))\n    ->send($user->phone);\n",[15,3957,3958,3963,3968,3972,3977],{"__ignoreMap":36},[40,3959,3960],{"class":42,"line":43},[40,3961,3962],{},"(new PaymentConfirmationNotification(new EmailChannel($mailer), $payment))\n",[40,3964,3965],{"class":42,"line":49},[40,3966,3967],{},"    ->send($user->email);\n",[40,3969,3970],{"class":42,"line":55},[40,3971,190],{"emptyLinePlaceholder":189},[40,3973,3974],{"class":42,"line":84},[40,3975,3976],{},"(new PaymentConfirmationNotification(new SmsChannel($smsProvider), $payment))\n",[40,3978,3979],{"class":42,"line":90},[40,3980,3981],{},"    ->send($user->phone);\n",[11,3983,3984],{},"In an application this belongs in one place, typically a dispatcher that knows the available channels and how to build each notification type:",[31,3986,3988],{"className":33,"code":3987,"language":35,"meta":36,"style":36},"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",[15,3989,3990,3995,3999,4004,4009,4014,4019,4023,4028,4033,4037,4041,4046,4050,4055,4060,4064,4069,4074,4079,4083,4088,4092,4096],{"__ignoreMap":36},[40,3991,3992],{"class":42,"line":43},[40,3993,3994],{},"final class NotificationDispatcher\n",[40,3996,3997],{"class":42,"line":49},[40,3998,76],{},[40,4000,4001],{"class":42,"line":55},[40,4002,4003],{},"    \u002F**\n",[40,4005,4006],{"class":42,"line":84},[40,4007,4008],{},"     * @param array\u003Cstring, NotificationChannel> $channels\n",[40,4010,4011],{"class":42,"line":90},[40,4012,4013],{},"     * @param array\u003Cstring, Closure(NotificationChannel, array): Notification> $factories\n",[40,4015,4016],{"class":42,"line":96},[40,4017,4018],{},"     *\u002F\n",[40,4020,4021],{"class":42,"line":102},[40,4022,81],{},[40,4024,4025],{"class":42,"line":193},[40,4026,4027],{},"        private readonly array $channels,\n",[40,4029,4030],{"class":42,"line":199},[40,4031,4032],{},"        private readonly array $factories,\n",[40,4034,4035],{"class":42,"line":204},[40,4036,99],{},[40,4038,4039],{"class":42,"line":210},[40,4040,190],{"emptyLinePlaceholder":189},[40,4042,4043],{"class":42,"line":216},[40,4044,4045],{},"    public function dispatch(string $type, array $payload, User $user): void\n",[40,4047,4048],{"class":42,"line":222},[40,4049,241],{},[40,4051,4052],{"class":42,"line":227},[40,4053,4054],{},"        $factory = $this->factories[$type]\n",[40,4056,4057],{"class":42,"line":232},[40,4058,4059],{},"            ?? throw new InvalidArgumentException(\"Unknown notification type: {$type}\");\n",[40,4061,4062],{"class":42,"line":238},[40,4063,190],{"emptyLinePlaceholder":189},[40,4065,4066],{"class":42,"line":244},[40,4067,4068],{},"        foreach ($user->enabledChannels() as $name) {\n",[40,4070,4071],{"class":42,"line":250},[40,4072,4073],{},"            $channel = $this->channels[$name]\n",[40,4075,4076],{"class":42,"line":256},[40,4077,4078],{},"                ?? throw new InvalidArgumentException(\"Unknown channel: {$name}\");\n",[40,4080,4081],{"class":42,"line":261},[40,4082,190],{"emptyLinePlaceholder":189},[40,4084,4085],{"class":42,"line":267},[40,4086,4087],{},"            $factory($channel, $payload)->send($user->contactFor($name));\n",[40,4089,4090],{"class":42,"line":272},[40,4091,353],{},[40,4093,4094],{"class":42,"line":278},[40,4095,253],{},[40,4097,4098],{"class":42,"line":283},[40,4099,105],{},[11,4101,4102,1633,4105,4108,4109,4112],{},[15,4103,4104],{},"enabledChannels()",[15,4106,4107],{},"contactFor()"," are methods on your own ",[15,4110,4111],{},"User"," model. The dispatcher is the only class that knows both hierarchies.",[23,4114,4116],{"id":4115},"when-the-dimensions-are-not-independent","When the dimensions are not independent",[11,4118,4119],{},"Bridge assumes that every notification makes sense on every channel. The assumption breaks when a type has content that only fits one channel, such as a weekly report rendered as an HTML table. The symptom is a type check in the abstraction:",[31,4121,4123],{"className":33,"code":4122,"language":35,"meta":36,"style":36},"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        $this->channel->send($recipient, 'Weekly report', $this->renderHtmlReport());\n    }\n}\n",[15,4124,4125,4130,4134,4138,4142,4147,4152,4157,4161,4165,4170,4174],{"__ignoreMap":36},[40,4126,4127],{"class":42,"line":43},[40,4128,4129],{},"final class WeeklyReportNotification extends Notification\n",[40,4131,4132],{"class":42,"line":49},[40,4133,76],{},[40,4135,4136],{"class":42,"line":55},[40,4137,3875],{},[40,4139,4140],{"class":42,"line":84},[40,4141,241],{},[40,4143,4144],{"class":42,"line":90},[40,4145,4146],{},"        if ($this->channel instanceof SmsChannel) {\n",[40,4148,4149],{"class":42,"line":96},[40,4150,4151],{},"            $this->channel->send($recipient, 'Weekly report', 'The full report was sent by email.');\n",[40,4153,4154],{"class":42,"line":102},[40,4155,4156],{},"            return;\n",[40,4158,4159],{"class":42,"line":193},[40,4160,353],{},[40,4162,4163],{"class":42,"line":199},[40,4164,190],{"emptyLinePlaceholder":189},[40,4166,4167],{"class":42,"line":204},[40,4168,4169],{},"        $this->channel->send($recipient, 'Weekly report', $this->renderHtmlReport());\n",[40,4171,4172],{"class":42,"line":210},[40,4173,253],{},[40,4175,4176],{"class":42,"line":216},[40,4177,105],{},[11,4179,4180,4181,4184],{},"The ",[15,4182,4183],{},"instanceof"," means the notification depends on a concrete channel, which is exactly what the pattern was supposed to prevent. Each new channel now requires checking every notification for such branches.",[11,4186,4187],{},"There are two reasonable responses:",[124,4189,4190,4200],{},[127,4191,4192,4193,1633,4196,4199],{},"Accept that this case is M × N and write separate classes: ",[15,4194,4195],{},"WeeklyReportEmailNotification",[15,4197,4198],{},"WeeklyReportSmsSummaryNotification",". Explicit per-channel classes are easier to read than a Bridge with exceptions.",[127,4201,4202],{},"If many types need channel-specific variants, change the contract. The notification builds a message object with several representations (full body, short text), and each channel picks the one it can deliver. The channel then decides, and the notification no longer needs to know which channel it is talking to.",[11,4204,4205,4206,4209,4210,711,4213,4216],{},"For comparison, Laravel's notification system sits between these options: a notification class declares its channels in ",[15,4207,4208],{},"via()"," and implements a method per channel (",[15,4211,4212],{},"toMail()",[15,4214,4215],{},"toArray()"," and so on). That is up to M × N methods inside M classes, a deliberate choice for a framework where content differs per channel more often than not.",[23,4218,4219],{"id":759},"Testing",[11,4221,4222],{},"Each hierarchy can be tested without the other. A channel is tested against a mocked client, a notification against a mocked channel:",[31,4224,4226],{"className":33,"code":4225,"language":35,"meta":36,"style":36},"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('alice@example.com', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n\n        (new EmailChannel($mailer))->send('alice@example.com', '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                'alice@example.com',\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('alice@example.com');\n    }\n}\n",[15,4227,4228,4233,4237,4242,4246,4251,4255,4260,4265,4270,4275,4279,4284,4288,4292,4296,4301,4305,4310,4314,4319,4324,4328,4333,4338,4343,4348,4353,4357,4362,4366,4371,4375],{"__ignoreMap":36},[40,4229,4230],{"class":42,"line":43},[40,4231,4232],{},"use PHPUnit\\Framework\\TestCase;\n",[40,4234,4235],{"class":42,"line":49},[40,4236,190],{"emptyLinePlaceholder":189},[40,4238,4239],{"class":42,"line":55},[40,4240,4241],{},"final class EmailChannelTest extends TestCase\n",[40,4243,4244],{"class":42,"line":84},[40,4245,76],{},[40,4247,4248],{"class":42,"line":90},[40,4249,4250],{},"    public function testDelegatesToMailer(): void\n",[40,4252,4253],{"class":42,"line":96},[40,4254,241],{},[40,4256,4257],{"class":42,"line":102},[40,4258,4259],{},"        $mailer = $this->createMock(Mailer::class);\n",[40,4261,4262],{"class":42,"line":193},[40,4263,4264],{},"        $mailer->expects($this->once())\n",[40,4266,4267],{"class":42,"line":199},[40,4268,4269],{},"            ->method('send')\n",[40,4271,4272],{"class":42,"line":204},[40,4273,4274],{},"            ->with('alice@example.com', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n",[40,4276,4277],{"class":42,"line":210},[40,4278,190],{"emptyLinePlaceholder":189},[40,4280,4281],{"class":42,"line":216},[40,4282,4283],{},"        (new EmailChannel($mailer))->send('alice@example.com', 'Test', '\u003Cp>Body\u003C\u002Fp>');\n",[40,4285,4286],{"class":42,"line":222},[40,4287,253],{},[40,4289,4290],{"class":42,"line":227},[40,4291,105],{},[40,4293,4294],{"class":42,"line":232},[40,4295,190],{"emptyLinePlaceholder":189},[40,4297,4298],{"class":42,"line":238},[40,4299,4300],{},"final class PaymentConfirmationNotificationTest extends TestCase\n",[40,4302,4303],{"class":42,"line":244},[40,4304,76],{},[40,4306,4307],{"class":42,"line":250},[40,4308,4309],{},"    public function testBuildsSubjectAndBody(): void\n",[40,4311,4312],{"class":42,"line":256},[40,4313,241],{},[40,4315,4316],{"class":42,"line":261},[40,4317,4318],{},"        $channel = $this->createMock(NotificationChannel::class);\n",[40,4320,4321],{"class":42,"line":267},[40,4322,4323],{},"        $channel->expects($this->once())\n",[40,4325,4326],{"class":42,"line":272},[40,4327,4269],{},[40,4329,4330],{"class":42,"line":278},[40,4331,4332],{},"            ->with(\n",[40,4334,4335],{"class":42,"line":283},[40,4336,4337],{},"                'alice@example.com',\n",[40,4339,4340],{"class":42,"line":288},[40,4341,4342],{},"                $this->stringContains('PAY-2024-001'),\n",[40,4344,4345],{"class":42,"line":294},[40,4346,4347],{},"                $this->stringContains('100.00'),\n",[40,4349,4350],{"class":42,"line":299},[40,4351,4352],{},"            );\n",[40,4354,4355],{"class":42,"line":305},[40,4356,190],{"emptyLinePlaceholder":189},[40,4358,4359],{"class":42,"line":310},[40,4360,4361],{},"        $payment = new Payment('PAY-2024-001', 10000, 'PLN', new DateTimeImmutable('2024-01-15 10:00'));\n",[40,4363,4364],{"class":42,"line":1571},[40,4365,190],{"emptyLinePlaceholder":189},[40,4367,4368],{"class":42,"line":1577},[40,4369,4370],{},"        (new PaymentConfirmationNotification($channel, $payment))->send('alice@example.com');\n",[40,4372,4373],{"class":42,"line":1583},[40,4374,253],{},[40,4376,4377],{"class":42,"line":1589},[40,4378,105],{},[11,4380,4381],{},"The number of tests grows as M + N, the same as the number of classes. No test needs a real mail server or the Slack API.",[23,4383,4385],{"id":4384},"when-to-use-it-and-when-not","When to use it, and when not",[11,4387,4388],{},"Use Bridge when:",[703,4390,4391,4394,4407],{},[127,4392,4393],{},"there are two dimensions that both change over time (new channels and new message types, new export formats and new report types),",[127,4395,4396,4397,711,4400,711,4403,4406],{},"class names start combining two concepts: ",[15,4398,4399],{},"PaymentEmailNotification",[15,4401,4402],{},"PdfInvoiceExporter",[15,4404,4405],{},"CsvAuditLogFormatter",",",[127,4408,4409],{},"almost every combination is valid.",[11,4411,4412],{},"Do not use it when:",[703,4414,4415,4418,4421],{},[127,4416,4417],{},"only one dimension varies. With a single channel, an interface on the notification side is enough,",[127,4419,4420],{},"many combinations need special handling. Then the problem is M × N, and explicit classes or per-channel methods describe it more honestly,",[127,4422,4423],{},"there are two or three combinations in total. The extra indirection costs more than the duplication it removes.",[729,4425,731],{},{"title":36,"searchDepth":49,"depth":49,"links":4427},[4428,4429,4430,4431,4432,4433],{"id":3524,"depth":49,"text":3525},{"id":3545,"depth":49,"text":3546},{"id":3948,"depth":49,"text":3949},{"id":4115,"depth":49,"text":4116},{"id":759,"depth":49,"text":4219},{"id":4384,"depth":49,"text":4385},"2023-10-19",{},"\u002Farticles\u002Fbridge-pattern",{"x":4438,"y":4439,"depth":3500,"size":743},0.86,0.18,[4441,4442],"factory-method","design-patterns-production",{"title":3515,"description":3521},"notification-hub","articles\u002Fbridge-pattern",[35,4447,4448,4449,4450,4451],"design-patterns","bridge","architecture","notifications","abstraction","eaNDcCKJgXvfRSffvKGP80oG2B48nOw100rlfVRQ9PI",{"id":4454,"title":4455,"articleId":4456,"body":4457,"category":3493,"codeLang":2517,"date":4988,"deploys":43,"description":4461,"excerpt":742,"extension":743,"lang":742,"meta":4989,"navigation":189,"path":4990,"pos":4991,"readMin":102,"related":4994,"seo":4996,"service":4997,"stem":4998,"tags":4999,"version":2161,"__hash__":5004},"articles\u002Farticles\u002Fcdn-cached-fallback.md","Cloudflare cached HTML instead of CSS: notes from moving a static site","cdn-cached-fallback",{"type":8,"value":4458,"toc":4980},[4459,4462,4466,4473,4506,4509,4515,4530,4567,4570,4574,4577,4580,4594,4597,4604,4608,4615,4707,4714,4721,4756,4759,4763,4781,4788,4808,4821,4831,4846,4849,4921,4924,4928,4943,4947,4977],[11,4460,4461],{},"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. Below is where that mismatch came from and what changed so it does not happen again.",[23,4463,4465],{"id":4464},"symptom-curl-says-200-the-browser-does-not-load-css","Symptom: curl says 200, the browser does not load CSS",[11,4467,4468,4469,4472],{},"The first check looked good. The HTML referenced ",[15,4470,4471],{},"\u002F_nuxt\u002Fentry.Czy_6vul.css"," and that file answered correctly:",[31,4474,4476],{"className":2515,"code":4475,"language":2517,"meta":36,"style":36},"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",[15,4477,4478,4501],{"__ignoreMap":36},[40,4479,4480,4483,4486,4489,4492,4495,4498],{"class":42,"line":43},[40,4481,4482],{"class":2524},"curl",[40,4484,4485],{"class":2345}," -s",[40,4487,4488],{"class":2345}," -o",[40,4490,4491],{"class":2229}," \u002Fdev\u002Fnull",[40,4493,4494],{"class":2345}," -w",[40,4496,4497],{"class":2229}," '%{http_code} %{content_type}\\n'",[40,4499,4500],{"class":2229}," https:\u002F\u002Ftkulesza.eu\u002F_nuxt\u002Fentry.Czy_6vul.css\n",[40,4502,4503],{"class":42,"line":49},[40,4504,4505],{"class":3206},"# 200 text\u002Fcss\n",[11,4507,4508],{},"A headless browser (puppeteer) showed what was actually happening. The console had this error:",[31,4510,4513],{"className":4511,"code":4512,"language":3534,"meta":36},[3532],"Failed to load module script: Expected a JavaScript-or-Wasm module script\nbut the server responded with a MIME type of \"text\u002Fhtml\".\n",[15,4514,4512],{"__ignoreMap":36},[11,4516,4517,4518,4521,4522,4525,4526,4529],{},"After adding a listener that logs ",[15,4519,4520],{},"_nuxt"," responses whose ",[15,4523,4524],{},"Content-Type"," contains ",[15,4527,4528],{},"html",", it turned out that the same CSS URL and several JS modules reached the browser as HTML, with status 200:",[31,4531,4535],{"className":4532,"code":4533,"language":4534,"meta":36,"style":36},"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",[15,4536,4537,4542,4547,4552,4557,4562],{"__ignoreMap":36},[40,4538,4539],{"class":42,"line":43},[40,4540,4541],{},"page.on('response', r => {\n",[40,4543,4544],{"class":42,"line":49},[40,4545,4546],{},"  const ct = r.headers()['content-type'] || ''\n",[40,4548,4549],{"class":42,"line":55},[40,4550,4551],{},"  if (r.url().includes('\u002F_nuxt\u002F') && ct.includes('html')) {\n",[40,4553,4554],{"class":42,"line":84},[40,4555,4556],{},"    console.log('BAD', r.status(), ct, r.url())\n",[40,4558,4559],{"class":42,"line":90},[40,4560,4561],{},"  }\n",[40,4563,4564],{"class":42,"line":96},[40,4565,4566],{},"})\n",[11,4568,4569],{},"The status was right and the type was wrong. A check that looks only at the status code will not catch this.",[23,4571,4573],{"id":4572},"cause-a-200-fallback-and-caching-by-extension","Cause: a 200 fallback and caching by extension",[11,4575,4576],{},"Two things combined.",[11,4578,4579],{},"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. For a single-page application this is sometimes intended. For a static site with prerendered files it is harmful.",[11,4581,4582,4583,711,4586,4589,4590,4593],{},"The second is Cloudflare. By default it caches responses based on the extension in the URL (",[15,4584,4585],{},".css",[15,4587,4588],{},".js",", images), not on the content type the origin returned. If the origin answers ",[15,4591,4592],{},"entry.Czy_6vul.css"," with HTML and status 200, Cloudflare stores that HTML under that URL and keeps serving it.",[11,4595,4596],{},"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,4598,4599,4600,4603],{},"Why did curl get the correct file? The browser and curl sent different headers (the browser asks for compression, among other things) and hit different cache entries. Both responses had ",[15,4601,4602],{},"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.",[23,4605,4607],{"id":4606},"fix","Fix",[11,4609,4610,4611,4614],{},"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 ",[15,4612,4613],{},".htaccess",", in which a missing file ends in a 404:",[31,4616,4620],{"className":4617,"code":4618,"language":4619,"meta":36,"style":36},"language-apache shiki shiki-themes github-light github-dark","DirectorySlash 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",[15,4621,4622,4627,4632,4637,4641,4646,4650,4655,4660,4665,4669,4674,4678,4683,4687,4692,4697,4702],{"__ignoreMap":36},[40,4623,4624],{"class":42,"line":43},[40,4625,4626],{},"DirectorySlash Off\n",[40,4628,4629],{"class":42,"line":49},[40,4630,4631],{},"DirectoryIndex index.html\n",[40,4633,4634],{"class":42,"line":55},[40,4635,4636],{},"ErrorDocument 404 \u002F404.html\n",[40,4638,4639],{"class":42,"line":84},[40,4640,190],{"emptyLinePlaceholder":189},[40,4642,4643],{"class":42,"line":90},[40,4644,4645],{},"RewriteEngine On\n",[40,4647,4648],{"class":42,"line":96},[40,4649,190],{"emptyLinePlaceholder":189},[40,4651,4652],{"class":42,"line":102},[40,4653,4654],{},"# \u002Fx\u002F → \u002Fx when a prerendered page exists\n",[40,4656,4657],{"class":42,"line":193},[40,4658,4659],{},"RewriteCond %{DOCUMENT_ROOT}\u002F$1\u002Findex.html -f\n",[40,4661,4662],{"class":42,"line":199},[40,4663,4664],{},"RewriteRule ^(.+)\u002F$ https:\u002F\u002Ftkulesza.eu\u002F$1 [R=301,L,NE]\n",[40,4666,4667],{"class":42,"line":204},[40,4668,190],{"emptyLinePlaceholder":189},[40,4670,4671],{"class":42,"line":210},[40,4672,4673],{},"# \u002Fx → \u002Fx\u002Findex.html without a redirect\n",[40,4675,4676],{"class":42,"line":216},[40,4677,4659],{},[40,4679,4680],{"class":42,"line":222},[40,4681,4682],{},"RewriteRule ^(.+)$ $1\u002Findex.html [L]\n",[40,4684,4685],{"class":42,"line":227},[40,4686,190],{"emptyLinePlaceholder":189},[40,4688,4689],{"class":42,"line":232},[40,4690,4691],{},"# Anything else that is not on disk → 404\n",[40,4693,4694],{"class":42,"line":238},[40,4695,4696],{},"RewriteCond %{REQUEST_FILENAME} !-f\n",[40,4698,4699],{"class":42,"line":244},[40,4700,4701],{},"RewriteCond %{REQUEST_FILENAME} !-d\n",[40,4703,4704],{"class":42,"line":250},[40,4705,4706],{},"RewriteRule ^ - [R=404,L]\n",[11,4708,4709,4710,4713],{},"I first tried ",[15,4711,4712],{},"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 works regardless of what the host has configured.",[11,4715,4716,4717,4720],{},"Hashed files also got a header that lets browsers and the CDN keep them for a long time. The exception is ",[15,4718,4719],{},"_nuxt\u002Fbuilds\u002F",", where Nuxt keeps a file with the current build id:",[31,4722,4724],{"className":4617,"code":4723,"language":4619,"meta":36,"style":36},"\u003CIf \"%{REQUEST_URI} =~ m#^\u002F_nuxt\u002F# && %{REQUEST_URI} !~ m#^\u002F_nuxt\u002Fbuilds\u002F#\">\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",[15,4725,4726,4731,4736,4741,4746,4751],{"__ignoreMap":36},[40,4727,4728],{"class":42,"line":43},[40,4729,4730],{},"\u003CIf \"%{REQUEST_URI} =~ m#^\u002F_nuxt\u002F# && %{REQUEST_URI} !~ m#^\u002F_nuxt\u002Fbuilds\u002F#\">\n",[40,4732,4733],{"class":42,"line":49},[40,4734,4735],{},"  Header always set Cache-Control \"public, max-age=31536000, immutable\"\n",[40,4737,4738],{"class":42,"line":55},[40,4739,4740],{},"\u003C\u002FIf>\n",[40,4742,4743],{"class":42,"line":84},[40,4744,4745],{},"\u003CElse>\n",[40,4747,4748],{"class":42,"line":90},[40,4749,4750],{},"  Header always set Cache-Control \"no-cache\"\n",[40,4752,4753],{"class":42,"line":96},[40,4754,4755],{},"\u003C\u002FElse>\n",[11,4757,4758],{},"Upload order matters too. The deploy script now uploads the hashed files first and the HTML that references them second, so there is no moment in which the HTML points at a file that is not there yet.",[23,4760,4762],{"id":4761},"second-issue-allowing-traffic-only-through-cloudflare","Second issue: allowing traffic only through Cloudflare",[11,4764,4765,4766,4769,4770,4777,4778,1751],{},"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 ",[15,4767,4768],{},"Host"," header straight to that address. The usual answer is to allow only the addresses on ",[4771,4772,4776],"a",{"href":4773,"rel":4774},"https:\u002F\u002Fwww.cloudflare.com\u002Fips\u002F",[4775],"nofollow","Cloudflare's list",", for example with ",[15,4779,4780],{},"Require ip",[11,4782,4783,4784,4787],{},"Before uploading such a rule I checked which address the server sees. A temporary PHP script printed ",[15,4785,4786],{},"REMOTE_ADDR"," for a request made through Cloudflare:",[31,4789,4791],{"className":33,"code":4790,"language":35,"meta":36,"style":36},"\u003C?php\nheader('Content-Type: text\u002Fplain');\necho $_SERVER['REMOTE_ADDR'] ?? '-', ' | ', $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '-';\n",[15,4792,4793,4798,4803],{"__ignoreMap":36},[40,4794,4795],{"class":42,"line":43},[40,4796,4797],{},"\u003C?php\n",[40,4799,4800],{"class":42,"line":49},[40,4801,4802],{},"header('Content-Type: text\u002Fplain');\n",[40,4804,4805],{"class":42,"line":55},[40,4806,4807],{},"echo $_SERVER['REMOTE_ADDR'] ?? '-', ' | ', $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '-';\n",[11,4809,4810,4811,4814,4815,4817,4818,4820],{},"It printed the same address twice: mine, not Cloudflare's. The host rewrites the client address (in Apache this is ",[15,4812,4813],{},"mod_remoteip","), so ",[15,4816,4786],{}," already holds the visitor's IP. A ",[15,4819,4780],{}," 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,4822,4823,4824,4827,4828,4830],{},"Apache exposes ",[15,4825,4826],{},"CONN_REMOTE_ADDR"," in expressions: the address of the actual TCP connection, which ",[15,4829,4813],{}," does not touch. The rule checks that variable:",[31,4832,4834],{"className":4617,"code":4833,"language":4619,"meta":36,"style":36},"RewriteCond expr \"!(%{CONN_REMOTE_ADDR} -ipmatch '173.245.48.0\u002F20' || %{CONN_REMOTE_ADDR} -ipmatch '103.21.244.0\u002F22' || ...)\"\nRewriteRule ^ - [F,L]\n",[15,4835,4836,4841],{"__ignoreMap":36},[40,4837,4838],{"class":42,"line":43},[40,4839,4840],{},"RewriteCond expr \"!(%{CONN_REMOTE_ADDR} -ipmatch '173.245.48.0\u002F20' || %{CONN_REMOTE_ADDR} -ipmatch '103.21.244.0\u002F22' || ...)\"\n",[40,4842,4843],{"class":42,"line":49},[40,4844,4845],{},"RewriteRule ^ - [F,L]\n",[11,4847,4848],{},"The list has 15 IPv4 and 7 IPv6 ranges. After uploading I checked both paths:",[31,4850,4852],{"className":2515,"code":4851,"language":2517,"meta":36,"style":36},"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",[15,4853,4854,4872,4877,4916],{"__ignoreMap":36},[40,4855,4856,4858,4860,4862,4864,4866,4869],{"class":42,"line":43},[40,4857,4482],{"class":2524},[40,4859,4485],{"class":2345},[40,4861,4488],{"class":2345},[40,4863,4491],{"class":2229},[40,4865,4494],{"class":2345},[40,4867,4868],{"class":2229}," '%{http_code}\\n'",[40,4870,4871],{"class":2229}," https:\u002F\u002Ftkulesza.eu\u002F\n",[40,4873,4874],{"class":42,"line":49},[40,4875,4876],{"class":3206},"# 200\n",[40,4878,4879,4881,4884,4887,4890,4894,4897,4900,4903,4906,4908,4910,4912,4914],{"class":42,"line":55},[40,4880,4482],{"class":2524},[40,4882,4883],{"class":2345}," -sk",[40,4885,4886],{"class":2345}," --resolve",[40,4888,4889],{"class":2229}," tkulesza.eu:443:",[40,4891,4893],{"class":4892},"szBVR","\u003C",[40,4895,4896],{"class":2229},"host",[40,4898,4899],{"class":2229}," I",[40,4901,4902],{"class":2218},"P",[40,4904,4905],{"class":4892},">",[40,4907,4488],{"class":2345},[40,4909,4491],{"class":2229},[40,4911,4494],{"class":2345},[40,4913,4868],{"class":2229},[40,4915,4871],{"class":2229},[40,4917,4918],{"class":42,"line":84},[40,4919,4920],{"class":3206},"# 403\n",[11,4922,4923],{},"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.",[23,4925,4927],{"id":4926},"a-note-on-scanning-your-own-site","A note on scanning your own site",[11,4929,4930,4931,4934,4935,4938,4939,4942],{},"While checking whether files such as ",[15,4932,4933],{},".git\u002Fconfig"," or ",[15,4936,4937],{},".env"," could be fetched from outside, I also requested a few common paths, ",[15,4940,4941],{},"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. If your host has this kind of protection and your home network and server share an outgoing address, keep it in mind.",[23,4944,4946],{"id":4945},"checklist-for-moving-a-static-site-behind-a-cdn","Checklist for moving a static site behind a CDN",[703,4948,4949,4952,4962,4965,4968,4971],{},[127,4950,4951],{},"A missing file must return 404, not the home page with status 200. Test it on a URL that certainly does not exist.",[127,4953,4954,4955,4957,4958,4961],{},"Check ",[15,4956,4524],{},", not only the status code. A stylesheet served as ",[15,4959,4960],{},"text\u002Fhtml"," still has status 200.",[127,4963,4964],{},"Test in a real browser or with puppeteer. Curl sends different headers and can hit a different cache entry.",[127,4966,4967],{},"After switching directory or server, purge the CDN cache before calling the deploy done.",[127,4969,4970],{},"Upload hashed files before the HTML that references them.",[127,4972,4973,4974,4976],{},"Before restricting access to CDN addresses, check whether ",[15,4975,4786],{}," has already been rewritten to the client address.",[729,4978,4979],{},"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 .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 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":36,"searchDepth":49,"depth":49,"links":4981},[4982,4983,4984,4985,4986,4987],{"id":4464,"depth":49,"text":4465},{"id":4572,"depth":49,"text":4573},{"id":4606,"depth":49,"text":4607},{"id":4761,"depth":49,"text":4762},{"id":4926,"depth":49,"text":4927},{"id":4945,"depth":49,"text":4946},"2026-10-06",{},"\u002Farticles\u002Fcdn-cached-fallback",{"x":4992,"y":4993,"depth":43,"size":743},0.28,0.14,[2166,4995],"dday-go-live",{"title":4455,"description":4461},"edge-cache","articles\u002Fcdn-cached-fallback",[5000,4619,5001,5002,5003],"cloudflare","caching","deployment","htaccess","qnVRTjQH0g6_QUbrQebnRG8d98GOtVzS-rysCDl55sE",{"id":5006,"title":5007,"articleId":4995,"body":5008,"category":5280,"codeLang":742,"date":5281,"deploys":43,"description":5012,"excerpt":742,"extension":743,"lang":742,"meta":5282,"navigation":189,"path":5283,"pos":5284,"readMin":96,"related":5286,"seo":5287,"service":5288,"stem":5289,"tags":5290,"version":760,"__hash__":5294},"articles\u002Farticles\u002Fdday-go-live.md","Go\u002Fno-go on 6 June 1944: what a release decision is made of",{"type":8,"value":5009,"toc":5272},[5010,5013,5016,5020,5023,5026,5030,5033,5036,5198,5206,5210,5213,5216,5227,5231,5234,5237,5241,5244,5247,5249,5269],[11,5011,5012],{},"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.",[11,5014,5015],{},"The decision is a well-documented example of a go\u002Fno-go call, and its structure maps directly onto releasing software: a window you do not control, criteria agreed before the day, incomplete and contradictory data, one accountable decision-maker, and a cost of waiting that has to be counted, not assumed to be zero.",[23,5017,5019],{"id":5018},"the-window-was-fixed-by-constraints-not-by-the-plan","The window was fixed by constraints, not by the plan",[11,5021,5022],{},"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 conditions 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,5024,5025],{},"Releases have the same kind of window, and it is 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. The practical consequence is to write the window down as a constraint early, together with the next window after it. \"If we miss Thursday, the next slot is after the freeze on the 19th\" is information the decision-maker needs on the day, not afterwards.",[23,5027,5029],{"id":5028},"the-criteria-existed-before-the-forecast","The criteria existed before the forecast",[11,5031,5032],{},"The planners did not 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,5034,5035],{},"That separation is worth copying. If the criteria are defined on the morning of the release, they will be bent to fit the deadline. Define them when the change is planned and keep them next to the change. Example of such a checklist (the format is illustrative, not a specific tool):",[31,5037,5039],{"className":2209,"code":5038,"language":2211,"meta":36,"style":36},"release: billing-v4\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",[15,5040,5041,5051,5058,5068,5078,5085,5097,5108,5119,5130,5141,5152,5164,5176,5188],{"__ignoreMap":36},[40,5042,5043,5046,5048],{"class":42,"line":43},[40,5044,5045],{"class":2222},"release",[40,5047,2226],{"class":2218},[40,5049,5050],{"class":2229},"billing-v4\n",[40,5052,5053,5056],{"class":42,"line":49},[40,5054,5055],{"class":2222},"window",[40,5057,2248],{"class":2218},[40,5059,5060,5063,5065],{"class":42,"line":55},[40,5061,5062],{"class":2222},"  primary",[40,5064,2226],{"class":2218},[40,5066,5067],{"class":2229},"2026-06-18T07:00\u002F10:00\n",[40,5069,5070,5073,5075],{"class":42,"line":84},[40,5071,5072],{"class":2222},"  next",[40,5074,2226],{"class":2218},[40,5076,5077],{"class":2229},"2026-06-25T07:00\u002F10:00\n",[40,5079,5080,5083],{"class":42,"line":90},[40,5081,5082],{"class":2222},"go_criteria",[40,5084,2248],{"class":2218},[40,5086,5087,5090,5093,5095],{"class":42,"line":96},[40,5088,5089],{"class":2218},"  - ",[40,5091,5092],{"class":2222},"ci_green_on_release_commit",[40,5094,2226],{"class":2218},[40,5096,2346],{"class":2345},[40,5098,5099,5101,5104,5106],{"class":42,"line":102},[40,5100,5089],{"class":2218},[40,5102,5103],{"class":2222},"migration_tested_on_prod_size_copy",[40,5105,2226],{"class":2218},[40,5107,2346],{"class":2345},[40,5109,5110,5112,5115,5117],{"class":42,"line":193},[40,5111,5089],{"class":2218},[40,5113,5114],{"class":2222},"migration_duration_max_minutes",[40,5116,2226],{"class":2218},[40,5118,2892],{"class":2345},[40,5120,5121,5123,5126,5128],{"class":42,"line":199},[40,5122,5089],{"class":2218},[40,5124,5125],{"class":2222},"rollback_rehearsed",[40,5127,2226],{"class":2218},[40,5129,2346],{"class":2345},[40,5131,5132,5134,5137,5139],{"class":42,"line":204},[40,5133,5089],{"class":2218},[40,5135,5136],{"class":2222},"error_rate_baseline_known",[40,5138,2226],{"class":2218},[40,5140,2346],{"class":2345},[40,5142,5143,5146,5149],{"class":42,"line":210},[40,5144,5145],{"class":2222},"abort_criteria",[40,5147,5148],{"class":2218},":            ",[40,5150,5151],{"class":3206},"# evaluated during the canary\n",[40,5153,5154,5156,5159,5161],{"class":42,"line":216},[40,5155,5089],{"class":2218},[40,5157,5158],{"class":2222},"error_rate_above_baseline_pct",[40,5160,2226],{"class":2218},[40,5162,5163],{"class":2345},"50\n",[40,5165,5166,5168,5171,5173],{"class":42,"line":222},[40,5167,5089],{"class":2218},[40,5169,5170],{"class":2222},"p95_latency_above_ms",[40,5172,2226],{"class":2218},[40,5174,5175],{"class":2345},"800\n",[40,5177,5178,5180,5183,5185],{"class":42,"line":227},[40,5179,5089],{"class":2218},[40,5181,5182],{"class":2222},"failed_payment_jobs",[40,5184,2226],{"class":2218},[40,5186,5187],{"class":2345},"1\n",[40,5189,5190,5193,5195],{"class":42,"line":232},[40,5191,5192],{"class":2222},"owner",[40,5194,2226],{"class":2218},[40,5196,5197],{"class":2229},"one named person\n",[11,5199,4180,5200,5202,5203,5205],{},[15,5201,5145],{}," block matters as much as the ",[15,5204,5082],{},". The postponement on 4 June was the process working: the conditions were outside the limits, so the answer was no.",[23,5207,5209],{"id":5208},"better-decisions-came-from-wider-data","Better decisions came from wider data",[11,5211,5212],{},"German forecasters saw the same storm and concluded that the stormy weather would last for about two weeks, too rough for a landing. They were not careless; they lacked observations from the Atlantic. 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 travelling to a staff exercise in Rennes.",[11,5214,5215],{},"For releases this means that a go\u002Fno-go can only be as good as the signals it is based on. In practice:",[703,5217,5218,5221,5224],{},[127,5219,5220],{},"You cannot judge a canary without a baseline. Know the normal error rate and latency of the affected endpoints before the deploy.",[127,5222,5223],{},"Measure the risky parts directly. A migration timed on a production-sized copy is data; \"it ran quickly on staging\" is not.",[127,5225,5226],{},"Look at business signals, not only technical ones. A payment flow can return 200 and still create no orders.",[23,5228,5230],{"id":5229},"conflicting-forecasts-need-one-owner","Conflicting forecasts need one owner",[11,5232,5233],{},"Stagg did not receive 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,5235,5236],{},"Release data is often contradictory in the same way: load tests pass, but one dependency's error budget is nearly spent; the feature works, but support reports something unexplained. The process should make two things explicit. First, who aggregates the signals and presents them. Second, who makes the call, by name. A decision made by a group chat at 16:55 tends to be a decision that nobody owns when it has to be reversed.",[23,5238,5240],{"id":5239},"waiting-also-has-a-cost","Waiting also has a cost",[11,5242,5243],{},"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 harbour off Omaha Beach. Had the landing been moved to that window, it would have run into the worst weather of the month.",[11,5245,5246],{},"This is hindsight; nobody could forecast that storm on 5 June. The point is narrower: postponing is a decision with its own risks, and those risks should be listed 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. Sometimes waiting is still right. It should be chosen for stated reasons, not as the default because it feels safer.",[23,5248,701],{"id":700},[703,5250,5251,5254,5257,5260,5263,5266],{},[127,5252,5253],{},"Write down the release window and the next one after it.",[127,5255,5256],{},"Agree go and abort criteria when the change is planned, not on release day.",[127,5258,5259],{},"Have a baseline for every metric you intend to watch during the rollout.",[127,5261,5262],{},"Rehearse the rollback before you need it.",[127,5264,5265],{},"Name one person who decides, and record why the decision was go or no-go.",[127,5267,5268],{},"List the cost of postponing next to the risk of shipping.",[729,5270,5271],{},"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":36,"searchDepth":49,"depth":49,"links":5273},[5274,5275,5276,5277,5278,5279],{"id":5018,"depth":49,"text":5019},{"id":5028,"depth":49,"text":5029},{"id":5208,"depth":49,"text":5209},{"id":5229,"depth":49,"text":5230},{"id":5239,"depth":49,"text":5240},{"id":700,"depth":49,"text":701},"arch","2026-06-06",{},"\u002Farticles\u002Fdday-go-live",{"x":3500,"y":5285,"depth":43,"size":743},0.85,[2152,4442],{"title":5007,"description":5012},"go-no-go","articles\u002Fdday-go-live",[5291,5002,5292,5293,1926],"go-live","decision-making","release-management","RGDEqrw75pwMN1lBotxttrHPD1Tu2mpEH-cFJa7CJNc",{"id":5296,"title":5297,"articleId":4442,"body":5298,"category":35,"codeLang":35,"date":6035,"deploys":43,"description":6036,"excerpt":742,"extension":743,"lang":742,"meta":6037,"navigation":189,"path":6038,"pos":6039,"readMin":199,"related":6042,"seo":6044,"service":6045,"stem":6046,"tags":6047,"version":760,"__hash__":6050},"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":5299,"toc":6017},[5300,5307,5310,5313,5317,5323,5391,5394,5398,5416,5425,5431,5437,5465,5469,5473,5476,5585,5596,5599,5602,5693,5696,5721,5728,5738,5742,5752,5756,5759,5763,5767,5778,5836,5839,5861,5864,5867,5944,5950,5952,5959,5963,5966,5970,5976,5982,5988,5992,6015],[11,5301,5302,5303,5306],{},"A design pattern is a name for a recurring shape of code. Its main value is communication: when a reviewer reads ",[15,5304,5305],{},"CachingUserRepository implements UserRepositoryInterface",", the word \"decorator\" tells them how the class is wired, what it can and cannot do, and how to test it, before they read a line of the body.",[11,5308,5309],{},"The cost is indirection. Every pattern adds at least one interface or one class between the caller and the code that does the work. That cost is paid immediately and on every read. The benefit is paid only when the change the pattern was built for actually happens.",[11,5311,5312],{},"This note covers the GoF patterns from the perspective of application-layer PHP (Laravel or Symfony, request-scoped processes, a DI container available). For each one: the problem it addresses, the typical misuse, and whether the container or the language already does the job.",[23,5314,5316],{"id":5315},"the-test-before-applying-any-pattern","The test before applying any pattern",[11,5318,5319,5320],{},"Answer one question: ",[130,5321,5322],{},"which future change does this make cheaper?",[5324,5325,5326,5339],"table",{},[5327,5328,5329],"thead",{},[5330,5331,5332,5336],"tr",{},[5333,5334,5335],"th",{},"Pattern",[5333,5337,5338],{},"Change it makes cheaper",[5340,5341,5342,5351,5359,5367,5375,5383],"tbody",{},[5330,5343,5344,5348],{},[5345,5346,5347],"td",{},"Adapter",[5345,5349,5350],{},"Replacing or upgrading an external dependency",[5330,5352,5353,5356],{},[5345,5354,5355],{},"Decorator",[5345,5357,5358],{},"Adding or removing a cross-cutting concern (cache, logging, rate limiting)",[5330,5360,5361,5364],{},[5345,5362,5363],{},"Strategy",[5345,5365,5366],{},"Adding a new algorithm variant without touching the caller",[5330,5368,5369,5372],{},[5345,5370,5371],{},"Observer \u002F Event",[5345,5373,5374],{},"Adding a new reaction to a state change without touching its source",[5330,5376,5377,5380],{},[5345,5378,5379],{},"Command",[5345,5381,5382],{},"Moving work to a queue, retrying it, auditing it",[5330,5384,5385,5388],{},[5345,5386,5387],{},"Factory Method",[5345,5389,5390],{},"Adding a new implementation selected at runtime",[11,5392,5393],{},"If you cannot name the change, or the change is hypothetical (\"we might switch databases\"), the pattern is speculative. Write the direct version and introduce the pattern in the commit that needs it. Refactoring towards a pattern when the second variant appears is cheap; maintaining an unused abstraction is a recurring cost.",[23,5395,5397],{"id":5396},"creational-patterns","Creational patterns",[11,5399,5400,5403,5404,5407,5408,5411,5412],{},[130,5401,5402],{},"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 (",[15,5405,5406],{},"$this->app->singleton(...)"," in Laravel) instead of writing a static ",[15,5409,5410],{},"getInstance()",". The container version is injectable and replaceable in tests; the static version is neither. ",[4771,5413,5415],{"href":5414},"\u002Farticles\u002Fsingleton-pattern","Separate article.",[11,5417,5418,5421,5422],{},[130,5419,5420],{},"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 and delegates construction to the container, returning an interface. ",[4771,5423,5415],{"href":5424},"\u002Farticles\u002Ffactory-method",[11,5426,5427,5430],{},[130,5428,5429],{},"Builder."," Fits objects that are assembled in steps and then frozen: a query builder collects conditions and produces one query. In tests, builders for fixtures are fine and improve readability. If production code needs a builder to construct an entity, the entity constructor usually takes too many arguments, and the fix is to split the entity or introduce value objects.",[11,5432,5433,5436],{},[130,5434,5435],{},"Abstract Factory."," Produces families of related objects that must be consistent with each other (for example, 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 abstract factory is rarely needed.",[11,5438,5439,5442,5443,1633,5446,5449,5450,5453,5454,5457,5458,5461,5462,5464],{},[130,5440,5441],{},"Prototype."," PHP has ",[15,5444,5445],{},"clone",[15,5447,5448],{},"__clone()"," built in. A separate ",[15,5451,5452],{},"Prototype"," interface with a ",[15,5455,5456],{},"copy()"," method that calls ",[15,5459,5460],{},"clone $this"," adds a layer without adding behaviour. Implement ",[15,5463,5448],{}," when you need deep copies of nested objects, and stop there.",[23,5466,5468],{"id":5467},"structural-patterns","Structural patterns",[5470,5471,5347],"h3",{"id":5472},"adapter",[11,5474,5475],{},"Every integration with an external API is an adapter: it translates the vendor's types and errors into your domain's types and errors. The rule that keeps adapters useful is that they stay thin. Retry policy, fee calculation, and business decisions belong in a service that depends on the adapter's interface, not in the adapter.",[31,5477,5479],{"className":33,"code":5478,"language":35,"meta":36,"style":36},"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                'payment_method_types' => ['card'],\n                'confirm'              => true,                 \u002F\u002F without it nothing is charged and no card error is raised\n            ]);\n\n            return ChargeResult::pending($intent->id);\n        } catch (\\Stripe\\Exception\\CardException $e) {\n            return ChargeResult::declined($e->getMessage());\n        }\n    }\n}\n",[15,5480,5481,5486,5490,5495,5499,5504,5508,5513,5518,5526,5531,5536,5541,5549,5554,5558,5563,5568,5573,5577,5581],{"__ignoreMap":36},[40,5482,5483],{"class":42,"line":43},[40,5484,5485],{},"final class StripePaymentGateway implements PaymentGatewayInterface\n",[40,5487,5488],{"class":42,"line":49},[40,5489,76],{},[40,5491,5492],{"class":42,"line":55},[40,5493,5494],{},"    public function __construct(private readonly \\Stripe\\StripeClient $stripe) {}\n",[40,5496,5497],{"class":42,"line":84},[40,5498,190],{"emptyLinePlaceholder":189},[40,5500,5501],{"class":42,"line":90},[40,5502,5503],{},"    public function charge(Money $amount, string $paymentMethodId): ChargeResult\n",[40,5505,5506],{"class":42,"line":96},[40,5507,241],{},[40,5509,5510],{"class":42,"line":102},[40,5511,5512],{},"        try {\n",[40,5514,5515],{"class":42,"line":193},[40,5516,5517],{},"            $intent = $this->stripe->paymentIntents->create([\n",[40,5519,5520,5523],{"class":42,"line":199},[40,5521,5522],{},"                'amount'               => $amount->minorUnits,",[40,5524,5525],{},"  \u002F\u002F Stripe expects the smallest currency unit\n",[40,5527,5528],{"class":42,"line":204},[40,5529,5530],{},"                'currency'             => strtolower($amount->currency),\n",[40,5532,5533],{"class":42,"line":210},[40,5534,5535],{},"                'payment_method'       => $paymentMethodId,\n",[40,5537,5538],{"class":42,"line":216},[40,5539,5540],{},"                'payment_method_types' => ['card'],\n",[40,5542,5543,5546],{"class":42,"line":222},[40,5544,5545],{},"                'confirm'              => true,",[40,5547,5548],{},"                 \u002F\u002F without it nothing is charged and no card error is raised\n",[40,5550,5551],{"class":42,"line":227},[40,5552,5553],{},"            ]);\n",[40,5555,5556],{"class":42,"line":232},[40,5557,190],{"emptyLinePlaceholder":189},[40,5559,5560],{"class":42,"line":238},[40,5561,5562],{},"            return ChargeResult::pending($intent->id);\n",[40,5564,5565],{"class":42,"line":244},[40,5566,5567],{},"        } catch (\\Stripe\\Exception\\CardException $e) {\n",[40,5569,5570],{"class":42,"line":250},[40,5571,5572],{},"            return ChargeResult::declined($e->getMessage());\n",[40,5574,5575],{"class":42,"line":256},[40,5576,353],{},[40,5578,5579],{"class":42,"line":261},[40,5580,253],{},[40,5582,5583],{"class":42,"line":267},[40,5584,105],{},[11,5586,5587,5588,5591,5592,5595],{},"The adapter does three things: maps ",[15,5589,5590],{},"Money"," to the request array, maps the response to ",[15,5593,5594],{},"ChargeResult",", maps a vendor exception to a domain outcome. Anything else is a sign the class has grown into a service.",[5470,5597,5355],{"id":5598},"decorator",[11,5600,5601],{},"A decorator wraps an object that implements the same interface and adds behaviour before or after delegating. Caching, logging, metrics, and rate limiting are the standard uses.",[31,5603,5605],{"className":33,"code":5604,"language":35,"meta":36,"style":36},"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",[15,5606,5607,5612,5616,5621,5625,5629,5634,5639,5644,5648,5652,5657,5661,5666,5671,5676,5681,5685,5689],{"__ignoreMap":36},[40,5608,5609],{"class":42,"line":43},[40,5610,5611],{},"use Illuminate\\Contracts\\Cache\\Repository as Cache;\n",[40,5613,5614],{"class":42,"line":49},[40,5615,190],{"emptyLinePlaceholder":189},[40,5617,5618],{"class":42,"line":55},[40,5619,5620],{},"final class CachingUserRepository implements UserRepositoryInterface\n",[40,5622,5623],{"class":42,"line":84},[40,5624,76],{},[40,5626,5627],{"class":42,"line":90},[40,5628,81],{},[40,5630,5631],{"class":42,"line":96},[40,5632,5633],{},"        private readonly UserRepositoryInterface $inner,\n",[40,5635,5636],{"class":42,"line":102},[40,5637,5638],{},"        private readonly Cache $cache,\n",[40,5640,5641],{"class":42,"line":193},[40,5642,5643],{},"        private readonly int $ttlSeconds = 300,\n",[40,5645,5646],{"class":42,"line":199},[40,5647,99],{},[40,5649,5650],{"class":42,"line":204},[40,5651,190],{"emptyLinePlaceholder":189},[40,5653,5654],{"class":42,"line":210},[40,5655,5656],{},"    public function findById(int $id): ?User\n",[40,5658,5659],{"class":42,"line":216},[40,5660,241],{},[40,5662,5663],{"class":42,"line":222},[40,5664,5665],{},"        return $this->cache->remember(\n",[40,5667,5668],{"class":42,"line":227},[40,5669,5670],{},"            \"user.{$id}\",\n",[40,5672,5673],{"class":42,"line":232},[40,5674,5675],{},"            $this->ttlSeconds,\n",[40,5677,5678],{"class":42,"line":238},[40,5679,5680],{},"            fn () => $this->inner->findById($id),\n",[40,5682,5683],{"class":42,"line":244},[40,5684,3934],{},[40,5686,5687],{"class":42,"line":250},[40,5688,253],{},[40,5690,5691],{"class":42,"line":256},[40,5692,105],{},[11,5694,5695],{},"Wiring in a service provider:",[31,5697,5699],{"className":33,"code":5698,"language":35,"meta":36,"style":36},"$this->app->bind(UserRepositoryInterface::class, fn ($app) => new CachingUserRepository(\n    $app->make(EloquentUserRepository::class),\n    $app->make(Cache::class),\n));\n",[15,5700,5701,5706,5711,5716],{"__ignoreMap":36},[40,5702,5703],{"class":42,"line":43},[40,5704,5705],{},"$this->app->bind(UserRepositoryInterface::class, fn ($app) => new CachingUserRepository(\n",[40,5707,5708],{"class":42,"line":49},[40,5709,5710],{},"    $app->make(EloquentUserRepository::class),\n",[40,5712,5713],{"class":42,"line":55},[40,5714,5715],{},"    $app->make(Cache::class),\n",[40,5717,5718],{"class":42,"line":84},[40,5719,5720],{},"));\n",[11,5722,5723,5724,5727],{},"Each layer is tested in isolation: the decorator test checks that ",[15,5725,5726],{},"inner"," is called on a miss and skipped on a hit; the repository test checks data access.",[11,5729,5730,5731,5734,5735,5737],{},"Failure mode: long decorator chains assembled in different orders in different contexts. Once a reader has to trace the container configuration to know 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. A detail of this particular decorator: Laravel's ",[15,5732,5733],{},"remember()"," treats a stored ",[15,5736,114],{}," as a miss, so lookups for IDs that do not exist always reach the database. If that traffic matters, cache an explicit \"not found\" marker.",[5470,5739,5741],{"id":5740},"facade","Facade",[11,5743,5744,5745,4934,5748,5751],{},"A facade gives a single entry point to a subsystem with many classes when callers need only a few operations from it. Laravel's static facades are a different mechanism with the same name: a static proxy to a container binding. They are convenient in application code and testable through ",[15,5746,5747],{},"::fake()",[15,5749,5750],{},"::shouldReceive()",", but they hide dependencies from the constructor signature. In domain services, prefer constructor injection so the dependency list stays visible.",[5470,5753,5755],{"id":5754},"proxy","Proxy",[11,5757,5758],{},"In PHP you mostly meet proxies generated by ORMs and containers for lazy loading (Doctrine entities, Symfony lazy services, and native lazy objects since PHP 8.4). Writing your own is rarely justified. For intercepting calls, a decorator does the same job explicitly and is easier to test.",[23,5760,5762],{"id":5761},"behavioural-patterns","Behavioural patterns",[5470,5764,5766],{"id":5765},"observer-events","Observer \u002F events",[11,5768,5769,5770,5773,5774,5777],{},"When ",[15,5771,5772],{},"Order"," becomes paid, the code that marks it paid dispatches ",[15,5775,5776],{},"OrderPaid",". Email, inventory, and analytics listeners subscribe independently, and adding a fourth listener does not change the order code.",[31,5779,5781],{"className":33,"code":5780,"language":35,"meta":36,"style":36},"final 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",[15,5782,5783,5788,5792,5797,5801,5805,5810,5814,5819,5823,5828,5832],{"__ignoreMap":36},[40,5784,5785],{"class":42,"line":43},[40,5786,5787],{},"final class OrderPaid\n",[40,5789,5790],{"class":42,"line":49},[40,5791,76],{},[40,5793,5794],{"class":42,"line":55},[40,5795,5796],{},"    public function __construct(public readonly int $orderId) {}\n",[40,5798,5799],{"class":42,"line":84},[40,5800,105],{},[40,5802,5803],{"class":42,"line":90},[40,5804,190],{"emptyLinePlaceholder":189},[40,5806,5807],{"class":42,"line":96},[40,5808,5809],{},"final class ReserveInventory implements ShouldQueue\n",[40,5811,5812],{"class":42,"line":102},[40,5813,76],{},[40,5815,5816],{"class":42,"line":193},[40,5817,5818],{},"    public function handle(OrderPaid $event): void\n",[40,5820,5821],{"class":42,"line":199},[40,5822,241],{},[40,5824,5825],{"class":42,"line":204},[40,5826,5827],{},"        \u002F\u002F load the order by id and reserve stock\n",[40,5829,5830],{"class":42,"line":210},[40,5831,253],{},[40,5833,5834],{"class":42,"line":216},[40,5835,105],{},[11,5837,5838],{},"The risk is cascading events. A listener that dispatches another event, whose listener dispatches a third, produces a call graph that nobody wrote down. Synchronous listeners in such a chain all run inside the original request, so the latency and the query count of a simple \"mark as paid\" grow with every subscriber. Three rules keep this under control:",[703,5840,5841,5847,5850],{},[127,5842,5843,5844,1751],{},"Listeners with side effects outside the database (email, HTTP calls) implement ",[15,5845,5846],{},"ShouldQueue",[127,5848,5849],{},"Events carry identifiers, not models; the listener loads fresh state.",[127,5851,5852,5853,5856,5857,5860],{},"Listeners that must not run before the transaction commits are marked accordingly (",[15,5854,5855],{},"ShouldDispatchAfterCommit"," on the event, or ",[15,5858,5859],{},"$afterCommit = true"," on the queued listener).",[5470,5862,5363],{"id":5863},"strategy",[11,5865,5866],{},"Strategy separates the choice of algorithm from its use. A shipping calculator with flat-rate, weight-based, and zone-based pricing is the textbook case.",[31,5868,5870],{"className":33,"code":5869,"language":35,"meta":36,"style":36},"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",[15,5871,5872,5877,5881,5886,5890,5894,5899,5903,5908,5913,5917,5922,5926,5931,5936,5940],{"__ignoreMap":36},[40,5873,5874],{"class":42,"line":43},[40,5875,5876],{},"interface ShippingPricing\n",[40,5878,5879],{"class":42,"line":49},[40,5880,76],{},[40,5882,5883],{"class":42,"line":55},[40,5884,5885],{},"    public function price(Parcel $parcel): Money;\n",[40,5887,5888],{"class":42,"line":84},[40,5889,105],{},[40,5891,5892],{"class":42,"line":90},[40,5893,190],{"emptyLinePlaceholder":189},[40,5895,5896],{"class":42,"line":96},[40,5897,5898],{},"final class ShippingQuote\n",[40,5900,5901],{"class":42,"line":102},[40,5902,76],{},[40,5904,5905],{"class":42,"line":193},[40,5906,5907],{},"    \u002F** @param array\u003Cstring, ShippingPricing> $pricings *\u002F\n",[40,5909,5910],{"class":42,"line":199},[40,5911,5912],{},"    public function __construct(private readonly array $pricings) {}\n",[40,5914,5915],{"class":42,"line":204},[40,5916,190],{"emptyLinePlaceholder":189},[40,5918,5919],{"class":42,"line":210},[40,5920,5921],{},"    public function for(Carrier $carrier, Parcel $parcel): Money\n",[40,5923,5924],{"class":42,"line":216},[40,5925,241],{},[40,5927,5928],{"class":42,"line":222},[40,5929,5930],{},"        return ($this->pricings[$carrier->value] ?? throw new UnsupportedCarrier($carrier))\n",[40,5932,5933],{"class":42,"line":227},[40,5934,5935],{},"            ->price($parcel);\n",[40,5937,5938],{"class":42,"line":232},[40,5939,253],{},[40,5941,5942],{"class":42,"line":238},[40,5943,105],{},[11,5945,5946,5947,5949],{},"Select the strategy once, at the edge of the operation. If the selection happens inside a loop over items, the code pays for the lookup on every iteration and becomes harder to follow. When there are only two variants and no third in sight, a ",[15,5948,403],{}," expression is shorter and just as clear.",[5470,5951,5379],{"id":2198},[11,5953,5954,5955,5958],{},"A command is a serialisable description of an intention: ",[15,5956,5957],{},"ChargeCustomer(customerId: 42, amountMinor: 1999)",". It does nothing on its own. Because it is data, it can be queued, delayed, retried, logged, and replayed. Laravel's queued jobs are commands with a handler attached. The design work is in the payload: keep it to identifiers and scalars, and make the handler idempotent, because a queue with retries will deliver the same command more than once.",[5470,5960,5962],{"id":5961},"template-method","Template Method",[11,5964,5965],{},"Two classes share a fixed sequence of steps and differ in one: a report that is built identically and exported as CSV or PDF. The base class defines the sequence and declares the varying step abstract. This is acceptable inheritance. If the variation grows to more than one step, or the variants need to be combined, switch to composition: inject the exporter as a strategy.",[23,5967,5969],{"id":5968},"patterns-that-rarely-fit-application-code","Patterns that rarely fit application code",[11,5971,5972,5975],{},[130,5973,5974],{},"Interpreter."," Building a parser and evaluator for a custom language. Before writing one, check whether an existing expression engine covers the need; Symfony ExpressionLanguage handles most business-rule cases (pricing conditions, feature-flag predicates) with far less code to maintain.",[11,5977,5978,5981],{},[130,5979,5980],{},"Mediator."," A central object that coordinates components which would otherwise reference each other. In a framework with an event dispatcher and a container, the dispatcher already plays this role for most use cases. A custom mediator is justified when the coordination logic itself is complex and stateful, such as a UI form where field changes affect each other.",[11,5983,5984,5987],{},[130,5985,5986],{},"Flyweight."," Shares immutable state between many small objects to save memory. PHP processes are short-lived and request-scoped, so this rarely matters. It can help in long-running CLI jobs that create very large numbers of identical value objects, for example token objects in a parser.",[23,5989,5991],{"id":5990},"checklist-for-code-review","Checklist for code review",[703,5993,5994,5997,6000,6003,6006,6009],{},[127,5995,5996],{},"Can the author name the concrete change the pattern makes cheaper, and is that change planned?",[127,5998,5999],{},"Is there more than one implementation of the new interface, or a test double that needs it?",[127,6001,6002],{},"Does the adapter contain only translation, with no business rules?",[127,6004,6005],{},"Is the decorator stack wired in one place and no deeper than two or three layers?",[127,6007,6008],{},"Do event listeners with external side effects run on the queue, after commit?",[127,6010,6011,6012,6014],{},"Would a ",[15,6013,403],{}," expression or a plain function do the same job with less code?",[729,6016,731],{},{"title":36,"searchDepth":49,"depth":49,"links":6018},[6019,6020,6021,6027,6033,6034],{"id":5315,"depth":49,"text":5316},{"id":5396,"depth":49,"text":5397},{"id":5467,"depth":49,"text":5468,"children":6022},[6023,6024,6025,6026],{"id":5472,"depth":55,"text":5347},{"id":5598,"depth":55,"text":5355},{"id":5740,"depth":55,"text":5741},{"id":5754,"depth":55,"text":5755},{"id":5761,"depth":49,"text":5762,"children":6028},[6029,6030,6031,6032],{"id":5765,"depth":55,"text":5766},{"id":5863,"depth":55,"text":5363},{"id":2198,"depth":55,"text":5379},{"id":5961,"depth":55,"text":5962},{"id":5968,"depth":49,"text":5969},{"id":5990,"depth":49,"text":5991},"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, the word \"decorator\" tells them how the class is wired, what it can and cannot do, and how to test it, before they read a line of the body.",{},"\u002Farticles\u002Fdesign-patterns-production",{"x":6040,"y":5285,"depth":43,"size":6041},0.62,"lg",[6043,2152,4441],"singleton-pattern",{"title":5297,"description":6036},"pattern-reference","articles\u002Fdesign-patterns-production",[4447,4449,35,6048,6049],"refactoring","code-review","jGQUsT-2DmT4P1t1NDGrGoLfBtHU8B-PnoIbuO_xCOE",{"id":6052,"title":6053,"articleId":4441,"body":6054,"category":35,"codeLang":35,"date":6717,"deploys":43,"description":6058,"excerpt":742,"extension":743,"lang":742,"meta":6718,"navigation":189,"path":5424,"pos":6719,"readMin":90,"related":6722,"seo":6723,"service":6724,"stem":6725,"tags":6726,"version":760,"__hash__":6729},"articles\u002Farticles\u002Ffactory-method.md","Factory Method in PHP: one place for a runtime choice of implementation",{"type":8,"value":6055,"toc":6707},[6056,6059,6063,6066,6165,6172,6176,6179,6214,6217,6221,6235,6313,6316,6402,6405,6453,6456,6490,6497,6515,6519,6529,6542,6546,6552,6556,6562,6603,6610,6664,6668,6685,6687,6705],[11,6057,6058],{},"A factory answers one question: which implementation should be created for this input. It is useful when the answer depends on runtime data (a request field, a database column) and when the same answer is needed in more than one place. If either condition is missing, a factory adds a layer without removing anything.",[23,6060,6062],{"id":6061},"terminology","Terminology",[11,6064,6065],{},"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.",[31,6067,6069],{"className":33,"code":6068,"language":35,"meta":36,"style":36},"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",[15,6070,6071,6076,6080,6085,6089,6094,6098,6103,6108,6113,6117,6122,6126,6130,6134,6139,6143,6148,6152,6157,6161],{"__ignoreMap":36},[40,6072,6073],{"class":42,"line":43},[40,6074,6075],{},"abstract class ReportExporter\n",[40,6077,6078],{"class":42,"line":49},[40,6079,76],{},[40,6081,6082],{"class":42,"line":55},[40,6083,6084],{},"    abstract protected function createWriter(): ReportWriter;\n",[40,6086,6087],{"class":42,"line":84},[40,6088,190],{"emptyLinePlaceholder":189},[40,6090,6091],{"class":42,"line":90},[40,6092,6093],{},"    public function export(Report $report): string\n",[40,6095,6096],{"class":42,"line":96},[40,6097,241],{},[40,6099,6100],{"class":42,"line":102},[40,6101,6102],{},"        $writer = $this->createWriter();\n",[40,6104,6105],{"class":42,"line":193},[40,6106,6107],{},"        foreach ($report->rows() as $row) {\n",[40,6109,6110],{"class":42,"line":199},[40,6111,6112],{},"            $writer->addRow($row);\n",[40,6114,6115],{"class":42,"line":204},[40,6116,353],{},[40,6118,6119],{"class":42,"line":210},[40,6120,6121],{},"        return $writer->finish();\n",[40,6123,6124],{"class":42,"line":216},[40,6125,253],{},[40,6127,6128],{"class":42,"line":222},[40,6129,105],{},[40,6131,6132],{"class":42,"line":227},[40,6133,190],{"emptyLinePlaceholder":189},[40,6135,6136],{"class":42,"line":232},[40,6137,6138],{},"final class CsvReportExporter extends ReportExporter\n",[40,6140,6141],{"class":42,"line":238},[40,6142,76],{},[40,6144,6145],{"class":42,"line":244},[40,6146,6147],{},"    protected function createWriter(): ReportWriter\n",[40,6149,6150],{"class":42,"line":250},[40,6151,241],{},[40,6153,6154],{"class":42,"line":256},[40,6155,6156],{},"        return new CsvWriter();\n",[40,6158,6159],{"class":42,"line":261},[40,6160,253],{},[40,6162,6163],{"class":42,"line":267},[40,6164,105],{},[11,6166,6167,6168,6171],{},"In PHP applications the more common form is a parameterised factory: one object with a ",[15,6169,6170],{},"make()"," method that takes a key and returns an implementation of an interface. The rest of this note is about that form, because it is the one that replaces duplicated conditionals.",[23,6173,6175],{"id":6174},"the-problem-the-same-branch-in-several-places","The problem: the same branch in several places",[11,6177,6178],{},"Example: an application accepts card payments, BLIK, bank transfers and instalment financing. Each provider has its own API, error model and webhook format. Without a single decision point, selection code like this appears in the controller:",[31,6180,6182],{"className":33,"code":6181,"language":35,"meta":36,"style":36},"$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    default => throw new InvalidArgumentException('Unknown payment method'),\n};\n",[15,6183,6184,6189,6194,6199,6204,6209],{"__ignoreMap":36},[40,6185,6186],{"class":42,"line":43},[40,6187,6188],{},"$gateway = match ($request->input('payment_method')) {\n",[40,6190,6191],{"class":42,"line":49},[40,6192,6193],{},"    'card' => new StripeGateway(config('services.stripe.secret')),\n",[40,6195,6196],{"class":42,"line":55},[40,6197,6198],{},"    'blik' => new BlikGateway(config('services.blik.merchant_id'), config('services.blik.key')),\n",[40,6200,6201],{"class":42,"line":84},[40,6202,6203],{},"    'transfer' => new BankTransferGateway(config('services.psp.endpoint')),\n",[40,6205,6206],{"class":42,"line":90},[40,6207,6208],{},"    default => throw new InvalidArgumentException('Unknown payment method'),\n",[40,6210,6211],{"class":42,"line":96},[40,6212,6213],{},"};\n",[11,6215,6216],{},"The same choice is needed again in the refund handler, the webhook endpoint and the reconciliation job, each time based on the method stored with the payment. Each copy also repeats the constructor arguments. Adding a provider, or a dependency to an existing one, means finding and editing every copy.",[23,6218,6220],{"id":6219},"interface-enum-factory","Interface, enum, factory",[11,6222,6223,6224,711,6226,711,6228,1633,6231,6234],{},"Start with an interface expressed in the application's own terms. ",[15,6225,5590],{},[15,6227,5594],{},[15,6229,6230],{},"RefundResult",[15,6232,6233],{},"WebhookEvent"," are the application's value objects.",[31,6236,6238],{"className":33,"code":6237,"language":35,"meta":36,"style":36},"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",[15,6239,6240,6245,6249,6254,6259,6264,6269,6273,6277,6282,6286,6291,6295,6300,6304,6309],{"__ignoreMap":36},[40,6241,6242],{"class":42,"line":43},[40,6243,6244],{},"enum PaymentMethod: string\n",[40,6246,6247],{"class":42,"line":49},[40,6248,76],{},[40,6250,6251],{"class":42,"line":55},[40,6252,6253],{},"    case Card = 'card';\n",[40,6255,6256],{"class":42,"line":84},[40,6257,6258],{},"    case Blik = 'blik';\n",[40,6260,6261],{"class":42,"line":90},[40,6262,6263],{},"    case Transfer = 'transfer';\n",[40,6265,6266],{"class":42,"line":96},[40,6267,6268],{},"    case Financing = 'financing';\n",[40,6270,6271],{"class":42,"line":102},[40,6272,105],{},[40,6274,6275],{"class":42,"line":193},[40,6276,190],{"emptyLinePlaceholder":189},[40,6278,6279],{"class":42,"line":199},[40,6280,6281],{},"interface PaymentGateway\n",[40,6283,6284],{"class":42,"line":204},[40,6285,76],{},[40,6287,6288],{"class":42,"line":210},[40,6289,6290],{},"    public function charge(Money $amount, array $metadata): ChargeResult;\n",[40,6292,6293],{"class":42,"line":216},[40,6294,190],{"emptyLinePlaceholder":189},[40,6296,6297],{"class":42,"line":222},[40,6298,6299],{},"    public function refund(string $chargeId, Money $amount): RefundResult;\n",[40,6301,6302],{"class":42,"line":227},[40,6303,190],{"emptyLinePlaceholder":189},[40,6305,6306],{"class":42,"line":232},[40,6307,6308],{},"    public function parseWebhook(string $payload, array $headers): WebhookEvent;\n",[40,6310,6311],{"class":42,"line":238},[40,6312,105],{},[11,6314,6315],{},"The factory maps a method to a class and lets the container build it:",[31,6317,6319],{"className":33,"code":6318,"language":35,"meta":36,"style":36},"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",[15,6320,6321,6326,6330,6335,6339,6344,6348,6353,6358,6362,6366,6371,6375,6380,6385,6389,6394,6398],{"__ignoreMap":36},[40,6322,6323],{"class":42,"line":43},[40,6324,6325],{},"use Illuminate\\Contracts\\Container\\Container;\n",[40,6327,6328],{"class":42,"line":49},[40,6329,190],{"emptyLinePlaceholder":189},[40,6331,6332],{"class":42,"line":55},[40,6333,6334],{},"final class PaymentGatewayFactory\n",[40,6336,6337],{"class":42,"line":84},[40,6338,76],{},[40,6340,6341],{"class":42,"line":90},[40,6342,6343],{},"    \u002F** @param array\u003Cstring, class-string\u003CPaymentGateway>> $gateways *\u002F\n",[40,6345,6346],{"class":42,"line":96},[40,6347,81],{},[40,6349,6350],{"class":42,"line":102},[40,6351,6352],{},"        private readonly Container $container,\n",[40,6354,6355],{"class":42,"line":193},[40,6356,6357],{},"        private readonly array $gateways,\n",[40,6359,6360],{"class":42,"line":199},[40,6361,99],{},[40,6363,6364],{"class":42,"line":204},[40,6365,190],{"emptyLinePlaceholder":189},[40,6367,6368],{"class":42,"line":210},[40,6369,6370],{},"    public function make(PaymentMethod $method): PaymentGateway\n",[40,6372,6373],{"class":42,"line":216},[40,6374,241],{},[40,6376,6377],{"class":42,"line":222},[40,6378,6379],{},"        $class = $this->gateways[$method->value]\n",[40,6381,6382],{"class":42,"line":227},[40,6383,6384],{},"            ?? throw new UnsupportedPaymentMethod($method->value);\n",[40,6386,6387],{"class":42,"line":232},[40,6388,190],{"emptyLinePlaceholder":189},[40,6390,6391],{"class":42,"line":238},[40,6392,6393],{},"        return $this->container->make($class);\n",[40,6395,6396],{"class":42,"line":244},[40,6397,253],{},[40,6399,6400],{"class":42,"line":250},[40,6401,105],{},[11,6403,6404],{},"Registration happens once, in a service provider. Each gateway's own constructor dependencies (credentials, HTTP client, logger) are bound separately and resolved by the container.",[31,6406,6408],{"className":33,"code":6407,"language":35,"meta":36,"style":36},"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",[15,6409,6410,6415,6419,6424,6429,6434,6439,6444,6449],{"__ignoreMap":36},[40,6411,6412],{"class":42,"line":43},[40,6413,6414],{},"public function register(): void\n",[40,6416,6417],{"class":42,"line":49},[40,6418,76],{},[40,6420,6421],{"class":42,"line":55},[40,6422,6423],{},"    $this->app->singleton(PaymentGatewayFactory::class, fn ($app) => new PaymentGatewayFactory($app, [\n",[40,6425,6426],{"class":42,"line":84},[40,6427,6428],{},"        PaymentMethod::Card->value => StripeGateway::class,\n",[40,6430,6431],{"class":42,"line":90},[40,6432,6433],{},"        PaymentMethod::Blik->value => BlikGateway::class,\n",[40,6435,6436],{"class":42,"line":96},[40,6437,6438],{},"        PaymentMethod::Transfer->value => BankTransferGateway::class,\n",[40,6440,6441],{"class":42,"line":102},[40,6442,6443],{},"        PaymentMethod::Financing->value => FinancingGateway::class,\n",[40,6445,6446],{"class":42,"line":193},[40,6447,6448],{},"    ]));\n",[40,6450,6451],{"class":42,"line":199},[40,6452,105],{},[11,6454,6455],{},"Call sites now only make the decision from their own input:",[31,6457,6459],{"className":33,"code":6458,"language":35,"meta":36,"style":36},"\u002F\u002F controller: the input is validated with 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",[15,6460,6461,6466,6471,6476,6480,6485],{"__ignoreMap":36},[40,6462,6463],{"class":42,"line":43},[40,6464,6465],{},"\u002F\u002F controller: the input is validated with Rule::enum(PaymentMethod::class)\n",[40,6467,6468],{"class":42,"line":49},[40,6469,6470],{},"$gateway = $this->gateways->make(PaymentMethod::from($request->validated('payment_method')));\n",[40,6472,6473],{"class":42,"line":55},[40,6474,6475],{},"$result = $gateway->charge($amount, ['order_id' => $order->id]);\n",[40,6477,6478],{"class":42,"line":84},[40,6479,190],{"emptyLinePlaceholder":189},[40,6481,6482],{"class":42,"line":90},[40,6483,6484],{},"\u002F\u002F refund job: the input is a column cast to PaymentMethod\n",[40,6486,6487],{"class":42,"line":96},[40,6488,6489],{},"$this->gateways->make($payment->method)->refund($payment->charge_id, $payment->amount);\n",[11,6491,6492,6493,6496],{},"A new provider requires a class implementing ",[15,6494,6495],{},"PaymentGateway",", an enum case and one line in the map.",[11,6498,6499,6500,6503,6504,711,6507,6510,6511,6514],{},"Laravel uses the same idea internally in ",[15,6501,6502],{},"Illuminate\\Support\\Manager"," (",[15,6505,6506],{},"driver()",[15,6508,6509],{},"extend()","), which backs drivers such as session, hashing and notification channels. Extending ",[15,6512,6513],{},"Manager"," is an option when the implementations are configured by name in config files; for a domain choice like this one a dedicated factory is easier to read.",[23,6516,6518],{"id":6517},"two-ways-to-get-it-wrong","Two ways to get it wrong",[11,6520,6521,6524,6525,6528],{},[130,6522,6523],{},"The factory constructs objects itself."," A factory that calls ",[15,6526,6527],{},"new StripeGateway(config(...))"," has to change every time a gateway's constructor changes, and it hides the gateway's dependencies from the container. Delegate construction to the container. Without a container, pass ready instances (or closures that build them) into the factory's constructor.",[11,6530,6531,6534,6535,6537,6538,6541],{},[130,6532,6533],{},"The interface follows one vendor."," If ",[15,6536,6495],{}," has methods like ",[15,6539,6540],{},"createPaymentIntent()"," because the first integration was Stripe, every other implementation has to emulate Stripe's model, and the emulation is where bugs accumulate. Name the operations after what the application does (charge, refund, interpret a webhook) and translate to each vendor's model inside its class.",[23,6543,6545],{"id":6544},"factory-or-strategy","Factory or Strategy",[11,6547,6548,6549,6551],{},"The two often appear together. Strategy describes an object whose behaviour is interchangeable behind an interface: each ",[15,6550,6495],{}," is a strategy for processing a payment. The factory is the place that decides which strategy to use for a given input. If the strategy is known when the application boots (from configuration), no factory is needed: bind the interface to the implementation in the container and inject it.",[23,6553,6555],{"id":6554},"tests","Tests",[11,6557,6558,6559,6561],{},"The factory needs one test: every enum case resolves to a ",[15,6560,6495],{},". It fails when someone adds a case and forgets the map entry.",[31,6563,6565],{"className":33,"code":6564,"language":35,"meta":36,"style":36},"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",[15,6566,6567,6572,6576,6581,6585,6590,6595,6599],{"__ignoreMap":36},[40,6568,6569],{"class":42,"line":43},[40,6570,6571],{},"public function test_every_payment_method_has_a_gateway(): void\n",[40,6573,6574],{"class":42,"line":49},[40,6575,76],{},[40,6577,6578],{"class":42,"line":55},[40,6579,6580],{},"    $factory = $this->app->make(PaymentGatewayFactory::class);\n",[40,6582,6583],{"class":42,"line":84},[40,6584,190],{"emptyLinePlaceholder":189},[40,6586,6587],{"class":42,"line":90},[40,6588,6589],{},"    foreach (PaymentMethod::cases() as $method) {\n",[40,6591,6592],{"class":42,"line":96},[40,6593,6594],{},"        $this->assertInstanceOf(PaymentGateway::class, $factory->make($method));\n",[40,6596,6597],{"class":42,"line":102},[40,6598,253],{},[40,6600,6601],{"class":42,"line":193},[40,6602,105],{},[11,6604,6605,6606,6609],{},"More value comes from a shared contract test that each gateway extends. Each subclass provides the gateway with a faked transport, for example ",[15,6607,6608],{},"Http::fake()"," if the gateway uses Laravel's HTTP client.",[31,6611,6613],{"className":33,"code":6612,"language":35,"meta":36,"style":36},"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",[15,6614,6615,6620,6624,6629,6633,6638,6642,6647,6651,6656,6660],{"__ignoreMap":36},[40,6616,6617],{"class":42,"line":43},[40,6618,6619],{},"abstract class PaymentGatewayContractTest extends TestCase\n",[40,6621,6622],{"class":42,"line":49},[40,6623,76],{},[40,6625,6626],{"class":42,"line":55},[40,6627,6628],{},"    abstract protected function gateway(): PaymentGateway;\n",[40,6630,6631],{"class":42,"line":84},[40,6632,190],{"emptyLinePlaceholder":189},[40,6634,6635],{"class":42,"line":90},[40,6636,6637],{},"    public function test_charge_returns_a_charge_id(): void\n",[40,6639,6640],{"class":42,"line":96},[40,6641,241],{},[40,6643,6644],{"class":42,"line":102},[40,6645,6646],{},"        $result = $this->gateway()->charge(new Money(1000, 'PLN'), ['order_id' => 'test-123']);\n",[40,6648,6649],{"class":42,"line":193},[40,6650,190],{"emptyLinePlaceholder":189},[40,6652,6653],{"class":42,"line":199},[40,6654,6655],{},"        $this->assertNotSame('', $result->chargeId);\n",[40,6657,6658],{"class":42,"line":204},[40,6659,253],{},[40,6661,6662],{"class":42,"line":210},[40,6663,105],{},[23,6665,6667],{"id":6666},"when-not-to-use-a-factory","When not to use a factory",[703,6669,6670,6673,6679],{},[127,6671,6672],{},"There is one implementation, or the choice is fixed per deployment. Use a container binding.",[127,6674,6675,6676,6678],{},"The choice is made in exactly one place. A ",[15,6677,403],{}," on an enum in that place is clearer than an extra class.",[127,6680,6681,6682,6684],{},"The implementations do not share an interface that callers can use without ",[15,6683,4183],{}," checks. Fix the interface first.",[23,6686,701],{"id":700},[703,6688,6689,6692,6695,6702],{},[127,6690,6691],{},"The decision depends on runtime data and is needed in more than one place.",[127,6693,6694],{},"Keys are an enum, not free strings, and a test covers every case.",[127,6696,6697,6698,6701],{},"The factory resolves through the container and does not call ",[15,6699,6700],{},"new"," with configuration.",[127,6703,6704],{},"The returned interface uses the application's vocabulary, not a vendor's.",[729,6706,731],{},{"title":36,"searchDepth":49,"depth":49,"links":6708},[6709,6710,6711,6712,6713,6714,6715,6716],{"id":6061,"depth":49,"text":6062},{"id":6174,"depth":49,"text":6175},{"id":6219,"depth":49,"text":6220},{"id":6517,"depth":49,"text":6518},{"id":6544,"depth":49,"text":6545},{"id":6554,"depth":49,"text":6555},{"id":6666,"depth":49,"text":6667},{"id":700,"depth":49,"text":701},"2024-07-15",{},{"x":6720,"y":6721,"depth":43,"size":743},0.58,0.48,[6043,2152],{"title":6053,"description":6058},"object-creation","articles\u002Ffactory-method",[35,4447,6727,6728,4449],"factory","dependency-injection","2b_UvwhvBp7cvaRBbgfHYuy_k0V14dU5zvb6Nu77-rg",{"id":6731,"title":6732,"articleId":6733,"body":6734,"category":2142,"codeLang":35,"date":8010,"deploys":43,"description":6738,"excerpt":742,"extension":743,"lang":742,"meta":8011,"navigation":189,"path":8012,"pos":8013,"readMin":210,"related":8016,"seo":8017,"service":8018,"stem":8019,"tags":8020,"version":760,"__hash__":8026},"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":6735,"toc":7999},[6736,6739,6742,6746,6749,6772,6775,6779,6926,6929,6936,6940,6943,6964,6967,7178,7192,7196,7217,7480,7491,7495,7506,7715,7718,7733,7737,7740,7800,7904,7922,7928,7931,7935,7945,7959,7962,7966,7969,7971,7997],[11,6737,6738],{},"A hosted language model (Anthropic, OpenAI, Mistral) is an HTTPS endpoint that takes JSON and returns JSON. The language of the calling application does not matter to it. Python's advantage is real 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. These are problems of queues, databases and contracts, and a PHP application already has the tools for them.",[11,6740,6741],{},"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. The same structure applies to other providers.",[23,6743,6745],{"id":6744},"library-or-plain-http","Library or plain HTTP",[11,6747,6748],{},"There are three reasonable options:",[703,6750,6751,6758,6765],{},[127,6752,6753,6754,6757],{},"Plain HTTP through Guzzle or Laravel's ",[15,6755,6756],{},"Http"," facade. One endpoint, a few headers, no extra dependency. Enough when you call one provider for a handful of features.",[127,6759,6760,6761,6764],{},"A client library: ",[15,6762,6763],{},"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.",[127,6766,6767,6768,6771],{},"Symfony's AI components (the ",[15,6769,6770],{},"symfony\u002Fai"," project), if the application is built on Symfony and you want integration with its container and configuration.",[11,6773,6774],{},"Whichever you choose, check four things before production: 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. A library that hides any of these will cost more time than it saves.",[23,6776,6778],{"id":6777},"a-thin-client","A thin client",[31,6780,6782],{"className":33,"code":6781,"language":35,"meta":36,"style":36},"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",[15,6783,6784,6789,6794,6799,6803,6808,6812,6816,6821,6826,6830,6834,6839,6844,6848,6853,6858,6863,6868,6873,6878,6883,6888,6893,6898,6903,6908,6913,6918,6922],{"__ignoreMap":36},[40,6785,6786],{"class":42,"line":43},[40,6787,6788],{},"use Illuminate\\Http\\Client\\ConnectionException;\n",[40,6790,6791],{"class":42,"line":49},[40,6792,6793],{},"use Illuminate\\Http\\Client\\RequestException;\n",[40,6795,6796],{"class":42,"line":55},[40,6797,6798],{},"use Illuminate\\Support\\Facades\\Http;\n",[40,6800,6801],{"class":42,"line":84},[40,6802,190],{"emptyLinePlaceholder":189},[40,6804,6805],{"class":42,"line":90},[40,6806,6807],{},"final class AnthropicClient\n",[40,6809,6810],{"class":42,"line":96},[40,6811,76],{},[40,6813,6814],{"class":42,"line":102},[40,6815,81],{},[40,6817,6818],{"class":42,"line":193},[40,6819,6820],{},"        private readonly string $apiKey,\n",[40,6822,6823],{"class":42,"line":199},[40,6824,6825],{},"        private readonly string $model,\n",[40,6827,6828],{"class":42,"line":204},[40,6829,99],{},[40,6831,6832],{"class":42,"line":210},[40,6833,190],{"emptyLinePlaceholder":189},[40,6835,6836],{"class":42,"line":216},[40,6837,6838],{},"    \u002F** @return array\u003Cstring, mixed> *\u002F\n",[40,6840,6841],{"class":42,"line":222},[40,6842,6843],{},"    public function messages(array $payload): array\n",[40,6845,6846],{"class":42,"line":227},[40,6847,241],{},[40,6849,6850],{"class":42,"line":232},[40,6851,6852],{},"        return Http::baseUrl('https:\u002F\u002Fapi.anthropic.com\u002Fv1')\n",[40,6854,6855],{"class":42,"line":238},[40,6856,6857],{},"            ->withHeaders([\n",[40,6859,6860],{"class":42,"line":244},[40,6861,6862],{},"                'x-api-key' => $this->apiKey,\n",[40,6864,6865],{"class":42,"line":250},[40,6866,6867],{},"                'anthropic-version' => '2023-06-01',\n",[40,6869,6870],{"class":42,"line":256},[40,6871,6872],{},"            ])\n",[40,6874,6875],{"class":42,"line":261},[40,6876,6877],{},"            ->connectTimeout(5)\n",[40,6879,6880],{"class":42,"line":267},[40,6881,6882],{},"            ->timeout(30)\n",[40,6884,6885],{"class":42,"line":272},[40,6886,6887],{},"            ->retry(2, 2000, fn (\\Throwable $e): bool =>\n",[40,6889,6890],{"class":42,"line":278},[40,6891,6892],{},"                $e instanceof ConnectionException\n",[40,6894,6895],{"class":42,"line":283},[40,6896,6897],{},"                || ($e instanceof RequestException\n",[40,6899,6900],{"class":42,"line":288},[40,6901,6902],{},"                    && in_array($e->response->status(), [429, 500, 529], true))\n",[40,6904,6905],{"class":42,"line":294},[40,6906,6907],{},"            )\n",[40,6909,6910],{"class":42,"line":299},[40,6911,6912],{},"            ->post('\u002Fmessages', ['model' => $this->model, ...$payload])\n",[40,6914,6915],{"class":42,"line":305},[40,6916,6917],{},"            ->json();\n",[40,6919,6920],{"class":42,"line":310},[40,6921,253],{},[40,6923,6924],{"class":42,"line":1571},[40,6925,105],{},[11,6927,6928],{},"The model name comes from configuration, not code, because 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 is wrong and repeating it changes nothing.",[11,6930,6931,6932,6935],{},"The read timeout must match the largest response you ask for. Generation time grows with ",[15,6933,6934],{},"max_tokens",". A classification limited to 256 tokens finishes in seconds; a long summary may not fit in 30 seconds and needs either a higher timeout or streaming.",[23,6937,6939],{"id":6938},"model-calls-in-queue-jobs","Model calls in queue jobs",[11,6941,6942],{},"Most LLM work belongs in a queue: the user should not hold an HTTP request open for several seconds, and the worker can retry. Two mechanisms cause trouble here.",[11,6944,6945,6946,6949,6950,6953,6954,6956,6957,6960,6961,6963],{},"The first is the relation between timeouts. In Laravel, a job that runs longer than the connection's ",[15,6947,6948],{},"retry_after"," (in ",[15,6951,6952],{},"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 ",[15,6955,6948],{},", by at least several seconds for the worker's ",[15,6958,6959],{},"--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 ",[15,6962,6948],{}," of 120 s are consistent. The numbers are an example; the inequalities are what matters.",[11,6965,6966],{},"The second is non-determinism. The same input can produce a different classification on the next call. A retry is therefore not 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:",[31,6968,6970],{"className":33,"code":6969,"language":35,"meta":36,"style":36},"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",[15,6971,6972,6977,6982,6987,6992,6997,7001,7006,7010,7015,7019,7024,7029,7034,7039,7043,7048,7052,7057,7061,7066,7070,7074,7079,7083,7088,7092,7097,7101,7105,7109,7114,7118,7123,7128,7133,7138,7143,7148,7152,7156,7161,7166,7170,7174],{"__ignoreMap":36},[40,6973,6974],{"class":42,"line":43},[40,6975,6976],{},"use Illuminate\\Contracts\\Queue\\ShouldBeUnique;\n",[40,6978,6979],{"class":42,"line":49},[40,6980,6981],{},"use Illuminate\\Contracts\\Queue\\ShouldQueue;\n",[40,6983,6984],{"class":42,"line":55},[40,6985,6986],{},"use Illuminate\\Foundation\\Bus\\Dispatchable;\n",[40,6988,6989],{"class":42,"line":84},[40,6990,6991],{},"use Illuminate\\Foundation\\Queue\\Queueable;\n",[40,6993,6994],{"class":42,"line":90},[40,6995,6996],{},"use Illuminate\\Queue\\InteractsWithQueue;\n",[40,6998,6999],{"class":42,"line":96},[40,7000,190],{"emptyLinePlaceholder":189},[40,7002,7003],{"class":42,"line":102},[40,7004,7005],{},"final class TriageTicket implements ShouldQueue, ShouldBeUnique\n",[40,7007,7008],{"class":42,"line":193},[40,7009,76],{},[40,7011,7012],{"class":42,"line":199},[40,7013,7014],{},"    use Dispatchable, InteractsWithQueue, Queueable;\n",[40,7016,7017],{"class":42,"line":204},[40,7018,190],{"emptyLinePlaceholder":189},[40,7020,7021],{"class":42,"line":210},[40,7022,7023],{},"    public int $timeout = 90;\n",[40,7025,7026],{"class":42,"line":216},[40,7027,7028],{},"    public int $tries = 3;\n",[40,7030,7031],{"class":42,"line":222},[40,7032,7033],{},"    public array $backoff = [10, 60];\n",[40,7035,7036],{"class":42,"line":227},[40,7037,7038],{},"    public int $uniqueFor = 600;\n",[40,7040,7041],{"class":42,"line":232},[40,7042,190],{"emptyLinePlaceholder":189},[40,7044,7045],{"class":42,"line":238},[40,7046,7047],{},"    public function __construct(public readonly int $ticketId) {}\n",[40,7049,7050],{"class":42,"line":244},[40,7051,190],{"emptyLinePlaceholder":189},[40,7053,7054],{"class":42,"line":250},[40,7055,7056],{},"    public function uniqueId(): string\n",[40,7058,7059],{"class":42,"line":256},[40,7060,241],{},[40,7062,7063],{"class":42,"line":261},[40,7064,7065],{},"        return (string) $this->ticketId;\n",[40,7067,7068],{"class":42,"line":267},[40,7069,253],{},[40,7071,7072],{"class":42,"line":272},[40,7073,190],{"emptyLinePlaceholder":189},[40,7075,7076],{"class":42,"line":278},[40,7077,7078],{},"    public function handle(TicketClassifier $classifier): void\n",[40,7080,7081],{"class":42,"line":283},[40,7082,241],{},[40,7084,7085],{"class":42,"line":288},[40,7086,7087],{},"        $ticket = Ticket::findOrFail($this->ticketId);\n",[40,7089,7090],{"class":42,"line":294},[40,7091,190],{"emptyLinePlaceholder":189},[40,7093,7094],{"class":42,"line":299},[40,7095,7096],{},"        if ($ticket->triaged_at !== null) {\n",[40,7098,7099],{"class":42,"line":305},[40,7100,4156],{},[40,7102,7103],{"class":42,"line":310},[40,7104,353],{},[40,7106,7107],{"class":42,"line":1571},[40,7108,190],{"emptyLinePlaceholder":189},[40,7110,7111],{"class":42,"line":1577},[40,7112,7113],{},"        $result = $classifier->classify($ticket->body);\n",[40,7115,7116],{"class":42,"line":1583},[40,7117,190],{"emptyLinePlaceholder":189},[40,7119,7120],{"class":42,"line":1589},[40,7121,7122],{},"        $updated = Ticket::whereKey($ticket->id)\n",[40,7124,7125],{"class":42,"line":1594},[40,7126,7127],{},"            ->whereNull('triaged_at')\n",[40,7129,7130],{"class":42,"line":1600},[40,7131,7132],{},"            ->update([\n",[40,7134,7135],{"class":42,"line":1606},[40,7136,7137],{},"                'department' => $result->department->value,\n",[40,7139,7140],{"class":42,"line":1612},[40,7141,7142],{},"                'priority' => $result->priority->value,\n",[40,7144,7145],{"class":42,"line":1618},[40,7146,7147],{},"                'triaged_at' => now(),\n",[40,7149,7150],{"class":42,"line":2895},[40,7151,5553],{},[40,7153,7154],{"class":42,"line":2905},[40,7155,190],{"emptyLinePlaceholder":189},[40,7157,7158],{"class":42,"line":2916},[40,7159,7160],{},"        if ($updated === 1) {\n",[40,7162,7163],{"class":42,"line":2926},[40,7164,7165],{},"            TicketTriaged::dispatch($ticket->id);\n",[40,7167,7168],{"class":42,"line":2936},[40,7169,353],{},[40,7171,7172],{"class":42,"line":2941},[40,7173,253],{},[40,7175,7176],{"class":42,"line":2949},[40,7177,105],{},[11,7179,7180,7183,7184,7187,7188,7191],{},[15,7181,7182],{},"ShouldBeUnique"," prevents a second copy of the job from being queued until the first one finishes processing or fails all its attempts (or ",[15,7185,7186],{},"uniqueFor"," expires). The ",[15,7189,7190],{},"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 can still cost a second API call, but it cannot produce a second effect.",[23,7193,7195],{"id":7194},"structured-output-and-validation","Structured output and validation",[11,7197,7198,7199,7202,7203,7205,7206,7209,7210,7213,7214,7216],{},"Free text is a poor interface between a model and code. For classification and extraction, use structured outputs: pass a JSON Schema in ",[15,7200,7201],{},"output_config.format"," and the model returns a ",[15,7204,3534],{}," block containing JSON that matches it. The older technique of forcing a tool call with ",[15,7207,7208],{},"tool_choice"," is rejected with a 400 error by the newest Claude models (Opus 5.5, Sonnet 5.5). The documentation lists cases where the output does not match the schema: a refusal (",[15,7211,7212],{},"stop_reason: refusal","), a response cut off by ",[15,7215,6934],{},", and enum values returned with different capitalisation. Validate it anyway.",[31,7218,7220],{"className":33,"code":7219,"language":35,"meta":36,"style":36},"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",[15,7221,7222,7227,7231,7236,7241,7246,7250,7254,7259,7263,7268,7273,7278,7282,7286,7291,7295,7300,7304,7309,7313,7318,7322,7327,7332,7337,7342,7347,7352,7357,7362,7367,7372,7377,7382,7387,7392,7397,7402,7407,7412,7417,7421,7426,7431,7435,7440,7445,7449,7454,7459,7463,7467,7472,7476],{"__ignoreMap":36},[40,7223,7224],{"class":42,"line":43},[40,7225,7226],{},"enum Department: string\n",[40,7228,7229],{"class":42,"line":49},[40,7230,76],{},[40,7232,7233],{"class":42,"line":55},[40,7234,7235],{},"    case Billing = 'billing';\n",[40,7237,7238],{"class":42,"line":84},[40,7239,7240],{},"    case Technical = 'technical';\n",[40,7242,7243],{"class":42,"line":90},[40,7244,7245],{},"    case Sales = 'sales';\n",[40,7247,7248],{"class":42,"line":96},[40,7249,105],{},[40,7251,7252],{"class":42,"line":102},[40,7253,190],{"emptyLinePlaceholder":189},[40,7255,7256],{"class":42,"line":193},[40,7257,7258],{},"enum Priority: string\n",[40,7260,7261],{"class":42,"line":199},[40,7262,76],{},[40,7264,7265],{"class":42,"line":204},[40,7266,7267],{},"    case Low = 'low';\n",[40,7269,7270],{"class":42,"line":210},[40,7271,7272],{},"    case Normal = 'normal';\n",[40,7274,7275],{"class":42,"line":216},[40,7276,7277],{},"    case Urgent = 'urgent';\n",[40,7279,7280],{"class":42,"line":222},[40,7281,105],{},[40,7283,7284],{"class":42,"line":227},[40,7285,190],{"emptyLinePlaceholder":189},[40,7287,7288],{"class":42,"line":232},[40,7289,7290],{},"final class TicketClassifier\n",[40,7292,7293],{"class":42,"line":238},[40,7294,76],{},[40,7296,7297],{"class":42,"line":244},[40,7298,7299],{},"    public function __construct(private readonly AnthropicClient $client) {}\n",[40,7301,7302],{"class":42,"line":250},[40,7303,190],{"emptyLinePlaceholder":189},[40,7305,7306],{"class":42,"line":256},[40,7307,7308],{},"    public function classify(string $body): TicketClassification\n",[40,7310,7311],{"class":42,"line":261},[40,7312,241],{},[40,7314,7315],{"class":42,"line":267},[40,7316,7317],{},"        $values = fn (array $cases): array => array_map(fn ($c) => $c->value, $cases);\n",[40,7319,7320],{"class":42,"line":272},[40,7321,190],{"emptyLinePlaceholder":189},[40,7323,7324],{"class":42,"line":278},[40,7325,7326],{},"        $response = $this->client->messages([\n",[40,7328,7329],{"class":42,"line":283},[40,7330,7331],{},"            'max_tokens' => 256,\n",[40,7333,7334],{"class":42,"line":288},[40,7335,7336],{},"            'system' => 'Classify the customer support ticket by department and priority.',\n",[40,7338,7339],{"class":42,"line":294},[40,7340,7341],{},"            'messages' => [['role' => 'user', 'content' => $body]],\n",[40,7343,7344],{"class":42,"line":299},[40,7345,7346],{},"            'output_config' => [\n",[40,7348,7349],{"class":42,"line":305},[40,7350,7351],{},"                'format' => [\n",[40,7353,7354],{"class":42,"line":310},[40,7355,7356],{},"                    'type' => 'json_schema',\n",[40,7358,7359],{"class":42,"line":1571},[40,7360,7361],{},"                    'schema' => [\n",[40,7363,7364],{"class":42,"line":1577},[40,7365,7366],{},"                        'type' => 'object',\n",[40,7368,7369],{"class":42,"line":1583},[40,7370,7371],{},"                        'properties' => [\n",[40,7373,7374],{"class":42,"line":1589},[40,7375,7376],{},"                            'department' => ['type' => 'string', 'enum' => $values(Department::cases())],\n",[40,7378,7379],{"class":42,"line":1594},[40,7380,7381],{},"                            'priority' => ['type' => 'string', 'enum' => $values(Priority::cases())],\n",[40,7383,7384],{"class":42,"line":1600},[40,7385,7386],{},"                        ],\n",[40,7388,7389],{"class":42,"line":1606},[40,7390,7391],{},"                        'required' => ['department', 'priority'],\n",[40,7393,7394],{"class":42,"line":1612},[40,7395,7396],{},"                        'additionalProperties' => false,\n",[40,7398,7399],{"class":42,"line":1618},[40,7400,7401],{},"                    ],\n",[40,7403,7404],{"class":42,"line":2895},[40,7405,7406],{},"                ],\n",[40,7408,7409],{"class":42,"line":2905},[40,7410,7411],{},"            ],\n",[40,7413,7414],{"class":42,"line":2916},[40,7415,7416],{},"        ]);\n",[40,7418,7419],{"class":42,"line":2926},[40,7420,190],{"emptyLinePlaceholder":189},[40,7422,7423],{"class":42,"line":2936},[40,7424,7425],{},"        $text = collect($response['content'])->firstWhere('type', 'text')['text'] ?? '';\n",[40,7427,7428],{"class":42,"line":2941},[40,7429,7430],{},"        $output = (array) json_decode($text, true);\n",[40,7432,7433],{"class":42,"line":2949},[40,7434,190],{"emptyLinePlaceholder":189},[40,7436,7437],{"class":42,"line":2961},[40,7438,7439],{},"        $department = Department::tryFrom(strtolower((string) ($output['department'] ?? '')));\n",[40,7441,7442],{"class":42,"line":2969},[40,7443,7444],{},"        $priority = Priority::tryFrom(strtolower((string) ($output['priority'] ?? '')));\n",[40,7446,7447],{"class":42,"line":2979},[40,7448,190],{"emptyLinePlaceholder":189},[40,7450,7451],{"class":42,"line":2988},[40,7452,7453],{},"        if ($department === null || $priority === null) {\n",[40,7455,7456],{"class":42,"line":2998},[40,7457,7458],{},"            throw new InvalidModelOutput('ticket_classification', $response);\n",[40,7460,7461],{"class":42,"line":3007},[40,7462,353],{},[40,7464,7465],{"class":42,"line":3018},[40,7466,190],{"emptyLinePlaceholder":189},[40,7468,7469],{"class":42,"line":3023},[40,7470,7471],{},"        return new TicketClassification($department, $priority, $response['usage']);\n",[40,7473,7474],{"class":42,"line":3035},[40,7475,253],{},[40,7477,7478],{"class":42,"line":3043},[40,7479,105],{},[11,7481,7482,7483,7486,7487,7490],{},"The enums are the single source of allowed values: they generate the schema and they validate the answer. Values are lowercased before ",[15,7484,7485],{},"tryFrom()",", because capitalisation of enum values is not guaranteed. An invalid answer raises an exception, the job retries, and after the last attempt the ticket stays unclassified and the job lands in ",[15,7488,7489],{},"failed_jobs",". That is a safer default than writing a guessed value.",[23,7492,7494],{"id":7493},"tool-calls-run-with-the-users-permissions","Tool calls run with the user's permissions",[11,7496,7497,7498,7501,7502,7505],{},"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 ",[15,7499,7500],{},"stop_reason: tool_use",", the application runs the tools and sends back ",[15,7503,7504],{},"tool_result"," blocks, and this repeats until the model returns text.",[31,7507,7509],{"className":33,"code":7508,"language":35,"meta":36,"style":36},"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",[15,7510,7511,7516,7520,7525,7529,7534,7538,7543,7548,7553,7557,7561,7566,7571,7576,7581,7586,7590,7594,7599,7603,7608,7613,7618,7623,7628,7633,7638,7643,7648,7653,7657,7662,7666,7670,7675,7679,7683,7688,7692,7697,7702,7707,7711],{"__ignoreMap":36},[40,7512,7513],{"class":42,"line":43},[40,7514,7515],{},"public function answer(User $user, string $question): string\n",[40,7517,7518],{"class":42,"line":49},[40,7519,76],{},[40,7521,7522],{"class":42,"line":55},[40,7523,7524],{},"    $messages = [['role' => 'user', 'content' => $question]];\n",[40,7526,7527],{"class":42,"line":84},[40,7528,190],{"emptyLinePlaceholder":189},[40,7530,7531],{"class":42,"line":90},[40,7532,7533],{},"    for ($step = 0; $step \u003C 5; $step++) {\n",[40,7535,7536],{"class":42,"line":96},[40,7537,7326],{},[40,7539,7540],{"class":42,"line":102},[40,7541,7542],{},"            'max_tokens' => 1024,\n",[40,7544,7545],{"class":42,"line":193},[40,7546,7547],{},"            'tools' => $this->tools,\n",[40,7549,7550],{"class":42,"line":199},[40,7551,7552],{},"            'messages' => $messages,\n",[40,7554,7555],{"class":42,"line":204},[40,7556,7416],{},[40,7558,7559],{"class":42,"line":210},[40,7560,190],{"emptyLinePlaceholder":189},[40,7562,7563],{"class":42,"line":216},[40,7564,7565],{},"        if ($response['stop_reason'] !== 'tool_use') {\n",[40,7567,7568],{"class":42,"line":222},[40,7569,7570],{},"            return collect($response['content'])\n",[40,7572,7573],{"class":42,"line":227},[40,7574,7575],{},"                ->where('type', 'text')\n",[40,7577,7578],{"class":42,"line":232},[40,7579,7580],{},"                ->pluck('text')\n",[40,7582,7583],{"class":42,"line":238},[40,7584,7585],{},"                ->implode(\"\\n\");\n",[40,7587,7588],{"class":42,"line":244},[40,7589,353],{},[40,7591,7592],{"class":42,"line":250},[40,7593,190],{"emptyLinePlaceholder":189},[40,7595,7596],{"class":42,"line":256},[40,7597,7598],{},"        $messages[] = ['role' => 'assistant', 'content' => $response['content']];\n",[40,7600,7601],{"class":42,"line":261},[40,7602,190],{"emptyLinePlaceholder":189},[40,7604,7605],{"class":42,"line":267},[40,7606,7607],{},"        $results = [];\n",[40,7609,7610],{"class":42,"line":272},[40,7611,7612],{},"        foreach ($response['content'] as $block) {\n",[40,7614,7615],{"class":42,"line":278},[40,7616,7617],{},"            if ($block['type'] !== 'tool_use') {\n",[40,7619,7620],{"class":42,"line":283},[40,7621,7622],{},"                continue;\n",[40,7624,7625],{"class":42,"line":288},[40,7626,7627],{},"            }\n",[40,7629,7630],{"class":42,"line":294},[40,7631,7632],{},"            $results[] = [\n",[40,7634,7635],{"class":42,"line":299},[40,7636,7637],{},"                'type' => 'tool_result',\n",[40,7639,7640],{"class":42,"line":305},[40,7641,7642],{},"                'tool_use_id' => $block['id'],\n",[40,7644,7645],{"class":42,"line":310},[40,7646,7647],{},"                'content' => json_encode($this->runTool($user, $block['name'], $block['input']), JSON_THROW_ON_ERROR),\n",[40,7649,7650],{"class":42,"line":1571},[40,7651,7652],{},"            ];\n",[40,7654,7655],{"class":42,"line":1577},[40,7656,353],{},[40,7658,7659],{"class":42,"line":1583},[40,7660,7661],{},"        $messages[] = ['role' => 'user', 'content' => $results];\n",[40,7663,7664],{"class":42,"line":1589},[40,7665,253],{},[40,7667,7668],{"class":42,"line":1594},[40,7669,190],{"emptyLinePlaceholder":189},[40,7671,7672],{"class":42,"line":1600},[40,7673,7674],{},"    throw new ToolLoopLimitExceeded($step);\n",[40,7676,7677],{"class":42,"line":1606},[40,7678,105],{},[40,7680,7681],{"class":42,"line":1612},[40,7682,190],{"emptyLinePlaceholder":189},[40,7684,7685],{"class":42,"line":1618},[40,7686,7687],{},"private function runTool(User $user, string $name, array $input): array\n",[40,7689,7690],{"class":42,"line":2895},[40,7691,76],{},[40,7693,7694],{"class":42,"line":2905},[40,7695,7696],{},"    return match ($name) {\n",[40,7698,7699],{"class":42,"line":2916},[40,7700,7701],{},"        'get_order_status' => $this->orders->statusFor($user, (string) ($input['order_id'] ?? '')),\n",[40,7703,7704],{"class":42,"line":2926},[40,7705,7706],{},"        default => ['error' => \"Unknown tool: {$name}\"],\n",[40,7708,7709],{"class":42,"line":2936},[40,7710,474],{},[40,7712,7713],{"class":42,"line":2941},[40,7714,105],{},[11,7716,7717],{},"Three rules follow from this code:",[703,7719,7720,7723,7730],{},[127,7721,7722],{},"The step limit is mandatory. Without it, a model that keeps requesting tools loops until the timeout and bills every iteration.",[127,7724,7725,7726,7729],{},"Arguments from the model are untrusted input, like a form field. ",[15,7727,7728],{},"statusFor($user, ...)"," scopes the query to the user's own orders. The model cannot see another customer's order even if a prompt in the conversation asks it to.",[127,7731,7732],{},"Tools that change state (refunds, cancellations) should not execute directly from the model's request. Let the tool prepare a proposal and require a confirmation outside the model: a button, a second request, a human.",[23,7734,7736],{"id":7735},"retrieval-in-postgres-with-pgvector","Retrieval in Postgres with pgvector",[11,7738,7739],{},"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 there is one embedding call and one SQL query.",[31,7741,7745],{"className":7742,"code":7743,"language":7744,"meta":36,"style":36},"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",[15,7746,7747,7752,7756,7761,7766,7771,7776,7781,7786,7790,7795],{"__ignoreMap":36},[40,7748,7749],{"class":42,"line":43},[40,7750,7751],{},"CREATE EXTENSION IF NOT EXISTS vector;\n",[40,7753,7754],{"class":42,"line":49},[40,7755,190],{"emptyLinePlaceholder":189},[40,7757,7758],{"class":42,"line":55},[40,7759,7760],{},"CREATE TABLE document_chunks (\n",[40,7762,7763],{"class":42,"line":84},[40,7764,7765],{},"    id          bigserial PRIMARY KEY,\n",[40,7767,7768],{"class":42,"line":90},[40,7769,7770],{},"    document_id bigint NOT NULL REFERENCES documents (id) ON DELETE CASCADE,\n",[40,7772,7773],{"class":42,"line":96},[40,7774,7775],{},"    content     text   NOT NULL,\n",[40,7777,7778],{"class":42,"line":102},[40,7779,7780],{},"    embedding   vector(1536) NOT NULL\n",[40,7782,7783],{"class":42,"line":193},[40,7784,7785],{},");\n",[40,7787,7788],{"class":42,"line":199},[40,7789,190],{"emptyLinePlaceholder":189},[40,7791,7792],{"class":42,"line":204},[40,7793,7794],{},"CREATE INDEX document_chunks_embedding_idx\n",[40,7796,7797],{"class":42,"line":210},[40,7798,7799],{},"    ON document_chunks USING hnsw (embedding vector_cosine_ops);\n",[31,7801,7803],{"className":33,"code":7802,"language":35,"meta":36,"style":36},"$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",[15,7804,7805,7810,7815,7820,7828,7833,7838,7843,7848,7852,7857,7861,7866,7871,7876,7881,7886,7891,7895,7899],{"__ignoreMap":36},[40,7806,7807],{"class":42,"line":43},[40,7808,7809],{},"$embedding = Http::withToken(config('services.openai.key'))\n",[40,7811,7812],{"class":42,"line":49},[40,7813,7814],{},"    ->timeout(15)\n",[40,7816,7817],{"class":42,"line":55},[40,7818,7819],{},"    ->post('https:\u002F\u002Fapi.openai.com\u002Fv1\u002Fembeddings', [\n",[40,7821,7822,7825],{"class":42,"line":84},[40,7823,7824],{},"        'model' => 'text-embedding-3-small',",[40,7826,7827],{}," \u002F\u002F 1536 dimensions, matches the column\n",[40,7829,7830],{"class":42,"line":90},[40,7831,7832],{},"        'input' => $question,\n",[40,7834,7835],{"class":42,"line":96},[40,7836,7837],{},"    ])\n",[40,7839,7840],{"class":42,"line":102},[40,7841,7842],{},"    ->throw()\n",[40,7844,7845],{"class":42,"line":193},[40,7846,7847],{},"    ->json('data.0.embedding');\n",[40,7849,7850],{"class":42,"line":199},[40,7851,190],{"emptyLinePlaceholder":189},[40,7853,7854],{"class":42,"line":204},[40,7855,7856],{},"$vector = '[' . implode(',', $embedding) . ']';\n",[40,7858,7859],{"class":42,"line":210},[40,7860,190],{"emptyLinePlaceholder":189},[40,7862,7863],{"class":42,"line":216},[40,7864,7865],{},"$chunks = DB::select(\n",[40,7867,7868],{"class":42,"line":222},[40,7869,7870],{},"    'SELECT id, content, embedding \u003C=> ?::vector AS distance\n",[40,7872,7873],{"class":42,"line":227},[40,7874,7875],{},"       FROM document_chunks\n",[40,7877,7878],{"class":42,"line":232},[40,7879,7880],{},"      ORDER BY embedding \u003C=> ?::vector\n",[40,7882,7883],{"class":42,"line":238},[40,7884,7885],{},"      LIMIT 5',\n",[40,7887,7888],{"class":42,"line":244},[40,7889,7890],{},"    [$vector, $vector],\n",[40,7892,7893],{"class":42,"line":250},[40,7894,7785],{},[40,7896,7897],{"class":42,"line":256},[40,7898,190],{"emptyLinePlaceholder":189},[40,7900,7901],{"class":42,"line":261},[40,7902,7903],{},"$relevant = array_filter($chunks, fn (object $c): bool => $c->distance \u003C= $maxDistance);\n",[11,7905,7906,7909,7910,7913,7914,7917,7918,7921],{},[15,7907,7908],{},"\u003C=>"," is cosine distance, and the ",[15,7911,7912],{},"hnsw"," index with ",[15,7915,7916],{},"vector_cosine_ops"," serves exactly this ordering. The distance cutoff is filtered in PHP after ",[15,7919,7920],{},"LIMIT",", so the query keeps a shape the index can use.",[11,7923,7924,7927],{},[15,7925,7926],{},"$maxDistance"," has no universal default. It depends on the embedding model, the language and the length of the chunks. 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,7929,7930],{},"When nothing passes the cutoff, say so to the user instead of calling the model with an empty context. An empty context invites an answer from the model's general knowledge, which is what retrieval was meant to prevent.",[23,7932,7934],{"id":7933},"what-to-measure","What to measure",[11,7936,7937,7938,711,7941,7944],{},"Record every call in a table or a metrics system with these fields: feature name, model, ",[15,7939,7940],{},"usage.input_tokens",[15,7942,7943],{},"usage.output_tokens",", latency, and outcome (success, invalid output, API error). This answers the questions that come up in practice:",[703,7946,7947,7950,7953,7956],{},[127,7948,7949],{},"Cost per feature. Cost scales with tokens, not requests. A long system prompt sent with every call often dominates the bill, and grouping by feature shows where.",[127,7951,7952],{},"Latency percentiles (p50, p95). The average hides the slow calls that hit timeouts.",[127,7954,7955],{},"Invalid output rate. Alert on a change relative to its own baseline. A jump usually means a prompt change, a different input distribution or a new model version.",[127,7957,7958],{},"Queue wait time for LLM jobs. It shows whether the number of workers keeps up before users notice.",[11,7960,7961],{},"If prompts contain personal or financial data, log identifiers and counts, not full prompt text.",[23,7963,7965],{"id":7964},"when-a-separate-python-service-makes-sense","When a separate Python service makes sense",[11,7967,7968],{},"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.",[23,7970,701],{"id":700},[703,7972,7973,7982,7985,7988,7991,7994],{},[127,7974,7975,7976,7979,7980,1751],{},"The job ",[15,7977,7978],{},"$timeout"," covers the whole client call with retries and is shorter than ",[15,7981,6948],{},[127,7983,7984],{},"Results are written conditionally; side effects run only for the attempt that wrote.",[127,7986,7987],{},"Model output is validated against enums or a schema; invalid output fails the job.",[127,7989,7990],{},"The tool loop has a step limit; tools check permissions; state-changing tools need confirmation.",[127,7992,7993],{},"The retrieval cutoff is measured on your own data and re-measured after a model change.",[127,7995,7996],{},"Tokens, latency and outcome are recorded per feature.",[729,7998,731],{},{"title":36,"searchDepth":49,"depth":49,"links":8000},[8001,8002,8003,8004,8005,8006,8007,8008,8009],{"id":6744,"depth":49,"text":6745},{"id":6777,"depth":49,"text":6778},{"id":6938,"depth":49,"text":6939},{"id":7194,"depth":49,"text":7195},{"id":7493,"depth":49,"text":7494},{"id":7735,"depth":49,"text":7736},{"id":7933,"depth":49,"text":7934},{"id":7964,"depth":49,"text":7965},{"id":700,"depth":49,"text":701},"2024-10-28",{},"\u002Farticles\u002Fllm-in-php",{"x":8014,"y":4439,"depth":8015,"size":6041},0.47,1.3,[1383,6043],{"title":6732,"description":6738},"llm-integration","articles\u002Fllm-in-php",[35,8021,8022,8023,8024,8025],"laravel","llm","anthropic-api","pgvector","queues","51cwlCJSngL0oh4Ht2MS-Klv35vZ9-0Druhb6l40JOQ",{"id":8028,"title":8029,"articleId":2152,"body":8030,"category":5280,"codeLang":3534,"date":8772,"deploys":43,"description":8034,"excerpt":742,"extension":743,"lang":742,"meta":8773,"navigation":189,"path":8774,"pos":8775,"readMin":193,"related":8779,"seo":8780,"service":8781,"stem":8782,"tags":8783,"version":8787,"__hash__":8788},"articles\u002Farticles\u002Fmicroservice-cost.md","The hidden cost of microservice boundaries: how to price a boundary before you draw it",{"type":8,"value":8031,"toc":8763},[8032,8035,8042,8046,8049,8087,8090,8094,8100,8103,8132,8141,8145,8150,8451,8458,8461,8465,8468,8503,8506,8510,8513,8695,8698,8712,8715,8719,8722,8736,8740,8760],[11,8033,8034],{},"Splitting a system into services moves complexity rather than removing 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. The boundary is the most expensive line in the system to move later, because moving it means migrating data and rewriting the contract.",[11,8036,8037,8038,8041],{},"The question to ask before drawing one: ",[130,8039,8040],{},"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, and no amount of tooling makes it cheap.",[23,8043,8045],{"id":8044},"what-a-boundary-costs","What a boundary costs",[11,8047,8048],{},"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 requires:",[703,8050,8051,8057,8063,8069,8075,8081],{},[127,8052,8053,8056],{},[130,8054,8055],{},"Versioning."," The producer has to support the old and the new contract at the same time, because the two services do not deploy at the same instant.",[127,8058,8059,8062],{},[130,8060,8061],{},"Ordered deploys."," Expand the producer, deploy, migrate consumers, deploy, then contract the producer, deploy again. One logical change becomes three releases.",[127,8064,8065,8068],{},[130,8066,8067],{},"Contract tests or integration environments."," Static analysis no longer sees both sides. Something else has to detect that a field was renamed.",[127,8070,8071,8074],{},[130,8072,8073],{},"Distributed failure handling."," A function call either returns or throws. A network call can also time out after the other side has already committed. Every cross-service write needs a decision about retries, idempotency keys, and what happens to partial state.",[127,8076,8077,8080],{},[130,8078,8079],{},"Data that used to be a join."," A report that joined two tables now needs an API call per row, a replicated read model, or an event stream feeding a separate store.",[127,8082,8083,8086],{},[130,8084,8085],{},"Observability."," A request that crosses three services needs distributed tracing to be debuggable at all.",[11,8088,8089],{},"None of these costs is large on its own. Together they are paid on every change that crosses the line, so the total depends on frequency.",[23,8091,8093],{"id":8092},"a-cost-model","A cost model",[31,8095,8098],{"className":8096,"code":8097,"language":3534,"meta":36},[3532],"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",[15,8099,8097],{"__ignoreMap":36},[11,8101,8102],{},"The terms are not meant to be computed to a number. They are meant to be estimated, roughly and in writing, before the decision:",[703,8104,8105,8111,8117,8126],{},[127,8106,8107,8110],{},[15,8108,8109],{},"f_change",": how many changes per month need both sides modified.",[127,8112,8113,8116],{},[15,8114,8115],{},"cost_per_change",": the overhead listed above, in engineer hours per coordinated change.",[127,8118,8119,1633,8122,8125],{},[15,8120,8121],{},"f_failure",[15,8123,8124],{},"blast_radius",": how often one side being down or slow affects the other, and how far the effect spreads.",[127,8127,8128,8131],{},[15,8129,8130],{},"autonomy_gained",": what each side can now do independently that it could not before. This term is close to zero when the same team owns both services, because the team still has to coordinate with itself.",[11,8133,8134,8135,8137,8138,8140],{},"Example: two services owned by one team, where most features touch both. ",[15,8136,8109],{}," is high, ",[15,8139,8130],{}," is near zero. The boundary adds versioning and ordered deploys to most features and returns nothing. That is a module boundary drawn as a network boundary.",[23,8142,8144],{"id":8143},"measuring-change-coupling-before-drawing-the-line","Measuring change coupling before drawing the line",[11,8146,8147,8149],{},[15,8148,8109],{}," 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.",[31,8151,8153],{"className":2515,"code":8152,"language":2517,"meta":36,"style":36},"#!\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 --since=\"$since\" --format='%H' -- \"$a\" | wc -l)\nboth=0\nfor h in $(git log --since=\"$since\" --format='%H' -- \"$a\"); do\n  if git show --name-only --format= \"$h\" | grep -q \"^$b\u002F\"; then\n    both=$((both + 1))\n  fi\ndone\n\necho \"commits touching $a: $total_a\"\necho \"of which also touch $b: $both\"\n",[15,8154,8155,8160,8165,8170,8225,8229,8284,8293,8337,8383,8404,8409,8414,8418,8435],{"__ignoreMap":36},[40,8156,8157],{"class":42,"line":43},[40,8158,8159],{"class":3206},"#!\u002Fusr\u002Fbin\u002Fenv bash\n",[40,8161,8162],{"class":42,"line":49},[40,8163,8164],{"class":3206},"# Change coupling between two directories over a time window.\n",[40,8166,8167],{"class":42,"line":55},[40,8168,8169],{"class":3206},"# Usage: .\u002Fcoupling.sh src\u002FBilling src\u002FFraud '6 months ago'\n",[40,8171,8172,8174,8177,8180,8183,8185,8188,8190,8192,8195,8197,8200,8202,8204,8207,8210,8213,8216,8219,8222],{"class":42,"line":84},[40,8173,4771],{"class":2218},[40,8175,8176],{"class":4892},"=",[40,8178,8179],{"class":2229},"\"",[40,8181,8182],{"class":2345},"$1",[40,8184,8179],{"class":2229},[40,8186,8187],{"class":2218},"; b",[40,8189,8176],{"class":4892},[40,8191,8179],{"class":2229},[40,8193,8194],{"class":2345},"$2",[40,8196,8179],{"class":2229},[40,8198,8199],{"class":2218},"; since",[40,8201,8176],{"class":4892},[40,8203,8179],{"class":2229},[40,8205,8206],{"class":2345},"${3",[40,8208,8209],{"class":4892},":-",[40,8211,8212],{"class":2345},"6",[40,8214,8215],{"class":2218}," months",[40,8217,8218],{"class":2218}," ago",[40,8220,8221],{"class":2345},"}",[40,8223,8224],{"class":2229},"\"\n",[40,8226,8227],{"class":42,"line":90},[40,8228,190],{"emptyLinePlaceholder":189},[40,8230,8231,8234,8236,8239,8242,8245,8248,8250,8253,8255,8258,8261,8264,8267,8270,8272,8275,8278,8281],{"class":42,"line":96},[40,8232,8233],{"class":2218},"total_a",[40,8235,8176],{"class":4892},[40,8237,8238],{"class":2218},"$(",[40,8240,8241],{"class":2524},"git",[40,8243,8244],{"class":2229}," log",[40,8246,8247],{"class":2345}," --since=",[40,8249,8179],{"class":2229},[40,8251,8252],{"class":2218},"$since",[40,8254,8179],{"class":2229},[40,8256,8257],{"class":2345}," --format=",[40,8259,8260],{"class":2229},"'%H'",[40,8262,8263],{"class":2345}," --",[40,8265,8266],{"class":2229}," \"",[40,8268,8269],{"class":2218},"$a",[40,8271,8179],{"class":2229},[40,8273,8274],{"class":4892}," |",[40,8276,8277],{"class":2524}," wc",[40,8279,8280],{"class":2345}," -l",[40,8282,8283],{"class":2218},")\n",[40,8285,8286,8289,8291],{"class":42,"line":102},[40,8287,8288],{"class":2218},"both",[40,8290,8176],{"class":4892},[40,8292,2606],{"class":2229},[40,8294,8295,8298,8301,8304,8307,8309,8311,8313,8315,8317,8319,8321,8323,8325,8327,8329,8331,8334],{"class":42,"line":193},[40,8296,8297],{"class":4892},"for",[40,8299,8300],{"class":2218}," h ",[40,8302,8303],{"class":4892},"in",[40,8305,8306],{"class":2218}," $(",[40,8308,8241],{"class":2524},[40,8310,8244],{"class":2229},[40,8312,8247],{"class":2345},[40,8314,8179],{"class":2229},[40,8316,8252],{"class":2218},[40,8318,8179],{"class":2229},[40,8320,8257],{"class":2345},[40,8322,8260],{"class":2229},[40,8324,8263],{"class":2345},[40,8326,8266],{"class":2229},[40,8328,8269],{"class":2218},[40,8330,8179],{"class":2229},[40,8332,8333],{"class":2218},"); ",[40,8335,8336],{"class":4892},"do\n",[40,8338,8339,8342,8345,8348,8351,8353,8355,8358,8360,8362,8365,8368,8371,8374,8377,8380],{"class":42,"line":199},[40,8340,8341],{"class":4892},"  if",[40,8343,8344],{"class":2524}," git",[40,8346,8347],{"class":2229}," show",[40,8349,8350],{"class":2345}," --name-only",[40,8352,8257],{"class":2345},[40,8354,8266],{"class":2229},[40,8356,8357],{"class":2218},"$h",[40,8359,8179],{"class":2229},[40,8361,8274],{"class":4892},[40,8363,8364],{"class":2524}," grep",[40,8366,8367],{"class":2345}," -q",[40,8369,8370],{"class":2229}," \"^",[40,8372,8373],{"class":2218},"$b",[40,8375,8376],{"class":2229},"\u002F\"",[40,8378,8379],{"class":2218},"; ",[40,8381,8382],{"class":4892},"then\n",[40,8384,8385,8388,8390,8393,8395,8398,8401],{"class":42,"line":204},[40,8386,8387],{"class":2218},"    both",[40,8389,8176],{"class":4892},[40,8391,8392],{"class":2218},"$((",[40,8394,8288],{"class":2524},[40,8396,8397],{"class":2229}," +",[40,8399,8400],{"class":2345}," 1",[40,8402,8403],{"class":2218},"))\n",[40,8405,8406],{"class":42,"line":210},[40,8407,8408],{"class":4892},"  fi\n",[40,8410,8411],{"class":42,"line":216},[40,8412,8413],{"class":4892},"done\n",[40,8415,8416],{"class":42,"line":222},[40,8417,190],{"emptyLinePlaceholder":189},[40,8419,8420,8423,8426,8428,8430,8433],{"class":42,"line":227},[40,8421,8422],{"class":2345},"echo",[40,8424,8425],{"class":2229}," \"commits touching ",[40,8427,8269],{"class":2218},[40,8429,2226],{"class":2229},[40,8431,8432],{"class":2218},"$total_a",[40,8434,8224],{"class":2229},[40,8436,8437,8439,8442,8444,8446,8449],{"class":42,"line":232},[40,8438,8422],{"class":2345},[40,8440,8441],{"class":2229}," \"of which also touch ",[40,8443,8373],{"class":2218},[40,8445,2226],{"class":2229},[40,8447,8448],{"class":2218},"$both",[40,8450,8224],{"class":2229},[11,8452,8453,8454,8457],{},"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 instead. 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 ",[15,8455,8456],{},"git log"," output.",[11,8459,8460],{},"A rough reading, as a starting point rather than a rule: 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.",[23,8462,8464],{"id":8463},"when-a-boundary-pays-for-itself","When a boundary pays for itself",[11,8466,8467],{},"A boundary earns its cost when the two sides differ in something that matters operationally:",[703,8469,8470,8476,8485,8491,8497],{},[127,8471,8472,8475],{},[130,8473,8474],{},"Deploy cadence."," Example: a payments service that ships several times a week and a fraud-scoring model that ships only after a validation cycle of several weeks. Keeping them separate lets payments release without waiting for model validation.",[127,8477,8478,8481,8482,8484],{},[130,8479,8480],{},"Ownership."," Different teams, different on-call rotations, different priorities. This is the term that makes ",[15,8483,8130],{}," real. A boundary that does not follow a team boundary rarely delivers autonomy.",[127,8486,8487,8490],{},[130,8488,8489],{},"Scaling profile."," A CPU-heavy document parser and a latency-sensitive API have different resource needs. Separate processes let each scale and fail independently.",[127,8492,8493,8496],{},[130,8494,8495],{},"Failure isolation."," A component that calls an unreliable third party can be put behind a queue so its outages do not block the request path.",[127,8498,8499,8502],{},[130,8500,8501],{},"Technology."," 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,8504,8505],{},"If none of these apply, the candidate boundary is a module boundary, and it belongs inside one deployable.",[23,8507,8509],{"id":8508},"start-with-modules","Start with modules",[11,8511,8512],{},"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:",[31,8514,8516],{"className":2209,"code":8515,"language":2211,"meta":36,"style":36},"# 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",[15,8517,8518,8523,8530,8537,8544,8551,8562,8569,8581,8591,8602,8608,8618,8627,8638,8644,8654,8663,8670,8684],{"__ignoreMap":36},[40,8519,8520],{"class":42,"line":43},[40,8521,8522],{"class":3206},"# deptrac.yaml\n",[40,8524,8525,8528],{"class":42,"line":49},[40,8526,8527],{"class":2222},"deptrac",[40,8529,2248],{"class":2218},[40,8531,8532,8535],{"class":42,"line":55},[40,8533,8534],{"class":2222},"  paths",[40,8536,2248],{"class":2218},[40,8538,8539,8541],{"class":42,"line":84},[40,8540,2622],{"class":2218},[40,8542,8543],{"class":2229},".\u002Fsrc\n",[40,8545,8546,8549],{"class":42,"line":90},[40,8547,8548],{"class":2222},"  layers",[40,8550,2248],{"class":2218},[40,8552,8553,8555,8557,8559],{"class":42,"line":96},[40,8554,2622],{"class":2218},[40,8556,2223],{"class":2222},[40,8558,2226],{"class":2218},[40,8560,8561],{"class":2229},"Billing\n",[40,8563,8564,8567],{"class":42,"line":102},[40,8565,8566],{"class":2222},"      collectors",[40,8568,2248],{"class":2218},[40,8570,8571,8573,8576,8578],{"class":42,"line":193},[40,8572,2690],{"class":2218},[40,8574,8575],{"class":2222},"type",[40,8577,2226],{"class":2218},[40,8579,8580],{"class":2229},"directory\n",[40,8582,8583,8586,8588],{"class":42,"line":199},[40,8584,8585],{"class":2222},"          value",[40,8587,2226],{"class":2218},[40,8589,8590],{"class":2229},"src\u002FBilling\u002F.*\n",[40,8592,8593,8595,8597,8599],{"class":42,"line":204},[40,8594,2622],{"class":2218},[40,8596,2223],{"class":2222},[40,8598,2226],{"class":2218},[40,8600,8601],{"class":2229},"Fraud\n",[40,8603,8604,8606],{"class":42,"line":210},[40,8605,8566],{"class":2222},[40,8607,2248],{"class":2218},[40,8609,8610,8612,8614,8616],{"class":42,"line":216},[40,8611,2690],{"class":2218},[40,8613,8575],{"class":2222},[40,8615,2226],{"class":2218},[40,8617,8580],{"class":2229},[40,8619,8620,8622,8624],{"class":42,"line":222},[40,8621,8585],{"class":2222},[40,8623,2226],{"class":2218},[40,8625,8626],{"class":2229},"src\u002FFraud\u002F.*\n",[40,8628,8629,8631,8633,8635],{"class":42,"line":227},[40,8630,2622],{"class":2218},[40,8632,2223],{"class":2222},[40,8634,2226],{"class":2218},[40,8636,8637],{"class":2229},"Shared\n",[40,8639,8640,8642],{"class":42,"line":232},[40,8641,8566],{"class":2222},[40,8643,2248],{"class":2218},[40,8645,8646,8648,8650,8652],{"class":42,"line":238},[40,8647,2690],{"class":2218},[40,8649,8575],{"class":2222},[40,8651,2226],{"class":2218},[40,8653,8580],{"class":2229},[40,8655,8656,8658,8660],{"class":42,"line":244},[40,8657,8585],{"class":2222},[40,8659,2226],{"class":2218},[40,8661,8662],{"class":2229},"src\u002FShared\u002F.*\n",[40,8664,8665,8668],{"class":42,"line":250},[40,8666,8667],{"class":2222},"  ruleset",[40,8669,2248],{"class":2218},[40,8671,8672,8675,8678,8681],{"class":42,"line":256},[40,8673,8674],{"class":2222},"    Billing",[40,8676,8677],{"class":2218},": [",[40,8679,8680],{"class":2229},"Shared",[40,8682,8683],{"class":2218},"]\n",[40,8685,8686,8689,8691,8693],{"class":42,"line":261},[40,8687,8688],{"class":2222},"    Fraud",[40,8690,8677],{"class":2218},[40,8692,8680],{"class":2229},[40,8694,8683],{"class":2218},[11,8696,8697],{},"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. Two further rules make later extraction cheap:",[703,8699,8700,8706],{},[127,8701,8702,8705],{},[130,8703,8704],{},"No cross-module joins."," A module reads another module's data only through its public interface. When the module is extracted, that interface becomes the API, and nothing else has to change.",[127,8707,8708,8711],{},[130,8709,8710],{},"Cross-module side effects through events."," If Billing needs Fraud to react to a payment, it dispatches an event rather than calling Fraud directly. Moving that event to a message broker later is a transport change.",[11,8713,8714],{},"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.",[23,8716,8718],{"id":8717},"platform-before-services","Platform before services",[11,8720,8721],{},"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 scales with the number of services rather than with the amount of business logic. Before splitting, have in place:",[703,8723,8724,8727,8730,8733],{},[127,8725,8726],{},"one CI\u002FCD template that a new service adopts without modification;",[127,8728,8729],{},"structured logs with a correlation ID propagated across calls;",[127,8731,8732],{},"distributed tracing;",[127,8734,8735],{},"a standard for timeouts, retries, and idempotency keys on every cross-service call.",[23,8737,8739],{"id":8738},"checklist-before-drawing-a-boundary","Checklist before drawing a boundary",[703,8741,8742,8745,8748,8751,8754,8757],{},[127,8743,8744],{},"What is the smallest change that crosses it, and how often has such a change happened in the last six months (measured, not estimated)?",[127,8746,8747],{},"Is each side owned by a different team?",[127,8749,8750],{},"Do the two sides differ in deploy cadence, scaling profile, failure mode, or technology?",[127,8752,8753],{},"Is the boundary already enforced as a module boundary inside the monolith, with no cross-module joins?",[127,8755,8756],{},"Is the platform (CI\u002FCD template, logs, tracing, retry policy) ready for one more service?",[127,8758,8759],{},"If the answer to the first question is \"most features\", keep it as a module.",[729,8761,8762],{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}",{"title":36,"searchDepth":49,"depth":49,"links":8764},[8765,8766,8767,8768,8769,8770,8771],{"id":8044,"depth":49,"text":8045},{"id":8092,"depth":49,"text":8093},{"id":8143,"depth":49,"text":8144},{"id":8463,"depth":49,"text":8464},{"id":8508,"depth":49,"text":8509},{"id":8717,"depth":49,"text":8718},{"id":8738,"depth":49,"text":8739},"2026-05-14",{},"\u002Farticles\u002Fmicroservice-cost",{"x":8776,"y":8777,"depth":8778,"size":6041},0.72,0.68,1.1,[1383,4442],{"title":8029,"description":8034},"service-boundaries","articles\u002Fmicroservice-cost",[4449,8784,8785,8786],"organisation","platform","devex","v6.0.0","40CiIh9g5aXYjcK7b_2CjSZmYb4EWMAO51VXePXW064",{"id":8790,"title":8791,"articleId":750,"body":8792,"category":2142,"codeLang":4534,"date":9672,"deploys":43,"description":8796,"excerpt":742,"extension":743,"lang":742,"meta":9673,"navigation":189,"path":9674,"pos":9675,"readMin":204,"related":9678,"seo":9679,"service":9680,"stem":9681,"tags":9682,"version":760,"__hash__":9687},"articles\u002Farticles\u002Fn8n-rag-data-quality.md","RAG ingestion in n8n: document versions, deletions and freshness metadata",{"type":8,"value":8793,"toc":9660},[8794,8797,8800,8804,8807,8835,8838,8883,8886,8890,8893,8910,8914,8943,9160,9167,9170,9174,9197,9297,9300,9304,9307,9374,9377,9381,9401,9434,9437,9441,9444,9571,9581,9585,9588,9623,9626,9630,9633,9635,9658],[11,8795,8796],{},"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 is fluent, it cites a real document, and it is wrong. No error is logged and no hallucination detector fires, because the model did not invent anything.",[11,8798,8799],{},"The defect lives in the ingestion workflow, not in the model or the prompt. This article covers the invariants an ingestion pipeline should hold and how to implement them in n8n with Pinecone. The same rules apply to Qdrant, Weaviate or pgvector.",[23,8801,8803],{"id":8802},"why-stale-chunks-survive","Why stale chunks survive",[11,8805,8806],{},"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. Three common patterns produce duplicates:",[124,8808,8809,8819,8829],{},[127,8810,8811,8814,8815,8818],{},[130,8812,8813],{},"IDs derived from the filename or content."," A new version saved as ",[15,8816,8817],{},"pricing-2026-v2.pdf",", or a version whose first paragraph changed, gets new IDs. The old records stay.",[127,8820,8821,8824,8825,8828],{},[130,8822,8823],{},"A shorter new version."," If version 1 produced 12 chunks and version 2 produces 9, an upsert by ",[15,8826,8827],{},"docId + index"," overwrites chunks 0 to 8 and leaves 9 to 11 from the old version.",[127,8830,8831,8834],{},[130,8832,8833],{},"Deletions at the source."," Moving a file to an archive folder or deleting it in Google Drive does not trigger anything in the vector store. Without an explicit delete the chunks remain retrievable indefinitely.",[11,8836,8837],{},"A typical first version of the workflow (Drive trigger, text splitter, Pinecone upsert) contains all three:",[31,8839,8841],{"className":4532,"code":8840,"language":4534,"meta":36,"style":36},"\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",[15,8842,8843,8848,8853,8858,8863,8868,8873,8878],{"__ignoreMap":36},[40,8844,8845],{"class":42,"line":43},[40,8846,8847],{},"\u002F\u002F n8n Code node: naive chunk preparation\n",[40,8849,8850],{"class":42,"line":49},[40,8851,8852],{},"return $input.all().map(chunk => ({\n",[40,8854,8855],{"class":42,"line":55},[40,8856,8857],{},"  json: {\n",[40,8859,8860],{"class":42,"line":84},[40,8861,8862],{},"    id: chunk.json.metadata.loc.pageNumber + '_' + chunk.json.pageContent.slice(0, 32),\n",[40,8864,8865],{"class":42,"line":90},[40,8866,8867],{},"    text: chunk.json.pageContent,\n",[40,8869,8870],{"class":42,"line":96},[40,8871,8872],{},"    metadata: { source: chunk.json.metadata.source },\n",[40,8874,8875],{"class":42,"line":102},[40,8876,8877],{},"  },\n",[40,8879,8880],{"class":42,"line":193},[40,8881,8882],{},"}));\n",[11,8884,8885],{},"The ID depends on page content, the only metadata is the filename, and there is no delete step anywhere in the flow.",[23,8887,8889],{"id":8888},"the-invariant","The invariant",[11,8891,8892],{},"For every document in the source folder, the index contains exactly the chunks of its current version, and nothing else. Every design decision below follows from that sentence:",[703,8894,8895,8898,8901,8904,8907],{},[127,8896,8897],{},"the document ID comes from the source system (the Drive file ID), not from the filename, because the file ID does not change on rename or upload of a new revision;",[127,8899,8900],{},"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;",[127,8902,8903],{},"replacing a document means writing the new chunks and then deleting every chunk with the same document prefix and a different hash;",[127,8905,8906],{},"a scheduled reconciliation removes documents that no longer exist at the source;",[127,8908,8909],{},"freshness is stored as numbers, because that is what metadata range filters operate on.",[23,8911,8913],{"id":8912},"preparing-chunks","Preparing chunks",[11,8915,8916,8917,711,8920,711,8922,711,8925,8928,8929,3475,8931,8934,8935,8938,8939,8942],{},"The node below runs in \"Run Once for All Items\" mode. It expects one item per Drive file with ",[15,8918,8919],{},"id",[15,8921,2223],{},[15,8923,8924],{},"modifiedTime",[15,8926,8927],{},"mimeType",", the extracted ",[15,8930,3534],{},[15,8932,8933],{},"docType"," (for example derived from the subfolder). On self-hosted n8n, ",[15,8936,8937],{},"require('crypto')"," works only if the instance runs with ",[15,8940,8941],{},"NODE_FUNCTION_ALLOW_BUILTIN=crypto"," (with external task runners, the variable is set on the runner, not on the main instance).",[31,8944,8946],{"className":4532,"code":8945,"language":4534,"meta":36,"style":36},"\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",[15,8947,8948,8953,8958,8962,8967,8972,8977,8982,8987,8992,8996,9001,9006,9011,9016,9021,9025,9030,9034,9038,9043,9048,9053,9058,9062,9067,9072,9077,9082,9087,9092,9096,9101,9106,9111,9116,9121,9126,9131,9136,9141,9146,9151,9155],{"__ignoreMap":36},[40,8949,8950],{"class":42,"line":43},[40,8951,8952],{},"\u002F\u002F n8n Code node, mode: Run Once for All Items\n",[40,8954,8955],{"class":42,"line":49},[40,8956,8957],{},"const crypto = require('crypto');\n",[40,8959,8960],{"class":42,"line":55},[40,8961,190],{"emptyLinePlaceholder":189},[40,8963,8964],{"class":42,"line":84},[40,8965,8966],{},"const CHUNK_SIZE = 1500; \u002F\u002F characters\n",[40,8968,8969],{"class":42,"line":90},[40,8970,8971],{},"const OVERLAP = 200;\n",[40,8973,8974],{"class":42,"line":96},[40,8975,8976],{},"\u002F\u002F Days after the last modification when a document stops being served.\n",[40,8978,8979],{"class":42,"line":102},[40,8980,8981],{},"\u002F\u002F null means the document type does not expire.\n",[40,8983,8984],{"class":42,"line":193},[40,8985,8986],{},"const TTL_DAYS = { pricing: 90, changelog: 180, adr: null, general: null };\n",[40,8988,8989],{"class":42,"line":199},[40,8990,8991],{},"const NEVER = 4102444800; \u002F\u002F 2100-01-01 as a Unix timestamp\n",[40,8993,8994],{"class":42,"line":204},[40,8995,190],{"emptyLinePlaceholder":189},[40,8997,8998],{"class":42,"line":210},[40,8999,9000],{},"function split(text) {\n",[40,9002,9003],{"class":42,"line":216},[40,9004,9005],{},"  const chunks = [];\n",[40,9007,9008],{"class":42,"line":222},[40,9009,9010],{},"  for (let start = 0; start \u003C text.length; start += CHUNK_SIZE - OVERLAP) {\n",[40,9012,9013],{"class":42,"line":227},[40,9014,9015],{},"    chunks.push(text.slice(start, start + CHUNK_SIZE));\n",[40,9017,9018],{"class":42,"line":232},[40,9019,9020],{},"    if (start + CHUNK_SIZE >= text.length) break;\n",[40,9022,9023],{"class":42,"line":238},[40,9024,4561],{},[40,9026,9027],{"class":42,"line":244},[40,9028,9029],{},"  return chunks;\n",[40,9031,9032],{"class":42,"line":250},[40,9033,105],{},[40,9035,9036],{"class":42,"line":256},[40,9037,190],{"emptyLinePlaceholder":189},[40,9039,9040],{"class":42,"line":261},[40,9041,9042],{},"const out = [];\n",[40,9044,9045],{"class":42,"line":267},[40,9046,9047],{},"for (const item of $input.all()) {\n",[40,9049,9050],{"class":42,"line":272},[40,9051,9052],{},"  const { id: docId, name, modifiedTime, mimeType, text } = item.json;\n",[40,9054,9055],{"class":42,"line":278},[40,9056,9057],{},"  if (typeof text !== 'string' || text.trim().length \u003C 50) continue;\n",[40,9059,9060],{"class":42,"line":283},[40,9061,190],{"emptyLinePlaceholder":189},[40,9063,9064],{"class":42,"line":288},[40,9065,9066],{},"  const docType = item.json.docType ?? 'general';\n",[40,9068,9069],{"class":42,"line":294},[40,9070,9071],{},"  const body = text.trim();\n",[40,9073,9074],{"class":42,"line":299},[40,9075,9076],{},"  const version = crypto.createHash('sha256').update(body).digest('hex').slice(0, 12);\n",[40,9078,9079],{"class":42,"line":305},[40,9080,9081],{},"  const modifiedTs = Math.floor(Date.parse(modifiedTime) \u002F 1000);\n",[40,9083,9084],{"class":42,"line":310},[40,9085,9086],{},"  const ttl = TTL_DAYS[docType] ?? null;\n",[40,9088,9089],{"class":42,"line":1571},[40,9090,9091],{},"  const expiresTs = ttl === null ? NEVER : modifiedTs + ttl * 86400;\n",[40,9093,9094],{"class":42,"line":1577},[40,9095,190],{"emptyLinePlaceholder":189},[40,9097,9098],{"class":42,"line":1583},[40,9099,9100],{},"  split(body).forEach((chunk, i) => {\n",[40,9102,9103],{"class":42,"line":1589},[40,9104,9105],{},"    out.push({\n",[40,9107,9108],{"class":42,"line":1594},[40,9109,9110],{},"      json: {\n",[40,9112,9113],{"class":42,"line":1600},[40,9114,9115],{},"        id: `${docId}#${version}#${String(i).padStart(4, '0')}`,\n",[40,9117,9118],{"class":42,"line":1606},[40,9119,9120],{},"        docId,\n",[40,9122,9123],{"class":42,"line":1612},[40,9124,9125],{},"        version,\n",[40,9127,9128],{"class":42,"line":1618},[40,9129,9130],{},"        text: chunk,\n",[40,9132,9133],{"class":42,"line":2895},[40,9134,9135],{},"        metadata: { docId, version, docType, source: name, mimeType, modifiedTs, expiresTs, text: chunk },\n",[40,9137,9138],{"class":42,"line":2905},[40,9139,9140],{},"      },\n",[40,9142,9143],{"class":42,"line":2916},[40,9144,9145],{},"    });\n",[40,9147,9148],{"class":42,"line":2926},[40,9149,9150],{},"  });\n",[40,9152,9153],{"class":42,"line":2936},[40,9154,105],{},[40,9156,9157],{"class":42,"line":2941},[40,9158,9159],{},"return out;\n",[11,9161,9162,9163,9166],{},"The hash in the ID has two uses. If the document has not changed, the chunk IDs are identical and the workflow can skip embedding entirely (check whether any ID with the prefix ",[15,9164,9165],{},"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,9168,9169],{},"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), which is why the chunk size is bounded.",[23,9171,9173],{"id":9172},"replacing-a-version","Replacing a version",[11,9175,9176,9177,9180,9181,9184,9185,9188,9189,9192,9193,9196],{},"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 (",[15,9178,9179],{},"GET \u002Fvectors\u002Flist?prefix=\u003CdocId>%23",", paginated) and delete by ID (",[15,9182,9183],{},"POST \u002Fvectors\u002Fdelete"," with an ",[15,9186,9187],{},"ids"," array). Listing by prefix is available on serverless indexes only. Pinecone also accepts delete by metadata filter (for example ",[15,9190,9191],{},"docId"," equal to the document and ",[15,9194,9195],{},"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:",[31,9198,9200],{"className":4532,"code":9199,"language":4534,"meta":36,"style":36},"\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",[15,9201,9202,9207,9212,9217,9222,9227,9231,9235,9240,9245,9250,9255,9260,9264,9268,9273,9278,9283,9288,9292],{"__ignoreMap":36},[40,9203,9204],{"class":42,"line":43},[40,9205,9206],{},"\u002F\u002F n8n Code node: select IDs of superseded versions\n",[40,9208,9209],{"class":42,"line":49},[40,9210,9211],{},"\u002F\u002F Input: items from \"List IDs by prefix\", each with { id }\n",[40,9213,9214],{"class":42,"line":55},[40,9215,9216],{},"\u002F\u002F Current version per document comes from the \"Prepare chunks\" node\n",[40,9218,9219],{"class":42,"line":84},[40,9220,9221],{},"const current = new Map(\n",[40,9223,9224],{"class":42,"line":90},[40,9225,9226],{},"  $('Prepare chunks').all().map(i => [i.json.docId, i.json.version])\n",[40,9228,9229],{"class":42,"line":96},[40,9230,7785],{},[40,9232,9233],{"class":42,"line":102},[40,9234,190],{"emptyLinePlaceholder":189},[40,9236,9237],{"class":42,"line":193},[40,9238,9239],{},"const stale = $input.all()\n",[40,9241,9242],{"class":42,"line":199},[40,9243,9244],{},"  .map(i => i.json.id)\n",[40,9246,9247],{"class":42,"line":204},[40,9248,9249],{},"  .filter(id => {\n",[40,9251,9252],{"class":42,"line":210},[40,9253,9254],{},"    const [docId, version] = id.split('#');\n",[40,9256,9257],{"class":42,"line":216},[40,9258,9259],{},"    return current.has(docId) && current.get(docId) !== version;\n",[40,9261,9262],{"class":42,"line":222},[40,9263,9150],{},[40,9265,9266],{"class":42,"line":227},[40,9267,190],{"emptyLinePlaceholder":189},[40,9269,9270],{"class":42,"line":232},[40,9271,9272],{},"\u002F\u002F Pinecone accepts up to 1000 IDs per delete call\n",[40,9274,9275],{"class":42,"line":238},[40,9276,9277],{},"const batches = [];\n",[40,9279,9280],{"class":42,"line":244},[40,9281,9282],{},"for (let i = 0; i \u003C stale.length; i += 1000) {\n",[40,9284,9285],{"class":42,"line":250},[40,9286,9287],{},"  batches.push({ json: { ids: stale.slice(i, i + 1000) } });\n",[40,9289,9290],{"class":42,"line":256},[40,9291,105],{},[40,9293,9294],{"class":42,"line":261},[40,9295,9296],{},"return batches;\n",[11,9298,9299],{},"Upsert first and 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.",[23,9301,9303],{"id":9302},"reconciling-deletions","Reconciling deletions",[11,9305,9306],{},"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.",[31,9308,9310],{"className":4532,"code":9309,"language":4534,"meta":36,"style":36},"\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",[15,9311,9312,9317,9322,9327,9331,9336,9341,9346,9351,9355,9359,9364,9369],{"__ignoreMap":36},[40,9313,9314],{"class":42,"line":43},[40,9315,9316],{},"\u002F\u002F n8n Code node: find documents deleted at the source\n",[40,9318,9319],{"class":42,"line":49},[40,9320,9321],{},"const live = new Set($('List Drive files').all().map(i => i.json.id));\n",[40,9323,9324],{"class":42,"line":55},[40,9325,9326],{},"const stored = new Set($('List index IDs').all().map(i => i.json.id.split('#')[0]));\n",[40,9328,9329],{"class":42,"line":84},[40,9330,190],{"emptyLinePlaceholder":189},[40,9332,9333],{"class":42,"line":90},[40,9334,9335],{},"\u002F\u002F Guard: an empty or partial Drive listing (expired credentials, wrong folder,\n",[40,9337,9338],{"class":42,"line":96},[40,9339,9340],{},"\u002F\u002F missed pagination) would otherwise delete the whole index.\n",[40,9342,9343],{"class":42,"line":102},[40,9344,9345],{},"if (live.size === 0 || live.size \u003C stored.size * 0.8) {\n",[40,9347,9348],{"class":42,"line":193},[40,9349,9350],{},"  throw new Error(`Drive returned ${live.size} files for ${stored.size} indexed documents, aborting`);\n",[40,9352,9353],{"class":42,"line":199},[40,9354,105],{},[40,9356,9357],{"class":42,"line":204},[40,9358,190],{"emptyLinePlaceholder":189},[40,9360,9361],{"class":42,"line":210},[40,9362,9363],{},"return [...stored]\n",[40,9365,9366],{"class":42,"line":216},[40,9367,9368],{},"  .filter(docId => !live.has(docId))\n",[40,9370,9371],{"class":42,"line":222},[40,9372,9373],{},"  .map(docId => ({ json: { docId } }));\n",[11,9375,9376],{},"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.",[23,9378,9380],{"id":9379},"filtering-by-freshness-at-query-time","Filtering by freshness at query time",[11,9382,9383,9384,711,9387,711,9390,711,9393,9396,9397,9400],{},"Pinecone metadata range operators (",[15,9385,9386],{},"$gt",[15,9388,9389],{},"$gte",[15,9391,9392],{},"$lt",[15,9394,9395],{},"$lte",") work on numbers, not on ISO date strings. That is why the chunk carries ",[15,9398,9399],{},"expiresTs"," as a Unix timestamp. The query body in an HTTP Request node, with expressions enabled:",[31,9402,9404],{"className":4532,"code":9403,"language":4534,"meta":36,"style":36},"{\n  \"vector\": {{ JSON.stringify($json.embedding) }},\n  \"topK\": 6,\n  \"includeMetadata\": true,\n  \"filter\": { \"expiresTs\": { \"$gte\": {{ Math.floor(Date.now() \u002F 1000) }} } }\n}\n",[15,9405,9406,9410,9415,9420,9425,9430],{"__ignoreMap":36},[40,9407,9408],{"class":42,"line":43},[40,9409,76],{},[40,9411,9412],{"class":42,"line":49},[40,9413,9414],{},"  \"vector\": {{ JSON.stringify($json.embedding) }},\n",[40,9416,9417],{"class":42,"line":55},[40,9418,9419],{},"  \"topK\": 6,\n",[40,9421,9422],{"class":42,"line":84},[40,9423,9424],{},"  \"includeMetadata\": true,\n",[40,9426,9427],{"class":42,"line":90},[40,9428,9429],{},"  \"filter\": { \"expiresTs\": { \"$gte\": {{ Math.floor(Date.now() \u002F 1000) }} } }\n",[40,9431,9432],{"class":42,"line":96},[40,9433,105],{},[11,9435,9436],{},"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 this is the preferred failure: the bot finds no context and says it does not know, which is 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.",[23,9438,9440],{"id":9439},"logging-retrieval","Logging retrieval",[11,9442,9443],{},"Log every retrieval, not a sample: the query, the returned IDs, their scores, document versions and modification dates. With that log, the failures above become queries you can run.",[31,9445,9447],{"className":4532,"code":9446,"language":4534,"meta":36,"style":36},"\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",[15,9448,9449,9454,9459,9464,9469,9473,9478,9482,9487,9492,9497,9502,9507,9512,9517,9522,9527,9532,9537,9542,9547,9552,9557,9562,9566],{"__ignoreMap":36},[40,9450,9451],{"class":42,"line":43},[40,9452,9453],{},"\u002F\u002F n8n Code node: structured retrieval log\n",[40,9455,9456],{"class":42,"line":49},[40,9457,9458],{},"const query = $('User query').first().json.text;\n",[40,9460,9461],{"class":42,"line":55},[40,9462,9463],{},"const matches = $('Pinecone query').first().json.matches ?? [];\n",[40,9465,9466],{"class":42,"line":84},[40,9467,9468],{},"const now = Date.now() \u002F 1000;\n",[40,9470,9471],{"class":42,"line":90},[40,9472,190],{"emptyLinePlaceholder":189},[40,9474,9475],{"class":42,"line":96},[40,9476,9477],{},"return [{\n",[40,9479,9480],{"class":42,"line":102},[40,9481,8857],{},[40,9483,9484],{"class":42,"line":193},[40,9485,9486],{},"    ts: new Date().toISOString(),\n",[40,9488,9489],{"class":42,"line":199},[40,9490,9491],{},"    query,\n",[40,9493,9494],{"class":42,"line":204},[40,9495,9496],{},"    matches: matches.map(m => ({\n",[40,9498,9499],{"class":42,"line":210},[40,9500,9501],{},"      id: m.id,\n",[40,9503,9504],{"class":42,"line":216},[40,9505,9506],{},"      score: m.score,\n",[40,9508,9509],{"class":42,"line":222},[40,9510,9511],{},"      docId: m.metadata.docId,\n",[40,9513,9514],{"class":42,"line":227},[40,9515,9516],{},"      version: m.metadata.version,\n",[40,9518,9519],{"class":42,"line":232},[40,9520,9521],{},"      ageDays: Math.round((now - m.metadata.modifiedTs) \u002F 86400),\n",[40,9523,9524],{"class":42,"line":238},[40,9525,9526],{},"    })),\n",[40,9528,9529],{"class":42,"line":244},[40,9530,9531],{},"    topScore: matches.length ? Math.max(...matches.map(m => m.score)) : null,\n",[40,9533,9534],{"class":42,"line":250},[40,9535,9536],{},"    versionsPerDoc: Object.values(\n",[40,9538,9539],{"class":42,"line":256},[40,9540,9541],{},"      matches.reduce((acc, m) => {\n",[40,9543,9544],{"class":42,"line":261},[40,9545,9546],{},"        (acc[m.metadata.docId] ??= new Set()).add(m.metadata.version);\n",[40,9548,9549],{"class":42,"line":267},[40,9550,9551],{},"        return acc;\n",[40,9553,9554],{"class":42,"line":272},[40,9555,9556],{},"      }, {})\n",[40,9558,9559],{"class":42,"line":278},[40,9560,9561],{},"    ).reduce((max, s) => Math.max(max, s.size), 0),\n",[40,9563,9564],{"class":42,"line":283},[40,9565,8877],{},[40,9567,9568],{"class":42,"line":288},[40,9569,9570],{},"}];\n",[11,9572,9573,9576,9577,9580],{},[15,9574,9575],{},"versionsPerDoc"," greater than 1 means version replacement failed for at least one document. A persistently low ",[15,9578,9579],{},"topScore"," for a class of questions means the knowledge base does not cover them. Score thresholds depend on the embedding model and the corpus, so derive them from the distribution in your own log instead of copying a number from someone else's setup.",[23,9582,9584],{"id":9583},"pgvector-as-an-alternative","pgvector as an alternative",[11,9586,9587],{},"If the vector store lives in Postgres, version replacement becomes a transaction and the prefix and hash tricks are unnecessary:",[31,9589,9591],{"className":7742,"code":9590,"language":7744,"meta":36,"style":36},"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",[15,9592,9593,9598,9603,9608,9613,9618],{"__ignoreMap":36},[40,9594,9595],{"class":42,"line":43},[40,9596,9597],{},"BEGIN;\n",[40,9599,9600],{"class":42,"line":49},[40,9601,9602],{},"DELETE FROM rag_chunks WHERE doc_id = $1;\n",[40,9604,9605],{"class":42,"line":55},[40,9606,9607],{},"INSERT INTO rag_chunks (doc_id, version, chunk_no, body, embedding, modified_at, expires_at)\n",[40,9609,9610],{"class":42,"line":84},[40,9611,9612],{},"VALUES ($1, $2, 0, $3, $4, $5, $6),\n",[40,9614,9615],{"class":42,"line":90},[40,9616,9617],{},"       ($1, $2, 1, $7, $8, $5, $6);  -- one row per chunk\n",[40,9619,9620],{"class":42,"line":96},[40,9621,9622],{},"COMMIT;\n",[11,9624,9625],{},"Readers see either the old version or the new one, never both and never 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.",[23,9627,9629],{"id":9628},"when-this-is-unnecessary","When this is unnecessary",[11,9631,9632],{},"A corpus that is loaded once and never edited, such as a fixed set of manuals for a released product, does not need versioning or reconciliation. A corpus small enough to fit into the model's context window does not need a vector store at all. The mechanisms above pay off when documents are edited, renamed and archived by people who do not think about the index, which is the normal state of a shared drive.",[23,9634,701],{"id":700},[703,9636,9637,9640,9643,9646,9649,9652,9655],{},[127,9638,9639],{},"The document ID comes from the source system and survives renames.",[127,9641,9642],{},"Chunk IDs contain the document ID and a version hash, so a whole document can be listed by prefix.",[127,9644,9645],{},"A new version is written first, then older versions of the same document are deleted.",[127,9647,9648],{},"A scheduled reconciliation deletes documents removed at the source and aborts on suspicious listings.",[127,9650,9651],{},"Freshness is stored as numeric timestamps, with the expiry policy set per document type at ingestion.",[127,9653,9654],{},"Every retrieval is logged with document versions and ages.",[127,9656,9657],{},"Review question: if someone edits a document now, what happens to the chunks of the previous version, and which node is responsible for that?",[729,9659,731],{},{"title":36,"searchDepth":49,"depth":49,"links":9661},[9662,9663,9664,9665,9666,9667,9668,9669,9670,9671],{"id":8802,"depth":49,"text":8803},{"id":8888,"depth":49,"text":8889},{"id":8912,"depth":49,"text":8913},{"id":9172,"depth":49,"text":9173},{"id":9302,"depth":49,"text":9303},{"id":9379,"depth":49,"text":9380},{"id":9439,"depth":49,"text":9440},{"id":9583,"depth":49,"text":9584},{"id":9628,"depth":49,"text":9629},{"id":700,"depth":49,"text":701},"2026-05-22",{},"\u002Farticles\u002Fn8n-rag-data-quality",{"x":9676,"y":9677,"depth":8778,"size":6041},0.4,0.6,[1383,6733],{"title":8791,"description":8796},"n8n-rag-pipeline","articles\u002Fn8n-rag-data-quality",[9683,9684,9685,9686,756],"n8n","rag","vector-db","pinecone","pJv9rpI_gdtczwoe4TL9JNCTrCaDy9ueObuOtHcLeMA",{"id":9689,"title":9690,"articleId":751,"body":9691,"category":3493,"codeLang":35,"date":10241,"deploys":43,"description":10242,"excerpt":742,"extension":743,"lang":742,"meta":10243,"navigation":189,"path":10244,"pos":10245,"readMin":96,"related":10248,"seo":10249,"service":10250,"stem":10251,"tags":10252,"version":760,"__hash__":10258},"articles\u002Farticles\u002Fphp-references.md","PHP references: what & actually does and where it breaks code",{"type":8,"value":9692,"toc":10232},[9693,9700,9704,9711,9718,9766,9778,9782,9838,9859,9862,9884,9887,9920,9924,9927,9995,10013,10016,10020,10023,10061,10070,10074,10077,10146,10156,10160,10185,10188,10190,10230],[11,9694,9695,9696,9699],{},"The PHP manual says references are not pointers and advises against returning by reference to gain performance. Both statements are correct and both are routinely ignored. This note covers what ",[15,9697,9698],{},"&"," does at the engine level, the bugs it causes most often, and the few cases where it is the right tool.",[23,9701,9703],{"id":9702},"values-copy-on-write-and-references","Values, copy-on-write and references",[11,9705,9706,9707,9710],{},"By default PHP assigns and passes values. Arrays and strings are refcounted, so ",[15,9708,9709],{},"$b = $a"," does not 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. This is copy-on-write.",[11,9712,9713,9714,9717],{},"A reference is different. ",[15,9715,9716],{},"$b = &$a"," wraps the value in a reference container that both names share. A write through either name changes the value seen by both, and no separation ever happens.",[31,9719,9721],{"className":33,"code":9720,"language":35,"meta":36,"style":36},"$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",[15,9722,9723,9728,9733,9738,9743,9747,9751,9756,9761],{"__ignoreMap":36},[40,9724,9725],{"class":42,"line":43},[40,9726,9727],{},"$a = 'original';\n",[40,9729,9730],{"class":42,"line":49},[40,9731,9732],{},"$b = $a;           \u002F\u002F no copy, both names share one value\n",[40,9734,9735],{"class":42,"line":55},[40,9736,9737],{},"$b = 'modified';   \u002F\u002F $b gets its own value\n",[40,9739,9740],{"class":42,"line":84},[40,9741,9742],{},"var_dump($a);      \u002F\u002F string(8) \"original\"\n",[40,9744,9745],{"class":42,"line":90},[40,9746,190],{"emptyLinePlaceholder":189},[40,9748,9749],{"class":42,"line":96},[40,9750,9727],{},[40,9752,9753],{"class":42,"line":102},[40,9754,9755],{},"$b = &$a;          \u002F\u002F $a and $b are now one variable with two names\n",[40,9757,9758],{"class":42,"line":193},[40,9759,9760],{},"$b = 'modified';\n",[40,9762,9763],{"class":42,"line":199},[40,9764,9765],{},"var_dump($a);      \u002F\u002F string(8) \"modified\"\n",[11,9767,9768,9769,9771,9772,9774,9775,9777],{},"After the assignment nothing in the code marks ",[15,9770,8373],{}," as a reference. To know how a write to ",[15,9773,8373],{}," behaves, the reader has to find the line where ",[15,9776,9698],{}," was used.",[23,9779,9781],{"id":9780},"bug-1-the-reference-left-behind-by-foreach","Bug 1: the reference left behind by foreach",[31,9783,9785],{"className":33,"code":9784,"language":35,"meta":36,"style":36},"$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",[15,9786,9787,9792,9796,9801,9806,9810,9815,9819,9824,9829,9833],{"__ignoreMap":36},[40,9788,9789],{"class":42,"line":43},[40,9790,9791],{},"$prices = [100, 200, 300, 400, 500];\n",[40,9793,9794],{"class":42,"line":49},[40,9795,190],{"emptyLinePlaceholder":189},[40,9797,9798],{"class":42,"line":55},[40,9799,9800],{},"foreach ($prices as &$price) {\n",[40,9802,9803],{"class":42,"line":84},[40,9804,9805],{},"    $price = $price * 0.9;\n",[40,9807,9808],{"class":42,"line":90},[40,9809,105],{},[40,9811,9812],{"class":42,"line":96},[40,9813,9814],{},"\u002F\u002F $prices is [90, 180, 270, 360, 450]\n",[40,9816,9817],{"class":42,"line":102},[40,9818,190],{"emptyLinePlaceholder":189},[40,9820,9821],{"class":42,"line":193},[40,9822,9823],{},"foreach ($prices as $price) {\n",[40,9825,9826],{"class":42,"line":199},[40,9827,9828],{},"    echo $price, ' ';\n",[40,9830,9831],{"class":42,"line":204},[40,9832,105],{},[40,9834,9835],{"class":42,"line":210},[40,9836,9837],{},"\u002F\u002F Output: 90 180 270 360 360\n",[11,9839,9840,9841,9844,9845,9848,9849,9851,9852,9854,9855,9858],{},"After the first loop ",[15,9842,9843],{},"$price"," is still a reference to ",[15,9846,9847],{},"$prices[4]",". The second loop assigns each element to ",[15,9850,9843],{},", which means it writes each element into ",[15,9853,9847],{},". On the fourth iteration it writes 360 there, so the fifth iteration reads 360 instead of 450. This still happens in PHP 8; the PHP 7 change to ",[15,9856,9857],{},"foreach"," semantics did not remove it.",[11,9860,9861],{},"There are two fixes. The minimal one is to break the reference right after the loop:",[31,9863,9865],{"className":33,"code":9864,"language":35,"meta":36,"style":36},"foreach ($prices as &$price) {\n    $price = $price * 0.9;\n}\nunset($price); \u002F\u002F removes the name $price; $prices[4] keeps its value\n",[15,9866,9867,9871,9875,9879],{"__ignoreMap":36},[40,9868,9869],{"class":42,"line":43},[40,9870,9800],{},[40,9872,9873],{"class":42,"line":49},[40,9874,9805],{},[40,9876,9877],{"class":42,"line":55},[40,9878,105],{},[40,9880,9881],{"class":42,"line":84},[40,9882,9883],{},"unset($price); \u002F\u002F removes the name $price; $prices[4] keeps its value\n",[11,9885,9886],{},"The better one is to avoid the reference entirely:",[31,9888,9890],{"className":33,"code":9889,"language":35,"meta":36,"style":36},"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",[15,9891,9892,9897,9902,9906,9910,9915],{"__ignoreMap":36},[40,9893,9894],{"class":42,"line":43},[40,9895,9896],{},"foreach ($prices as $i => $price) {\n",[40,9898,9899],{"class":42,"line":49},[40,9900,9901],{},"    $prices[$i] = $price * 0.9;\n",[40,9903,9904],{"class":42,"line":55},[40,9905,105],{},[40,9907,9908],{"class":42,"line":84},[40,9909,190],{"emptyLinePlaceholder":189},[40,9911,9912],{"class":42,"line":90},[40,9913,9914],{},"\u002F\u002F or, when a new array is acceptable\n",[40,9916,9917],{"class":42,"line":96},[40,9918,9919],{},"$prices = array_map(fn (int|float $p): float => $p * 0.9, $prices);\n",[23,9921,9923],{"id":9922},"bug-2-a-by-reference-parameter-changes-the-contract","Bug 2: a by-reference parameter changes the contract",[11,9925,9926],{},"Example: a normalisation function receives large arrays, so someone switches it to a by-reference parameter \"to avoid copying\".",[31,9928,9930],{"className":33,"code":9929,"language":35,"meta":36,"style":36},"\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",[15,9931,9932,9937,9942,9946,9951,9956,9961,9965,9969,9974,9979,9983,9987,9991],{"__ignoreMap":36},[40,9933,9934],{"class":42,"line":43},[40,9935,9936],{},"\u002F\u002F Before: pure function, the caller's array is untouched\n",[40,9938,9939],{"class":42,"line":49},[40,9940,9941],{},"function normaliseProduct(array $product): array\n",[40,9943,9944],{"class":42,"line":55},[40,9945,76],{},[40,9947,9948],{"class":42,"line":84},[40,9949,9950],{},"    $product['title'] = trim(strtolower($product['title']));\n",[40,9952,9953],{"class":42,"line":90},[40,9954,9955],{},"    $product['price'] = round($product['price'], 2);\n",[40,9957,9958],{"class":42,"line":96},[40,9959,9960],{},"    return $product;\n",[40,9962,9963],{"class":42,"line":102},[40,9964,105],{},[40,9966,9967],{"class":42,"line":193},[40,9968,190],{"emptyLinePlaceholder":189},[40,9970,9971],{"class":42,"line":199},[40,9972,9973],{},"\u002F\u002F After: mutates the argument and returns nothing\n",[40,9975,9976],{"class":42,"line":204},[40,9977,9978],{},"function normaliseProduct(array &$product): void\n",[40,9980,9981],{"class":42,"line":210},[40,9982,76],{},[40,9984,9985],{"class":42,"line":216},[40,9986,9950],{},[40,9988,9989],{"class":42,"line":222},[40,9990,9955],{},[40,9992,9993],{"class":42,"line":227},[40,9994,105],{},[11,9996,9997,9998,10001,10002,10004,10005,10008,10009,10012],{},"Every existing call of the form ",[15,9999,10000],{},"$normalised = normaliseProduct($product)"," now stores ",[15,10003,114],{}," and modifies ",[15,10006,10007],{},"$product"," as a side effect. PHP itself does not report using the result of a ",[15,10010,10011],{},"void"," function; a static analyser does.",[11,10014,10015],{},"The optimisation 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 they are written. The by-reference version avoids that copy only when the caller's array is not shared: if it is shared with another variable (refcount above one), the first write inside the function still separates it.",[23,10017,10019],{"id":10018},"bug-3-references-survive-inside-arrays","Bug 3: references survive inside arrays",[11,10021,10022],{},"A reference to an array element stays attached to that element, and copying the array copies the reference, not the value.",[31,10024,10026],{"className":33,"code":10025,"language":35,"meta":36,"style":36},"$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",[15,10027,10028,10033,10038,10042,10047,10052,10056],{"__ignoreMap":36},[40,10029,10030],{"class":42,"line":43},[40,10031,10032],{},"$a = [1, 2];\n",[40,10034,10035],{"class":42,"line":49},[40,10036,10037],{},"$ref = &$a[0];\n",[40,10039,10040],{"class":42,"line":55},[40,10041,190],{"emptyLinePlaceholder":189},[40,10043,10044],{"class":42,"line":84},[40,10045,10046],{},"$b = $a;        \u002F\u002F looks like a copy\n",[40,10048,10049],{"class":42,"line":90},[40,10050,10051],{},"$b[0] = 99;\n",[40,10053,10054],{"class":42,"line":96},[40,10055,190],{"emptyLinePlaceholder":189},[40,10057,10058],{"class":42,"line":102},[40,10059,10060],{},"var_dump($a[0]); \u002F\u002F int(99)\n",[11,10062,10063,10064,10066,10067,10069],{},"This is documented in the manual and is the hardest of the three to spot, because the ",[15,10065,9698],{}," can be far away from the copy. It typically appears after a by-reference ",[15,10068,9857],{}," whose loop variable was never unset.",[23,10071,10073],{"id":10072},"objects-are-not-passed-by-reference","Objects are not passed by reference",[11,10075,10076],{},"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.",[31,10078,10080],{"className":33,"code":10079,"language":35,"meta":36,"style":36},"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",[15,10081,10082,10087,10091,10096,10100,10104,10109,10113,10118,10123,10127,10131,10136,10141],{"__ignoreMap":36},[40,10083,10084],{"class":42,"line":43},[40,10085,10086],{},"final class Counter\n",[40,10088,10089],{"class":42,"line":49},[40,10090,76],{},[40,10092,10093],{"class":42,"line":55},[40,10094,10095],{},"    public int $count = 0;\n",[40,10097,10098],{"class":42,"line":84},[40,10099,105],{},[40,10101,10102],{"class":42,"line":90},[40,10103,190],{"emptyLinePlaceholder":189},[40,10105,10106],{"class":42,"line":96},[40,10107,10108],{},"function increment(Counter $counter): void\n",[40,10110,10111],{"class":42,"line":102},[40,10112,76],{},[40,10114,10115],{"class":42,"line":193},[40,10116,10117],{},"    $counter->count++;       \u002F\u002F visible to the caller\n",[40,10119,10120],{"class":42,"line":199},[40,10121,10122],{},"    $counter = new Counter(); \u002F\u002F not visible to the caller\n",[40,10124,10125],{"class":42,"line":204},[40,10126,105],{},[40,10128,10129],{"class":42,"line":210},[40,10130,190],{"emptyLinePlaceholder":189},[40,10132,10133],{"class":42,"line":216},[40,10134,10135],{},"$c = new Counter();\n",[40,10137,10138],{"class":42,"line":222},[40,10139,10140],{},"increment($c);\n",[40,10142,10143],{"class":42,"line":227},[40,10144,10145],{},"var_dump($c->count); \u002F\u002F int(1)\n",[11,10147,10148,10149,10151,10152,10155],{},"Adding ",[15,10150,9698],{}," to an object parameter (",[15,10153,10154],{},"Counter &$counter",") is only needed when the function must replace the caller's object, which is almost never the right design.",[23,10157,10159],{"id":10158},"when-a-reference-is-justified","When a reference is justified",[703,10161,10162,10175,10182],{},[127,10163,10164,10165,711,10168,711,10171,10174],{},"Output parameters of built-in functions: ",[15,10166,10167],{},"preg_match($pattern, $subject, $matches)",[15,10169,10170],{},"sort($array)",[15,10172,10173],{},"array_push()"," and others take their argument by reference by design.",[127,10176,10177,10178,10181],{},"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 ",[15,10179,10180],{},"memory_get_peak_usage()"," before deciding; for most request-sized data the difference is negligible.",[127,10183,10184],{},"Building a nested structure through a pointer 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,10186,10187],{},"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.",[23,10189,3420],{"id":3419},[703,10191,10192,10205,10215,10221,10224],{},[127,10193,10194,10197,10198,10201,10202,1751],{},[15,10195,10196],{},"foreach (... as &$x)"," is followed by ",[15,10199,10200],{},"unset($x)",", or rewritten with keys or ",[15,10203,10204],{},"array_map()",[127,10206,10207,10208,711,10211,10214],{},"A by-reference parameter is part of the function's documented contract, and the function's name says it mutates (",[15,10209,10210],{},"sortInPlace",[15,10212,10213],{},"applyDefaults",").",[127,10216,10217,10218,10220],{},"No \"performance\" ",[15,10219,9698],{}," without a measurement.",[127,10222,10223],{},"No array copied after a reference into it was taken, unless the shared element is intended.",[127,10225,10226,10227,10229],{},"PHPStan or Psalm runs in CI, so a ",[15,10228,10011],{}," result assigned to a variable is reported.",[729,10231,731],{},{"title":36,"searchDepth":49,"depth":49,"links":10233},[10234,10235,10236,10237,10238,10239,10240],{"id":9702,"depth":49,"text":9703},{"id":9780,"depth":49,"text":9781},{"id":9922,"depth":49,"text":9923},{"id":10018,"depth":49,"text":10019},{"id":10072,"depth":49,"text":10073},{"id":10158,"depth":49,"text":10159},{"id":3419,"depth":49,"text":3420},"2023-09-30","The PHP manual says references are not pointers and advises against returning by reference to gain performance. Both statements are correct and both are routinely ignored. This note covers what & does at the engine level, the bugs it causes most often, and the few cases where it is the right tool.",{},"\u002Farticles\u002Fphp-references",{"x":10246,"y":10247,"depth":3499,"size":743},0.12,0.5,[6043,4441],{"title":9690,"description":10242},"memory-management","articles\u002Fphp-references",[35,10253,10254,10255,10256,10257],"memory","debugging","references","performance","footguns","Me4rkyBpPjiXTyF_OmnhzyD3KURr_k0eCGNcuDSQoAM",{"id":10260,"title":10261,"articleId":2153,"body":10262,"category":3493,"codeLang":7744,"date":10937,"deploys":193,"description":10938,"excerpt":742,"extension":743,"lang":742,"meta":10939,"navigation":189,"path":10940,"pos":10941,"readMin":102,"related":10944,"seo":10946,"service":10947,"stem":10948,"tags":10949,"version":760,"__hash__":10954},"articles\u002Farticles\u002Fpostgres-edge.md","Postgres with more than one writer: primary keys and how to migrate them",{"type":8,"value":10263,"toc":10931},[10264,10274,10278,10281,10284,10295,10302,10306,10312,10337,10340,10346,10356,10411,10429,10432,10443,10447,10461,10467,10487,10497,10582,10592,10601,10635,10645,10658,10753,10769,10779,10879,10890,10908,10910,10929],[11,10265,10266,10267,4934,10270,10273],{},"A ",[15,10268,10269],{},"bigserial",[15,10271,10272],{},"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 is already the case and the key type is not your problem. It becomes a problem when more than one node accepts writes for the same table.",[23,10275,10277],{"id":10276},"when-the-key-type-matters","When the key type matters",[11,10279,10280],{},"Changing the key type does not 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,10282,10283],{},"The key type matters when:",[703,10285,10286,10289,10292],{},[127,10287,10288],{},"several nodes accept writes for the same table (bidirectional logical replication, multi-master extensions, sharding by region),",[127,10290,10291],{},"the identifier has to exist before the row reaches the database (offline clients, idempotency keys generated by the client, events published before the insert),",[127,10293,10294],{},"data from several independent databases is merged into one place.",[11,10296,10297,10298,10301],{},"In each of these cases two writers can produce the same ",[15,10299,10300],{},"bigint"," unless they coordinate.",[23,10303,10305],{"id":10304},"the-options","The options",[11,10307,10308,10311],{},[130,10309,10310],{},"Per-node sequences."," Each node gets its own sequence with a different start and a shared step:",[31,10313,10315],{"className":7742,"code":10314,"language":7744,"meta":36,"style":36},"-- 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",[15,10316,10317,10322,10327,10332],{"__ignoreMap":36},[40,10318,10319],{"class":42,"line":43},[40,10320,10321],{},"-- node 1\n",[40,10323,10324],{"class":42,"line":49},[40,10325,10326],{},"create sequence orders_id_seq start with 1 increment by 16;\n",[40,10328,10329],{"class":42,"line":55},[40,10330,10331],{},"-- node 2\n",[40,10333,10334],{"class":42,"line":84},[40,10335,10336],{},"create sequence orders_id_seq start with 2 increment by 16;\n",[11,10338,10339],{},"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,10341,10342,10345],{},[130,10343,10344],{},"UUIDv4."," Unique without coordination, but random. Each insert lands on a random leaf page of the B-tree index, so the working set of the index is the whole index, page splits are spread across it, and after a checkpoint more pages are written in full to WAL. On a table that fits in memory this is acceptable; on a large table it means more I\u002FO per insert.",[11,10347,10348,10351,10352,10355],{},[130,10349,10350],{},"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 ",[15,10353,10354],{},"uuidv7()"," function. For older versions it can be written in SQL:",[31,10357,10359],{"className":7742,"code":10358,"language":7744,"meta":36,"style":36},"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((extract(epoch from ts) * 1000)::bigint) from 3)\n              from 1 for 6),\n      52, 1), 53, 1),\n    'hex')::uuid;\n$$;\n",[15,10360,10361,10366,10371,10376,10381,10386,10391,10396,10401,10406],{"__ignoreMap":36},[40,10362,10363],{"class":42,"line":43},[40,10364,10365],{},"create function uuidv7_at(ts timestamptz) returns uuid\n",[40,10367,10368],{"class":42,"line":49},[40,10369,10370],{},"language sql volatile as $$\n",[40,10372,10373],{"class":42,"line":55},[40,10374,10375],{},"  select encode(\n",[40,10377,10378],{"class":42,"line":84},[40,10379,10380],{},"    set_bit(set_bit(\n",[40,10382,10383],{"class":42,"line":90},[40,10384,10385],{},"      overlay(uuid_send(gen_random_uuid())\n",[40,10387,10388],{"class":42,"line":96},[40,10389,10390],{},"              placing substring(int8send((extract(epoch from ts) * 1000)::bigint) from 3)\n",[40,10392,10393],{"class":42,"line":102},[40,10394,10395],{},"              from 1 for 6),\n",[40,10397,10398],{"class":42,"line":193},[40,10399,10400],{},"      52, 1), 53, 1),\n",[40,10402,10403],{"class":42,"line":199},[40,10404,10405],{},"    'hex')::uuid;\n",[40,10407,10408],{"class":42,"line":204},[40,10409,10410],{},"$$;\n",[11,10412,10413,10414,10417,10418,10421,10422,10425,10426,10428],{},"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 ",[15,10415,10416],{},"ts"," parameter also makes it usable for backfilling old rows from ",[15,10419,10420],{},"created_at",". On Postgres 18 the same effect comes from ",[15,10423,10424],{},"uuidv7(created_at - clock_timestamp())",", because ",[15,10427,10354],{}," accepts an interval that shifts the timestamp.",[11,10430,10431],{},"For new tables with more than one writer I would choose UUIDv7. The costs to accept:",[703,10433,10434,10437,10440],{},[127,10435,10436],{},"16 bytes instead of 8 in the primary key and in every foreign key and index that contains it,",[127,10438,10439],{},"the identifier reveals when the row was created, which matters if IDs appear in public URLs,",[127,10441,10442],{},"ordering by ID is only approximately chronological across nodes, because it depends on their clocks.",[23,10444,10446],{"id":10445},"migrating-an-existing-table-expand-backfill-swap","Migrating an existing table: expand, backfill, swap",[11,10448,10449,10450,10453,10454,10456,10457,10460],{},"Example: an ",[15,10451,10452],{},"orders"," table with a ",[15,10455,10300],{}," primary key and an ",[15,10458,10459],{},"order_items"," table that references it. The goal is a UUIDv7 primary key without locking either table for longer than a few seconds.",[11,10462,10463,10466],{},[130,10464,10465],{},"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.",[31,10468,10470],{"className":7742,"code":10469,"language":7744,"meta":36,"style":36},"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());  -- older versions\n",[15,10471,10472,10477,10482],{"__ignoreMap":36},[40,10473,10474],{"class":42,"line":43},[40,10475,10476],{},"alter table orders add column id_v2 uuid;\n",[40,10478,10479],{"class":42,"line":49},[40,10480,10481],{},"alter table orders alter column id_v2 set default uuidv7();            -- Postgres 18\n",[40,10483,10484],{"class":42,"line":55},[40,10485,10486],{},"-- alter table orders alter column id_v2 set default uuidv7_at(clock_timestamp());  -- older versions\n",[11,10488,10489,10492,10493,10496],{},[130,10490,10491],{},"2. Backfill in batches."," A single ",[15,10494,10495],{},"update"," on the whole table holds row locks for its entire duration and leaves a large amount of dead tuples at once. Batches with a commit in between avoid both:",[31,10498,10500],{"className":7742,"code":10499,"language":7744,"meta":36,"style":36},"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",[15,10501,10502,10507,10512,10517,10522,10527,10532,10537,10542,10547,10552,10557,10562,10567,10572,10577],{"__ignoreMap":36},[40,10503,10504],{"class":42,"line":43},[40,10505,10506],{},"do $$\n",[40,10508,10509],{"class":42,"line":49},[40,10510,10511],{},"declare\n",[40,10513,10514],{"class":42,"line":55},[40,10515,10516],{},"  batch_size constant bigint := 10000;\n",[40,10518,10519],{"class":42,"line":84},[40,10520,10521],{},"  from_id bigint := 0;\n",[40,10523,10524],{"class":42,"line":90},[40,10525,10526],{},"  max_id bigint;\n",[40,10528,10529],{"class":42,"line":96},[40,10530,10531],{},"begin\n",[40,10533,10534],{"class":42,"line":102},[40,10535,10536],{},"  select max(id) into max_id from orders;\n",[40,10538,10539],{"class":42,"line":193},[40,10540,10541],{},"  while from_id \u003C= max_id loop\n",[40,10543,10544],{"class":42,"line":199},[40,10545,10546],{},"    update orders\n",[40,10548,10549],{"class":42,"line":204},[40,10550,10551],{},"       set id_v2 = uuidv7_at(created_at)\n",[40,10553,10554],{"class":42,"line":210},[40,10555,10556],{},"     where id >= from_id and id \u003C from_id + batch_size\n",[40,10558,10559],{"class":42,"line":216},[40,10560,10561],{},"       and id_v2 is null;\n",[40,10563,10564],{"class":42,"line":222},[40,10565,10566],{},"    commit;\n",[40,10568,10569],{"class":42,"line":227},[40,10570,10571],{},"    from_id := from_id + batch_size;\n",[40,10573,10574],{"class":42,"line":232},[40,10575,10576],{},"  end loop;\n",[40,10578,10579],{"class":42,"line":238},[40,10580,10581],{},"end $$;\n",[11,10583,10584,10587,10588,10591],{},[15,10585,10586],{},"commit"," inside a ",[15,10589,10590],{},"do"," block works when the block is not run inside an explicit transaction. In production it is easier to run the same loop from a script that can pause when replication lag grows.",[11,10593,10594],{},[130,10595,10596,10597,10600],{},"3. Index and ",[15,10598,10599],{},"not null"," without a long lock.",[31,10602,10604],{"className":7742,"code":10603,"language":7744,"meta":36,"style":36},"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",[15,10605,10606,10611,10615,10620,10625,10630],{"__ignoreMap":36},[40,10607,10608],{"class":42,"line":43},[40,10609,10610],{},"create unique index concurrently orders_id_v2_uq on orders (id_v2);\n",[40,10612,10613],{"class":42,"line":49},[40,10614,190],{"emptyLinePlaceholder":189},[40,10616,10617],{"class":42,"line":55},[40,10618,10619],{},"alter table orders add constraint orders_id_v2_nn check (id_v2 is not null) not valid;\n",[40,10621,10622],{"class":42,"line":84},[40,10623,10624],{},"alter table orders validate constraint orders_id_v2_nn;\n",[40,10626,10627],{"class":42,"line":90},[40,10628,10629],{},"alter table orders alter column id_v2 set not null;   -- uses the validated check, no full scan\n",[40,10631,10632],{"class":42,"line":96},[40,10633,10634],{},"alter table orders drop constraint orders_id_v2_nn;\n",[11,10636,10637,10640,10641,10644],{},[15,10638,10639],{},"set not null"," skips the table scan when a validated check constraint already proves the condition (Postgres 12 and newer). ",[15,10642,10643],{},"validate constraint"," takes a lock that does not block reads or writes.",[11,10646,10647,10650,10651,10654,10655,10657],{},[130,10648,10649],{},"4. The same for child tables."," Add ",[15,10652,10653],{},"order_id_v2",", keep it filled for new rows with a trigger, backfill old rows in batches by joining to ",[15,10656,10452],{},":",[31,10659,10661],{"className":7742,"code":10660,"language":7744,"meta":36,"style":36},"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",[15,10662,10663,10668,10672,10677,10682,10686,10691,10696,10700,10704,10709,10714,10719,10723,10728,10733,10738,10743,10748],{"__ignoreMap":36},[40,10664,10665],{"class":42,"line":43},[40,10666,10667],{},"alter table order_items add column order_id_v2 uuid;\n",[40,10669,10670],{"class":42,"line":49},[40,10671,190],{"emptyLinePlaceholder":189},[40,10673,10674],{"class":42,"line":55},[40,10675,10676],{},"create function order_items_sync_order_id_v2() returns trigger\n",[40,10678,10679],{"class":42,"line":84},[40,10680,10681],{},"language plpgsql as $$\n",[40,10683,10684],{"class":42,"line":90},[40,10685,10531],{},[40,10687,10688],{"class":42,"line":96},[40,10689,10690],{},"  select o.id_v2 into new.order_id_v2 from orders o where o.id = new.order_id;\n",[40,10692,10693],{"class":42,"line":102},[40,10694,10695],{},"  return new;\n",[40,10697,10698],{"class":42,"line":193},[40,10699,10581],{},[40,10701,10702],{"class":42,"line":199},[40,10703,190],{"emptyLinePlaceholder":189},[40,10705,10706],{"class":42,"line":204},[40,10707,10708],{},"create trigger order_items_sync_order_id_v2\n",[40,10710,10711],{"class":42,"line":210},[40,10712,10713],{},"  before insert or update of order_id on order_items\n",[40,10715,10716],{"class":42,"line":216},[40,10717,10718],{},"  for each row execute function order_items_sync_order_id_v2();\n",[40,10720,10721],{"class":42,"line":222},[40,10722,190],{"emptyLinePlaceholder":189},[40,10724,10725],{"class":42,"line":227},[40,10726,10727],{},"update order_items i\n",[40,10729,10730],{"class":42,"line":232},[40,10731,10732],{},"   set order_id_v2 = o.id_v2\n",[40,10734,10735],{"class":42,"line":238},[40,10736,10737],{},"  from orders o\n",[40,10739,10740],{"class":42,"line":244},[40,10741,10742],{}," where o.id = i.order_id\n",[40,10744,10745],{"class":42,"line":250},[40,10746,10747],{},"   and i.order_id_v2 is null\n",[40,10749,10750],{"class":42,"line":256},[40,10751,10752],{},"   and i.id >= 0 and i.id \u003C 10000;   -- repeated per batch\n",[11,10754,10755,10756,10759,10760,10762,10763,10765,10766,1751],{},"Then the same ",[15,10757,10758],{},"not valid"," \u002F ",[15,10761,3130],{}," sequence for ",[15,10764,10599],{}," on ",[15,10767,10768],{},"order_items.order_id_v2",[11,10770,10771,10774,10775,10778],{},[130,10772,10773],{},"5. Swap in one short transaction."," If anything still looks rows up by the old numeric ID, create a unique index on ",[15,10776,10777],{},"orders (id)"," concurrently before this step, because dropping the primary key removes the old one.",[31,10780,10782],{"className":7742,"code":10781,"language":7744,"meta":36,"style":36},"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\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",[15,10783,10784,10789,10794,10798,10803,10808,10812,10817,10822,10826,10831,10836,10841,10846,10851,10855,10860,10865,10870,10874],{"__ignoreMap":36},[40,10785,10786],{"class":42,"line":43},[40,10787,10788],{},"begin;\n",[40,10790,10791],{"class":42,"line":49},[40,10792,10793],{},"set local lock_timeout = '5s';\n",[40,10795,10796],{"class":42,"line":55},[40,10797,190],{"emptyLinePlaceholder":189},[40,10799,10800],{"class":42,"line":84},[40,10801,10802],{},"drop trigger order_items_sync_order_id_v2 on order_items;\n",[40,10804,10805],{"class":42,"line":90},[40,10806,10807],{},"alter table order_items drop constraint order_items_order_id_fkey;\n",[40,10809,10810],{"class":42,"line":96},[40,10811,190],{"emptyLinePlaceholder":189},[40,10813,10814],{"class":42,"line":102},[40,10815,10816],{},"alter table orders drop constraint orders_pkey;\n",[40,10818,10819],{"class":42,"line":193},[40,10820,10821],{},"alter table orders add constraint orders_pkey primary key using index orders_id_v2_uq;\n",[40,10823,10824],{"class":42,"line":199},[40,10825,190],{"emptyLinePlaceholder":189},[40,10827,10828],{"class":42,"line":204},[40,10829,10830],{},"alter table orders rename column id to legacy_id;\n",[40,10832,10833],{"class":42,"line":210},[40,10834,10835],{},"alter table orders rename column id_v2 to id;\n",[40,10837,10838],{"class":42,"line":216},[40,10839,10840],{},"alter table order_items rename column order_id to legacy_order_id;\n",[40,10842,10843],{"class":42,"line":222},[40,10844,10845],{},"alter table order_items rename column order_id_v2 to order_id;\n",[40,10847,10848],{"class":42,"line":227},[40,10849,10850],{},"alter table order_items alter column legacy_order_id drop not null;  -- new rows no longer fill it\n",[40,10852,10853],{"class":42,"line":232},[40,10854,190],{"emptyLinePlaceholder":189},[40,10856,10857],{"class":42,"line":238},[40,10858,10859],{},"alter table order_items add constraint order_items_order_id_fkey\n",[40,10861,10862],{"class":42,"line":244},[40,10863,10864],{},"  foreign key (order_id) references orders (id) not valid;\n",[40,10866,10867],{"class":42,"line":250},[40,10868,10869],{},"commit;\n",[40,10871,10872],{"class":42,"line":256},[40,10873,190],{"emptyLinePlaceholder":189},[40,10875,10876],{"class":42,"line":261},[40,10877,10878],{},"alter table order_items validate constraint order_items_order_id_fkey;\n",[11,10880,10881,10882,10885,10886,10889],{},"Every statement in the transaction changes only the catalog, so the ",[15,10883,10884],{},"access exclusive"," lock is held briefly. ",[15,10887,10888],{},"lock_timeout"," makes the transaction fail fast instead of queueing behind a long-running query and blocking everything behind it. The trigger is dropped in the same transaction because its body refers to the column names that are being renamed.",[11,10891,10892,10893,10896,10897,10900,10901,1633,10904,10907],{},"The application has to be ready for this moment: after the rename, ",[15,10894,10895],{},"orders.id"," is a ",[15,10898,10899],{},"uuid",". In practice that means a release that reads and writes both columns before the swap, and a cleanup release that removes ",[15,10902,10903],{},"legacy_id",[15,10905,10906],{},"legacy_order_id"," after it.",[23,10909,701],{"id":700},[703,10911,10912,10917,10920,10923,10926],{},[127,10913,10914,10915,1751],{},"Confirm that there really is more than one writer. With a single primary, keep ",[15,10916,10300],{},[127,10918,10919],{},"Inventory every table, view, materialized view, report query and external system that stores the old ID before starting.",[127,10921,10922],{},"Backfill child tables in the same way as the parent; they are often larger and lack a timestamp column, so they need a join.",[127,10924,10925],{},"Watch replication lag and autovacuum during the backfill.",[127,10927,10928],{},"Keep the legacy columns until every consumer has switched, then drop them in a separate change.",[729,10930,731],{},{"title":36,"searchDepth":49,"depth":49,"links":10932},[10933,10934,10935,10936],{"id":10276,"depth":49,"text":10277},{"id":10304,"depth":49,"text":10305},{"id":10445,"depth":49,"text":10446},{"id":700,"depth":49,"text":701},"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 is already the case and the key type is not your problem. It becomes a problem when more than one node accepts writes for the same table.",{},"\u002Farticles\u002Fpostgres-edge",{"x":10942,"y":10943,"depth":3500,"size":743},0.21,0.71,[2152,10945],"state-machine",{"title":10261,"description":10938},"pg-primary-keys","articles\u002Fpostgres-edge",[10950,10951,10952,10953],"postgres","distributed-systems","uuidv7","migrations","ItLaBmBFxERBEZ57iSdOI7Y3oHfZ4X6u0O8IZwT6g-Q",{"id":10956,"title":10957,"articleId":6043,"body":10958,"category":35,"codeLang":35,"date":11565,"deploys":55,"description":11566,"excerpt":742,"extension":743,"lang":742,"meta":11567,"navigation":189,"path":5414,"pos":11568,"readMin":96,"related":11572,"seo":11573,"service":11574,"stem":11575,"tags":11576,"version":11578,"__hash__":11579},"articles\u002Farticles\u002Fsingleton-pattern.md","Singleton in PHP: what \"one instance\" means under PHP-FPM, Octane and PHPUnit",{"type":8,"value":10959,"toc":11557},[10960,10966,10969,10973,11025,11028,11032,11035,11130,11137,11148,11168,11172,11175,11182,11185,11237,11245,11249,11256,11267,11334,11398,11401,11405,11408,11422,11425,11505,11518,11525,11527,11534,11555],[11,10961,10962,10963,10965],{},"The classic Singleton stores an instance in a static property and hands it out through ",[15,10964,5410],{},". The pattern bundles two things: lazy construction and global access. Lazy construction is useful. Global access is what causes the problems, and the pattern gives you both whether you need them or not.",[11,10967,10968],{},"The more important question in PHP is the scope of \"one\". A static property lives as long as the PHP process keeps the class loaded, and that depends on the runtime.",[23,10970,10972],{"id":10971},"what-the-static-property-actually-survives","What the static property actually survives",[5324,10974,10975,10988],{},[5327,10976,10977],{},[5330,10978,10979,10982],{},[5333,10980,10981],{},"Runtime",[5333,10983,10984,10985],{},"Lifetime of ",[15,10986,10987],{},"self::$instance",[5340,10989,10990,10998,11009,11017],{},[5330,10991,10992,10995],{},[5345,10993,10994],{},"PHP-FPM, mod_php",[5345,10996,10997],{},"One request. The engine discards all static state at the end of the request.",[5330,10999,11000,11006],{},[5345,11001,11002,11003],{},"Laravel Octane (Swoole, RoadRunner, FrankenPHP), ",[15,11004,11005],{},"queue:work",[5345,11007,11008],{},"One worker process, across many requests or jobs.",[5330,11010,11011,11014],{},[5345,11012,11013],{},"CLI script, cron command",[5345,11015,11016],{},"One execution.",[5330,11018,11019,11022],{},[5345,11020,11021],{},"PHPUnit",[5345,11023,11024],{},"The whole test run in that process, across all test cases.",[11,11026,11027],{},"The same class therefore behaves differently in production, in a queue worker and in tests. That is the main reason to be careful with it.",[23,11029,11031],{"id":11030},"php-fpm-one-instance-per-request","PHP-FPM: one instance per request",[11,11033,11034],{},"Example: a rate limiter written as a Singleton.",[31,11036,11038],{"className":33,"code":11037,"language":35,"meta":36,"style":36},"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",[15,11039,11040,11045,11049,11054,11058,11063,11068,11072,11077,11081,11086,11090,11094,11099,11103,11108,11113,11117,11122,11126],{"__ignoreMap":36},[40,11041,11042],{"class":42,"line":43},[40,11043,11044],{},"final class RateLimiter\n",[40,11046,11047],{"class":42,"line":49},[40,11048,76],{},[40,11050,11051],{"class":42,"line":55},[40,11052,11053],{},"    private static ?self $instance = null;\n",[40,11055,11056],{"class":42,"line":84},[40,11057,190],{"emptyLinePlaceholder":189},[40,11059,11060],{"class":42,"line":90},[40,11061,11062],{},"    \u002F** @var array\u003Cstring, int> *\u002F\n",[40,11064,11065],{"class":42,"line":96},[40,11066,11067],{},"    private array $hits = [];\n",[40,11069,11070],{"class":42,"line":102},[40,11071,190],{"emptyLinePlaceholder":189},[40,11073,11074],{"class":42,"line":193},[40,11075,11076],{},"    public static function getInstance(): self\n",[40,11078,11079],{"class":42,"line":199},[40,11080,241],{},[40,11082,11083],{"class":42,"line":204},[40,11084,11085],{},"        return self::$instance ??= new self();\n",[40,11087,11088],{"class":42,"line":210},[40,11089,253],{},[40,11091,11092],{"class":42,"line":216},[40,11093,190],{"emptyLinePlaceholder":189},[40,11095,11096],{"class":42,"line":222},[40,11097,11098],{},"    public function allow(string $ip, int $limitPerMinute): bool\n",[40,11100,11101],{"class":42,"line":227},[40,11102,241],{},[40,11104,11105],{"class":42,"line":232},[40,11106,11107],{},"        $key = $ip . ':' . intdiv(time(), 60);\n",[40,11109,11110],{"class":42,"line":238},[40,11111,11112],{},"        $this->hits[$key] = ($this->hits[$key] ?? 0) + 1;\n",[40,11114,11115],{"class":42,"line":244},[40,11116,190],{"emptyLinePlaceholder":189},[40,11118,11119],{"class":42,"line":250},[40,11120,11121],{},"        return $this->hits[$key] \u003C= $limitPerMinute;\n",[40,11123,11124],{"class":42,"line":256},[40,11125,253],{},[40,11127,11128],{"class":42,"line":261},[40,11129,105],{},[11,11131,11132,11133,11136],{},"Under PHP-FPM each request starts with an empty ",[15,11134,11135],{},"$hits"," array, so the counter reaches 1 and the limit is never enforced. Under Octane it would count per worker, so the effective limit becomes the configured value multiplied by the number of workers, and requests from one IP are spread across workers in no predictable way.",[11,11138,11139,11140,11143,11144,11147],{},"The requirement (\"100 requests per minute per IP across the application\") is a constraint on shared state across processes and servers. No in-process object can satisfy it. The state has to live in Redis, Memcached or the database. In Laravel this already exists: the ",[15,11141,11142],{},"throttle"," middleware and the ",[15,11145,11146],{},"RateLimiter"," facade store counters in the cache store.",[31,11149,11151],{"className":33,"code":11150,"language":35,"meta":36,"style":36},"Route::middleware('throttle:100,1')->group(function () {\n    Route::post('\u002Fapi\u002Fscoring', ScoringController::class);\n});\n",[15,11152,11153,11158,11163],{"__ignoreMap":36},[40,11154,11155],{"class":42,"line":43},[40,11156,11157],{},"Route::middleware('throttle:100,1')->group(function () {\n",[40,11159,11160],{"class":42,"line":49},[40,11161,11162],{},"    Route::post('\u002Fapi\u002Fscoring', ScoringController::class);\n",[40,11164,11165],{"class":42,"line":55},[40,11166,11167],{},"});\n",[23,11169,11171],{"id":11170},"long-running-workers-state-leaks-between-requests","Long-running workers: state leaks between requests",[11,11173,11174],{},"In Octane or a queue worker the static instance survives, which creates the opposite problem. Anything request-specific that a Singleton captures is visible to the next request handled by the same worker.",[11,11176,11177,11178,11181],{},"Example: a ",[15,11179,11180],{},"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,11183,11184],{},"Laravel's container gives explicit lifetimes for this:",[31,11186,11188],{"className":33,"code":11187,"language":35,"meta":36,"style":36},"\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",[15,11189,11190,11195,11199,11204,11209,11213,11218,11223,11227,11232],{"__ignoreMap":36},[40,11191,11192],{"class":42,"line":43},[40,11193,11194],{},"\u002F\u002F AppServiceProvider::register()\n",[40,11196,11197],{"class":42,"line":49},[40,11198,190],{"emptyLinePlaceholder":189},[40,11200,11201],{"class":42,"line":55},[40,11202,11203],{},"\u002F\u002F One instance per application instance. Safe only for immutable, request-independent objects.\n",[40,11205,11206],{"class":42,"line":84},[40,11207,11208],{},"$this->app->singleton(ExchangeRateTable::class, fn () => ExchangeRateTable::fromConfig(config('rates')));\n",[40,11210,11211],{"class":42,"line":90},[40,11212,190],{"emptyLinePlaceholder":189},[40,11214,11215],{"class":42,"line":96},[40,11216,11217],{},"\u002F\u002F One instance per request or job; Octane and the queue worker flush scoped instances.\n",[40,11219,11220],{"class":42,"line":102},[40,11221,11222],{},"$this->app->scoped(TenantContext::class, fn ($app) => TenantContext::fromRequest($app['request']));\n",[40,11224,11225],{"class":42,"line":193},[40,11226,190],{"emptyLinePlaceholder":189},[40,11228,11229],{"class":42,"line":199},[40,11230,11231],{},"\u002F\u002F New instance on every resolve.\n",[40,11233,11234],{"class":42,"line":204},[40,11235,11236],{},"$this->app->bind(PaymentGateway::class, StripeGateway::class);\n",[11,11238,11239,711,11242,11244],{},[15,11240,11241],{},"ExchangeRateTable",[15,11243,11180],{}," and the gateway classes are placeholders. The point is that the lifetime is declared in one place and can be changed without touching the classes that use the dependency.",[23,11246,11248],{"id":11247},"phpunit-one-instance-for-the-whole-suite","PHPUnit: one instance for the whole suite",[11,11250,11251,11252,11255],{},"PHPUnit runs test cases in one process by default. Laravel's ",[15,11253,11254],{},"TestCase"," builds a fresh application for each test, so container singletons are reset, but plain static properties are not. A Singleton modified in one test keeps that state in the next test, and the result depends on execution order.",[11,11257,11258,11259,11262,11263,11266],{},"The usual workaround is a ",[15,11260,11261],{},"resetInstance()"," method called in ",[15,11264,11265],{},"tearDown()",". It works until someone forgets it in one test class. A more robust option is to stop reaching for the global and inject the dependency:",[31,11268,11270],{"className":33,"code":11269,"language":35,"meta":36,"style":36},"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",[15,11271,11272,11277,11281,11286,11290,11294,11299,11303,11308,11312,11317,11321,11326,11330],{"__ignoreMap":36},[40,11273,11274],{"class":42,"line":43},[40,11275,11276],{},"interface Clock\n",[40,11278,11279],{"class":42,"line":49},[40,11280,76],{},[40,11282,11283],{"class":42,"line":55},[40,11284,11285],{},"    public function now(): DateTimeImmutable;\n",[40,11287,11288],{"class":42,"line":84},[40,11289,105],{},[40,11291,11292],{"class":42,"line":90},[40,11293,190],{"emptyLinePlaceholder":189},[40,11295,11296],{"class":42,"line":96},[40,11297,11298],{},"final class InvoiceDueDate\n",[40,11300,11301],{"class":42,"line":102},[40,11302,76],{},[40,11304,11305],{"class":42,"line":193},[40,11306,11307],{},"    public function __construct(private readonly Clock $clock) {}\n",[40,11309,11310],{"class":42,"line":199},[40,11311,190],{"emptyLinePlaceholder":189},[40,11313,11314],{"class":42,"line":204},[40,11315,11316],{},"    public function forTermDays(int $days): DateTimeImmutable\n",[40,11318,11319],{"class":42,"line":210},[40,11320,241],{},[40,11322,11323],{"class":42,"line":216},[40,11324,11325],{},"        return $this->clock->now()->modify(\"+{$days} days\");\n",[40,11327,11328],{"class":42,"line":222},[40,11329,253],{},[40,11331,11332],{"class":42,"line":227},[40,11333,105],{},[31,11335,11337],{"className":33,"code":11336,"language":35,"meta":36,"style":36},"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",[15,11338,11339,11344,11348,11353,11358,11363,11368,11372,11376,11380,11385,11389,11394],{"__ignoreMap":36},[40,11340,11341],{"class":42,"line":43},[40,11342,11343],{},"public function test_due_date_is_counted_from_today(): void\n",[40,11345,11346],{"class":42,"line":49},[40,11347,76],{},[40,11349,11350],{"class":42,"line":55},[40,11351,11352],{},"    $clock = new class implements Clock {\n",[40,11354,11355],{"class":42,"line":84},[40,11356,11357],{},"        public function now(): DateTimeImmutable\n",[40,11359,11360],{"class":42,"line":90},[40,11361,11362],{},"        {\n",[40,11364,11365],{"class":42,"line":96},[40,11366,11367],{},"            return new DateTimeImmutable('2024-10-15');\n",[40,11369,11370],{"class":42,"line":102},[40,11371,353],{},[40,11373,11374],{"class":42,"line":193},[40,11375,474],{},[40,11377,11378],{"class":42,"line":199},[40,11379,190],{"emptyLinePlaceholder":189},[40,11381,11382],{"class":42,"line":204},[40,11383,11384],{},"    $dueDate = (new InvoiceDueDate($clock))->forTermDays(14);\n",[40,11386,11387],{"class":42,"line":210},[40,11388,190],{"emptyLinePlaceholder":189},[40,11390,11391],{"class":42,"line":216},[40,11392,11393],{},"    $this->assertSame('2024-10-29', $dueDate->format('Y-m-d'));\n",[40,11395,11396],{"class":42,"line":222},[40,11397,105],{},[11,11399,11400],{},"No static state, no teardown, and the test controls the one input that matters.",[23,11402,11404],{"id":11403},"when-a-static-singleton-is-defensible","When a static Singleton is defensible",[11,11406,11407],{},"The pattern holds up when the object satisfies all of these conditions:",[703,11409,11410,11413,11416,11419],{},[127,11411,11412],{},"all state is set in the constructor and never modified afterwards,",[127,11414,11415],{},"the state does not depend on the request, the user or the tenant,",[127,11417,11418],{},"construction is expensive enough that doing it once per process matters,",[127,11420,11421],{},"tests never need a different instance.",[11,11423,11424],{},"Immutable configuration read from the environment fits:",[31,11426,11428],{"className":33,"code":11427,"language":35,"meta":36,"style":36},"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",[15,11429,11430,11435,11439,11443,11447,11451,11456,11461,11465,11469,11474,11478,11483,11488,11493,11497,11501],{"__ignoreMap":36},[40,11431,11432],{"class":42,"line":43},[40,11433,11434],{},"final class AppConfig\n",[40,11436,11437],{"class":42,"line":49},[40,11438,76],{},[40,11440,11441],{"class":42,"line":55},[40,11442,11053],{},[40,11444,11445],{"class":42,"line":84},[40,11446,190],{"emptyLinePlaceholder":189},[40,11448,11449],{"class":42,"line":90},[40,11450,207],{},[40,11452,11453],{"class":42,"line":96},[40,11454,11455],{},"        public readonly string $appEnv,\n",[40,11457,11458],{"class":42,"line":102},[40,11459,11460],{},"        public readonly string $databaseUrl,\n",[40,11462,11463],{"class":42,"line":193},[40,11464,99],{},[40,11466,11467],{"class":42,"line":199},[40,11468,190],{"emptyLinePlaceholder":189},[40,11470,11471],{"class":42,"line":204},[40,11472,11473],{},"    public static function get(): self\n",[40,11475,11476],{"class":42,"line":210},[40,11477,241],{},[40,11479,11480],{"class":42,"line":216},[40,11481,11482],{},"        return self::$instance ??= new self(\n",[40,11484,11485],{"class":42,"line":222},[40,11486,11487],{},"            appEnv: getenv('APP_ENV') ?: 'production',\n",[40,11489,11490],{"class":42,"line":227},[40,11491,11492],{},"            databaseUrl: getenv('DATABASE_URL') ?: throw new RuntimeException('DATABASE_URL is not set'),\n",[40,11494,11495],{"class":42,"line":232},[40,11496,3934],{},[40,11498,11499],{"class":42,"line":238},[40,11500,253],{},[40,11502,11503],{"class":42,"line":244},[40,11504,105],{},[11,11506,11507,11510,11511,11514,11515,1751],{},[15,11508,11509],{},"readonly"," properties prevent mutation, and a missing variable fails at the first call instead of later in an unrelated code path. In a Laravel application the same object is better registered with ",[15,11512,11513],{},"$this->app->singleton()",", because tests can then swap it with ",[15,11516,11517],{},"$this->app->instance()",[11,11519,11520,11521,11524],{},"For memoizing an expensive computation, Laravel 11 and newer provide the ",[15,11522,11523],{},"once()"," helper, which caches a closure's result per object and call site. Octane flushes it between requests, so it does not carry the long-running-worker problem described above.",[23,11526,3420],{"id":3419},[11,11528,11529,11530,11533],{},"When a Singleton or a ",[15,11531,11532],{},"private static"," instance appears in a review:",[124,11535,11536,11539,11546,11549,11552],{},[127,11537,11538],{},"Does the object hold mutable state? If not, the pattern is acceptable, though container registration is still easier to test.",[127,11540,11541,11542,11545],{},"Is any of that state request-, user- or tenant-specific? Then it needs a ",[15,11543,11544],{},"scoped"," lifetime or must be passed explicitly.",[127,11547,11548],{},"Must the state be shared across processes or servers (counters, locks, caches)? Then it belongs in Redis or the database, not in PHP memory.",[127,11550,11551],{},"Will this code run under Octane or in a queue worker? Check the behaviour under that runtime, not only under PHP-FPM.",[127,11553,11554],{},"Can a test replace it without calling a reset method? If not, inject it.",[729,11556,731],{},{"title":36,"searchDepth":49,"depth":49,"links":11558},[11559,11560,11561,11562,11563,11564],{"id":10971,"depth":49,"text":10972},{"id":11030,"depth":49,"text":11031},{"id":11170,"depth":49,"text":11171},{"id":11247,"depth":49,"text":11248},{"id":11403,"depth":49,"text":11404},{"id":3419,"depth":49,"text":3420},"2024-10-15","The classic Singleton stores an instance in a static property and hands it out through getInstance(). The pattern bundles two things: lazy construction and global access. Lazy construction is useful. Global access is what causes the problems, and the pattern gives you both whether you need them or not.",{},{"x":11569,"y":11570,"depth":11571,"size":6041},0.22,0.34,1.2,[2152,1383],{"title":10957,"description":11566},"global-state-mgmt","articles\u002Fsingleton-pattern",[35,4447,6728,8021,759,11577],"php-fpm","v5.0.0","Gom6tPKS1ea86UsADD53oeeG7pweEDgMXfbuw2Rba8Q",{"id":11581,"title":11582,"articleId":10945,"body":11583,"category":35,"codeLang":35,"date":12472,"deploys":43,"description":12473,"excerpt":742,"extension":743,"lang":742,"meta":12474,"navigation":189,"path":12475,"pos":12476,"readMin":102,"related":12478,"seo":12479,"service":12480,"stem":12481,"tags":12482,"version":760,"__hash__":12485},"articles\u002Farticles\u002Fstate-machine.md","State machines in PHP: an explicit order lifecycle with enums, locks and events",{"type":8,"value":11584,"toc":12462},[11585,11596,11600,11685,11695,11699,11702,11861,11873,11880,11884,11895,12029,12043,12046,12084,12087,12091,12105,12124,12131,12148,12152,12155,12161,12282,12289,12293,12296,12401,12404,12408,12438,12440,12460],[11,11586,11587,11588,11591,11592,11595],{},"Any entity with a lifecycle (an order, a subscription, a loan application) is a state machine. In most codebases that machine is implicit: a ",[15,11589,11590],{},"status"," column and ",[15,11593,11594],{},"if"," checks in the services that change it. This article shows how to make it explicit in PHP 8.3 and Laravel, how to apply transitions safely under concurrency, and where side effects belong.",[23,11597,11599],{"id":11598},"the-implicit-version","The implicit version",[31,11601,11603],{"className":33,"code":11602,"language":35,"meta":36,"style":36},"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",[15,11604,11605,11610,11614,11619,11623,11628,11633,11637,11642,11646,11650,11655,11659,11664,11669,11673,11677,11681],{"__ignoreMap":36},[40,11606,11607],{"class":42,"line":43},[40,11608,11609],{},"class OrderService\n",[40,11611,11612],{"class":42,"line":49},[40,11613,76],{},[40,11615,11616],{"class":42,"line":55},[40,11617,11618],{},"    public function markPaid(Order $order): void\n",[40,11620,11621],{"class":42,"line":84},[40,11622,241],{},[40,11624,11625],{"class":42,"line":90},[40,11626,11627],{},"        if ($order->status !== 'payment_pending') {\n",[40,11629,11630],{"class":42,"line":96},[40,11631,11632],{},"            throw new \\LogicException(\"Cannot mark order {$order->id} as paid\");\n",[40,11634,11635],{"class":42,"line":102},[40,11636,353],{},[40,11638,11639],{"class":42,"line":193},[40,11640,11641],{},"        \u002F\u002F ...\n",[40,11643,11644],{"class":42,"line":199},[40,11645,253],{},[40,11647,11648],{"class":42,"line":204},[40,11649,190],{"emptyLinePlaceholder":189},[40,11651,11652],{"class":42,"line":210},[40,11653,11654],{},"    public function cancel(Order $order): void\n",[40,11656,11657],{"class":42,"line":216},[40,11658,241],{},[40,11660,11661],{"class":42,"line":222},[40,11662,11663],{},"        if (in_array($order->status, ['shipped', 'delivered', 'refunded'], true)) {\n",[40,11665,11666],{"class":42,"line":227},[40,11667,11668],{},"            throw new \\LogicException(\"Cannot cancel order {$order->id}\");\n",[40,11670,11671],{"class":42,"line":232},[40,11672,353],{},[40,11674,11675],{"class":42,"line":238},[40,11676,11641],{},[40,11678,11679],{"class":42,"line":244},[40,11680,253],{},[40,11682,11683],{"class":42,"line":250},[40,11684,105],{},[11,11686,11687,11688,11690,11691,11694],{},"Each method checks what it considers forbidden. The full set of allowed transitions is not written down anywhere; it is the sum of all checks in all methods. Two problems follow. A new code path that changes ",[15,11689,11590],{}," (a controller, an import command, an admin panel) has no rule to follow unless the author finds and copies the right check. And ",[15,11692,11693],{},"cancel()"," uses a deny list, so a new status added later is cancellable by default, which is probably not what the business wants.",[23,11696,11698],{"id":11697},"transitions-in-one-place","Transitions in one place",[11,11700,11701],{},"A backed enum can hold both the states and the transition graph:",[31,11703,11705],{"className":33,"code":11704,"language":35,"meta":36,"style":36},"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",[15,11706,11707,11712,11716,11721,11726,11731,11736,11741,11746,11751,11755,11760,11765,11769,11774,11779,11784,11789,11794,11799,11804,11809,11813,11817,11822,11826,11831,11835,11839,11844,11848,11853,11857],{"__ignoreMap":36},[40,11708,11709],{"class":42,"line":43},[40,11710,11711],{},"enum OrderStatus: string\n",[40,11713,11714],{"class":42,"line":49},[40,11715,76],{},[40,11717,11718],{"class":42,"line":55},[40,11719,11720],{},"    case Draft = 'draft';\n",[40,11722,11723],{"class":42,"line":84},[40,11724,11725],{},"    case PaymentPending = 'payment_pending';\n",[40,11727,11728],{"class":42,"line":90},[40,11729,11730],{},"    case Paid = 'paid';\n",[40,11732,11733],{"class":42,"line":96},[40,11734,11735],{},"    case Shipped = 'shipped';\n",[40,11737,11738],{"class":42,"line":102},[40,11739,11740],{},"    case Delivered = 'delivered';\n",[40,11742,11743],{"class":42,"line":193},[40,11744,11745],{},"    case Cancelled = 'cancelled';\n",[40,11747,11748],{"class":42,"line":199},[40,11749,11750],{},"    case Refunded = 'refunded';\n",[40,11752,11753],{"class":42,"line":204},[40,11754,190],{"emptyLinePlaceholder":189},[40,11756,11757],{"class":42,"line":210},[40,11758,11759],{},"    \u002F** @return list\u003Cself> *\u002F\n",[40,11761,11762],{"class":42,"line":216},[40,11763,11764],{},"    public function allowedTransitions(): array\n",[40,11766,11767],{"class":42,"line":222},[40,11768,241],{},[40,11770,11771],{"class":42,"line":227},[40,11772,11773],{},"        return match ($this) {\n",[40,11775,11776],{"class":42,"line":232},[40,11777,11778],{},"            self::Draft => [self::PaymentPending, self::Cancelled],\n",[40,11780,11781],{"class":42,"line":238},[40,11782,11783],{},"            self::PaymentPending => [self::Paid, self::Cancelled],\n",[40,11785,11786],{"class":42,"line":244},[40,11787,11788],{},"            self::Paid => [self::Shipped, self::Refunded],\n",[40,11790,11791],{"class":42,"line":250},[40,11792,11793],{},"            self::Shipped => [self::Delivered],\n",[40,11795,11796],{"class":42,"line":256},[40,11797,11798],{},"            self::Delivered => [self::Refunded],\n",[40,11800,11801],{"class":42,"line":261},[40,11802,11803],{},"            self::Cancelled, self::Refunded => [],\n",[40,11805,11806],{"class":42,"line":267},[40,11807,11808],{},"        };\n",[40,11810,11811],{"class":42,"line":272},[40,11812,253],{},[40,11814,11815],{"class":42,"line":278},[40,11816,190],{"emptyLinePlaceholder":189},[40,11818,11819],{"class":42,"line":283},[40,11820,11821],{},"    public function canTransitionTo(self $to): bool\n",[40,11823,11824],{"class":42,"line":288},[40,11825,241],{},[40,11827,11828],{"class":42,"line":294},[40,11829,11830],{},"        return in_array($to, $this->allowedTransitions(), true);\n",[40,11832,11833],{"class":42,"line":299},[40,11834,253],{},[40,11836,11837],{"class":42,"line":305},[40,11838,190],{"emptyLinePlaceholder":189},[40,11840,11841],{"class":42,"line":310},[40,11842,11843],{},"    public function isFinal(): bool\n",[40,11845,11846],{"class":42,"line":1571},[40,11847,241],{},[40,11849,11850],{"class":42,"line":1577},[40,11851,11852],{},"        return $this->allowedTransitions() === [];\n",[40,11854,11855],{"class":42,"line":1583},[40,11856,253],{},[40,11858,11859],{"class":42,"line":1589},[40,11860,105],{},[11,11862,11863,11864,11866,11867,11869,11870,11872],{},"The rules are now an allow list. A new case without its own ",[15,11865,403],{}," arm throws ",[15,11868,407],{}," at runtime, and PHPStan (from rule level 4) or Psalm report the non-exhaustive ",[15,11871,403],{}," before that. Whoever adds a status has to decide its transitions explicitly.",[11,11874,11875,11876,11879],{},"On the model, cast the column to the enum (",[15,11877,11878],{},"protected $casts = ['status' => OrderStatus::class];",") so the rest of the code never compares raw strings.",[23,11881,11883],{"id":11882},"applying-a-transition","Applying a transition",[11,11885,11886,11887,11890,11891,11894],{},"The check alone is not enough. Two requests can read the same order in ",[15,11888,11889],{},"payment_pending",", both pass ",[15,11892,11893],{},"canTransitionTo(Paid)"," and both write. The transition has to read and write the row under a lock:",[31,11896,11898],{"className":33,"code":11897,"language":35,"meta":36,"style":36},"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",[15,11899,11900,11905,11909,11914,11918,11923,11928,11933,11937,11942,11947,11951,11955,11960,11965,11970,11974,11979,11984,11989,11994,11998,12002,12007,12011,12016,12021,12025],{"__ignoreMap":36},[40,11901,11902],{"class":42,"line":43},[40,11903,11904],{},"final class OrderTransitions\n",[40,11906,11907],{"class":42,"line":49},[40,11908,76],{},[40,11910,11911],{"class":42,"line":55},[40,11912,11913],{},"    public function apply(int $orderId, OrderStatus $to, ?string $reason = null): Order\n",[40,11915,11916],{"class":42,"line":84},[40,11917,241],{},[40,11919,11920],{"class":42,"line":90},[40,11921,11922],{},"        return DB::transaction(function () use ($orderId, $to, $reason): Order {\n",[40,11924,11925],{"class":42,"line":96},[40,11926,11927],{},"            $order = Order::query()->lockForUpdate()->findOrFail($orderId);\n",[40,11929,11930],{"class":42,"line":102},[40,11931,11932],{},"            $from = $order->status;\n",[40,11934,11935],{"class":42,"line":193},[40,11936,190],{"emptyLinePlaceholder":189},[40,11938,11939],{"class":42,"line":199},[40,11940,11941],{},"            if (! $from->canTransitionTo($to)) {\n",[40,11943,11944],{"class":42,"line":204},[40,11945,11946],{},"                throw InvalidTransition::between($order->id, $from, $to);\n",[40,11948,11949],{"class":42,"line":210},[40,11950,7627],{},[40,11952,11953],{"class":42,"line":216},[40,11954,190],{"emptyLinePlaceholder":189},[40,11956,11957],{"class":42,"line":222},[40,11958,11959],{},"            $order->status = $to;\n",[40,11961,11962],{"class":42,"line":227},[40,11963,11964],{},"            $order->status_changed_at = now();\n",[40,11966,11967],{"class":42,"line":232},[40,11968,11969],{},"            $order->save();\n",[40,11971,11972],{"class":42,"line":238},[40,11973,190],{"emptyLinePlaceholder":189},[40,11975,11976],{"class":42,"line":244},[40,11977,11978],{},"            $order->statusHistory()->create([\n",[40,11980,11981],{"class":42,"line":250},[40,11982,11983],{},"                'from' => $from->value,\n",[40,11985,11986],{"class":42,"line":256},[40,11987,11988],{},"                'to' => $to->value,\n",[40,11990,11991],{"class":42,"line":261},[40,11992,11993],{},"                'reason' => $reason,\n",[40,11995,11996],{"class":42,"line":267},[40,11997,5553],{},[40,11999,12000],{"class":42,"line":272},[40,12001,190],{"emptyLinePlaceholder":189},[40,12003,12004],{"class":42,"line":278},[40,12005,12006],{},"            OrderStatusChanged::dispatch($order->id, $from, $to);\n",[40,12008,12009],{"class":42,"line":283},[40,12010,190],{"emptyLinePlaceholder":189},[40,12012,12013],{"class":42,"line":288},[40,12014,12015],{},"            return $order;\n",[40,12017,12018],{"class":42,"line":294},[40,12019,12020],{},"        });\n",[40,12022,12023],{"class":42,"line":299},[40,12024,253],{},[40,12026,12027],{"class":42,"line":305},[40,12028,105],{},[11,12030,12031,12034,12035,12038,12039,12042],{},[15,12032,12033],{},"lockForUpdate()"," adds ",[15,12036,12037],{},"FOR UPDATE"," to the ",[15,12040,12041],{},"SELECT",". A second transaction that wants the same row waits until the first one commits, then reads the new status, and the check rejects the transition. The history table records what changed, when and why (add the user's id if you need to know who), which is the first thing needed when a customer disputes an order.",[11,12044,12045],{},"If you prefer to avoid holding a lock, a conditional update gives the same guarantee for a single transition:",[31,12047,12049],{"className":33,"code":12048,"language":35,"meta":36,"style":36},"$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 another request changed the status first\n}\n",[15,12050,12051,12056,12061,12066,12070,12075,12080],{"__ignoreMap":36},[40,12052,12053],{"class":42,"line":43},[40,12054,12055],{},"$affected = Order::whereKey($orderId)\n",[40,12057,12058],{"class":42,"line":49},[40,12059,12060],{},"    ->where('status', OrderStatus::PaymentPending->value)\n",[40,12062,12063],{"class":42,"line":55},[40,12064,12065],{},"    ->update(['status' => OrderStatus::Paid->value, 'status_changed_at' => now()]);\n",[40,12067,12068],{"class":42,"line":84},[40,12069,190],{"emptyLinePlaceholder":189},[40,12071,12072],{"class":42,"line":90},[40,12073,12074],{},"if ($affected === 0) {\n",[40,12076,12077],{"class":42,"line":96},[40,12078,12079],{},"    \u002F\u002F another request changed the status first\n",[40,12081,12082],{"class":42,"line":102},[40,12083,105],{},[11,12085,12086],{},"This works well for hot paths such as payment webhooks. The locked version is easier to extend when the transition also needs to read other data or write several rows.",[23,12088,12090],{"id":12089},"repeated-calls-from-external-systems","Repeated calls from external systems",[11,12092,12093,12094,12097,12098,12101,12102,12104],{},"Payment providers deliver webhooks at least once, so the same \"payment succeeded\" event can arrive twice. The second delivery finds the order already ",[15,12095,12096],{},"paid",", and ",[15,12099,12100],{},"Paid"," is not a valid target from ",[15,12103,12100],{},". For such callers, treat \"already in the target state\" as success:",[31,12106,12108],{"className":33,"code":12107,"language":35,"meta":36,"style":36},"if ($from === $to) {\n    return $order; \u002F\u002F idempotent repeat, nothing to do\n}\n",[15,12109,12110,12115,12120],{"__ignoreMap":36},[40,12111,12112],{"class":42,"line":43},[40,12113,12114],{},"if ($from === $to) {\n",[40,12116,12117],{"class":42,"line":49},[40,12118,12119],{},"    return $order; \u002F\u002F idempotent repeat, nothing to do\n",[40,12121,12122],{"class":42,"line":55},[40,12123,105],{},[11,12125,12126,12127,12130],{},"Put this check in the code path for idempotent callers (webhooks, retried jobs), not in the generic ",[15,12128,12129],{},"apply()",". For a user clicking \"Cancel\" on an order that is already cancelled, an error message is the correct response.",[11,12132,12133,12134,12136,12137,12140,12141,12144,12145,12147],{},"The state machine guarantees that the order changes to ",[15,12135,12096],{}," once. It does not stop the payment provider from charging twice. Example: the customer clicks \"Pay\" twice and two requests each create a payment at the provider. Prevent that at the point where the payment is created. Create it only in the ",[15,12138,12139],{},"Draft → PaymentPending"," transition, under the same lock, and let the second request find ",[15,12142,12143],{},"PaymentPending"," and return the existing payment instead of creating another. In addition, send an idempotency key with the create request where the provider supports it (Stripe, for example, accepts an ",[15,12146,1837],{}," header).",[23,12149,12151],{"id":12150},"side-effects-go-to-events","Side effects go to events",[11,12153,12154],{},"Sending an email, reserving stock or notifying analytics inside the transition couples the state machine to those systems and raises a question with no good answer: if the email fails, should the payment status be rolled back? It should not. The order is paid regardless of the email.",[11,12156,12157,12158,12160],{},"The transition therefore only dispatches an event. The event implements ",[15,12159,5855],{},", so listeners do not run if the transaction rolls back, and do not see the old row state if they read from another connection:",[31,12162,12164],{"className":33,"code":12163,"language":35,"meta":36,"style":36},"use Illuminate\\Contracts\\Events\\ShouldDispatchAfterCommit;\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",[15,12165,12166,12171,12176,12180,12185,12189,12194,12198,12202,12207,12212,12217,12221,12225,12229,12234,12238,12243,12247,12252,12256,12260,12264,12269,12274,12278],{"__ignoreMap":36},[40,12167,12168],{"class":42,"line":43},[40,12169,12170],{},"use Illuminate\\Contracts\\Events\\ShouldDispatchAfterCommit;\n",[40,12172,12173],{"class":42,"line":49},[40,12174,12175],{},"use Illuminate\\Foundation\\Events\\Dispatchable;\n",[40,12177,12178],{"class":42,"line":55},[40,12179,190],{"emptyLinePlaceholder":189},[40,12181,12182],{"class":42,"line":84},[40,12183,12184],{},"final class OrderStatusChanged implements ShouldDispatchAfterCommit\n",[40,12186,12187],{"class":42,"line":90},[40,12188,76],{},[40,12190,12191],{"class":42,"line":96},[40,12192,12193],{},"    use Dispatchable;\n",[40,12195,12196],{"class":42,"line":102},[40,12197,190],{"emptyLinePlaceholder":189},[40,12199,12200],{"class":42,"line":193},[40,12201,81],{},[40,12203,12204],{"class":42,"line":199},[40,12205,12206],{},"        public readonly int $orderId,\n",[40,12208,12209],{"class":42,"line":204},[40,12210,12211],{},"        public readonly OrderStatus $from,\n",[40,12213,12214],{"class":42,"line":210},[40,12215,12216],{},"        public readonly OrderStatus $to,\n",[40,12218,12219],{"class":42,"line":216},[40,12220,99],{},[40,12222,12223],{"class":42,"line":222},[40,12224,105],{},[40,12226,12227],{"class":42,"line":227},[40,12228,190],{"emptyLinePlaceholder":189},[40,12230,12231],{"class":42,"line":232},[40,12232,12233],{},"final class SendPaymentConfirmation implements ShouldQueue\n",[40,12235,12236],{"class":42,"line":238},[40,12237,76],{},[40,12239,12240],{"class":42,"line":244},[40,12241,12242],{},"    public function handle(OrderStatusChanged $event): void\n",[40,12244,12245],{"class":42,"line":250},[40,12246,241],{},[40,12248,12249],{"class":42,"line":256},[40,12250,12251],{},"        if ($event->to !== OrderStatus::Paid) {\n",[40,12253,12254],{"class":42,"line":261},[40,12255,4156],{},[40,12257,12258],{"class":42,"line":267},[40,12259,353],{},[40,12261,12262],{"class":42,"line":272},[40,12263,190],{"emptyLinePlaceholder":189},[40,12265,12266],{"class":42,"line":278},[40,12267,12268],{},"        $order = Order::findOrFail($event->orderId);\n",[40,12270,12271],{"class":42,"line":283},[40,12272,12273],{},"        Mail::to($order->customer_email)->send(new PaymentConfirmed($order));\n",[40,12275,12276],{"class":42,"line":288},[40,12277,253],{},[40,12279,12280],{"class":42,"line":294},[40,12281,105],{},[11,12283,12284,12285,12288],{},"Each queued listener runs as a separate job with its own attempts (Laravel attempts a job once unless you set ",[15,12286,12287],{},"$tries","). A failing mail server delays the confirmation email and leaves the order status alone.",[23,12290,12292],{"id":12291},"tests-that-check-the-graph","Tests that check the graph",[11,12294,12295],{},"Testing every pair of states against a copy of the same table only duplicates the definition. Test properties of the graph and the business rules that matter:",[31,12297,12299],{"className":33,"code":12298,"language":35,"meta":36,"style":36},"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",[15,12300,12301,12306,12310,12315,12320,12324,12329,12334,12339,12344,12349,12353,12357,12361,12365,12370,12375,12379,12383,12388,12392,12397],{"__ignoreMap":36},[40,12302,12303],{"class":42,"line":43},[40,12304,12305],{},"public function test_every_status_is_reachable_from_draft(): void\n",[40,12307,12308],{"class":42,"line":49},[40,12309,76],{},[40,12311,12312],{"class":42,"line":55},[40,12313,12314],{},"    $seen = [OrderStatus::Draft];\n",[40,12316,12317],{"class":42,"line":84},[40,12318,12319],{},"    $queue = [OrderStatus::Draft];\n",[40,12321,12322],{"class":42,"line":90},[40,12323,190],{"emptyLinePlaceholder":189},[40,12325,12326],{"class":42,"line":96},[40,12327,12328],{},"    while ($queue !== []) {\n",[40,12330,12331],{"class":42,"line":102},[40,12332,12333],{},"        foreach (array_shift($queue)->allowedTransitions() as $next) {\n",[40,12335,12336],{"class":42,"line":193},[40,12337,12338],{},"            if (! in_array($next, $seen, true)) {\n",[40,12340,12341],{"class":42,"line":199},[40,12342,12343],{},"                $seen[] = $next;\n",[40,12345,12346],{"class":42,"line":204},[40,12347,12348],{},"                $queue[] = $next;\n",[40,12350,12351],{"class":42,"line":210},[40,12352,7627],{},[40,12354,12355],{"class":42,"line":216},[40,12356,353],{},[40,12358,12359],{"class":42,"line":222},[40,12360,253],{},[40,12362,12363],{"class":42,"line":227},[40,12364,190],{"emptyLinePlaceholder":189},[40,12366,12367],{"class":42,"line":232},[40,12368,12369],{},"    $values = fn (array $states): array => array_map(fn (OrderStatus $s) => $s->value, $states);\n",[40,12371,12372],{"class":42,"line":238},[40,12373,12374],{},"    $this->assertEqualsCanonicalizing($values(OrderStatus::cases()), $values($seen));\n",[40,12376,12377],{"class":42,"line":244},[40,12378,105],{},[40,12380,12381],{"class":42,"line":250},[40,12382,190],{"emptyLinePlaceholder":189},[40,12384,12385],{"class":42,"line":256},[40,12386,12387],{},"public function test_shipped_order_cannot_be_cancelled(): void\n",[40,12389,12390],{"class":42,"line":261},[40,12391,76],{},[40,12393,12394],{"class":42,"line":267},[40,12395,12396],{},"    $this->assertFalse(OrderStatus::Shipped->canTransitionTo(OrderStatus::Cancelled));\n",[40,12398,12399],{"class":42,"line":272},[40,12400,105],{},[11,12402,12403],{},"The first test catches a status added to the enum without any way into it. The second documents a rule someone may later want to change, so the change has to be deliberate.",[23,12405,12407],{"id":12406},"limits-and-alternatives","Limits and alternatives",[703,12409,12410,12413,12420,12427],{},[127,12411,12412],{},"Several independent dimensions (payment, fulfilment, invoicing) are better as several small machines than one status with every combination. Seven states times four payment states gives a graph nobody can review.",[127,12414,12415,12416,12419],{},"A boolean flag with no rules attached (for example ",[15,12417,12418],{},"is_archived",") does not need a state machine.",[127,12421,12422,12423,12426],{},"Database ",[15,12424,12425],{},"CHECK"," constraints can restrict the set of values, but not transitions. Enforcing transitions in a trigger is possible, though it moves business rules to a place most PHP teams do not review.",[127,12428,12429,12430,12433,12434,12437],{},"Ready-made components exist: the Symfony Workflow component (with a ",[15,12431,12432],{},"state_machine"," type) and ",[15,12435,12436],{},"spatie\u002Flaravel-model-states"," for Laravel. Symfony Workflow adds guards, transition events and a command that dumps the graph; the Spatie package adds state classes, custom transition classes and transition events. The enum above is enough when the graph fits on one screen.",[23,12439,701],{"id":700},[703,12441,12442,12445,12448,12451,12454,12457],{},[127,12443,12444],{},"Allowed transitions are defined in one place as an allow list.",[127,12446,12447],{},"Every status change goes through one method that checks the graph under a lock or a conditional update.",[127,12449,12450],{},"Repeated calls from idempotent sources are handled explicitly.",[127,12452,12453],{},"External side effects (payments, emails) are protected separately; the state machine does not prevent a double charge at the provider.",[127,12455,12456],{},"Side effects run in listeners after commit.",[127,12458,12459],{},"Transitions are recorded in a history table.",[729,12461,731],{},{"title":36,"searchDepth":49,"depth":49,"links":12463},[12464,12465,12466,12467,12468,12469,12470,12471],{"id":11598,"depth":49,"text":11599},{"id":11697,"depth":49,"text":11698},{"id":11882,"depth":49,"text":11883},{"id":12089,"depth":49,"text":12090},{"id":12150,"depth":49,"text":12151},{"id":12291,"depth":49,"text":12292},{"id":12406,"depth":49,"text":12407},{"id":700,"depth":49,"text":701},"2024-02-10","Any entity with a lifecycle (an order, a subscription, a loan application) is a state machine. In most codebases that machine is implicit: a status column and if checks in the services that change it. This article shows how to make it explicit in PHP 8.3 and Laravel, how to apply transitions safely under concurrency, and where side effects belong.",{},"\u002Farticles\u002Fstate-machine",{"x":12477,"y":9676,"depth":8778,"size":743},0.82,[6043,4441],{"title":11582,"description":12473},"order-lifecycle","articles\u002Fstate-machine",[35,8021,4447,10945,12483,12484],"fsm","order-management","6pnJaY61fDQ6iiquI6mPQ0Cn82XOe_ADmD6mlp9Fg2M",{"id":4,"title":5,"articleId":6,"body":12487,"category":739,"codeLang":35,"date":740,"deploys":43,"description":741,"excerpt":742,"extension":743,"lang":742,"meta":13042,"navigation":189,"path":745,"pos":13043,"readMin":96,"related":13044,"seo":13045,"service":753,"stem":754,"tags":13046,"version":760,"__hash__":761},{"type":8,"value":12488,"toc":13035},[12489,12493,12495,12497,12499,12515,12517,12549,12551,12555,12557,12559,12573,12577,12697,12699,12771,12777,12837,12843,12845,12847,12849,12921,12923,12925,12927,13011,13013,13015,13017,13033],[11,12490,13,12491,18],{},[15,12492,17],{},[11,12494,21],{},[23,12496,26],{"id":25},[11,12498,29],{},[31,12500,12501],{"className":33,"code":34,"language":35,"meta":36,"style":36},[15,12502,12503,12507,12511],{"__ignoreMap":36},[40,12504,12505],{"class":42,"line":43},[40,12506,46],{},[40,12508,12509],{"class":42,"line":49},[40,12510,52],{},[40,12512,12513],{"class":42,"line":55},[40,12514,58],{},[11,12516,61],{},[31,12518,12519],{"className":33,"code":64,"language":35,"meta":36,"style":36},[15,12520,12521,12525,12529,12533,12537,12541,12545],{"__ignoreMap":36},[40,12522,12523],{"class":42,"line":43},[40,12524,71],{},[40,12526,12527],{"class":42,"line":49},[40,12528,76],{},[40,12530,12531],{"class":42,"line":55},[40,12532,81],{},[40,12534,12535],{"class":42,"line":84},[40,12536,87],{},[40,12538,12539],{"class":42,"line":90},[40,12540,93],{},[40,12542,12543],{"class":42,"line":96},[40,12544,99],{},[40,12546,12547],{"class":42,"line":102},[40,12548,105],{},[11,12550,108],{},[11,12552,111,12553,115],{},[15,12554,114],{},[23,12556,119],{"id":118},[11,12558,122],{},[124,12560,12561,12565,12569],{},[127,12562,12563,133],{},[130,12564,132],{},[127,12566,12567,139],{},[130,12568,138],{},[127,12570,12571,145],{},[130,12572,144],{},[11,12574,148,12575,151],{},[15,12576,17],{},[31,12578,12579],{"className":33,"code":154,"language":35,"meta":36,"style":36},[15,12580,12581,12585,12589,12593,12597,12601,12605,12609,12613,12617,12621,12625,12629,12633,12637,12641,12645,12649,12653,12657,12661,12665,12669,12673,12677,12681,12685,12689,12693],{"__ignoreMap":36},[40,12582,12583],{"class":42,"line":43},[40,12584,161],{},[40,12586,12587],{"class":42,"line":49},[40,12588,76],{},[40,12590,12591],{"class":42,"line":55},[40,12592,170],{},[40,12594,12595],{"class":42,"line":84},[40,12596,175],{},[40,12598,12599],{"class":42,"line":90},[40,12600,180],{},[40,12602,12603],{"class":42,"line":96},[40,12604,105],{},[40,12606,12607],{"class":42,"line":102},[40,12608,190],{"emptyLinePlaceholder":189},[40,12610,12611],{"class":42,"line":193},[40,12612,196],{},[40,12614,12615],{"class":42,"line":199},[40,12616,76],{},[40,12618,12619],{"class":42,"line":204},[40,12620,207],{},[40,12622,12623],{"class":42,"line":210},[40,12624,213],{},[40,12626,12627],{"class":42,"line":216},[40,12628,219],{},[40,12630,12631],{"class":42,"line":222},[40,12632,99],{},[40,12634,12635],{"class":42,"line":227},[40,12636,190],{"emptyLinePlaceholder":189},[40,12638,12639],{"class":42,"line":232},[40,12640,235],{},[40,12642,12643],{"class":42,"line":238},[40,12644,241],{},[40,12646,12647],{"class":42,"line":244},[40,12648,247],{},[40,12650,12651],{"class":42,"line":250},[40,12652,253],{},[40,12654,12655],{"class":42,"line":256},[40,12656,190],{"emptyLinePlaceholder":189},[40,12658,12659],{"class":42,"line":261},[40,12660,264],{},[40,12662,12663],{"class":42,"line":267},[40,12664,241],{},[40,12666,12667],{"class":42,"line":272},[40,12668,275],{},[40,12670,12671],{"class":42,"line":278},[40,12672,253],{},[40,12674,12675],{"class":42,"line":283},[40,12676,190],{"emptyLinePlaceholder":189},[40,12678,12679],{"class":42,"line":288},[40,12680,291],{},[40,12682,12683],{"class":42,"line":294},[40,12684,241],{},[40,12686,12687],{"class":42,"line":299},[40,12688,302],{},[40,12690,12691],{"class":42,"line":305},[40,12692,253],{},[40,12694,12695],{"class":42,"line":310},[40,12696,105],{},[11,12698,315],{},[31,12700,12701],{"className":33,"code":318,"language":35,"meta":36,"style":36},[15,12702,12703,12707,12711,12715,12719,12723,12727,12731,12735,12739,12743,12747,12751,12755,12759,12763,12767],{"__ignoreMap":36},[40,12704,12705],{"class":42,"line":43},[40,12706,325],{},[40,12708,12709],{"class":42,"line":49},[40,12710,76],{},[40,12712,12713],{"class":42,"line":55},[40,12714,334],{},[40,12716,12717],{"class":42,"line":84},[40,12718,241],{},[40,12720,12721],{"class":42,"line":90},[40,12722,343],{},[40,12724,12725],{"class":42,"line":96},[40,12726,348],{},[40,12728,12729],{"class":42,"line":102},[40,12730,353],{},[40,12732,12733],{"class":42,"line":193},[40,12734,190],{"emptyLinePlaceholder":189},[40,12736,12737],{"class":42,"line":199},[40,12738,362],{},[40,12740,12741],{"class":42,"line":204},[40,12742,190],{"emptyLinePlaceholder":189},[40,12744,12745],{"class":42,"line":210},[40,12746,371],{},[40,12748,12749],{"class":42,"line":216},[40,12750,376],{},[40,12752,12753],{"class":42,"line":222},[40,12754,353],{},[40,12756,12757],{"class":42,"line":227},[40,12758,190],{"emptyLinePlaceholder":189},[40,12760,12761],{"class":42,"line":232},[40,12762,389],{},[40,12764,12765],{"class":42,"line":238},[40,12766,253],{},[40,12768,12769],{"class":42,"line":244},[40,12770,105],{},[11,12772,400,12773,404,12775,408],{},[15,12774,403],{},[15,12776,407],{},[31,12778,12779],{"className":33,"code":411,"language":35,"meta":36,"style":36},[15,12780,12781,12785,12789,12793,12797,12801,12805,12809,12813,12817,12821,12825,12829,12833],{"__ignoreMap":36},[40,12782,12783],{"class":42,"line":43},[40,12784,418],{},[40,12786,12787],{"class":42,"line":49},[40,12788,76],{},[40,12790,12791],{"class":42,"line":55},[40,12792,427],{},[40,12794,12795],{"class":42,"line":84},[40,12796,432],{},[40,12798,12799],{"class":42,"line":90},[40,12800,437],{},[40,12802,12803],{"class":42,"line":96},[40,12804,105],{},[40,12806,12807],{"class":42,"line":102},[40,12808,190],{"emptyLinePlaceholder":189},[40,12810,12811],{"class":42,"line":193},[40,12812,450],{},[40,12814,12815],{"class":42,"line":199},[40,12816,76],{},[40,12818,12819],{"class":42,"line":204},[40,12820,459],{},[40,12822,12823],{"class":42,"line":210},[40,12824,464],{},[40,12826,12827],{"class":42,"line":216},[40,12828,469],{},[40,12830,12831],{"class":42,"line":222},[40,12832,474],{},[40,12834,12835],{"class":42,"line":227},[40,12836,105],{},[11,12838,481,12839,485,12841,489],{},[15,12840,484],{},[15,12842,488],{},[11,12844,492],{},[23,12846,496],{"id":495},[11,12848,499],{},[31,12850,12851],{"className":33,"code":502,"language":35,"meta":36,"style":36},[15,12852,12853,12857,12861,12865,12869,12873,12877,12881,12885,12889,12893,12897,12901,12905,12909,12913,12917],{"__ignoreMap":36},[40,12854,12855],{"class":42,"line":43},[40,12856,509],{},[40,12858,12859],{"class":42,"line":49},[40,12860,76],{},[40,12862,12863],{"class":42,"line":55},[40,12864,518],{},[40,12866,12867],{"class":42,"line":84},[40,12868,523],{},[40,12870,12871],{"class":42,"line":90},[40,12872,190],{"emptyLinePlaceholder":189},[40,12874,12875],{"class":42,"line":96},[40,12876,532],{},[40,12878,12879],{"class":42,"line":102},[40,12880,190],{"emptyLinePlaceholder":189},[40,12882,12883],{"class":42,"line":193},[40,12884,541],{},[40,12886,12887],{"class":42,"line":199},[40,12888,546],{},[40,12890,12891],{"class":42,"line":204},[40,12892,105],{},[40,12894,12895],{"class":42,"line":210},[40,12896,190],{"emptyLinePlaceholder":189},[40,12898,12899],{"class":42,"line":216},[40,12900,559],{},[40,12902,12903],{"class":42,"line":222},[40,12904,76],{},[40,12906,12907],{"class":42,"line":227},[40,12908,568],{},[40,12910,12911],{"class":42,"line":232},[40,12912,190],{"emptyLinePlaceholder":189},[40,12914,12915],{"class":42,"line":238},[40,12916,577],{},[40,12918,12919],{"class":42,"line":244},[40,12920,105],{},[11,12922,584],{},[23,12924,588],{"id":587},[11,12926,591],{},[31,12928,12929],{"className":33,"code":594,"language":35,"meta":36,"style":36},[15,12930,12931,12935,12939,12943,12947,12951,12955,12959,12963,12967,12971,12975,12979,12983,12987,12991,12995,12999,13003,13007],{"__ignoreMap":36},[40,12932,12933],{"class":42,"line":43},[40,12934,601],{},[40,12936,12937],{"class":42,"line":49},[40,12938,606],{},[40,12940,12941],{"class":42,"line":55},[40,12942,611],{},[40,12944,12945],{"class":42,"line":84},[40,12946,616],{},[40,12948,12949],{"class":42,"line":90},[40,12950,621],{},[40,12952,12953],{"class":42,"line":96},[40,12954,76],{},[40,12956,12957],{"class":42,"line":102},[40,12958,630],{},[40,12960,12961],{"class":42,"line":193},[40,12962,635],{},[40,12964,12965],{"class":42,"line":199},[40,12966,253],{},[40,12968,12969],{"class":42,"line":204},[40,12970,190],{"emptyLinePlaceholder":189},[40,12972,12973],{"class":42,"line":210},[40,12974,648],{},[40,12976,12977],{"class":42,"line":216},[40,12978,190],{"emptyLinePlaceholder":189},[40,12980,12981],{"class":42,"line":222},[40,12982,657],{},[40,12984,12985],{"class":42,"line":227},[40,12986,662],{},[40,12988,12989],{"class":42,"line":232},[40,12990,667],{},[40,12992,12993],{"class":42,"line":238},[40,12994,672],{},[40,12996,12997],{"class":42,"line":244},[40,12998,677],{},[40,13000,13001],{"class":42,"line":250},[40,13002,682],{},[40,13004,13005],{"class":42,"line":256},[40,13006,687],{},[40,13008,13009],{"class":42,"line":261},[40,13010,105],{},[11,13012,694],{},[11,13014,697],{},[23,13016,701],{"id":700},[703,13018,13019,13025,13027,13029,13031],{},[127,13020,707,13021,711,13023,715],{},[15,13022,710],{},[15,13024,714],{},[127,13026,718],{},[127,13028,721],{},[127,13030,724],{},[127,13032,727],{},[729,13034,731],{},{"title":36,"searchDepth":49,"depth":49,"links":13036},[13037,13038,13039,13040,13041],{"id":25,"depth":49,"text":26},{"id":118,"depth":49,"text":119},{"id":495,"depth":49,"text":496},{"id":587,"depth":49,"text":588},{"id":700,"depth":49,"text":701},{},{"x":747,"y":748,"depth":43,"size":743},[750,751],{"title":5,"description":741},[35,756,757,758,759],1791270179442]