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