Order status is an enum now

One row in orders had status 'canceled', with one l. Nobody knows how long it sat there. The enum found it on the first day. PHP 8.1 enums are six weeks old, enough time in one project to say something practical. The first candidate was obvious: order status. For years it was a class with string constants, Order::STATUS_PAID, Order::STATUS_SHIPPED. Constants look safe, but nothing stops a function from receiving 'payed'. The parameter type was string, and string accepts everything. ...

January 8, 2022 · 2 min · Murat Useinov

PHP 8.1: enums, readonly and a word about Fibers

'payed'. One letter, half a day of debugging, a few years ago. The string walked straight through OrderStatus::isValid(), because someone had added it to the constants list “for compatibility”. Every project I touched has that class: a bag of string constants, an isValid() helper, and a prayer. The type system never knew these strings were special. PHP 8.1 came out yesterday. Point release on paper, and it brings the feature I wanted since forever. Enums. ...

November 26, 2021 · 2 min · Murat Useinov

Nobody closes your database connection anymore

“MySQL server has gone away”, first thing in the morning, on a worker that had no traffic all night. That was week one after we moved a project from FPM to RoadRunner. For all these years FPM managed our database connections. Not with clever code. By dying. Process ends, connection closes, transaction rolls back, session variables reset. Nobody thought about connection lifecycle, because there was none. Move the same code to RoadRunner or Swoole workers and the topic is back. A connection lives for hours now. Two things go wrong, and both showed up within a week. ...

July 20, 2021 · 2 min · Murat Useinov

The outbox table

About once a month an order existed and nobody heard about it. The order service saved the order, then published order.created to RabbitMQ, and once a month the process died between the two. Rare enough to be mysterious. Frequent enough to ruin a weekend. Two operations, two systems, no common transaction. Whatever order you do them in, you lose. Commit first, then publish: the order exists, the event never leaves. Publish first, then commit: the commit fails, consumers are already busy with an order that does not exist. We had the first variant in production. ...

March 23, 2021 · 2 min · Murat Useinov

Attributes: metadata moves in with the code

#[Route('/orders/{id}', methods: ['GET'])] public function show(int $id): Response Symfony 5.2 accepts this on PHP 8, and the annotation era quietly starts to end. Same shape as the annotation, but now it is language, not a comment. The engine parses it, static analysis sees it, a typo is a compile-time complaint instead of a route that silently does not exist. Doctrine and the validator are heading the same way. Everything that lived in docblocks will move over the next year or two. ...

December 18, 2020 · 2 min · Murat Useinov

Types are for reading, not for the compiler

OrderRepository in a constructor. Click. I am there. That is how I explore a big Symfony project now, and I noticed my main reason for types has changed. Not bug catching anymore. Navigation. Symfony 5.1 is out, PHP 7.4 is everywhere I work, and the IDE knows every caller and every implementation. Compare with the array-passing style we all wrote for years: public function register($data) { // what is in $data? read three call sites to find out } versus ...

June 27, 2020 · 2 min · Murat Useinov

A Redis lock is a promise you cannot fully keep

Two workers, one order, processed twice. Every project gets this day. Someone writes SETNX, calls it a distributed lock, closes the ticket. I want to slow down here, because the ticket is not closed. The small bugs first. A lock needs a TTL, or a crashed worker holds it forever. A lock needs an owner token, or worker A releases the lock of worker B: $token = bin2hex(random_bytes(16)); $ok = $redis->set('lock:order:'.$orderId, $token, ['nx', 'ex' => 30]); And the release must be atomic: compare the token and delete in one Lua script. Check in PHP, delete in a second command, and there is a gap. Something will land in that gap. ...

February 26, 2020 · 2 min · Murat Useinov

Cursor pagination is part of your API contract

Someone inserts a row while the client walks the pages, and the whole window shifts. Page two shows an item the client already saw on page one. Or an item falls between pages and the client never sees it. For a feed this is annoying. For an export or a sync endpoint this is a data loss bug that nobody can reproduce. I used to think keyset pagination is a performance trick. Deep OFFSET is slow, keyset is fast, end of story. Now I think the performance part is the boring half. Offset pagination over a changing dataset lies to the client. That is the interesting half. ...

January 8, 2020 · 2 min · Murat Useinov

One use case, one class

Four hundred lines in a checkout action, half of them inside one try. That is one way a project dies. The other is OrderManager: thirty methods, six injected services, and nobody can say what the class is for, because it is for everything about orders. Both diseases have the same cure and it is embarrassingly simple. One use case, one class, one public method. final class PlaceOrder { public function __construct( OrderRepository $orders, PaymentGateway $payments, EventDispatcher $events ) { ... } public function handle(PlaceOrderCommand $command): OrderId { // the whole story, top to bottom } } The shape answers three questions by itself. What comes in: one command object, a thing you can log, validate, put in a queue. What it needs: the constructor lists dependencies of this use case, not of orders in general, so when PlaceOrder suddenly needs the mailer you see it and can ask why. What comes out: one result. Reading handle() from top to bottom tells the whole story of placing an order. No chapter hidden in a base class or a trait. ...

June 26, 2019 · 2 min · Murat Useinov

Eloquent observers and hidden control flow

Three thousand welcome emails. A colleague ran a user import on one project last month: loop over a CSV, $user->save(), go home. Next morning we found the script had also warmed the search index three thousand times and invalidated cache after every row. Nobody wrote that in the import script. The observers did. Laravel 5.8 came out yesterday, and reading the changelog brought that evening back, so here is the note. ...

February 27, 2019 · 2 min · Murat Useinov