[{"content":"Ten years ago a system design conversation was short. A server, a monolith, Postgres, maybe a load balancer in front. For most products that was the entire architecture, and it held.\nNow the same conversation opens with API gateways and doesn\u0026rsquo;t stop: Kubernetes, service meshes, Kafka, Redis, three databases because each one is good at one thing, gRPC, GraphQL, Elasticsearch, Terraform, Prometheus, tracing, sharding, replication, autoscaling.\nThe awkward part is that none of it is junk. Every one of those tools exists because someone hit a real wall: more users, more data, more teams, more regions, more nines of uptime.\nSo the problem isn\u0026rsquo;t the tools. It\u0026rsquo;s that we reach for them before asking anything about the problem.\nWhat scale are we actually designing for? Where is the bottleneck today, not in the imagined future? What genuinely needs strong consistency? What can be cached? What breaks when this service goes down? And the question that almost never comes up in a design review: can this team operate what we\u0026rsquo;re about to build, at 3am, on call, a year from now?\nThe strongest engineers I\u0026rsquo;ve worked with know distributed systems cold. That\u0026rsquo;s exactly why they\u0026rsquo;re comfortable saying \u0026ldquo;a single server and a database is enough here\u0026rdquo; without feeling like they\u0026rsquo;re underselling themselves.\n","permalink":"https://useinov.com/posts/2026/single-server-is-enough/","summary":"\u003cp\u003eTen years ago a system design conversation was short. A server, a monolith, Postgres, maybe a load balancer in front. For most products that was the entire architecture, and it held.\u003c/p\u003e\n\u003cp\u003eNow the same conversation opens with API gateways and doesn\u0026rsquo;t stop: Kubernetes, service meshes, Kafka, Redis, three databases because each one is good at one thing, gRPC, GraphQL, Elasticsearch, Terraform, Prometheus, tracing, sharding, replication, autoscaling.\u003c/p\u003e\n\u003cp\u003eThe awkward part is that none of it is junk. Every one of those tools exists because someone hit a real wall: more users, more data, more teams, more regions, more nines of uptime.\u003c/p\u003e","title":"A single server and a database is enough here"},{"content":"Accepted, declined, discussion drama. That is how most posts about PHP 8.6 RFCs read, and the vote cycle is in full swing this month. I read RFCs for a different reason. Not \u0026ldquo;what do I get\u0026rdquo;, but \u0026ldquo;what will my current code look like next year\u0026rdquo;.\nOne RFC is a feature. Ten RFCs are a direction. When several push the same way, toward stricter types, explicit declarations, deprecating some old freedom, the direction tells you which of your habits are becoming legacy while you still type them. Syntax sugar is the least interesting part. Deprecations are the most interesting, because every deprecation is a migration with a countdown attached.\nSo for each accepted change I ask an existing codebase three things. Does our code do the thing that is becoming discouraged, and how much of it. Can we move toward the new way today, on the current version, without waiting for the release. And do the tools understand the change yet, static analysis and the framework, because adopting syntax your analyzer cannot parse means turning the analyzer off. Bad trade for sugar.\nThe answers split into two piles. Prepare now: stop writing the deprecated pattern in new code. This costs nothing and shrinks the future migration every day. Leave alone: anything that requires touching stable production code before the version exists. RFCs get rejected. Details change until GA. Preparing direction is cheap. Preparing specifics is gambling.\nA year from now half of these RFCs will be forgotten and one of them will be the reason some project cannot upgrade. I have been that project. Reading them in August is how I make sure it is not this one.\n","permalink":"https://useinov.com/posts/2026/php-86-rfcs/","summary":"\u003cp\u003eAccepted, declined, discussion drama. That is how most posts about PHP 8.6 RFCs read, and the vote cycle is in full swing this month. I read RFCs for a different reason. Not \u0026ldquo;what do I get\u0026rdquo;, but \u0026ldquo;what will my current code look like next year\u0026rdquo;.\u003c/p\u003e\n\u003cp\u003eOne RFC is a feature. Ten RFCs are a direction. When several push the same way, toward stricter types, explicit declarations, deprecating some old freedom, the direction tells you which of your habits are becoming legacy while you still type them. Syntax sugar is the least interesting part. Deprecations are the most interesting, because every deprecation is a migration with a countdown attached.\u003c/p\u003e","title":"Reading PHP 8.6 RFCs as constraints"},{"content":"A production-like dump, a separate PostgreSQL 19 beta instance, one evening plus machine time. That is the whole method, and it is the only thing a database beta is for: finding out where the new version changes the behavior of your application while the change is still a bug report and not your incident.\nRestore the dump first. Data size matters. A plan that is fine on a thousand rows goes bad on fifty million.\nCollect twenty or thirty queries that represent the application. The report page, the search, the dashboard, the nightly job. The fast ones teach nothing. Then on both versions:\nEXPLAIN (ANALYZE, BUFFERS) SELECT ... Diff the plans. Timings alone lie on a quiet test box. A changed join order or an index that stopped being used shows in the plan first and in production later.\nThen run migrations from zero and the ORM test suite against the beta. Drivers and ORMs hold assumptions too, and theirs break quieter than yours.\nThe most likely result of the whole exercise: everything is fine. That is the result, and it is worth writing down. Keep the plans, keep the numbers. When GA comes in autumn, the upgrade decision is a diff against your own saved baseline. And if something did regress, you have months to rewrite a query or file a report, while the version is still beta and people are still listening.\nGuessing is also a strategy. It is the one with the worst worst case. I used it for years, and it mostly worked, which is exactly how it keeps getting used.\n","permalink":"https://useinov.com/posts/2026/testing-postgres-beta/","summary":"\u003cp\u003eA production-like dump, a separate PostgreSQL 19 beta instance, one evening plus machine time. That is the whole method, and it is the only thing a database beta is for: finding out where the new version changes the behavior of your application while the change is still a bug report and not your incident.\u003c/p\u003e\n\u003cp\u003eRestore the dump first. Data size matters. A plan that is fine on a thousand rows goes bad on fifty million.\u003c/p\u003e","title":"Testing PostgreSQL 19 beta without guessing"},{"content":"Major means breakage possible, minor means safe. For twenty years a MySQL version number carried that promise, and you could plan by reading it. With the move to calendar-style releases the number tells you one thing: when it was built. The promise moved elsewhere, and you have to know where to look.\nThe signals now are the channel and the dates. Is this an LTS or an innovation release. When does support end. What do the compatibility notes actually say, because \u0026ldquo;no major version\u0026rdquo; does not mean \u0026ldquo;no behavior change\u0026rdquo;. Optimizer changes ship in any release. Your query plans do not read version numbers.\nThis changes process more than SQL. A support matrix in the repository: which server versions we run, which we test in CI, which we are leaving. A staging database that gets the new release first, with a fixed set of regression queries instead of hope. And expectations for the managed provider written down, because they will move you on their schedule if you have none of your own.\nI ended up with a simple policy. Production follows LTS. Once or twice a year, run the test suite and the regression queries against the current release, to see the future early. And put the next upgrade in the calendar. Versions live there now anyway.\nI still read the number first. Old habit. Then I go and find the date.\n","permalink":"https://useinov.com/posts/2026/calendar-versions/","summary":"\u003cp\u003eMajor means breakage possible, minor means safe. For twenty years a MySQL version number carried that promise, and you could plan by reading it. With the move to calendar-style releases the number tells you one thing: when it was built. The promise moved elsewhere, and you have to know where to look.\u003c/p\u003e\n\u003cp\u003eThe signals now are the channel and the dates. Is this an LTS or an innovation release. When does support end. What do the compatibility notes actually say, because \u0026ldquo;no major version\u0026rdquo; does not mean \u0026ldquo;no behavior change\u0026rdquo;. Optimizer changes ship in any release. Your query plans do not read version numbers.\u003c/p\u003e","title":"When version numbers stop talking"},{"content":"All the logic inside execute(), exit code always zero, no lifecycle thinking at all. A controller without a request. I still see this console command in every second codebase. Symfony stopped being only an HTTP framework a long time ago, but habits are slower than releases, and with 8.1 giving console and workers first-class attention the excuse is gone.\nAn HTTP request lives for milliseconds. The kernel builds services, handles, throws everything away. A worker lives for hours. Same container, very different lifecycle. Every service that quietly keeps state, an in-memory cache, an accumulating buffer, an entity manager full of tracked objects, is invisible in HTTP and becomes a memory leak in a worker. If your consumer needs a nightly restart by cron, this is where the night went.\nThe fix that worked for us is structural. The command is an adapter, ten lines:\nfinal class ImportCommand extends Command { public function __construct(private ImportOrders $useCase) { parent::__construct(); } protected function execute(InputInterface $in, OutputInterface $out): int { $report = $this-\u0026gt;useCase-\u0026gt;run(BatchSize::fromInput($in)); $out-\u0026gt;writeln($report-\u0026gt;summary()); return $report-\u0026gt;failed() ? Command::FAILURE : Command::SUCCESS; } } The use case knows nothing about the console. The same use case is callable from a message handler, from the scheduler, from a test. Web and workers become equal adapters over one application. Which is what they always were. We just did not write it that way.\nTwo more worker rules. Handle the stop signal and finish the current item, do not die in the middle of it. And watch worker memory in production the way you watch response time for HTTP. A worker has no request duration graph to embarrass you, so nobody looks until the OOM killer does.\nThe nightly cron restart in that project was mine. The comment above it still says temporary.\n","permalink":"https://useinov.com/posts/2026/worker-not-controller/","summary":"\u003cp\u003eAll the logic inside \u003ccode\u003eexecute()\u003c/code\u003e, exit code always zero, no lifecycle thinking at all. A controller without a request. I still see this console command in every second codebase. Symfony stopped being only an HTTP framework a long time ago, but habits are slower than releases, and with 8.1 giving console and workers first-class attention the excuse is gone.\u003c/p\u003e\n\u003cp\u003eAn HTTP request lives for milliseconds. The kernel builds services, handles, throws everything away. A worker lives for hours. Same container, very different lifecycle. Every service that quietly keeps state, an in-memory cache, an accumulating buffer, an entity manager full of tracked objects, is invisible in HTTP and becomes a memory leak in a worker. If your consumer needs a nightly restart by cron, this is where the night went.\u003c/p\u003e","title":"A worker is not a controller"},{"content":"An SQL mode set in 2019 that nobody remembers why. An authentication plugin the driver happened to support. A charset and collation nobody chose. That is what a MySQL upgrade is made of. MySQL 9.7 LTS is out, so I am making the list again.\nAn LTS release of a database is a date more than a feature list. The date until which somebody else worries about patches, and the date after which the worry is yours. For a working PHP project the question is not \u0026ldquo;what is new\u0026rdquo;. It is \u0026ldquo;what did we assume\u0026rdquo;.\nAssumptions accumulate. Optimizer behavior that your slowest query silently depends on. Defaults that became decisions by staying. None of it appears in code review. All of it appears in the upgrade.\nSo the plan is mostly archaeology. Take real queries from the production profile and compare execution plans on the old and the new server. The plans that changed are the risk list. Run migrations from zero on the new version, because migrations are code too, and they encode assumptions better than any document. Check the PHP driver and ORM compatibility matrix before anything else: cheapest check, most common failure.\nThen canary. One replica on 9.7, read traffic, a week of watching. Then the rest, with a rollback path you have run at least once. Written down is a different thing.\nBoring plan, on purpose. LTS is an operational choice, so the upgrade is operations. The news was on release day.\nThe 2019 SQL mode was mine. I still do not remember why.\n","permalink":"https://useinov.com/posts/2026/mysql-97-lts/","summary":"\u003cp\u003eAn SQL mode set in 2019 that nobody remembers why. An authentication plugin the driver happened to support. A charset and collation nobody chose. That is what a MySQL upgrade is made of. MySQL 9.7 LTS is out, so I am making the list again.\u003c/p\u003e\n\u003cp\u003eAn LTS release of a database is a date more than a feature list. The date until which somebody else worries about patches, and the date after which the worry is yours. For a working PHP project the question is not \u0026ldquo;what is new\u0026rdquo;. It is \u0026ldquo;what did we assume\u0026rdquo;.\u003c/p\u003e","title":"MySQL 9.7 LTS is an operational choice"},{"content":"One method, response back. Laravel 13 makes calling a model feel like calling a mailer, and the demo level is now twenty minutes of work. Which is exactly when architecture starts to matter, because everybody will ship the demo.\nAn LLM call is IO. Slow IO, expensive IO, IO that sometimes fails and sometimes lies. We know how to handle IO like that. We forget, because the response text looks smart and the API looks native to the framework.\nWe shipped one small feature like this recently. Text goes in, structured summary comes out. The shape that survived code review:\nThe HTTP layer validates input and pushes a job. Nothing waits for the model inside a request. The job makes the call with a hard timeout, retries with backoff, and an idempotency key, because a retry that bills twice and writes twice is a bug. The provider sits behind our own small interface. The prompt lives in the adapter, not in the domain. Business rules that must hold are checked in code after the response, never delegated to the prompt. \u0026ldquo;Please respond with valid JSON\u0026rdquo; is a wish.\nAnd metrics from day one. Cost per call, latency, failure rate. The invoice arrives monthly. The surprise should not.\nThe provider is infrastructure, like the database driver, except worse: non-deterministic and priced per use. Keep it replaceable. When domain code starts importing the provider SDK, stop and refactor. The framework making something convenient does not make it central.\nThe first version of that feature called the model straight from the controller. Mine. It worked in the demo, which is the whole problem with demos.\n","permalink":"https://useinov.com/posts/2026/llm-call-is-io/","summary":"\u003cp\u003eOne method, response back. Laravel 13 makes calling a model feel like calling a mailer, and the demo level is now twenty minutes of work. Which is exactly when architecture starts to matter, because everybody will ship the demo.\u003c/p\u003e\n\u003cp\u003eAn LLM call is IO. Slow IO, expensive IO, IO that sometimes fails and sometimes lies. We know how to handle IO like that. We forget, because the response text looks smart and the API looks native to the framework.\u003c/p\u003e","title":"An LLM call is just IO"},{"content":"A composer.lock that had not changed in three years. A colleague showed it to me last week, not as a confession, as a normal fact about the project. Nobody decided this. Every single upgrade looked small and risky, so everybody postponed it. Three years of postponed small risks equals one migration project with a budget and a name.\nThe alternative is dull and it works. Upgrades as normal operations, like backups.\ncomposer outdated --direct Run it weekly. Direct dependencies only, transitive ones follow. One small PR per package or per group. Patch and minor versions merge on green CI, no discussion. Major versions get a ticket and a reading of the upgrade guide, one at a time.\nTwo things make this cheap. Tests you trust: full coverage is optional, coverage of the things that make money is the minimum. And deprecations treated as a budget. When a library warns, fix it this quarter, while the old way and the new way both work. A deprecation warning is the cheapest migration you will ever get. Ignoring it converts cheap work into expensive work at a date somebody else chooses.\nFramework majors, same principle. A project that follows minors continuously does a Symfony or Laravel major in days. A project that waits does it in months, and half of that time is ten transitive packages that froze along with it.\nDependency debt has an interest rate. Small payments forever, or one payment that hurts. Set up a bot to open the PRs if typing the command is too much.\nI opened the lock file of my own side project after that conversation. Not three years. Not a number I am proud of either.\n","permalink":"https://useinov.com/posts/2026/small-dependency-upgrades/","summary":"\u003cp\u003eA \u003ccode\u003ecomposer.lock\u003c/code\u003e that had not changed in three years. A colleague showed it to me last week, not as a confession, as a normal fact about the project. Nobody decided this. Every single upgrade looked small and risky, so everybody postponed it. Three years of postponed small risks equals one migration project with a budget and a name.\u003c/p\u003e\n\u003cp\u003eThe alternative is dull and it works. Upgrades as normal operations, like backups.\u003c/p\u003e","title":"Dependency upgrades without a big bang"},{"content":"Last week of December, low traffic, good test suite. That is when we moved one project to PHP 8.5. Not brave, convenient. A month later I can sort the release into two piles.\nPile one is the demo features. Every release has them. They look great in a tweet-sized example, then you search the codebase and find two places to use them. Fine. Not why you upgrade.\nPile two is the boring changes. Less boilerplate in places you touch every day. Types that say what a function really returns. Small things that make PHPStan complain earlier instead of production complaining later. This pile is why you upgrade, and you only see it after some weeks, never in the release notes.\nThe real cost of going early was everything around the language. One static analysis rule misfired on new syntax and we pinned the analyzer for two weeks. One library declared support too narrowly, out of caution, and we waited for a one-line release. The framework itself was fine, the minor with official support came fast.\nSo the checklist for a working project is short. Run the test suite on 8.5 in CI today, even if you deploy on 8.4. It costs one line in the pipeline and tells you the real distance. If CI is green and the tooling has caught up, upgrade. If not, do not write clever compatibility layers. Wait one framework minor. The language is not going anywhere.\nAnd do not adopt new syntax in the same week you upgrade. First make old code run. New features read better when the dust settles.\nI used new syntax on day two anyway. The analyzer misfire was that commit.\n","permalink":"https://useinov.com/posts/2026/php-85-month-later/","summary":"\u003cp\u003eLast week of December, low traffic, good test suite. That is when we moved one project to PHP 8.5. Not brave, convenient. A month later I can sort the release into two piles.\u003c/p\u003e\n\u003cp\u003ePile one is the demo features. Every release has them. They look great in a tweet-sized example, then you search the codebase and find two places to use them. Fine. Not why you upgrade.\u003c/p\u003e\n\u003cp\u003ePile two is the boring changes. Less boilerplate in places you touch every day. Types that say what a function really returns. Small things that make PHPStan complain earlier instead of production complaining later. This pile is why you upgrade, and you only see it after some weeks, never in the release notes.\u003c/p\u003e","title":"PHP 8.5 one month later"},{"content":"A Kohana ORM, an N+1 on a catalog page, and a blog post about it. That was November 2014. It is December 2025, the code around me runs PHP 8.5, and I went back and read that first post.\nThe surface changed completely. A 2014 controller was a fat method: it read the request, validated by hand, called the ORM, formatted the response, sometimes sent an email on the way out. Types lived in docblocks and in hope. Deploy was files over FTP and a prayer. Today the same feature is a typed handler with constructor injection, validation at the boundary, a use case object, a message on a queue for the email, a trace id through the whole thing. The engine checks what a comment used to promise. I do not miss the old way.\nThe problems I debug did not change. They changed clothes.\nBoundaries: the fat controller of 2014 is the fat \u0026ldquo;service\u0026rdquo; of 2025 that knows about HTTP, the database and the business rule at once. Consistency: the race in a checkout is the same race in a queue consumer, with better logging. Latency: N+1 survived Kohana, Doctrine proxies and now native lazy objects, because it was never an ORM bug. Upgrades: the team that skipped five years of releases pays the same price as in 2014, in larger money.\nLanguages and frameworks solved the mechanical problems. Typing, packaging, deployment, async, mostly solved. What remains is the part that was never mechanical. Where does this logic belong. What happens when two things are true at once. What does this request cost. Can I prove the system is fine without ssh.\nFrameworks were the vocabulary of the year. The sentences were ours.\nEleven years of this blog, and the Host header is still user input.\n","permalink":"https://useinov.com/posts/2025/eleven-years-later/","summary":"\u003cp\u003eA Kohana ORM, an N+1 on a catalog page, and a blog post about it. That was November 2014. It is December 2025, the code around me runs PHP 8.5, and I went back and read that first post.\u003c/p\u003e\n\u003cp\u003eThe surface changed completely. A 2014 controller was a fat method: it read the request, validated by hand, called the ORM, formatted the response, sometimes sent an email on the way out. Types lived in docblocks and in hope. Deploy was files over FTP and a prayer. Today the same feature is a typed handler with constructor injection, validation at the boundary, a use case object, a message on a queue for the email, a trace id through the whole thing. The engine checks what a comment used to promise. I do not miss the old way.\u003c/p\u003e","title":"From Kohana to PHP 8.5"},{"content":"PHP 8.5 came out yesterday. Symfony 7.4 LTS and 8.0 land at the end of the month, same as every year. November is upgrade month now. First the cargo, then the timetable.\nFrom 8.5 I care about three things. The pipe operator makes transformation chains read left to right:\n$slug = $title |\u0026gt; trim(...) |\u0026gt; strtolower(...) |\u0026gt; (fn ($s) =\u0026gt; preg_replace(\u0026#39;/\\s+/\u0026#39;, \u0026#39;-\u0026#39;, $s)); The new URI extension puts standards-correct URL parsing in core. parse_url had opinions instead of a specification, I will not miss it. And #[\\NoDiscard] lets a method declare that ignoring its return value is a bug. Made for result-style APIs, where a dropped return is a swallowed error.\nOn the Symfony side, the usual two steps. Go to 7.4 now: it is the LTS and it runs the same code. Turn on deprecation logging, clean the list. Then 8.0 is the same framework with the dead weight removed. Teams that treat x.0 as a separate mountain have usually skipped the deprecation homework.\nNow the timetable. Twelve years ago upgrades were events. Unplanned, feared, postponed until something forced them. Today PHP releases every November, Symfony minors land every May and November, LTS boundaries are published years ahead. Infrastructure with a schedule. It goes into the engineering calendar like a fire drill: one compatibility window a year, a few days of work.\nThe teams in trouble are not the ones on old versions. They are the ones with no rhythm. A yearly upgrade costs days. A five-year jump costs a quarter, and the same people sign both budgets.\nWe skipped last November on one project. Too busy, I said. I signed that one.\n","permalink":"https://useinov.com/posts/2025/november-upgrade-train/","summary":"\u003cp\u003ePHP 8.5 came out yesterday. Symfony 7.4 LTS and 8.0 land at the end of the month, same as every year. November is upgrade month now. First the cargo, then the timetable.\u003c/p\u003e\n\u003cp\u003eFrom 8.5 I care about three things. The pipe operator makes transformation chains read left to right:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$slug\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nv\"\u003e$title\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"o\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003etrim\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e...\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"o\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003estrtolower\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e...\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"o\"\u003e|\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003efn\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$s\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003epreg_replace\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;/\\s+/\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;-\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003e$s\u003c/span\u003e\u003cspan class=\"p\"\u003e));\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe new URI extension puts standards-correct URL parsing in core. \u003ccode\u003eparse_url\u003c/code\u003e had opinions instead of a specification, I will not miss it. And \u003ccode\u003e#[\\NoDiscard]\u003c/code\u003e lets a method declare that ignoring its return value is a bug. Made for result-style APIs, where a dropped return is a swallowed error.\u003c/p\u003e","title":"The November upgrade train"},{"content":"A strange-looking condition in a 2015 module, \u0026ldquo;fixed\u0026rdquo; by an agent. All tests green, static analysis clean, and the condition was a business rule with history. My review caught it. Barely.\nThat came out of an experiment I ran this month: a coding agent on one legacy PHP module, since agents can do multi-file changes now and every old codebase suddenly looks like a target.\nThe setup matters more than the model. I did not ask \u0026ldquo;modernize this\u0026rdquo;. I gave a narrow migration goal on one bounded module: replace a deprecated API, add proper namespaces, keep behavior. The same shape of task I would give to Rector. The difference is that the agent also handles the irregular cases Rector cannot express, the places where people wrote creative code in 2015.\nThe fences: the module has tests, the tests must pass, static analysis must stay clean, and I read the diff line by line before merge. Without the fences this is gambling with extra steps.\nResult, honestly. Around eighty percent of the edits were mechanical and correct, and they would have cost me a boring day. Two changes passed every check and were still wrong, the condition above was one of them. The tests did not cover that branch.\nThe agent is a very fast junior with no fear and no memory of why the code is weird. Excellent for mechanical migrations. Dangerous exactly where the code encodes decisions instead of mechanics.\nSo we wrote down ban zones: authentication, anything touching money, database schema changes. Those diffs are written by a human who will own them at 2 a.m. Everything else: narrow goal, fences, review. Same rules as Rector, plus more suspicion.\nI nearly approved that condition. It looked like a cleanup, and I wanted the boring day back.\n","permalink":"https://useinov.com/posts/2025/agents-and-legacy/","summary":"\u003cp\u003eA strange-looking condition in a 2015 module, \u0026ldquo;fixed\u0026rdquo; by an agent. All tests green, static analysis clean, and the condition was a business rule with history. My review caught it. Barely.\u003c/p\u003e\n\u003cp\u003eThat came out of an experiment I ran this month: a coding agent on one legacy PHP module, since agents can do multi-file changes now and every old codebase suddenly looks like a target.\u003c/p\u003e\n\u003cp\u003eThe setup matters more than the model. I did not ask \u0026ldquo;modernize this\u0026rdquo;. I gave a narrow migration goal on one bounded module: replace a deprecated API, add proper namespaces, keep behavior. The same shape of task I would give to Rector. The difference is that the agent also handles the irregular cases Rector cannot express, the places where people wrote creative code in 2015.\u003c/p\u003e","title":"Coding agents meet legacy PHP"},{"content":"PostgreSQL 18 went GA yesterday. I ran the beta in July. Now it is not a preview, it is a ticket, and these are the notes from planning it.\nThe quiet feature that matters most for the upgrade itself: pg_upgrade now carries planner statistics over. Before, the minutes or hours after switching versions were the scary part. Fresh cluster, empty statistics, the planner guessing, production limping until ANALYZE finished. That cliff is mostly gone. This alone changes how a major upgrade feels at 2 a.m.\nSkip scan is the feature I will be checking plans for. A multicolumn btree index can now serve queries that do not filter on the leading column, when that column has few distinct values. Some of our single-column indexes exist only as workarounds for exactly this. After the upgrade some of them may be deletable. May. The release notes do not know your data. EXPLAIN does. I will believe each deletion only after seeing the plan.\nAsync I/O and uuidv7() I already tested on the beta, both held up. New projects get v7 keys from day one. Migrating existing v4 keys is a separate, unpleasant conversation that no release solves for you.\nRollout, nothing original: a replica on 18, replay of real production queries, plan diffs on the top fifty statements, regression tests, then the switch. Not on a Friday.\nAnd one number in the version string matters to me: .0. Staging runs 18.0 from Monday. Production waits for 18.1. Not because I expect disaster. Because waiting two months is free, and being early costs weekends. I have paid in weekends before.\n","permalink":"https://useinov.com/posts/2025/postgres-18-upgrade/","summary":"\u003cp\u003ePostgreSQL 18 went GA yesterday. I ran the beta in July. Now it is not a preview, it is a ticket, and these are the notes from planning it.\u003c/p\u003e\n\u003cp\u003eThe quiet feature that matters most for the upgrade itself: \u003ccode\u003epg_upgrade\u003c/code\u003e now carries planner statistics over. Before, the minutes or hours after switching versions were the scary part. Fresh cluster, empty statistics, the planner guessing, production limping until ANALYZE finished. That cliff is mostly gone. This alone changes how a major upgrade feels at 2 a.m.\u003c/p\u003e","title":"PostgreSQL 18, the upgrade notes"},{"content":"46 milliseconds. That is how long the bootstrap of one large application took before the framework even started, in a trace I pulled this week. A good share of it was autoloading.\nrequire vendor/autoload.php looks like a constant of nature. With plain PSR-4 rules it is a loop: every class load walks the prefixes and asks the filesystem whether a file exists. A few thousand classes on a cold request, and the loop becomes a number you can see in a flame graph.\nThe fixes are old, documented, and still skipped on half of the projects I meet.\ncomposer dump-autoload --optimize composer dump-autoload --classmap-authoritative Optimize builds a classmap: known classes resolve with one array lookup. Authoritative goes further. A class not in the map is treated as not on disk, composer does not even check. That kills the filesystem probing completely, but it is only safe when the deploy artifact is immutable. Anything that generates classes at runtime into autoloaded paths becomes invisible. Know your build before you enable it. There is also --apcu for caching lookups when authoritative is not an option.\nTwo more things from the same trace. A legacy library bundled its own autoloader, so we had two registered, and every miss paid twice. And opcache did not help at all. It caches compiled files, not the search for them.\nAfter the flags: 46 ms became about 30. Not heroic for one web request. But the same constant sits in front of every CLI worker start, every cron job, every test run. Thousands of small payments a day.\nI have had those two flags in my notes since 2016. This is the first project where I actually checked they were on.\n","permalink":"https://useinov.com/posts/2025/autoload-not-free/","summary":"\u003cp\u003e46 milliseconds. That is how long the bootstrap of one large application took before the framework even started, in a trace I pulled this week. A good share of it was autoloading.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003erequire vendor/autoload.php\u003c/code\u003e looks like a constant of nature. With plain PSR-4 rules it is a loop: every class load walks the prefixes and asks the filesystem whether a file exists. A few thousand classes on a cold request, and the loop becomes a number you can see in a flame graph.\u003c/p\u003e","title":"vendor/autoload.php is not free"},{"content":"A staging copy of one database, restored over the weekend onto PostgreSQL 18 beta. GA is expected in autumn. Two things I wanted to touch: asynchronous I/O and the built-in uuidv7().\nStart with uuidv7, because it is about a mistake many of us already made, me included. Random UUIDv4 primary keys scatter inserts across the whole index. Every insert lands on a random page, the working set is the entire index, buffers churn. UUIDv7 is time-ordered: new rows go to the same few pages, like a sequence, but still globally unique.\nSELECT uuidv7(); -- 01980a9c-... the prefix is a timestamp On my copy I replayed one day of inserts into a v4-keyed table and into a v7-keyed clone. The v7 run was clearly faster and wrote visibly less WAL. Exact numbers are from my hardware, so I will not quote them. The direction matches the theory. And no extension needed anymore.\nThe new I/O subsystem is less visible but wider. io_method = worker is the default now, io_uring is an option on Linux. Cold sequential scans on the copy got noticeably better. This is the kind of feature you do not tune. You just receive it.\nNow the restraint. A beta benchmark is preparation, not a migration ticket. Plans can change before GA, and numbers from a laptop-grade staging box show direction. Magnitude waits for real hardware. My plan: run the application test suite against the beta, keep notes on anything strange, decide after the release.\nIf your primary keys are UUIDv4, start that conversation now. The keys are the one part you cannot fix with a config flag. I picked v4 for that table years ago. I knew about the index then. It seemed far away.\n","permalink":"https://useinov.com/posts/2025/postgres-18-beta/","summary":"\u003cp\u003eA staging copy of one database, restored over the weekend onto PostgreSQL 18 beta. GA is expected in autumn. Two things I wanted to touch: asynchronous I/O and the built-in \u003ccode\u003euuidv7()\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eStart with uuidv7, because it is about a mistake many of us already made, me included. Random UUIDv4 primary keys scatter inserts across the whole index. Every insert lands on a random page, the working set is the entire index, buffers churn. UUIDv7 is time-ordered: new rows go to the same few pages, like a sequence, but still globally unique.\u003c/p\u003e","title":"A weekend with PostgreSQL 18 beta"},{"content":"\u0026ldquo;Hello, Ivan\u0026rdquo; in the header. That is what broke the full-page cache on one project: either the whole page became uncacheable, or Ivan\u0026rsquo;s greeting was served to strangers. We managed both, in the same month.\nFull-page cache behind a reverse proxy is a great first step. It dies on the first personalized element.\nThe way out is to stop thinking of the page as one object. It is regions with different speeds. The header with the user name changes per user. Navigation changes per deploy. The product list changes every few minutes. Prices and stock change every few seconds. Give all of that one TTL and you must pick the minimum, which means almost no cache at all.\nTwo working approaches.\nServer-side composition, ESI being the classic. The proxy caches fragments separately, each with its own TTL, and assembles the page. Powerful, but your templates now know about the proxy, and debugging a page becomes debugging a distributed system.\nOr cache the fully anonymous page and load the personal bits from the client. The header fetches \u0026ldquo;who am I\u0026rdquo; with one small request after render. For pages where the personal part is small, this is my default. The cacheable page stays trivially cacheable, the personal endpoint is cheap and honest.\nOne trap in both approaches: cache key explosion. Vary a fragment by language, then by currency, then by user segment, and you quietly multiply. Three dimensions of five values is 125 copies of every fragment, and the hit rate is gone. Count the variants before adding a dimension. If the number does not fit in your head, the cache will not fit in memory either.\n","permalink":"https://useinov.com/posts/2025/one-ttl-lie/","summary":"\u003cp\u003e\u0026ldquo;Hello, Ivan\u0026rdquo; in the header. That is what broke the full-page cache on one project: either the whole page became uncacheable, or Ivan\u0026rsquo;s greeting was served to strangers. We managed both, in the same month.\u003c/p\u003e\n\u003cp\u003eFull-page cache behind a reverse proxy is a great first step. It dies on the first personalized element.\u003c/p\u003e\n\u003cp\u003eThe way out is to stop thinking of the page as one object. It is regions with different speeds. The header with the user name changes per user. Navigation changes per deploy. The product list changes every few minutes. Prices and stock change every few seconds. Give all of that one TTL and you must pick the minimum, which means almost no cache at all.\u003c/p\u003e","title":"One TTL does not fit the whole page"},{"content":"Symfony 7.3 was released this week. I opened the release notes with a text file next to them. The file is a list of things to delete.\nEvery project older than three years carries a layer of custom helpers that were reasonable when written. The framework moves, the helpers stay. Nobody reviews them, because they work.\nTwo examples from one project.\nA base console command class with sugar for arguments and options, written around 2021. Symfony 7.3 has invokable commands, arguments come as parameters with attributes:\n#[AsCommand(\u0026#39;app:prune-sessions\u0026#39;)] final class PruneSessionsCommand { public function __invoke(SymfonyStyle $io, #[Option] int $days = 30): int { // ... return Command::SUCCESS; } } Our base class is now a duplicate of the framework, only worse tested. It goes away.\nSecond, a hand-written mapper from request DTOs to entities. The new ObjectMapper component covers most of it. It is experimental, so production code does not move onto it yet. But the mapper is on notice.\nThe process is dull, on purpose. List the custom abstractions that smell like framework code. Read the release notes of the framework you actually run, the version in composer.lock, not the one in the blog posts. Where a native replacement exists, estimate migration cost honestly. Migrate the cheap ones now, put the rest in the backlog with a link.\nWe do this once a year now, as a calendar event. Deleted code is the only code that never needs an upgrade.\nThe base command class was mine. I was proud of it in 2021. The pull request that removes it is the best thing I ship this month.\n","permalink":"https://useinov.com/posts/2025/delete-your-helpers/","summary":"\u003cp\u003eSymfony 7.3 was released this week. I opened the release notes with a text file next to them. The file is a list of things to delete.\u003c/p\u003e\n\u003cp\u003eEvery project older than three years carries a layer of custom helpers that were reasonable when written. The framework moves, the helpers stay. Nobody reviews them, because they work.\u003c/p\u003e\n\u003cp\u003eTwo examples from one project.\u003c/p\u003e\n\u003cp\u003eA base console command class with sugar for arguments and options, written around 2021. Symfony 7.3 has invokable commands, arguments come as parameters with attributes:\u003c/p\u003e","title":"Symfony 7.3 and the code I get to delete"},{"content":"Open the Doctrine proxy folder of any project older than a year. A generated subclass for every entity, getters overridden, one file per class in the cache dir. It worked for fifteen years, and the seams always showed: final classes, public typed properties, instanceof surprises, a folder of code you pretend not to see.\nPHP 8.4 lazy objects were designed with exactly this in mind. The native way needs no generated class:\n$reflector = new ReflectionClass(User::class); $user = $reflector-\u0026gt;newLazyGhost(function (User $user) use ($loader) { $loader-\u0026gt;hydrate($user); // one query, runs on first access }); The ghost is a real User. Same class, no subclass, no file in cache. The engine intercepts the first property access and runs the initializer. Everything Doctrine did with __get and generated code becomes a language feature. The runtime does the ugly part, the ORM shrinks.\nNow the part that did not change.\nThe query still fires on first access. Loop over fifty orders, touch $order-\u0026gt;customer-\u0026gt;name, and you get fifty one queries. I wrote about this exact bug in 2014, with Kohana. Eleven years, three ORMs, one bug.\nNative lazy objects make lazy loading better implemented. They do not make it cheaper. Fetch strategy is still your job: join, batch by ids, or accept the lazy hit and know that you did.\nSo when your ORM switches to native lazy objects, take the upgrade. Enjoy deleting the proxy folder. Then open the SQL log and count. I counted last week, on code I wrote myself. Fifty one.\n","permalink":"https://useinov.com/posts/2025/doctrine-lazy-objects/","summary":"\u003cp\u003eOpen the Doctrine proxy folder of any project older than a year. A generated subclass for every entity, getters overridden, one file per class in the cache dir. It worked for fifteen years, and the seams always showed: final classes, public typed properties, \u003ccode\u003einstanceof\u003c/code\u003e surprises, a folder of code you pretend not to see.\u003c/p\u003e\n\u003cp\u003ePHP 8.4 lazy objects were designed with exactly this in mind. The native way needs no generated class:\u003c/p\u003e","title":"Native lazy objects, the ORM view"},{"content":"Last year we turned tracing on everywhere. This year the telemetry invoice is a line item a manager asks about by name. The dashboards did not get more useful. More data, same confusion at 2 a.m.\nTime to design instead of collect.\nThe design starts from the questions I actually ask when the pager goes off. In practice there are four: is the latency of key endpoints normal, did the error rate jump, how far behind are the queues, is the database saturated. That is the core set. Metrics for these must be cheap, always on, with alerts. Everything else can be sampled traces I pull up on demand.\nThe main cost is cardinality. Someone puts a user id into a metric label and now you pay for one time series per user. Forever. Per-user detail belongs in traces, and traces belong under sampling: head sampling low and boring, tail sampling for errors and slow requests if the pipeline can do it.\nSame discipline for logs. A log line is a set of fields you will filter by. Request id, route, status, duration, tenant. And no personal data in spans or logs at all. Nobody audits telemetry storage the way they audit the main database, so keep it the least interesting one.\nThe rule we adopted: every dashboard panel must answer a question that was asked in a real incident. Once a quarter we delete the panels that answer nothing. A metric nobody queries is a log with worse compression.\nThe user id label was mine. Added during an incident, marked temporary. It outlived the incident by a year.\n","permalink":"https://useinov.com/posts/2025/observability-budget/","summary":"\u003cp\u003eLast year we turned tracing on everywhere. This year the telemetry invoice is a line item a manager asks about by name. The dashboards did not get more useful. More data, same confusion at 2 a.m.\u003c/p\u003e\n\u003cp\u003eTime to design instead of collect.\u003c/p\u003e\n\u003cp\u003eThe design starts from the questions I actually ask when the pager goes off. In practice there are four: is the latency of key endpoints normal, did the error rate jump, how far behind are the queues, is the database saturated. That is the core set. Metrics for these must be cheap, always on, with alerts. Everything else can be sampled traces I pull up on demand.\u003c/p\u003e","title":"Observability has a budget"},{"content":"Laravel 12 came out on Monday. Tuesday evening I upgraded one mid-size project. The whole diff is composer constraints and the lock file. Tests green, deployed before midnight.\nPeople in chats are disappointed. Where are the big features. A major that mostly refreshes dependencies and starter kits, is it even a major.\nI remember 4.2 to 5.0. New folder structure, new config system, half of the packages dead on arrival. We planned it as a separate project, because it was one. Skip a major, and the next one cost double.\nSo no, I am not disappointed. A framework major that feels like routine maintenance means the ecosystem grew up. The public API is stable enough that a version bump does not have to break you.\nTwo notes.\nA boring upgrade is not only the framework\u0026rsquo;s merit. It is a diagnosis of your codebase. If the release notes are short and your upgrade still hurts, the pain is yours: you depend on internals, you pinned a fork, you ignored deprecation warnings for two years.\nAnd boring does not mean blind. The checklist did not change. Read the actual dependency diff, not the marketing post. Run the tests that cover money and auth; the fast unit ones prove nothing here. Audit the packages. This round the audit found one abandoned dependency that blocks nothing today and everything next year. That package was the real work of the upgrade, and Laravel had nothing to do with it.\nI waited ten years for this kind of disappointment.\n","permalink":"https://useinov.com/posts/2025/laravel-12-boring/","summary":"\u003cp\u003eLaravel 12 came out on Monday. Tuesday evening I upgraded one mid-size project. The whole diff is composer constraints and the lock file. Tests green, deployed before midnight.\u003c/p\u003e\n\u003cp\u003ePeople in chats are disappointed. Where are the big features. A major that mostly refreshes dependencies and starter kits, is it even a major.\u003c/p\u003e\n\u003cp\u003eI remember 4.2 to 5.0. New folder structure, new config system, half of the packages dead on arrival. We planned it as a separate project, because it was one. Skip a major, and the next one cost double.\u003c/p\u003e","title":"Laravel 12 is a boring release"},{"content":"A private field, a getter, a setter with one line of normalization. Three members for one idea, repeated through every entity folder I have ever opened. PHP 8.4 is two months old, the conference demos are done, and the honest question is which features earn a place in a working codebase.\nMy filter is simple. A feature is good when it deletes code.\nProperty hooks pass:\nclass User { public string $email { set =\u0026gt; strtolower(trim($value)); } } The class is shorter and the normalization cannot be bypassed. But keep hooks boring. A hook that talks to the database is a magic getter from 2008 in new syntax.\nAsymmetric visibility also passes. public private(set) string $id says what half of my getters were saying anyway: read freely, write only inside. Before it was a comment and discipline. Now the engine checks it.\nLazy objects I do not touch in application code. They exist for ORMs and DI containers. Doctrine and the containers will use them for proxies, my project will simply have fewer generated classes in the cache dir. Good. Not my layer.\nOne warning from real adoption. Check your tooling before you put hooks on entities. Some serializers and older static analysis versions still get confused by a property that is not a plain property. We hit one such case, rolled the hook back, waited a week for the library update.\nThe guideline I wrote for the team fits in two lines. Use a new feature when the diff is red. If the new syntax makes the file longer or cleverer, wait a year and look again.\nI wanted hooks on the entities from day one. The rolled-back one was mine.\n","permalink":"https://useinov.com/posts/2025/php84-in-production/","summary":"\u003cp\u003eA private field, a getter, a setter with one line of normalization. Three members for one idea, repeated through every entity folder I have ever opened. PHP 8.4 is two months old, the conference demos are done, and the honest question is which features earn a place in a working codebase.\u003c/p\u003e\n\u003cp\u003eMy filter is simple. A feature is good when it deletes code.\u003c/p\u003e\n\u003cp\u003eProperty hooks pass:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eUser\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"nx\"\u003estring\u003c/span\u003e \u003cspan class=\"nv\"\u003e$email\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nx\"\u003eset\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003estrtolower\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003etrim\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$value\u003c/span\u003e\u003cspan class=\"p\"\u003e));\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe class is shorter and the normalization cannot be bypassed. But keep hooks boring. A hook that talks to the database is a magic getter from 2008 in new syntax.\u003c/p\u003e","title":"PHP 8.4 after the hype"},{"content":"One header, and the FPM load graph fell off a cliff.\nCache-Control: public, s-maxage=60 A public catalog page on one project renders the same HTML for every anonymous visitor. Same queries, same JSON from the search service, same template, thousands of times per hour. We profiled it, we tuned it, and only then asked the obvious question: why is PHP involved in the second request at all.\nHTTP had the answer before my career started. public says a shared cache may store the response. s-maxage gives the CDN or reverse proxy its own lifetime, separate from browser max-age. With Varnish in front, the request path splits in two. MISS: full stack, FPM, database, sixty milliseconds. HIT: the proxy answers from memory, the PHP process never starts, the database never hears about it. Nothing in the application got faster. There was simply less application running.\nInvalidation is the famous hard part, so do not start there. Start with a short TTL. Sixty seconds on a page hit ten times per second removes 99.8 percent of the work, and a one minute stale price is acceptable almost everywhere. Purge-on-change can come later, if ever.\nThe real danger is caching a personalized response. One Set-Cookie, one \u0026ldquo;Hello, Anna\u0026rdquo; block, and a shared cache serves Anna\u0026rsquo;s page to everyone. Anything behind auth is private, full stop. Sessions must not start on public pages: one framework middleware that attaches cookies to everything silently kills all caching, check yours. And read Vary before trusting any of it.\nCache the anonymous majority, run PHP for the logged-in minority. Twenty years old. It beat every optimization I did this year, and I spent months on those.\n","permalink":"https://useinov.com/posts/2024/http-caching/","summary":"\u003cp\u003eOne header, and the FPM load graph fell off a cliff.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eCache-Control: public, s-maxage=60\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eA public catalog page on one project renders the same HTML for every anonymous visitor. Same queries, same JSON from the search service, same template, thousands of times per hour. We profiled it, we tuned it, and only then asked the obvious question: why is PHP involved in the second request at all.\u003c/p\u003e\n\u003cp\u003eHTTP had the answer before my career started. \u003ccode\u003epublic\u003c/code\u003e says a shared cache may store the response. \u003ccode\u003es-maxage\u003c/code\u003e gives the CDN or reverse proxy its own lifetime, separate from browser \u003ccode\u003emax-age\u003c/code\u003e. With Varnish in front, the request path splits in two. MISS: full stack, FPM, database, sixty milliseconds. HIT: the proxy answers from memory, the PHP process never starts, the database never hears about it. Nothing in the application got faster. There was simply less application running.\u003c/p\u003e","title":"The fastest request never reaches PHP"},{"content":"Fifty ghosts initialized in a loop are still fifty one queries.\nPHP 8.4 is out since Thursday. Property hooks get the headlines, but the feature I keep coming back to is native lazy objects. It legalizes a trick ORMs have done with generated code for fifteen years.\nThe trick: you load an order, $order-\u0026gt;customer should be a Customer, and you do not want a query until someone actually touches it. Doctrine generates a proxy class at build time, a subclass that overrides every method with \u0026ldquo;initialize first, then call parent\u0026rdquo;. It works. It is also a pile of magic: generated files, edge cases with final classes and private properties, strange things in var_dump.\nNow the engine does it:\n$reflector = new ReflectionClass(Customer::class); $customer = $reflector-\u0026gt;newLazyGhost(function (Customer $c) use ($id, $db) { $c-\u0026gt;hydrateFrom($db-\u0026gt;findCustomerRow($id)); }); You get a real Customer, correct class, passes every type check. The closure runs at first property access. There is the ghost variant, where the object fills itself in place, and a proxy variant that delegates to a separately created instance. No generated subclass, no build step. For Doctrine and anything Doctrine-shaped this is a better foundation, and I expect proxy generation to shrink release by release.\nTwo sober notes.\nA lazy object initializes when something pokes it, and debuggers love to poke. Your var_dump in a log can be the thing that fires ten queries. Same for a serializer walking properties.\nAnd the old disappointment. Lazy loading does not fix N+1, it industrializes it. The proxy makes each extra query cheap to write, not cheap to run. Exactly the bug from my Kohana post in 2014. Ten years, three frameworks, one bug. The language now decides who implements the laziness. The SQL log is still mine to read, and I still read it too late.\n","permalink":"https://useinov.com/posts/2024/lazy-objects/","summary":"\u003cp\u003eFifty ghosts initialized in a loop are still fifty one queries.\u003c/p\u003e\n\u003cp\u003ePHP 8.4 is out since Thursday. Property hooks get the headlines, but the feature I keep coming back to is native lazy objects. It legalizes a trick ORMs have done with generated code for fifteen years.\u003c/p\u003e\n\u003cp\u003eThe trick: you load an order, \u003ccode\u003e$order-\u0026gt;customer\u003c/code\u003e should be a Customer, and you do not want a query until someone actually touches it. Doctrine generates a proxy class at build time, a subclass that overrides every method with \u0026ldquo;initialize first, then call parent\u0026rdquo;. It works. It is also a pile of magic: generated files, edge cases with final classes and private properties, strange things in var_dump.\u003c/p\u003e","title":"Lazy objects move into the engine"},{"content":"Six lines of getter and setter ceremony around one strtolower. PHP 8.4 is in release candidates, and property hooks delete exactly that:\nclass Customer { public string $email { set =\u0026gt; strtolower(trim($value)); } } Assignment stays assignment, $customer-\u0026gt;email = $input, and normalization happens on the way in. For this exact case, one value, no dependencies, I am convinced. The property remains a property.\nWhere I get careful is everything past normalization. A setter method is ugly, but it announces itself. setEmail() in a call stack tells you code ran. $obj-\u0026gt;email = $x looks free, and with hooks it is not. Put validation that throws into a hook, and every plain assignment becomes a possible exception, invisible at the call site. Put a side effect in, and you have built magic the next person discovers through a debugger at midnight. We spent years removing __get and __set from codebases for this exact crime. Hooks are their respectable cousins, better typed, analyzable, and the temptation is identical.\nMy other question is ORM hydration. Doctrine and friends write properties through reflection, bypassing whatever the class thinks. Whether hooks fire during hydration, and whether you want them to, will produce subtle bugs and long GitHub threads. No hooks on entities from me until the libraries say something official.\nSo the boundary I will try to hold: a hook may compute or normalize a value. The moment the logic wants a dependency, can fail, or changes anything outside the object, it is an action, and actions deserve a verb. recalculateTotal() is honest. A property that secretly recalculates is not.\nIf you would not put it in a plain getter in 2015, do not put it in a hook in 2024. I wrote plenty of bad getters in 2015.\n","permalink":"https://useinov.com/posts/2024/property-hooks/","summary":"\u003cp\u003eSix lines of getter and setter ceremony around one \u003ccode\u003estrtolower\u003c/code\u003e. PHP 8.4 is in release candidates, and property hooks delete exactly that:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eCustomer\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"nx\"\u003estring\u003c/span\u003e \u003cspan class=\"nv\"\u003e$email\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nx\"\u003eset\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nx\"\u003estrtolower\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003etrim\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$value\u003c/span\u003e\u003cspan class=\"p\"\u003e));\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eAssignment stays assignment, \u003ccode\u003e$customer-\u0026gt;email = $input\u003c/code\u003e, and normalization happens on the way in. For this exact case, one value, no dependencies, I am convinced. The property remains a property.\u003c/p\u003e\n\u003cp\u003eWhere I get careful is everything past normalization. A setter method is ugly, but it announces itself. \u003ccode\u003esetEmail()\u003c/code\u003e in a call stack tells you code ran. \u003ccode\u003e$obj-\u0026gt;email = $x\u003c/code\u003e looks free, and with hooks it is not. Put validation that throws into a hook, and every plain assignment becomes a possible exception, invisible at the call site. Put a side effect in, and you have built magic the next person discovers through a debugger at midnight. We spent years removing \u003ccode\u003e__get\u003c/code\u003e and \u003ccode\u003e__set\u003c/code\u003e from codebases for this exact crime. Hooks are their respectable cousins, better typed, analyzable, and the temptation is identical.\u003c/p\u003e","title":"Property hooks, before the hype settles"},{"content":"A reporting transaction open for hours, and n_dead_tup on the hot table climbing all day while autovacuum ran non-stop and changed nothing.\nThat was one project. PostgreSQL 17 came out yesterday, and the headline work is exactly this plumbing: vacuum got a new memory structure for tracking dead tuples, far less memory and no longer capped the old way, plus better throughput for high-concurrency writes and bulk loads. Maintenance internals. Which is why backend people should care.\nShort recap. Postgres never updates a row in place. UPDATE writes a new version, DELETE only marks. The old versions, dead tuples, stay in the table until vacuum reclaims them. When vacuum cannot keep up, tables and indexes bloat, every scan reads pages that are mostly corpses, and your API latency degrades because of a background process you never think about.\nYou can watch it:\nSELECT relname, n_dead_tup, n_live_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10; On that project the villain was not vacuum. Vacuum cannot reclaim row versions some old transaction might still need to see, so one idle-in-transaction connection silently disabled cleanup for the whole database. Since then idle_in_transaction_session_timeout is a mandatory setting for me, not an option.\nWhat 17 changes in practice: vacuum passes over big tables need dramatically less memory, so they finish instead of thrashing, and the same maintenance budget goes further. After the upgrade check your autovacuum stats again rather than assuming. Bloat you already have does not disappear, you still repack the table once.\nA table has a metabolism. Feed it long transactions and it gets sick quietly.\nThe forgotten report was mine.\n","permalink":"https://useinov.com/posts/2024/postgres-vacuum/","summary":"\u003cp\u003eA reporting transaction open for hours, and \u003ccode\u003en_dead_tup\u003c/code\u003e on the hot table climbing all day while autovacuum ran non-stop and changed nothing.\u003c/p\u003e\n\u003cp\u003eThat was one project. PostgreSQL 17 came out yesterday, and the headline work is exactly this plumbing: vacuum got a new memory structure for tracking dead tuples, far less memory and no longer capped the old way, plus better throughput for high-concurrency writes and bulk loads. Maintenance internals. Which is why backend people should care.\u003c/p\u003e","title":"PostgreSQL 17 and why VACUUM is an API concern"},{"content":"Two hundred thousand ops per second against eighty. That was the bar chart, and the team was ready to pick a serializer from it. I asked for one day.\nThe day went like this.\nTake a representative workload. Not a synthetic three-field object, but a hundred real payloads from production logs, with the nested structures, the nullable mess, the one field that is sometimes a list and sometimes a map. Anonymize them, save as fixtures. This step is the whole method. The bar chart was measured on someone else\u0026rsquo;s data with someone else\u0026rsquo;s configuration, and a serializer is exactly the kind of code whose cost depends on the shape of the input.\nRun both libraries over the fixtures under a profiler and look at three numbers. Wall time inside our call graph, no loops. Peak memory, because our worker runs eight jobs concurrently. And allocations, because the fast library turned out to build a metadata cache per instance, and our code created instances in a loop. In the bar chart that cache is warm. In our code it never was.\nResult: the \u0026ldquo;slow\u0026rdquo; library was faster on our data. Twice.\nCorrectness got its own hour. Round-trip every fixture, diff the results. One library silently dropped keys with null values by default. That default is a future incident.\nThe rest of the decision had nothing to do with performance. Release history, open issues, how the maintainer answers, how painful the API is to mock in tests. A dependency is a small marriage.\nBenchmarks published by others answer their question. Ours cost one day, and I almost did not ask for it.\n","permalink":"https://useinov.com/posts/2024/profiler-not-benchmark/","summary":"\u003cp\u003eTwo hundred thousand ops per second against eighty. That was the bar chart, and the team was ready to pick a serializer from it. I asked for one day.\u003c/p\u003e\n\u003cp\u003eThe day went like this.\u003c/p\u003e\n\u003cp\u003eTake a representative workload. Not a synthetic three-field object, but a hundred real payloads from production logs, with the nested structures, the nullable mess, the one field that is sometimes a list and sometimes a map. Anonymize them, save as fixtures. This step is the whole method. The bar chart was measured on someone else\u0026rsquo;s data with someone else\u0026rsquo;s configuration, and a serializer is exactly the kind of code whose cost depends on the shape of the input.\u003c/p\u003e","title":"Pick the library with a profiler, not a chart"},{"content":"Every client reconnects at once. That is what a deploy means now on one project of mine, since Reverb made WebSockets a first-party Laravel feature this spring and order updates go to the browser instead of polling. The code was the easy part.\nA classic PHP app is stateless between requests. An FPM worker takes a request, answers, forgets. Capacity planning is requests per second. A WebSocket server is the opposite animal: thousands of open connections that mostly do nothing, but each one holds memory and a file descriptor, and each one is state that dies with the process. Your quiet realtime feature has a thundering herd built in. Plan for reconnect storms, raise descriptor limits, and make the client reconnect with jitter, never on a fixed timer.\nAuth moves too. An HTTP request authenticates itself every time. A channel subscription authenticates once, at subscribe. So private channels need a real authorization callback, and you must decide what happens to an open connection when access is revoked. Most teams decide nothing, by not thinking about it. We were most teams for the first month.\nScaling is fine once you accept the pattern: several Reverb nodes, Redis pub/sub between them, so an event lands on whichever node holds the subscriber.\nAnd one bug worth its own paragraph. Broadcasting from inside a database transaction:\nDB::transaction(function () use ($order) { $order-\u0026gt;markPaid(); OrderPaid::dispatch($order); // too early }); The browser receives the event, requests fresh data, and hits a replica where the transaction is not committed yet. Or the transaction rolls back and the clients celebrated a payment that never happened. Dispatch after commit, always. Laravel has ShouldDispatchAfterCommit for exactly this.\nRealtime is worth it where users watch a screen and wait. But price it honestly. You are adding a long-lived stateful service to a stateless system. New class of problems. Not a new route.\n","permalink":"https://useinov.com/posts/2024/reverb-operations/","summary":"\u003cp\u003eEvery client reconnects at once. That is what a deploy means now on one project of mine, since Reverb made WebSockets a first-party Laravel feature this spring and order updates go to the browser instead of polling. The code was the easy part.\u003c/p\u003e\n\u003cp\u003eA classic PHP app is stateless between requests. An FPM worker takes a request, answers, forgets. Capacity planning is requests per second. A WebSocket server is the opposite animal: thousands of open connections that mostly do nothing, but each one holds memory and a file descriptor, and each one is state that dies with the process. Your quiet realtime feature has a thundering herd built in. Plan for reconnect storms, raise descriptor limits, and make the client reconnect with jitter, never on a fixed timer.\u003c/p\u003e","title":"WebSockets change your operational model"},{"content":"Three times faster. That is the number in the benchmark posts about PHP 8.4, where simple sprintf() calls with basic placeholders get turned into plain string operations at compile time. The function call disappears.\nfor ($i = 0; $i \u0026lt; 1000000; $i++) { $s = sprintf(\u0026#39;%s-%d\u0026#39;, $prefix, $i); } I like this work. I do not like how people read it.\nA microbenchmark measures one function in a loop, alone, with a warm cache and nothing else to do. Now profile a real request from the same application. In every backend I have profiled the picture has the same shape: most of the wall time is waiting. SQL queries, Redis round trips, an HTTP call to some partner API. The CPU spent inside PHP is a modest slice, and sprintf inside that slice is a rounding error. Three times faster rounding error is still a rounding error.\nThis gives a simple order for optimization work, from big to small. Architecture first: do not do the work at all, cache the page, precompute the answer. Then I/O: the N+1 query, the missing index, the API call in a loop. Then the hot CPU paths the profiler actually points at, usually serialization or hydration of thousands of objects. Micro wins come last, and mostly they come for free with the engine upgrade, which is exactly what 8.4 does for everyone at once.\nThat is the honest praise. Engine optimizations are wonderful because nobody has to change their code to get them. Take the free percent and say thank you. Just do not book it as your performance strategy.\nYour p99 lives in the database. I know this, and I still opened the sprintf PR before the slow query log this morning.\n","permalink":"https://useinov.com/posts/2024/microbenchmark-trap/","summary":"\u003cp\u003eThree times faster. That is the number in the benchmark posts about PHP 8.4, where simple \u003ccode\u003esprintf()\u003c/code\u003e calls with basic placeholders get turned into plain string operations at compile time. The function call disappears.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003efor\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$i\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"mi\"\u003e0\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"nv\"\u003e$i\u003c/span\u003e \u003cspan class=\"o\"\u003e\u0026lt;\u003c/span\u003e \u003cspan class=\"mi\"\u003e1000000\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e \u003cspan class=\"nv\"\u003e$i\u003c/span\u003e\u003cspan class=\"o\"\u003e++\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nv\"\u003e$s\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003esprintf\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;%s-%d\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003e$prefix\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003e$i\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eI like this work. I do not like how people read it.\u003c/p\u003e\n\u003cp\u003eA microbenchmark measures one function in a loop, alone, with a warm cache and nothing else to do. Now profile a real request from the same application. In every backend I have profiled the picture has the same shape: most of the wall time is waiting. SQL queries, Redis round trips, an HTTP call to some partner API. The CPU spent inside PHP is a modest slice, and \u003ccode\u003esprintf\u003c/code\u003e inside that slice is a rounding error. Three times faster rounding error is still a rounding error.\u003c/p\u003e","title":"A faster sprintf will not fix your p99"},{"content":"composer update, the changelog over coffee, two deprecation notices fixed, done before lunch. That was Symfony 7.1 on one project this morning. Nothing in the release will change your architecture, and I mean this as a compliment.\nThere are nice small things. An attribute that maps an uploaded file straight into a controller argument, one more piece of request plumbing gone. A new experimental component for type introspection that libraries will quietly benefit from. A layer of deprecations preparing the next major. That is the whole show.\nA minor release like this is a test of the team more than of the framework. Projects split into two kinds. One kind upgrades within a week or two. The other kind waits. Each skipped minor makes the next jump scarier, so they wait more, and three years later the \u0026ldquo;upgrade project\u0026rdquo; gets its own budget, its own branch and its own tired person.\nThe mechanics of staying in the first kind are not clever. A recurring calendar slot for dependency updates, monthly is enough. Tests you actually trust, because without them every update is gambling. And deprecation warnings collected from production logs, because today\u0026rsquo;s warning is the exact line that breaks on the next major. Symfony is honestly good here: the deprecation contract means 7.1 tells you in plain text what 8.0 will punish.\nA colleague once called this routine paying rent. Right words. Rent is small and regular. The alternative is not free housing, it is eviction day with interest.\nUpgrade while it is boring. I have been the tired person on the three-year branch. Once was enough.\n","permalink":"https://useinov.com/posts/2024/symfony-seven-one/","summary":"\u003cp\u003e\u003ccode\u003ecomposer update\u003c/code\u003e, the changelog over coffee, two deprecation notices fixed, done before lunch. That was Symfony 7.1 on one project this morning. Nothing in the release will change your architecture, and I mean this as a compliment.\u003c/p\u003e\n\u003cp\u003eThere are nice small things. An attribute that maps an uploaded file straight into a controller argument, one more piece of request plumbing gone. A new experimental component for type introspection that libraries will quietly benefit from. A layer of deprecations preparing the next major. That is the whole show.\u003c/p\u003e","title":"Symfony 7.1 and the value of boring releases"},{"content":"Half the industry is sitting on MySQL 5.7 right now, unsupported since last autumn, and calling it stability.\nMySQL 8.4 came out today, and the important word in the announcement is not a feature. It is LTS. First long-term release of the new model: innovation releases every quarter for the brave, an LTS every couple of years for production, bugfixes only after that.\nPeople underestimate how much pain the old model caused. 8.0 was a rolling target. A \u0026ldquo;minor\u0026rdquo; 8.0.x could change optimizer behavior or deprecate something you used. Staying current meant re-testing, staying behind meant missing security fixes.\nSo the upgrade argument for 8.4 is the support horizon. You are buying years of predictable patches. For a database that is the correct thing to buy.\nThe path from 5.7 is two hops, 5.7 to 8.0 to 8.4, no shortcuts. From my notes, what actually bites.\nDeprecated features you forgot you use. mysql_native_password is disabled by default in 8.4, and there is always one ancient client that needs it. Run the upgrade checker, then grep your connection configs anyway.\nCharset and collation. The 5.7 to 8.0 hop changes defaults around utf8mb4, and mixed collations surface as broken joins and duplicate keys where \u0026ldquo;the same\u0026rdquo; strings stop being the same.\nQuery plans. The optimizer is not your old optimizer. A handful of queries will get slower and no checklist predicts which.\nWhich is why the real method is rehearsal. Restore a production dump on the new version, replay traffic on a read replica, compare plans for your top queries. Do it twice. The upgrade itself should be the boring part.\nOne of the 5.7 servers in that half is mine.\n","permalink":"https://useinov.com/posts/2024/mysql-lts/","summary":"\u003cp\u003eHalf the industry is sitting on MySQL 5.7 right now, unsupported since last autumn, and calling it stability.\u003c/p\u003e\n\u003cp\u003eMySQL 8.4 came out today, and the important word in the announcement is not a feature. It is LTS. First long-term release of the new model: innovation releases every quarter for the brave, an LTS every couple of years for production, bugfixes only after that.\u003c/p\u003e\n\u003cp\u003ePeople underestimate how much pain the old model caused. 8.0 was a rolling target. A \u0026ldquo;minor\u0026rdquo; 8.0.x could change optimizer behavior or deprecate something you used. Staying current meant re-testing, staying behind meant missing security fixes.\u003c/p\u003e","title":"MySQL 8.4 gives us a place to stand"},{"content":"The diff of a Laravel 11 upgrade on one mid-size API this week is mostly deletions. Almost empty app/, configuration in one fluent bootstrap file, the slim skeleton I wrote about in January now real. It feels great.\nNow look at what survived untouched. The use case class that wraps an order state change in a transaction. The listener that must fire only after commit, because it queues an email about a row that must exist. The cache invalidation that follows every write to a heavily read table. The decision that validation lives in a request class and business rules one layer deeper. None of this is skeleton. None of it got smaller.\nThe framework became simpler. The product did not.\nA framework automates the plumbing between an HTTP request and your code: routing, validation wiring, container, queue transport. It cannot automate where a transaction begins and ends. It cannot know that two writes are one invariant. It cannot decide which endpoint is allowed to be eventually consistent. Those are product decisions, and they are the actual engineering content of a backend.\nThis is why \u0026ldquo;framework X is bloated\u0026rdquo; against \u0026ldquo;framework Y is simple\u0026rdquo; bores me. The hard part of my job has been the same through Kohana, Symfony and Laravel: boundaries, invariants, failure modes. The framework only decides how much ceremony surrounds that work. Laravel 11 cuts ceremony well.\nUpgrade, enjoy the empty folders. The weight was never in those files. I still felt lighter deleting them, which says more about me than about the framework.\n","permalink":"https://useinov.com/posts/2024/laravel-eleven/","summary":"\u003cp\u003eThe diff of a Laravel 11 upgrade on one mid-size API this week is mostly deletions. Almost empty \u003ccode\u003eapp/\u003c/code\u003e, configuration in one fluent bootstrap file, the slim skeleton I wrote about in January now real. It feels great.\u003c/p\u003e\n\u003cp\u003eNow look at what survived untouched. The use case class that wraps an order state change in a transaction. The listener that must fire only after commit, because it queues an email about a row that must exist. The cache invalidation that follows every write to a heavily read table. The decision that validation lives in a request class and business rules one layer deeper. None of this is skeleton. None of it got smaller.\u003c/p\u003e","title":"Laravel 11 removes files, not decisions"},{"content":"A request creates a job. The job calls an external API and writes to the database. Something in this chain is slow, and the logs tell four disconnected stories.\nGrepping a request id across services is our folklore. OpenTelemetry finally makes the boring standard version practical in PHP.\nThe core is small. One trace id for the whole causal chain, a span id for every operation, and one rule: pass the context along. Over HTTP it is a single header:\ntraceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 Incoming middleware reads it and opens a server span. Every outgoing HTTP call opens a child span and sends the header further. Nothing new so far, Zipkin did this years ago. The part people forget is the queue. A job is not a new story. It was caused by a request. So the producer writes the trace context into message metadata, next to the payload, and the worker restores it before running the handler. Now the slow API call inside the job hangs from the same trace as the click that caused it. That is the whole point: causality that survives process boundaries.\nAdd SQL spans and the waterfall is complete. On one project the first full trace paid for the setup in a day. The \u0026ldquo;slow endpoint\u0026rdquo; was a job doing three retries against a dead cache, invisible in request logs.\nTwo warnings.\nSampling. You do not need every trace, you need enough of them plus all the errors. Decide the percentage early, exporters are not free.\nAttributes go to a backend that stores and indexes them. Statement text is fine, bound values are not. No emails, no tokens, and keep attribute values low cardinality, or the tracing backend becomes the most expensive database you run.\nWe had the request id in the logs for three years before this. I never once managed to follow it into a job.\n","permalink":"https://useinov.com/posts/2024/one-trace/","summary":"\u003cp\u003eA request creates a job. The job calls an external API and writes to the database. Something in this chain is slow, and the logs tell four disconnected stories.\u003c/p\u003e\n\u003cp\u003eGrepping a request id across services is our folklore. OpenTelemetry finally makes the boring standard version practical in PHP.\u003c/p\u003e\n\u003cp\u003eThe core is small. One trace id for the whole causal chain, a span id for every operation, and one rule: pass the context along. Over HTTP it is a single header:\u003c/p\u003e","title":"One trace through HTTP, queue and database"},{"content":"No app/Http/Kernel.php. That is the first thing you notice in the Laravel 11 preview, and the first thing every comment thread asks: where did my middleware go.\nThe skeleton is the loudest change this quarter, louder than any feature. An app/ directory that is almost empty, no folder of middleware stubs, a pile of service providers collapsed into one, config files trimmed.\nThe middleware did not go anywhere. It moved into the framework. The mechanism exists exactly as before, only the file does not, and bootstrap/app.php gets a small fluent API to reconfigure the stack when you actually need to.\nAbsent file is not absent mechanism. The old skeleton printed framework internals into your repository, and everyone treated the printout as their code. It was not their code. Most projects never edit the HTTP kernel. They carried it through every upgrade anyway, diffed it, resolved conflicts in it.\nSo is less generated code less complexity. No. The complexity was always in the framework, the skeleton only made a photocopy. What the diet buys is an honest boundary. What sits in app/ is yours, you wrote it, you meant it. What is not there has a default. Defaults end debates.\nThere is a real cost, and it is discoverability. A junior could open Kernel.php and read the middleware stack top to bottom, in order. Now that list lives in a class inside vendor/. You will explain it once per new hire. Cheap, but not zero.\nMy prediction: within a month of release someone publishes a package that generates all the old files back. Some people are only calm when they can see the machinery. I understand them. I have opened vendor/ to read that class three times already.\n","permalink":"https://useinov.com/posts/2024/laravel-slim-skeleton/","summary":"\u003cp\u003eNo \u003ccode\u003eapp/Http/Kernel.php\u003c/code\u003e. That is the first thing you notice in the Laravel 11 preview, and the first thing every comment thread asks: where did my middleware go.\u003c/p\u003e\n\u003cp\u003eThe skeleton is the loudest change this quarter, louder than any feature. An \u003ccode\u003eapp/\u003c/code\u003e directory that is almost empty, no folder of middleware stubs, a pile of service providers collapsed into one, config files trimmed.\u003c/p\u003e\n\u003cp\u003eThe middleware did not go anywhere. It moved into the framework. The mechanism exists exactly as before, only the file does not, and \u003ccode\u003ebootstrap/app.php\u003c/code\u003e gets a small fluent API to reconfigure the stack when you actually need to.\u003c/p\u003e","title":"The shrinking Laravel skeleton"},{"content":"A DTO with a dozen fields, a framework migration, a slow query incident. Three tasks from the last few months, all done with Copilot and ChatGPT open. Here is where the line runs for me.\nTask one, boilerplate. The DTO, its validation rules, a test with fixtures. The assistant writes this faster than I type, and the result needs only a quick read. Same for converting an array-shaped legacy structure into typed classes. This is honest time saved, maybe an hour a day. The code is the kind where being generic is correct.\nTask two, the migration. Mixed. It knows the official upgrade guide, sometimes in a version from last year. It confidently suggested a config format that does not exist in the version I was moving to. Useful as a reminder of what to check, dangerous as an authority. Trust level: a junior who read the docs once.\nTask three, the slow query. Here the model has nothing. It produced five generic tips, add an index, avoid SELECT *. The answer lived in EXPLAIN ANALYZE on production-sized data, in the fact that the planner chose a nested loop because the statistics on one column were stale. The model cannot see my p99, my data distribution, my execution plan. Nobody trained it on my database, and a better prompt does not fix that.\nSo the rules I settled on. Generated code gets the same review as human code, no exceptions for confidence of tone. Nothing from the incident channel goes into a public model. Performance work starts from the profiler, not from the chat window.\nThe assistant is a very fast typist with wide but shallow memory. Useful. I still caught myself pasting the slow query into the chat first, before opening EXPLAIN. Old habits are three months old now.\n","permalink":"https://useinov.com/posts/2023/ai-assistants-limits/","summary":"\u003cp\u003eA DTO with a dozen fields, a framework migration, a slow query incident. Three tasks from the last few months, all done with Copilot and ChatGPT open. Here is where the line runs for me.\u003c/p\u003e\n\u003cp\u003eTask one, boilerplate. The DTO, its validation rules, a test with fixtures. The assistant writes this faster than I type, and the result needs only a quick read. Same for converting an array-shaped legacy structure into typed classes. This is honest time saved, maybe an hour a day. The code is the kind where being generic is correct.\u003c/p\u003e","title":"AI assistants do not know your production"},{"content":"public const int TIMEOUT_SECONDS = 30; One week old, and the one thing I waited for from PHP 8.3.\nBusy November. 8.3 released last week, and yesterday Symfony shipped 6.4 LTS and 7.0 on the same day. Time to plan, not to jump.\nTyped class constants are a small thing, but constants were the last untyped island in a class, and a child class could silently redefine one to a different type. Also json_validate(), which checks JSON syntax without building the whole tree in memory. Everyone had a helper that did json_decode and threw the result away. Now the helper can retire.\nThe Symfony pair is the interesting mechanism. 6.4 and 7.0 are the same code, released together. 6.4 keeps the deprecated paths and runs on PHP 8.1. 7.0 removes everything deprecated and wants 8.2 minimum. So the upgrade plan writes itself and there is no heroism in it. Go to 6.4, turn on deprecation logging, clean your code until the log is silent. Meanwhile raise PHP, 8.3 is fine for both. Then the jump to 7.0 is close to a version bump in composer.json, because a 6.4 codebase without deprecations is a 7.0 codebase.\nThe blocker, as always, will be third-party bundles. Check their constraints now: anything demanding ^6.0 and not yet allowing ^7.0 goes on a list, and the list goes to the maintainers or gets forked. Better to know in November than in the middle of the migration.\nNothing here needs to happen this week. LTS exists exactly so you can move without hurry. But the deprecation log should start collecting today.\nI turned it on this morning. By lunch it was long, and half of it was mine.\n","permalink":"https://useinov.com/posts/2023/november-baselines/","summary":"\u003cp\u003e\u003ccode\u003epublic const int TIMEOUT_SECONDS = 30;\u003c/code\u003e One week old, and the one thing I waited for from PHP 8.3.\u003c/p\u003e\n\u003cp\u003eBusy November. 8.3 released last week, and yesterday Symfony shipped 6.4 LTS and 7.0 on the same day. Time to plan, not to jump.\u003c/p\u003e\n\u003cp\u003eTyped class constants are a small thing, but constants were the last untyped island in a class, and a child class could silently redefine one to a different type. Also \u003ccode\u003ejson_validate()\u003c/code\u003e, which checks JSON syntax without building the whole tree in memory. Everyone had a helper that did \u003ccode\u003ejson_decode\u003c/code\u003e and threw the result away. Now the helper can retire.\u003c/p\u003e","title":"PHP 8.3 and Symfony 7 in one month"},{"content":"One binary, my code, a Caddyfile. That is the whole deployment of a small internal service I moved to FrankenPHP in staging this week.\nThe classic PHP deployment is a couple: Nginx in front, PHP-FPM behind, FastCGI between them. Two configs, two processes to supervise, one socket to misconfigure. It works, we all know it by heart, and knowing it by heart is the only reason we call it simple.\nFrankenPHP folds the pair into one binary. It is Caddy, the Go web server, with PHP embedded inside. The request does not travel over FastCGI to a separate process pool. The server runs your script itself. You get HTTP/2, HTTP/3 and automatic HTTPS because Caddy brings them. For a container image this is honestly attractive. The Nginx-plus-FPM image with supervisord inside always felt like a small apartment with too many tenants.\nThere is also worker mode. The application boots once and stays in memory, requests are handled by the already-booted app. Same idea as RoadRunner or Octane, same wins, same fine print: request state must be cleaned, transactions must not leak between requests, memory must be watched. I wrote about that discipline in April, all of it applies here unchanged. Classic mode, one fresh script per request, is the safe entry.\nProduction material today: not yet. The project is young and moving fast, no stable 1.0. I would not move a money-handling monolith this week. But the staging experience is pleasant: config is short, TLS is automatic, one process to watch instead of three.\nFPM solved process management for the shared-hosting era. Containers changed the question. A static binary that speaks modern HTTP and runs my app is a good answer to the new one.\nI will keep the Nginx config around a while longer. Not because I need it. Because I know it by heart.\n","permalink":"https://useinov.com/posts/2023/frankenphp-first-look/","summary":"\u003cp\u003eOne binary, my code, a Caddyfile. That is the whole deployment of a small internal service I moved to FrankenPHP in staging this week.\u003c/p\u003e\n\u003cp\u003eThe classic PHP deployment is a couple: Nginx in front, PHP-FPM behind, FastCGI between them. Two configs, two processes to supervise, one socket to misconfigure. It works, we all know it by heart, and knowing it by heart is the only reason we call it simple.\u003c/p\u003e","title":"FrankenPHP: the server absorbs PHP"},{"content":"A nightly import, a few hundred million rows through COPY, then reporting queries in the morning. That is the only workload I care about, so that is how I read the PostgreSQL 16 release notes: with one pipeline in mind and everything else skipped.\nThree items survived the filter.\nCOPY got faster, noticeably when several loads run at once. Our import is exactly this shape, parallel COPY streams into partitioned tables. I will take the free speed. But the old lesson stays: if your import still does row-by-row INSERT from PHP, no Postgres release will save you. Batch into COPY first, then talk about versions.\npg_stat_io. One view with I/O by backend type: how much reading and writing comes from queries, from autovacuum, from the checkpointer. Until now we assembled this picture from indirect signals and guesswork. First morning on the test upgrade it showed that autovacuum, not the import, produces most of the write traffic on one table. We suspected this for months. Now it is a number. For me this view alone pays for the upgrade.\nLogical replication grew up. A standby can now be a source, and big transactions are applied in parallel on the subscriber. If a search index or analytics is fed from replication, the lag behind a fat import transaction was the classic problem. Worth retesting.\nThe method behind this is boring. Never read a database changelog abstractly. Benchmarks in the announcement are someone else\u0026rsquo;s workload. Take your slowest pipeline, upgrade a test environment, run it, open the new statistics views. Two evenings.\nI have read release notes cover to cover before. I remembered none of it a week later.\n","permalink":"https://useinov.com/posts/2023/postgres-sixteen/","summary":"\u003cp\u003eA nightly import, a few hundred million rows through \u003ccode\u003eCOPY\u003c/code\u003e, then reporting queries in the morning. That is the only workload I care about, so that is how I read the PostgreSQL 16 release notes: with one pipeline in mind and everything else skipped.\u003c/p\u003e\n\u003cp\u003eThree items survived the filter.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eCOPY\u003c/code\u003e got faster, noticeably when several loads run at once. Our import is exactly this shape, parallel COPY streams into partitioned tables. I will take the free speed. But the old lesson stays: if your import still does row-by-row INSERT from PHP, no Postgres release will save you. Batch into COPY first, then talk about versions.\u003c/p\u003e","title":"PostgreSQL 16, read through one workload"},{"content":"The same table, the same rows, about 1.5 times the disk. That was the measurement after someone made UUIDv4 the primary key in InnoDB, and that is before counting secondary indexes.\nEvery new API wants UUIDs. Distributed generation, no coordination, nothing leaks about row counts. All true. Then the database quietly starts paying.\nInnoDB clusters the table by primary key. Auto-increment inserts always land in the rightmost page, warm and predictable. Random UUIDs land anywhere in the tree: page splits everywhere, half-empty pages, and the set of hot pages becomes the whole index. Secondary indexes store the primary key in every entry, so each of them inherits the full 16 bytes too. In PostgreSQL the heap does not cluster, so the pain is smaller, but index bloat and lost locality are still there.\nThe fix is not \u0026ldquo;bigint everywhere again\u0026rdquo;. It is time-ordered identifiers. ULID, or UUIDv7, which is going through the RFC process right now. The high bits are a timestamp, so new keys sort near each other and inserts stay local, while the id is still generated anywhere without coordination. Symfony\u0026rsquo;s uid component and Ramsey\u0026rsquo;s library both give you such ids today.\nAnd a separate thought that removes half the debate. Public identifier and primary key do not have to be the same column. Internal bigint PK for joins and clustering, external ULID for URLs and API payloads, unique index on it. Sixteen bytes and one index. The argument about leaking sequence numbers and the argument about B-tree locality stop fighting, because they were never about the same column.\nSHOW TABLE STATUS is where this decision actually lives. I did not open it until the table had already grown by half.\n","permalink":"https://useinov.com/posts/2023/uuid-primary-keys/","summary":"\u003cp\u003eThe same table, the same rows, about 1.5 times the disk. That was the measurement after someone made UUIDv4 the primary key in InnoDB, and that is before counting secondary indexes.\u003c/p\u003e\n\u003cp\u003eEvery new API wants UUIDs. Distributed generation, no coordination, nothing leaks about row counts. All true. Then the database quietly starts paying.\u003c/p\u003e\n\u003cp\u003eInnoDB clusters the table by primary key. Auto-increment inserts always land in the rightmost page, warm and predictable. Random UUIDs land anywhere in the tree: page splits everywhere, half-empty pages, and the set of hot pages becomes the whole index. Secondary indexes store the primary key in every entry, so each of them inherits the full 16 bytes too. In PostgreSQL the heap does not cluster, so the pain is smaller, but index bloat and lost locality are still there.\u003c/p\u003e","title":"UUID primary keys and the B-tree bill"},{"content":"MySQL 8.1 came out this week, and its support window is measured in months. That is by design. Oracle changed the release model: two tracks now. Innovation releases every quarter or so, 8.1 is the first, short support, new features fast. And an LTS line with years of support, promised for next year. 8.0 stays on bugfixes until its end of life in 2026.\nFor years the question was \u0026ldquo;when do we finally move to 8.0\u0026rdquo;. Now the question has a second dimension: which track are you even on. That is a product decision, and it is worth making explicitly instead of by inertia.\nMy position is not original. Production follows LTS. A database is the one component where I want other people to find the bugs first. Quarterly majors with short support windows mean quarterly upgrade projects, and nobody budgets for that. An upgrade you are forced into by a support calendar is the worst kind.\nBut I would not ignore the innovation track completely. If some 8.1+ feature really matters to you, run one test environment on it. You learn the migration pain early, where pain is cheap, and by the time the feature lands in an LTS you already know your schema and your queries survive it.\nOne trap to write down now: connector and client compatibility. With fast-moving server versions, the version matrix of PDO drivers, ProxySQL and backup tools becomes the real constraint. The server is usually ready before the tooling around it.\nPostgreSQL people are smiling, they had a predictable yearly cadence forever. Fair. But two clear tracks are still better than one ambiguous 8.0 that has been \u0026ldquo;current\u0026rdquo; for five years.\nOne project I help with is still on 5.7 and calls 8.0 \u0026ldquo;the new one\u0026rdquo;. The calendar will fix our vocabulary before we do.\n","permalink":"https://useinov.com/posts/2023/mysql-release-tracks/","summary":"\u003cp\u003eMySQL 8.1 came out this week, and its support window is measured in months. That is by design. Oracle changed the release model: two tracks now. Innovation releases every quarter or so, 8.1 is the first, short support, new features fast. And an LTS line with years of support, promised for next year. 8.0 stays on bugfixes until its end of life in 2026.\u003c/p\u003e\n\u003cp\u003eFor years the question was \u0026ldquo;when do we finally move to 8.0\u0026rdquo;. Now the question has a second dimension: which track are you even on. That is a product decision, and it is worth making explicitly instead of by inertia.\u003c/p\u003e","title":"MySQL now has two speeds"},{"content":"An email confirming an order that does not exist. A colleague showed me this one last week, together with the mirror bug: an order that exists and never got its email.\nThe code was the obvious kind. Commit the Doctrine transaction, then dispatch to the AMQP transport. Two systems, two writes, no shared transaction. Publish after commit and the process can die between them, event lost. Publish inside the transaction and the broker gets an event for data that may still roll back. Both orders are wrong, and under real load this fires weekly.\nThe boring fix is the transactional outbox. One extra table:\nCREATE TABLE outbox ( id BIGINT AUTO_INCREMENT PRIMARY KEY, event_type VARCHAR(255) NOT NULL, payload JSON NOT NULL, created_at DATETIME NOT NULL, published_at DATETIME NULL ); The event row goes into the same transaction as the order. One database, one commit, so the event exists exactly when the order exists. A separate publisher reads unpublished rows, pushes them to the transport, marks them published. With Messenger this maps cleanly: the Doctrine transport can play the outbox, and a worker relays to AMQP.\nThe price is honesty about delivery. The publisher can crash after sending and before marking, so the same event goes out twice. That is at-least-once, and there is no cheap way around it. Every consumer needs an idempotency key, the outbox row id works. Store processed ids, skip repeats. This is not a detail on top of the pattern. It is the pattern.\nNo new infrastructure. No distributed transactions, no CDC pipeline. A table, a loop, a unique key on the consumer side.\nI wrote the publish-after-commit version myself, on another project, three years ago. It is probably still there.\n","permalink":"https://useinov.com/posts/2023/transactional-outbox/","summary":"\u003cp\u003eAn email confirming an order that does not exist. A colleague showed me this one last week, together with the mirror bug: an order that exists and never got its email.\u003c/p\u003e\n\u003cp\u003eThe code was the obvious kind. Commit the Doctrine transaction, then dispatch to the AMQP transport. Two systems, two writes, no shared transaction. Publish after commit and the process can die between them, event lost. Publish inside the transaction and the broker gets an event for data that may still roll back. Both orders are wrong, and under real load this fires weekly.\u003c/p\u003e","title":"Outbox: events that do not lie"},{"content":"crontab -l under a login nobody remembers creating. That is where the real business logic of one server lived: a nightly cleanup, an export, a retry script.\nSymfony 6.3 came out yesterday with a Scheduler component. Periodic tasks defined in PHP, executed as Messenger messages. Experimental, but the idea deserves a note.\n#[AsSchedule(\u0026#39;default\u0026#39;)] final class MainSchedule implements ScheduleProviderInterface { public function getSchedule(): Schedule { return (new Schedule())-\u0026gt;add( RecurringMessage::every(\u0026#39;10 minutes\u0026#39;, new CleanupExpiredCarts()), ); } } Then messenger:consume scheduler_default, and a worker fires the messages.\nWhat this gives over crontab. The task is a message, so it goes through the same middleware as everything else: retries, failure transport, logging. Crontab gives you an email nobody reads. And the schedule lives in the repository, so it travels through code review instead of through someone\u0026rsquo;s shell history.\nTwo things to get right. Several workers, add the lock, or two of them fire the same task. And decide what happens to runs missed while the worker was down. The component can store state and catch up, but think first whether you want a cleanup to fire forty times after a long deploy. Usually you want it once.\nWhere I still take plain cron: one server, one or two tasks, no queue in the project. */10 * * * * calling a console command is honest and everyone understands it. Bringing up Messenger workers under supervisor to run a nightly cleanup is architecture for the sake of architecture.\nBut if the workers are already there, the schedule might as well live with the code. I am moving that zoo of cron entries over, slowly. The old login stays for now. Nobody knows what else it owns.\n","permalink":"https://useinov.com/posts/2023/symfony-scheduler/","summary":"\u003cp\u003e\u003ccode\u003ecrontab -l\u003c/code\u003e under a login nobody remembers creating. That is where the real business logic of one server lived: a nightly cleanup, an export, a retry script.\u003c/p\u003e\n\u003cp\u003eSymfony 6.3 came out yesterday with a Scheduler component. Periodic tasks defined in PHP, executed as Messenger messages. Experimental, but the idea deserves a note.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e#[\u003c/span\u003e\u003cspan class=\"nd\"\u003eAsSchedule\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;default\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003efinal\u003c/span\u003e \u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eMainSchedule\u003c/span\u003e \u003cspan class=\"k\"\u003eimplements\u003c/span\u003e \u003cspan class=\"nx\"\u003eScheduleProviderInterface\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"nf\"\u003egetSchedule\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"nx\"\u003eSchedule\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003enew\u003c/span\u003e \u003cspan class=\"nx\"\u003eSchedule\u003c/span\u003e\u003cspan class=\"p\"\u003e())\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003eadd\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e            \u003cspan class=\"nx\"\u003eRecurringMessage\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"na\"\u003eevery\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;10 minutes\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003enew\u003c/span\u003e \u003cspan class=\"nx\"\u003eCleanupExpiredCarts\u003c/span\u003e\u003cspan class=\"p\"\u003e()),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThen \u003ccode\u003emessenger:consume scheduler_default\u003c/code\u003e, and a worker fires the messages.\u003c/p\u003e","title":"Scheduler component, or cron as messages"},{"content":"A static array with the comment \u0026ldquo;cache, cleared per request\u0026rdquo;. It went to production on RoadRunner last month, and the memory graph turned into a staircase.\nPHP had one superpower nobody advertised: the request died. Every leak, every forgotten static, every open transaction was erased when the process shut down. Shared-nothing was not architecture. It was amnesia, and amnesia forgave us everything.\nRoadRunner and Swoole take it away. The worker lives for thousands of requests, and the class of bugs changes on the first day. That \u0026ldquo;per request\u0026rdquo; cache became a cache per worker lifetime, and the supervisor killed the process when it ran out of memory. A logger kept the request id in a property, so entries from user B carried the id of user A. And the best one: an exception in the middle of a Doctrine transaction left the connection with the transaction open, and the next request on that worker silently joined it.\nNone of this code was wrong. The runtime it ran on changed.\nMy checklist now, before a service goes on persistent workers. Everything request-scoped gets reset between requests, including things you never think of, like a locale set on some intl formatter. Every transaction gets a rollback in finally. Every in-process cache gets a size bound. Database and Redis connections must survive a server-side disconnect: read the reconnect code yourself, the readme lies.\nAnd one measurement instead of faith. Run ten thousand requests through one worker and watch memory_get_usage(true) between them. Flat line, good. Staircase, you have a leak that FPM would have hidden from you forever.\nWe cut p50 roughly in half by not rebuilding the container every request. The win is real. It is paid for with discipline FPM never asked from us, and I am not sure the whole team signed up for it.\n","permalink":"https://useinov.com/posts/2023/persistent-worker-discipline/","summary":"\u003cp\u003eA static array with the comment \u0026ldquo;cache, cleared per request\u0026rdquo;. It went to production on RoadRunner last month, and the memory graph turned into a staircase.\u003c/p\u003e\n\u003cp\u003ePHP had one superpower nobody advertised: the request died. Every leak, every forgotten static, every open transaction was erased when the process shut down. Shared-nothing was not architecture. It was amnesia, and amnesia forgave us everything.\u003c/p\u003e\n\u003cp\u003eRoadRunner and Swoole take it away. The worker lives for thousands of requests, and the class of bugs changes on the first day. That \u0026ldquo;per request\u0026rdquo; cache became a cache per worker lifetime, and the supervisor killed the process when it ran out of memory. A logger kept the request id in a property, so entries from user B carried the id of user A. And the best one: an exception in the middle of a Doctrine transaction left the connection with the transaction open, and the next request on that worker silently joined it.\u003c/p\u003e","title":"Persistent PHP workers need discipline"},{"content":"Three lines of YAML in a file two directories away, for one integer that one service reads. That was the first thing I converted when I started moving a project\u0026rsquo;s service config from YAML into attributes, to see where the line is.\npublic function __construct( #[Autowire(\u0026#39;%env(int:IMPORT_BATCH_SIZE)%\u0026#39;)] private int $batchSize, ) {} Now the class tells you everything about itself. Same with #[TaggedIterator] for collecting all implementations of an interface, and #[AsDecorator] for wrapping a service. These are local facts. A local fact belongs next to the code it describes. When I open the class, I want to stop searching.\nThen the bad case, on the same project. Someone put #[When(env: 'prod')] and environment-specific wiring on half the services. To understand what runs in production you now open twenty classes and read attributes on each. Which mailer is real and which is a null object, which cache backend, what is decorated in test. This is policy. Policy should sit in one file you can read top to bottom. YAML, PHP config, does not matter. One place.\nThe rule I ended up with: if the configuration answers \u0026ldquo;what does this class need\u0026rdquo;, attribute. If it answers \u0026ldquo;how is this application assembled\u0026rdquo;, central config. The second question has one answer per environment and it deserves one page.\nAttributes are not a new idea, annotations did the same for routing years ago. What is new is that container support is finally complete enough that YAML becomes optional. Optional, not forbidden. Keep it for the assembly.\nI found the line by crossing it first.\n","permalink":"https://useinov.com/posts/2023/symfony-di-attributes/","summary":"\u003cp\u003eThree lines of YAML in a file two directories away, for one integer that one service reads. That was the first thing I converted when I started moving a project\u0026rsquo;s service config from YAML into attributes, to see where the line is.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"fm\"\u003e__construct\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e#[\u003c/span\u003e\u003cspan class=\"nd\"\u003eAutowire\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;%env(int:IMPORT_BATCH_SIZE)%\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eprivate\u003c/span\u003e \u003cspan class=\"nx\"\u003eint\u003c/span\u003e \u003cspan class=\"nv\"\u003e$batchSize\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eNow the class tells you everything about itself. Same with \u003ccode\u003e#[TaggedIterator]\u003c/code\u003e for collecting all implementations of an interface, and \u003ccode\u003e#[AsDecorator]\u003c/code\u003e for wrapping a service. These are local facts. A local fact belongs next to the code it describes. When I open the class, I want to stop searching.\u003c/p\u003e","title":"DI attributes: config moves into the class"},{"content":"Process::run('gzip -k dump.sql'). Out of the whole Laravel 10 release this week, that is the line I will actually use.\nThe rest is quiet. Skeleton and framework code got native type declarations instead of docblocks, PHP 8.1 is the floor, and there is the Process facade. That is roughly the whole story. Some people are disappointed. I am not. A major release that is mostly maintenance means the framework is an adult.\nuse Illuminate\\Support\\Facades\\Process; $result = Process::run(\u0026#39;gzip -k dump.sql\u0026#39;); if ($result-\u0026gt;failed()) { Log::error($result-\u0026gt;errorOutput()); } Before this it was raw Symfony Process or exec() hidden in some helper. Now it is a facade, which means it is fakeable in tests. Process::fake() and your test does not really call gzip. Shelling out was always the untested corner of every Laravel app I saw. Less excuse now.\nThe typing change is bigger than it looks. Generated classes come with real parameter and return types. Your IDE stops guessing. But your own classes that extend framework ones may need signature updates. This is the main source of upgrade friction, and it is mechanical work. Rector handles most of it.\nYearly majors used to scare people. In practice 8 to 9 took me an afternoon, and 9 to 10 looks the same. The trick is never being more than one major behind. Upgrade cost is not linear in versions skipped. It is worse.\nRead the upgrade guide, it is two screens. Run the tests. Ship it while it is boring.\nThe untested shell-out corner in my own project is a backup script. It has called exec() for years. It still does.\n","permalink":"https://useinov.com/posts/2023/laravel-ten-quiet/","summary":"\u003cp\u003e\u003ccode\u003eProcess::run('gzip -k dump.sql')\u003c/code\u003e. Out of the whole Laravel 10 release this week, that is the line I will actually use.\u003c/p\u003e\n\u003cp\u003eThe rest is quiet. Skeleton and framework code got native type declarations instead of docblocks, PHP 8.1 is the floor, and there is the Process facade. That is roughly the whole story. Some people are disappointed. I am not. A major release that is mostly maintenance means the framework is an adult.\u003c/p\u003e","title":"Laravel 10 is a quiet major"},{"content":"composer why-not php 8.1. One command, and it tells you who holds you back. Run it before making any plans.\nLaravel 10 comes next month and requires PHP 8.1. One project I help with is on Laravel 9 and PHP 8.0. The temptation is one heroic branch: new PHP, new framework, new package versions. When that branch breaks in production, you will not know which of the three changes broke it.\nThe order that works for me.\nFirst, PHP 8.1 while staying on Laravel 9. Laravel 9 supports it, so this step is legal. Run the test suite on 8.1 in CI next to 8.0. Fix deprecations. Grep for enum used as a class name, it is a keyword now and old generated code sometimes has it. Deploy. Live with it for a week or two.\nSecond, dependencies. composer outdated -D and go through the list. Every package must declare support for the next framework major before you touch the framework itself. If one does not, you found your real blocker in January, with nothing else in flight.\nThird, the framework. By this point the diff is small and boring. Boring is the goal.\nNew features of Laravel 10 are step four. Upgrade first, deploy, watch the logs. Adopt new toys only on a green stack. A framework upgrade that also changes application behavior is two migrations pretending to be one.\nThe whole thing is dull. That is the point.\nI did the heroic branch once. It was green in CI for a week. Production found the third change on a Sunday.\n","permalink":"https://useinov.com/posts/2023/laravel-upgrade-baseline/","summary":"\u003cp\u003e\u003ccode\u003ecomposer why-not php 8.1\u003c/code\u003e. One command, and it tells you who holds you back. Run it before making any plans.\u003c/p\u003e\n\u003cp\u003eLaravel 10 comes next month and requires PHP 8.1. One project I help with is on Laravel 9 and PHP 8.0. The temptation is one heroic branch: new PHP, new framework, new package versions. When that branch breaks in production, you will not know which of the three changes broke it.\u003c/p\u003e","title":"Before Laravel 10: raise the baseline first"},{"content":"PHP 8.2 was released on December 8. My checklist for it looks like every other December, which is the whole point of a yearly cadence.\nOn the surface a quiet release. Readonly classes, DNF types, standalone true, false and null types, constants in traits. Underneath, the more important half: another round of cleaning old dynamic behavior. Dynamic properties deprecated, ${var} string interpolation deprecated, utf8_encode deprecated. The language keeps trading looseness for predictability, and I keep voting for the trade.\nThe order. First the deprecation pass: run the test suite on 8.2 with deprecations turned into log lines, and read the log. This is where the dynamic property typos show up, I wrote about them in September. Then the dependency audit, because the blockers are rarely your code. Frameworks were ready early this year, the long tail of small packages was not. Then CI runs both versions for a week or two, and only then production.\nOn the new features, be lazy. Readonly classes I take immediately: value objects and command DTOs become one keyword shorter and honestly immutable. That is a real improvement to the model of the code.\nDNF types I will use maybe twice a year. (Countable\u0026amp;ArrayAccess)|null is precise, and precision at the border of a library is worth it. Inside application code, if a parameter needs a type like that, I first ask why the design produced such a parameter. Usually the type is not the problem.\nAnd do not rewrite anything just to use new syntax. A diff that changes working code for fashion has review cost, merge cost, and zero value. New features earn their place in new code first. The old code will meet them when it changes for its own reasons.\nUpgrade the runtime every year. Adopt features when they pay. Two separate decisions, and I mix them up every time I see a new keyword.\n","permalink":"https://useinov.com/posts/2022/php-eight-two/","summary":"\u003cp\u003ePHP 8.2 was released on December 8. My checklist for it looks like every other December, which is the whole point of a yearly cadence.\u003c/p\u003e\n\u003cp\u003eOn the surface a quiet release. Readonly classes, DNF types, standalone \u003ccode\u003etrue\u003c/code\u003e, \u003ccode\u003efalse\u003c/code\u003e and \u003ccode\u003enull\u003c/code\u003e types, constants in traits. Underneath, the more important half: another round of cleaning old dynamic behavior. Dynamic properties deprecated, \u003ccode\u003e${var}\u003c/code\u003e string interpolation deprecated, \u003ccode\u003eutf8_encode\u003c/code\u003e deprecated. The language keeps trading looseness for predictability, and I keep voting for the trade.\u003c/p\u003e","title":"PHP 8.2: stricter by default"},{"content":"Two readonly keywords for two properties in a two-property class. That is what an immutable Money looks like in PHP 8.1:\nfinal class Money { public function __construct( public readonly int $amount, public readonly string $currency, ) {} } PHP 8.2 is at release candidate stage, and the feature I am waiting for is this one. The keyword moves up and says it once:\nfinal readonly class Money { public function __construct( public int $amount, public string $currency, ) {} } Every property is readonly, and the class refuses dynamic properties on top. Cosmetics, yes. But cosmetics that make the right thing the short thing, and that changes what people actually write.\nWhy I care about immutability here at all. A Money that can be mutated is a bug generator. Some method receives it, \u0026ldquo;adjusts\u0026rdquo; the amount for its own calculation, and the caller\u0026rsquo;s variable changed too, because objects travel by handle. With readonly this mutation is impossible. Want a different amount, construct a new value. The number of states in the system drops, and with it the number of surprises. Same story for command DTOs: what the handler received is exactly what was created, nobody edited it on the way.\nWhere I will not use it: entities. An ORM entity is mutable by its job description, its whole purpose is tracked change. Doctrine also wants to hydrate properties from outside the constructor, and proxies want to write to them. Readonly fights the tool. Same caution with anything that goes through a serializer that likes to construct empty and fill in.\nSo the border is clean. Values and commands: readonly class. Entities and anything with a lifecycle: normal class.\nI have a Money in production right now with a setter on it. Somebody needed it for an import. December is close.\n","permalink":"https://useinov.com/posts/2022/readonly-classes/","summary":"\u003cp\u003eTwo \u003ccode\u003ereadonly\u003c/code\u003e keywords for two properties in a two-property class. That is what an immutable \u003ccode\u003eMoney\u003c/code\u003e looks like in PHP 8.1:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003efinal\u003c/span\u003e \u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eMoney\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"fm\"\u003e__construct\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"nx\"\u003ereadonly\u003c/span\u003e \u003cspan class=\"nx\"\u003eint\u003c/span\u003e \u003cspan class=\"nv\"\u003e$amount\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"nx\"\u003ereadonly\u003c/span\u003e \u003cspan class=\"nx\"\u003estring\u003c/span\u003e \u003cspan class=\"nv\"\u003e$currency\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003ePHP 8.2 is at release candidate stage, and the feature I am waiting for is this one. The keyword moves up and says it once:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003efinal\u003c/span\u003e \u003cspan class=\"nx\"\u003ereadonly\u003c/span\u003e \u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eMoney\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"fm\"\u003e__construct\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"nx\"\u003eint\u003c/span\u003e \u003cspan class=\"nv\"\u003e$amount\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"nx\"\u003estring\u003c/span\u003e \u003cspan class=\"nv\"\u003e$currency\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eEvery property is readonly, and the class refuses dynamic properties on top. Cosmetics, yes. But cosmetics that make the right thing the short thing, and that changes what people actually write.\u003c/p\u003e","title":"Readonly classes for value objects"},{"content":"A supplier price list, loaded into staging_prices, then reconciled against prices. Since PostgreSQL 15 came out two weeks ago, that is one statement:\nMERGE INTO prices p USING staging_prices s ON p.sku = s.sku WHEN MATCHED AND s.price IS NULL THEN DELETE WHEN MATCHED AND p.price \u0026lt;\u0026gt; s.price THEN UPDATE SET price = s.price, updated_at = now() WHEN NOT MATCHED THEN INSERT (sku, price) VALUES (s.sku, s.price); Before 15 this was three statements in a transaction, or a stored procedure, or a loop in PHP. Now it is one statement that says what it does. Update changed rows, delete withdrawn ones, insert new ones. People coming from Oracle and SQL Server waited a decade for this. The conditional WHEN MATCHED AND ... branches are the real value. ON CONFLICT cannot express \u0026ldquo;delete when the source says so\u0026rdquo; at all.\nBut MERGE does not replace INSERT ... ON CONFLICT, and this is the part worth remembering. ON CONFLICT uses speculative insertion. Two concurrent transactions upserting the same key both succeed, one waits for the other, no error. MERGE gives no such promise. It checks for a match, then acts, and between the check and the insert another transaction can slip in. Under concurrency MERGE can fail with a unique violation, and your code must be ready to retry. The docs say this directly. Few will read that paragraph, and some of them will meet it in production at 2am.\nSo my split. Concurrent upsert of single rows from application code, the counter, the session, the cache row: ON CONFLICT, as before. Batch reconciliation, migrations, one writer moving a dataset into place: MERGE.\nAnd if the logic fits in ON CONFLICT DO UPDATE, keep it there. The simpler statement is also documentation. MERGE with five branches is powerful, and the person reading it after you will need coffee.\nI wrote a five-branch one the first evening. It was beautiful. I rewrote it the next morning.\n","permalink":"https://useinov.com/posts/2022/postgres-merge/","summary":"\u003cp\u003eA supplier price list, loaded into \u003ccode\u003estaging_prices\u003c/code\u003e, then reconciled against \u003ccode\u003eprices\u003c/code\u003e. Since PostgreSQL 15 came out two weeks ago, that is one statement:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"n\"\u003eMERGE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eINTO\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eprices\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ep\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eUSING\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003estaging_prices\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003es\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eON\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ep\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esku\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003es\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esku\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eWHEN\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eMATCHED\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eAND\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003es\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eprice\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eIS\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eNULL\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eTHEN\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"k\"\u003eDELETE\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eWHEN\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eMATCHED\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eAND\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ep\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eprice\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e\u0026lt;\u0026gt;\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003es\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eprice\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eTHEN\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"k\"\u003eUPDATE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eSET\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eprice\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003es\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eprice\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eupdated_at\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003enow\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eWHEN\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eNOT\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eMATCHED\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eTHEN\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"k\"\u003eINSERT\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003esku\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eprice\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eVALUES\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003es\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003esku\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003es\u003c/span\u003e\u003cspan class=\"p\"\u003e.\u003c/span\u003e\u003cspan class=\"n\"\u003eprice\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eBefore 15 this was three statements in a transaction, or a stored procedure, or a loop in PHP. Now it is one statement that says what it does. Update changed rows, delete withdrawn ones, insert new ones. People coming from Oracle and SQL Server waited a decade for this. The conditional \u003ccode\u003eWHEN MATCHED AND ...\u003c/code\u003e branches are the real value. \u003ccode\u003eON CONFLICT\u003c/code\u003e cannot express \u0026ldquo;delete when the source says so\u0026rdquo; at all.\u003c/p\u003e","title":"MERGE arrives in PostgreSQL 15"},{"content":"$invoice-\u0026gt;totall = 250; One extra letter. No error, no warning. A second property appears, the real total stays null, and the bug surfaces three screens later as \u0026ldquo;total is empty sometimes\u0026rdquo;.\nI have hunted this exact bug. Twice. Both times it took hours, because the write looks correct and the read looks correct. They just disagree on one letter.\nPHP 8.2 lands in December, and the deprecation that will touch the most code is this one. Writing to an undeclared property becomes deprecated. In some future major it becomes an error. For fifteen years it was legal:\nclass Invoice { public $total; } $invoice = new Invoice(); $invoice-\u0026gt;totall = 250; // new property, created silently This week I ran one older codebase on an 8.2 release candidate with deprecations logged. The harvest: two real typos, both in code \u0026ldquo;that worked for years\u0026rdquo;. It worked the way a broken clock works. The rest were objects used as bags, $row-\u0026gt;computed = ... on things fetched from here and there.\nThe fixes, in order of preference. Declare the property, which is what should have happened in the first place. If the class really carries arbitrary keys, that is an array or a real __get/__set pair. stdClass stays exempt anyway. For third-party or legacy classes you cannot touch now, #[AllowDynamicProperties] on the class keeps the old behavior. Treat that attribute as a bookmark, not as a fix.\nYou do not have to wait for December. PHPStan flags undeclared property access today, on any PHP version. Turn the rule on, fix what it finds, and 8.2 becomes a non-event.\nTwo typos in one evening of reading a log. That is a better rate than my two hunts.\n","permalink":"https://useinov.com/posts/2022/dynamic-properties-deprecated/","summary":"\u003cp\u003e\u003ccode\u003e$invoice-\u0026gt;totall = 250;\u003c/code\u003e One extra letter. No error, no warning. A second property appears, the real \u003ccode\u003etotal\u003c/code\u003e stays null, and the bug surfaces three screens later as \u0026ldquo;total is empty sometimes\u0026rdquo;.\u003c/p\u003e\n\u003cp\u003eI have hunted this exact bug. Twice. Both times it took hours, because the write looks correct and the read looks correct. They just disagree on one letter.\u003c/p\u003e\n\u003cp\u003ePHP 8.2 lands in December, and the deprecation that will touch the most code is this one. Writing to an undeclared property becomes deprecated. In some future major it becomes an error. For fifteen years it was legal:\u003c/p\u003e","title":"Dynamic properties are leaving"},{"content":"The profiler said the query takes 20 ms. The export endpoint took 900 ms and a quarter of a gigabyte of memory. The missing 880 ms was Doctrine turning five thousand rows into five thousand entities.\nHydration is not free. For every row the ORM builds an object, fills properties through reflection, registers it in the unit of work, creates proxies for relations. Per row it is nothing. Times five thousand, it is the endpoint. The profiler shows SQL because SQL is easy to show.\nThe endpoint did not need entities. It read four fields and wrote CSV. So:\n$rows = $qb-\u0026gt;select(\u0026#39;o.id, o.number, o.total, c.email\u0026#39;) -\u0026gt;join(\u0026#39;o.customer\u0026#39;, \u0026#39;c\u0026#39;) -\u0026gt;getQuery() -\u0026gt;getArrayResult(); Same SQL. 60 ms total, memory flat. Array hydration skips the unit of work completely. For a nicer shape, select into a DTO with NEW OrderRow(o.id, o.number, ...) in DQL. Typed objects, still no tracking, still cheap.\nDo this measurement yourself once, on your data. memory_get_peak_usage() before and after, three variants: full entities, arrays, DTO. On my data entities lost by an order of magnitude. After you see the number you stop arguing about it.\nThe wider point is read models. Entities exist for writes: identity, invariants, change tracking. A list page, a report, an export need none of that. They need rows. Giving read paths their own thin queries is not heresy against the ORM. It is using the ORM for what it is good at and SQL for what it is good at.\nKeep entities for commands. Queries can eat from the table directly.\nI knew all this before the incident. I still wrote getResult() there, because it was the default, and the default is the thing you do not think about.\n","permalink":"https://useinov.com/posts/2022/hydration-cost/","summary":"\u003cp\u003eThe profiler said the query takes 20 ms. The export endpoint took 900 ms and a quarter of a gigabyte of memory. The missing 880 ms was Doctrine turning five thousand rows into five thousand entities.\u003c/p\u003e\n\u003cp\u003eHydration is not free. For every row the ORM builds an object, fills properties through reflection, registers it in the unit of work, creates proxies for relations. Per row it is nothing. Times five thousand, it is the endpoint. The profiler shows SQL because SQL is easy to show.\u003c/p\u003e","title":"Fast query, slow endpoint"},{"content":"Audit snapshots, product attributes, and a document with two hot keys. Three cases from one Postgres project, one question for each: column or JSONB.\nI answer it by looking at queries, not at data.\nAudit snapshots. When an order changes, we store the whole previous state. Nobody queries inside it. It is read as a blob, whole, rarely, by a human during an incident. Perfect JSONB. Normalizing it would mean a dozen tables for data nobody joins.\nProduct attributes that users filter by. Started as JSONB, \u0026ldquo;because attributes are flexible\u0026rdquo;. Then came filtering by brand plus size plus color, with counts. A GIN index helps with containment, but the planner estimates JSONB predicates badly, and every query in the code is a string of -\u0026gt;\u0026gt;'...' with the key name repeated and no one checking the spelling. We pulled the three attributes people actually filter by into real columns with real indexes and real statistics. The rest stayed in JSONB.\nThe middle case: one or two hot keys inside a document. Before moving to columns, try an expression index, CREATE INDEX ON products ((attrs-\u0026gt;\u0026gt;'brand')). Cheap, often enough. When you find yourself making the third one, that is the data telling you it wants to be a table.\nTwo things JSONB will not give you. Foreign keys, so any id stored inside a document is a promise nobody enforces. And cheap partial updates: Postgres rewrites the whole value, so a large document that changes often is write amplification you signed up for.\nSo the border for me: JSONB for data you store and read, columns for data you query and join. Write the five most important queries first. The storage form follows from them.\nThe \u0026ldquo;SQL vs NoSQL\u0026rdquo; argument is dead, and I do not miss it. I spent years in it, on the wrong side more than once.\n","permalink":"https://useinov.com/posts/2022/jsonb-or-columns/","summary":"\u003cp\u003eAudit snapshots, product attributes, and a document with two hot keys. Three cases from one Postgres project, one question for each: column or JSONB.\u003c/p\u003e\n\u003cp\u003eI answer it by looking at queries, not at data.\u003c/p\u003e\n\u003cp\u003eAudit snapshots. When an order changes, we store the whole previous state. Nobody queries inside it. It is read as a blob, whole, rarely, by a human during an incident. Perfect JSONB. Normalizing it would mean a dozen tables for data nobody joins.\u003c/p\u003e","title":"JSONB or a normal table"},{"content":"Forty lines of $request-\u0026gt;get() before the first line of real logic. That was the controller nobody wanted to touch, and the Symfony 6.1 upgrade was my excuse to open it.\nAn action that reads ten request fields by hand does two jobs. It translates HTTP into data, and it runs the use case. The first job is boring and repeated in every action, which is exactly why it should not be written by hand ten times. Symfony has argument resolvers for this since 3.1. People just do not use them for their own types.\nThe idea: declare a DTO as the action argument, teach a resolver to build it.\nfinal class CreateOrderRequest { public function __construct( #[Assert\\NotBlank] public readonly string $customerId, #[Assert\\Count(min: 1)] public readonly array $items, ) {} } public function create(CreateOrderRequest $request): Response { // only the use case here } The resolver implements ArgumentValueResolverInterface, decodes the JSON body, constructs the DTO, runs the validator, and throws a 422 with the violation list if the input is bad. Written once, about fifty lines. Every action after that gets a typed, validated object for free. The controller shrinks to one call into the application layer, and the question \u0026ldquo;is this field a string or null here\u0026rdquo; has one answer in one place.\nThe trap comes later. I watched a colleague walk into it last month. The resolver starts loading entities. Then checking permissions. Then calling an external service, because \u0026ldquo;the data is needed anyway\u0026rdquo;. Stop. A resolver translates the request and nothing else. If it queries half the system, your use case now runs before the controller, in a place where nobody looks for it.\nMy rule: a resolver may see the request and the validator. If it needs a repository, that lookup belongs to the handler.\nI broke this rule myself once, for one small lookup. It stayed small for two weeks.\n","permalink":"https://useinov.com/posts/2022/argument-resolver-dto/","summary":"\u003cp\u003eForty lines of \u003ccode\u003e$request-\u0026gt;get()\u003c/code\u003e before the first line of real logic. That was the controller nobody wanted to touch, and the Symfony 6.1 upgrade was my excuse to open it.\u003c/p\u003e\n\u003cp\u003eAn action that reads ten request fields by hand does two jobs. It translates HTTP into data, and it runs the use case. The first job is boring and repeated in every action, which is exactly why it should not be written by hand ten times. Symfony has argument resolvers for this since 3.1. People just do not use them for their own types.\u003c/p\u003e","title":"Argument resolvers and the HTTP border"},{"content":"FCALL update_if_newer 1 user:42 17. One round trip, a name instead of a SHA, and it survives a restart. Redis 7 came out last week, and Functions are the feature I care about. Everything EVAL scripts should have been.\nThe problem they solve is real. Take compare-and-update: set a value only if the stored version is older. From PHP it is three round trips and a race between them. WATCH/MULTI works but is ugly and retries under contention. A Lua script does it atomically in one trip, and that is why we all wrote Lua scripts. But scripts are anonymous blobs. The application carries the source, sends it on every deploy, and debugging starts with \u0026ldquo;which SHA is this\u0026rdquo;. Functions fix the operational part: the library lives in Redis, has a name, has a version.\nSo the mechanics improved. The rule did not change. Redis runs your logic on the same single thread that serves everyone else. A function that loops over a big hash blocks every client in the instance. Nobody sees it on the laptop. Everybody sees it at peak traffic.\nMy limit for server-side logic stays where it was in the EVAL years. A few commands around one key or a small key group, done in microseconds. Compare-and-set, rate limiter token math, atomic move between two structures. That is all. The moment a function wants a loop over unknown size, or a business decision, the logic goes back to PHP and the atomicity gets designed differently.\nRedis is fast because it does almost nothing per command. Named, versioned, persistent logic makes it easier to put more in there. That is the part I am watching in myself.\n","permalink":"https://useinov.com/posts/2022/redis-seven-functions/","summary":"\u003cp\u003e\u003ccode\u003eFCALL update_if_newer 1 user:42 17\u003c/code\u003e. One round trip, a name instead of a SHA, and it survives a restart. Redis 7 came out last week, and Functions are the feature I care about. Everything EVAL scripts should have been.\u003c/p\u003e\n\u003cp\u003eThe problem they solve is real. Take compare-and-update: set a value only if the stored version is older. From PHP it is three round trips and a race between them. WATCH/MULTI works but is ugly and retries under contention. A Lua script does it atomically in one trip, and that is why we all wrote Lua scripts. But scripts are anonymous blobs. The application carries the source, sends it on every deploy, and debugging starts with \u0026ldquo;which SHA is this\u0026rdquo;. Functions fix the operational part: the library lives in Redis, has a name, has a version.\u003c/p\u003e","title":"Redis 7 functions, same old rule"},{"content":"A failed job that retries every second, one hundred times, against a payment provider that is already down. That is a small DDoS with a queue in front of it. If the provider needs five minutes to recover, hammering it for the first two achieves nothing except log volume.\nSymfony Messenger has everything needed. You just have to configure it on purpose:\nframework: messenger: transports: async: dsn: \u0026#39;%env(MESSENGER_TRANSPORT_DSN)%\u0026#39; retry_strategy: max_retries: 5 delay: 2000 multiplier: 4 failure_transport: failed Two seconds, eight, thirty two, and so on. Exponential backoff gives the external service room to breathe. I also add jitter in a custom retry strategy, because a hundred messages that failed together will otherwise retry together, in one synchronized wave. Same failure, five times.\nThe part people skip is the difference between transient and permanent failures. Timeout is transient, retry makes sense. \u0026ldquo;Invalid card number\u0026rdquo; is permanent, five retries will produce five identical rejections. Throw UnrecoverableMessageHandlingException for the permanent ones and they go straight to the failure transport. Retrying a validation error is not persistence. It is denial.\nThe failure transport is the second half. It is a queue of business incidents that need a human. messenger:failed:show to look, messenger:failed:retry to replay after the cause is fixed. On one project we review it every morning, same as Sentry. A message that sits there is usually a customer who did not get something.\nAnd one alert that earns its place: age of the oldest message in the queue. Queue length can stay low while one poisoned message cycles forever. Age cannot. If the oldest message is an hour old, something is stuck.\nThe hundred-retries config at the top was real. Git blame said it was me.\n","permalink":"https://useinov.com/posts/2022/messenger-retry-strategy/","summary":"\u003cp\u003eA failed job that retries every second, one hundred times, against a payment provider that is already down. That is a small DDoS with a queue in front of it. If the provider needs five minutes to recover, hammering it for the first two achieves nothing except log volume.\u003c/p\u003e\n\u003cp\u003eSymfony Messenger has everything needed. You just have to configure it on purpose:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003eframework\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003emessenger\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"nt\"\u003etransports\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e            \u003c/span\u003e\u003cspan class=\"nt\"\u003easync\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"nt\"\u003edsn\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;%env(MESSENGER_TRANSPORT_DSN)%\u0026#39;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                \u003c/span\u003e\u003cspan class=\"nt\"\u003eretry_strategy\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                    \u003c/span\u003e\u003cspan class=\"nt\"\u003emax_retries\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"m\"\u003e5\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                    \u003c/span\u003e\u003cspan class=\"nt\"\u003edelay\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"m\"\u003e2000\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e                    \u003c/span\u003e\u003cspan class=\"nt\"\u003emultiplier\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"m\"\u003e4\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"nt\"\u003efailure_transport\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003efailed\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eTwo seconds, eight, thirty two, and so on. Exponential backoff gives the external service room to breathe. I also add jitter in a custom retry strategy, because a hundred messages that failed together will otherwise retry together, in one synchronized wave. Same failure, five times.\u003c/p\u003e","title":"Retry is a strategy, not a loop"},{"content":"Sixty million rows in orders, a new nullable column total_cents from expand-and-contract, and a Friday afternoon. The question is how to fill it.\nThe naive answer is one statement. UPDATE orders SET total_cents = ROUND(total * 100). On a test database it works. On production it holds row locks on the whole table for the whole run, writes one giant chunk of WAL, and the replica falls minutes behind. On MySQL the binlog gets the same present. One project I worked on learned this on that Friday. Since then I backfill only in batches.\nThe pattern is a loop over primary key ranges:\n$lastId = 0; do { $count = $db-\u0026gt;executeStatement( \u0026#39;UPDATE orders SET total_cents = ROUND(total * 100) WHERE id \u0026gt; ? AND id \u0026lt;= ? AND total_cents IS NULL\u0026#39;, [$lastId, $lastId + 5000] ); $lastId += 5000; usleep(200_000); } while ($lastId \u0026lt; $maxId); Each batch is a short transaction. Locks live for milliseconds. Replication chews small pieces. The usleep is throttling, and the number is tuned by watching replica lag, not by feeling.\nStore $lastId somewhere persistent after each batch. The script will die. Deploy, OOM, someone closes the terminal. With a checkpoint you continue from where you stopped. Without it you start from zero and pretend that was the plan. The IS NULL condition makes the whole thing idempotent, which is the same insurance from the other side.\nWhile the backfill runs, the application must dual-write: every new or updated row fills both columns. Otherwise you chase a moving target. Reads fall back to the old column when the new one is null.\nOnly when the backfill is done and verified, add NOT NULL. On Postgres, add CHECK (total_cents IS NOT NULL) NOT VALID first, validate it separately, then set the constraint. Validation scans without blocking writes.\nNobody remembers that a backfill took three days. Everybody remembers who took the site down.\n","permalink":"https://useinov.com/posts/2022/batched-backfill/","summary":"\u003cp\u003eSixty million rows in \u003ccode\u003eorders\u003c/code\u003e, a new nullable column \u003ccode\u003etotal_cents\u003c/code\u003e from expand-and-contract, and a Friday afternoon. The question is how to fill it.\u003c/p\u003e\n\u003cp\u003eThe naive answer is one statement. \u003ccode\u003eUPDATE orders SET total_cents = ROUND(total * 100)\u003c/code\u003e. On a test database it works. On production it holds row locks on the whole table for the whole run, writes one giant chunk of WAL, and the replica falls minutes behind. On MySQL the binlog gets the same present. One project I worked on learned this on that Friday. Since then I backfill only in batches.\u003c/p\u003e","title":"Backfill in batches, not in one UPDATE"},{"content":"One evening. That was the Laravel 9 upgrade on a mid-size project last week. Most of the diff was composer.json and a few config files.\nLaravel 9 is out this month. Symfony 6 components under the hood, PHP 8.0 minimum, Flysystem 3. And from now on, one major per year. The upgrade guide is short, and the \u0026ldquo;high impact\u0026rdquo; section is shorter. This is a policy: small majors, often.\nI want to defend this policy, because I remember the other kind. A project that sat on an old major for three years, because \u0026ldquo;we will upgrade when there is time\u0026rdquo;. There is never time. Then the version goes EOL, a security fix forces the jump, and you pay three years of drift in one horrible month. Packages you depend on drop support one by one, and suddenly it is not a framework upgrade anymore. It is archaeology.\nYearly majors turn this into a subscription. You pay a little every year. An evening, maybe a weekend. The price is predictable, so it goes into the plan like any other maintenance.\nWhat actually took time in my upgrade was two community packages that pinned illuminate components too tightly. This is the usual shape: framework breaking changes are documented, package breaking changes are a surprise. So the first step of any upgrade is reading your own composer.json and asking each line: who maintains you, and are you ready.\nMy budget rule now: one day per year per project for the framework major, reserved in advance. If the upgrade fits in a day, good. If it does not, that is a signal about the project.\nThe three-year project was mine. I was the one who said \u0026ldquo;when there is time\u0026rdquo;.\n","permalink":"https://useinov.com/posts/2022/laravel-nine-cadence/","summary":"\u003cp\u003eOne evening. That was the Laravel 9 upgrade on a mid-size project last week. Most of the diff was \u003ccode\u003ecomposer.json\u003c/code\u003e and a few config files.\u003c/p\u003e\n\u003cp\u003eLaravel 9 is out this month. Symfony 6 components under the hood, PHP 8.0 minimum, Flysystem 3. And from now on, one major per year. The upgrade guide is short, and the \u0026ldquo;high impact\u0026rdquo; section is shorter. This is a policy: small majors, often.\u003c/p\u003e\n\u003cp\u003eI want to defend this policy, because I remember the other kind. A project that sat on an old major for three years, because \u0026ldquo;we will upgrade when there is time\u0026rdquo;. There is never time. Then the version goes EOL, a security fix forces the jump, and you pay three years of drift in one horrible month. Packages you depend on drop support one by one, and suddenly it is not a framework upgrade anymore. It is archaeology.\u003c/p\u003e","title":"Laravel 9 and the boring major"},{"content":"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.\nPHP 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.\nenum OrderStatus: string { case New = \u0026#39;new\u0026#39;; case Paid = \u0026#39;paid\u0026#39;; case Shipped = \u0026#39;shipped\u0026#39;; case Cancelled = \u0026#39;cancelled\u0026#39;; public function isFinal(): bool { return $this === self::Shipped || $this === self::Cancelled; } } Now a method takes OrderStatus, and the set of possible values is closed. A typo does not turn into a mystery, it fails at OrderStatus::from() at the boundary. That is the whole win. Not shorter code. Fewer possible states.\nThe boundary is the interesting part. The database still stores a string. So the cast lives in one place, in the mapping layer, and inside the domain nobody touches raw strings. The migration was boring: the column already had valid values, from() just confirmed it. Except that one row. A string constant would never have noticed.\nOne warning from this month. It is tempting to grow the enum into a brain. Allowed transitions, side effects, notifications. I stopped at isFinal() and a small canTransitionTo(). Anything that needs a repository or a clock does not belong in an enum. It is a value.\nAnd do not reach for enums where the set is open. Country codes, currencies, anything that changes by configuration. Enum is for states that change only when the code changes. Status fits.\nI wanted to put the transitions in there too. I still do. That is the instinct I check at the door.\n","permalink":"https://useinov.com/posts/2022/enums-for-status/","summary":"\u003cp\u003eOne row in \u003ccode\u003eorders\u003c/code\u003e had status \u003ccode\u003e'canceled'\u003c/code\u003e, with one l. Nobody knows how long it sat there. The enum found it on the first day.\u003c/p\u003e\n\u003cp\u003ePHP 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, \u003ccode\u003eOrder::STATUS_PAID\u003c/code\u003e, \u003ccode\u003eOrder::STATUS_SHIPPED\u003c/code\u003e. Constants look safe, but nothing stops a function from receiving \u003ccode\u003e'payed'\u003c/code\u003e. The parameter type was \u003ccode\u003estring\u003c/code\u003e, and \u003ccode\u003estring\u003c/code\u003e accepts everything.\u003c/p\u003e","title":"Order status is an enum now"},{"content":"The deprecation counter in the profiler, three digits. That is where one project stands this week, and that number is the whole Symfony 6 migration.\nSymfony shipped 5.4 and 6.0 together at the end of November, same code, same day. 4.4 and 5.0 worked exactly like this two years ago, so the story is rehearsed. 6.0 is 5.4 with the deprecated code deleted. You never migrate to a major. You migrate to the last minor, and the major is a formality.\nThe plan fits in three sentences. Move to 5.4 and get the deprecation count to zero, the PHPUnit bridge and the profiler show every hit. Raise the baseline, 6.0 wants PHP 8, which for us was done back in spring. Then flip the constraints to 6.0 and expect an anticlimax.\nThe one place 6.0 will really poke you is native types. Symfony added parameter and return types across its interfaces, so every place where you extend or implement a framework class must now declare matching types. Mechanical work, the deprecation layer in 5.4 warns about most of it, but on an old codebase there are more such places than you remember.\nThe discipline I insist on: the upgrade branch contains compatibility changes only. Nothing else. Mid-upgrade you will see old sins everywhere, the service that should be rewritten, the bundle we stopped needing in 2019. Write them down, walk past. A pull request that says \u0026ldquo;Symfony 6\u0026rdquo; and touches four hundred files for six reasons cannot be reviewed and cannot be reverted. Boring diff, quick review, calm deploy.\nImprovements get their own branches next week, on a fresh major, with deprecations at zero. Best ground for refactoring you will ever get.\nI have already rewritten one service inside the upgrade branch. I am moving it out tonight, before anyone sees the diff.\n","permalink":"https://useinov.com/posts/2021/symfony6-via-54/","summary":"\u003cp\u003eThe deprecation counter in the profiler, three digits. That is where one project stands this week, and that number is the whole Symfony 6 migration.\u003c/p\u003e\n\u003cp\u003eSymfony shipped 5.4 and 6.0 together at the end of November, same code, same day. 4.4 and 5.0 worked exactly like this two years ago, so the story is rehearsed. 6.0 is 5.4 with the deprecated code deleted. You never migrate to a major. You migrate to the last minor, and the major is a formality.\u003c/p\u003e","title":"Symfony 6.0 is boring, take the compliment"},{"content":"'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 \u0026ldquo;for compatibility\u0026rdquo;. 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.\nPHP 8.1 came out yesterday. Point release on paper, and it brings the feature I wanted since forever. Enums.\nenum OrderStatus: string { case New = \u0026#39;new\u0026#39;; case Paid = \u0026#39;paid\u0026#39;; case Shipped = \u0026#39;shipped\u0026#39;; public function isFinal(): bool { return $this === self::Shipped; } } public function transition(Order $order, OrderStatus $to): void A wrong string cannot enter transition() at all, the engine stops it. OrderStatus::from('payed') throws at the boundary, exactly where bad input should die. Behavior lives on the enum itself, so the endless StatusHelper classes can retire. Backed cases handle the database side. This is not sugar. It moves a whole family of bugs from runtime to type check.\nReadonly properties are the second gift, aimed at value objects. Public property, written once in the constructor, immutable after. All those private fields with a getter that exists only to protect against mutation, gone. A Money class is now five honest lines.\nAnd Fibers, the loud one. What they are: a low-level way to pause a function and resume it later, cooperative, no scheduler included. Your Laravel app does not become non-blocking by upgrading, and no flag will make it so. Fibers exist so Amp and ReactPHP can hide their event loops behind normal-looking code, without promise chains. Plumbing for library authors. Important plumbing. If you are not writing an event loop, you will meet Fibers only indirectly, some years from now, inside a dependency.\nEnums alone are worth the upgrade. Start with the status field everyone is afraid to touch. In our case that is OrderStatus, and the one afraid is me.\n","permalink":"https://useinov.com/posts/2021/php81-enums/","summary":"\u003cp\u003e\u003ccode\u003e'payed'\u003c/code\u003e. One letter, half a day of debugging, a few years ago. The string walked straight through \u003ccode\u003eOrderStatus::isValid()\u003c/code\u003e, because someone had added it to the constants list \u0026ldquo;for compatibility\u0026rdquo;. Every project I touched has that class: a bag of string constants, an \u003ccode\u003eisValid()\u003c/code\u003e helper, and a prayer. The type system never knew these strings were special.\u003c/p\u003e\n\u003cp\u003ePHP 8.1 came out yesterday. Point release on paper, and it brings the feature I wanted since forever. Enums.\u003c/p\u003e","title":"PHP 8.1: enums, readonly and a word about Fibers"},{"content":"A template calls getEmail() on an Order at three in the morning. The method returned array, the docblock said \u0026ldquo;array of User\u0026rdquo;, and eight months later someone put an Order in it. Everything was fine until it was not.\nPHP still has no generics and will not get them soon. Meanwhile PHPStan and Psalm shipped them anyway, in docblocks. Checked at analysis, not at runtime. For everyday backend work that turns out to be most of the value.\nWith template types the container knows what it holds:\n/** * @template T */ class Collection { /** @param T $item */ public function add($item): void { /* ... */ } /** @return list\u0026lt;T\u0026gt; */ public function all(): array { /* ... */ } } /** @var Collection\u0026lt;User\u0026gt; $users */ $users-\u0026gt;add($order); PHPStan rejects that last line before any test runs. The type flows through all() too, so a foreach over the result knows it iterates users, and a rename of getEmail() gets checked at every real call site. This is the quiet payoff: refactoring a large codebase stops being archaeology. The analyzer knows what is inside every array, so it can tell you what your change breaks.\nThere is a cost, and it is readability. Docblock generics live in comments with their own dialect. Past some point, @template bounds on top of conditional types on top of class-string\u0026lt;T\u0026gt;, the annotation gets harder to understand than the code it describes. I saw a repository base class where the docblock was longer than the class. Annotation for the sake of annotation.\nSo, a rule of appetite. Start with the cheap forms: list\u0026lt;User\u0026gt;, array\u0026lt;int, Order\u0026gt;, array shapes. They cover most real containers and everyone can read them. Reach for @template only when a class really works with many types, collections, repositories, result wrappers. When a plain typed array does the job, let it.\nThe repository base class with the long docblock was mine. I was proud of it for about a month.\n","permalink":"https://useinov.com/posts/2021/phpstan-generics/","summary":"\u003cp\u003eA template calls \u003ccode\u003egetEmail()\u003c/code\u003e on an Order at three in the morning. The method returned \u003ccode\u003earray\u003c/code\u003e, the docblock said \u0026ldquo;array of User\u0026rdquo;, and eight months later someone put an Order in it. Everything was fine until it was not.\u003c/p\u003e\n\u003cp\u003ePHP still has no generics and will not get them soon. Meanwhile PHPStan and Psalm shipped them anyway, in docblocks. Checked at analysis, not at runtime. For everyday backend work that turns out to be most of the value.\u003c/p\u003e","title":"Generics we already have"},{"content":"A few hundred connections, mostly idle, and the whole server gets slower. That was Postgres for as long as I remember it: a process per connection, and past some count the snapshot bookkeeping starts to eat everything.\nPostgreSQL 14 is released today. I had the RC on a test box for a week, so a few notes from a backend seat. The headline for me is that this internal work got a serious rewrite. On our connection-heavy profile, many workers, short queries, lots of idle time, the difference is visible without squinting. Idle connections finally cost close to what people always assumed they cost.\nDoes this retire pgbouncer? No. A pooler still turns thousands of client connections into tens of server ones, still smooths spikes, still saves you when a deploy doubles the worker count. Version 14 makes the penalty for imperfect pooling smaller. It does not make pooling optional. With PHP moving to long-lived workers the topic stops being theoretical: every worker is a standing connection now.\nAlso in the release: vacuum keeps getting better at not falling behind on write-heavy tables, and libpq got pipeline mode, several queries in flight without waiting for each round trip. PHP drivers will need time to expose that one. The foundation is there.\nThe usual caution. Release notes describe someone else\u0026rsquo;s benchmark. \u0026ldquo;Better under high concurrency\u0026rdquo; means nothing for your system until you look at your own waits, your own throughput, your own vacuum lag. Upgrade a replica, replay something close to real traffic, then decide what the new version gave you. Sometimes the honest answer is \u0026ldquo;two percent\u0026rdquo;, and that is still a fine reason. Just know your number.\nOurs was more than two percent. I will not say how much, because I do not fully trust my test box either.\n","permalink":"https://useinov.com/posts/2021/postgres-14-connections/","summary":"\u003cp\u003eA few hundred connections, mostly idle, and the whole server gets slower. That was Postgres for as long as I remember it: a process per connection, and past some count the snapshot bookkeeping starts to eat everything.\u003c/p\u003e\n\u003cp\u003ePostgreSQL 14 is released today. I had the RC on a test box for a week, so a few notes from a backend seat. The headline for me is that this internal work got a serious rewrite. On our connection-heavy profile, many workers, short queries, lots of idle time, the difference is visible without squinting. Idle connections finally cost close to what people always assumed they cost.\u003c/p\u003e","title":"PostgreSQL 14 and the price of a connection"},{"content":"\u0026ldquo;Should we move the queue to Streams? They are the proper way now.\u0026rdquo; A colleague, this week, about our Redis list queue. Wrong question. Streams are not a newer version of the same thing.\nA list queue is a hand-off. LPUSH on one side, BRPOP on the other, and once a consumer takes the message, it is gone. Crash after the pop and the message died with you, unless you built the pending-list dance yourself. No history, no second reader. For \u0026ldquo;send this email eventually\u0026rdquo; it is honestly enough.\nStreams are a log. XADD appends an entry and the entry stays. Consumer groups each keep their own position, so billing and analytics read the same events independently. Delivery is tracked: a consumer must XACK, unacknowledged entries sit in a pending list where you can find them and XCLAIM them from a dead consumer. And you can replay. Read the stream from the beginning after you fix a bug. This alone justifies the feature.\nSo the real question is whether your messages are commands or facts. A command wants to be executed once and forgotten. A queue. A fact wants to be recorded and interpreted, maybe by several readers, maybe again later. A log.\nTwo warnings from practice. Retention is your job: XADD with MAXLEN, or the stream eats your memory, Redis will not decide what to forget. And do not squint at Streams until they look like Kafka. No partitions, no replicated log across a cluster, everything lives in one Redis and its RAM. As a light log inside a system that already has Redis, very good. As the company event backbone, no.\nWe kept the list. The email job does not need history, and I did not want to explain XCLAIM to whoever is on call.\n","permalink":"https://useinov.com/posts/2021/redis-streams-log/","summary":"\u003cp\u003e\u0026ldquo;Should we move the queue to Streams? They are the proper way now.\u0026rdquo; A colleague, this week, about our Redis list queue. Wrong question. Streams are not a newer version of the same thing.\u003c/p\u003e\n\u003cp\u003eA list queue is a hand-off. LPUSH on one side, BRPOP on the other, and once a consumer takes the message, it is gone. Crash after the pop and the message died with you, unless you built the pending-list dance yourself. No history, no second reader. For \u0026ldquo;send this email eventually\u0026rdquo; it is honestly enough.\u003c/p\u003e","title":"Redis Streams is a log, not a better queue"},{"content":"\u0026ldquo;MySQL server has gone away\u0026rdquo;, 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.\nFor 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.\nMove 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.\nFirst, staleness. MySQL drops an idle connection after wait_timeout. A worker that slept overnight wakes up, runs a query and gets \u0026ldquo;gone away\u0026rdquo;. The fix is dull: check the connection when the worker picks up a request, reconnect if it is dead. A cheap ping, or one retry on that specific error. Doctrine and most pools can do it, if you turn it on. Nobody turns it on before the first incident.\nSecond, leftover state, and this one is nastier. A request opens a transaction, throws halfway. FPM would have rolled it back at death. The long-lived worker keeps the connection, transaction still open, and hands it to the next request. Now someone else\u0026rsquo;s queries run inside a doomed transaction, locks are held, and the symptoms point everywhere except the cause. Cleanup between requests must be explicit: if a transaction is active when the request ends, roll it back and log it loudly. It is a bug upstream.\nAnd do the arithmetic. Workers times connections per worker, across all services, plus crons, migrations and humans with consoles. Compare with max_connections. Dying FPM processes kept that number honest by accident. Persistent workers hold every slot they ever opened. If the numbers do not fit, that is what pgbouncer and ProxySQL are for.\nCount your connections before the database counts them for you.\n","permalink":"https://useinov.com/posts/2021/long-lived-connections/","summary":"\u003cp\u003e\u0026ldquo;MySQL server has gone away\u0026rdquo;, 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.\u003c/p\u003e\n\u003cp\u003eFor 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.\u003c/p\u003e\n\u003cp\u003eMove 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.\u003c/p\u003e","title":"Nobody closes your database connection anymore"},{"content":"#[Route('/orders/{id}')] right above the method, and nothing in config/routes. Symfony 5.3 came out last week, and with PHP 8 attributes configuration finally found its place. Routes, autowiring hints, some validation, native syntax next to the code:\n#[Route(\u0026#39;/orders/{id}\u0026#39;, methods: [\u0026#39;GET\u0026#39;])] public function show(int $id): Response { // ... } I spent years defending YAML routing. The argument was separation: code is code, wiring is wiring, one file shows the whole URL map. The argument was never wrong. It lost to practice. In every real project the first thing you do with a route is jump to the controller, and the first thing you do with a controller is wonder which route hits it. Two files, one mental join, forever. Attributes remove the join. Rename a method, the metadata moves with it. Delete the class, no orphaned YAML block stays behind to confuse the next person.\nDocblock annotations did the same thing, of course. But they were comments pretending to be syntax, with a userland parser and no help from the engine. Attributes are the same idea made legal. Typos fail loudly. IDEs understand them without plugins.\nThe overview argument still deserves an answer, and the answer is debug:router. A generated map is always current. A hand-maintained one only tries.\nOne line I try to hold: attributes are for metadata, not for decisions. A route path, a service tag, a validation constraint, yes. The moment an attribute encodes a business rule, which discount, which workflow, it is logic hiding in a declaration. Logic wants to be in code you can test and step through.\nWe spent a decade moving configuration out of code. Now we move it back. Both directions were called progress. This time I think it is, and I remember thinking the same the last time.\n","permalink":"https://useinov.com/posts/2021/symfony-attributes/","summary":"\u003cp\u003e\u003ccode\u003e#[Route('/orders/{id}')]\u003c/code\u003e right above the method, and nothing in \u003ccode\u003econfig/routes\u003c/code\u003e. Symfony 5.3 came out last week, and with PHP 8 attributes configuration finally found its place. Routes, autowiring hints, some validation, native syntax next to the code:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e#[\u003c/span\u003e\u003cspan class=\"nd\"\u003eRoute\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;/orders/{id}\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003emethods\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;GET\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e])]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"nf\"\u003eshow\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eint\u003c/span\u003e \u003cspan class=\"nv\"\u003e$id\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"nd\"\u003eResponse\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e// ...\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eI spent years defending YAML routing. The argument was separation: code is code, wiring is wiring, one file shows the whole URL map. The argument was never wrong. It lost to practice. In every real project the first thing you do with a route is jump to the controller, and the first thing you do with a controller is wonder which route hits it. Two files, one mental join, forever. Attributes remove the join. Rename a method, the metadata moves with it. Delete the class, no orphaned YAML block stays behind to confuse the next person.\u003c/p\u003e","title":"Attributes in Symfony 5.3"},{"content":"One customer sees another customer\u0026rsquo;s cart. No crash, no error in the log. That is the bug Laravel Octane brings to an old codebase, and the benchmarks are the least interesting part of the story.\nOctane is out in beta, Swoole and RoadRunner became first-class overnight. In FPM every request gets a fresh application. Boot, handle, die. The model forgives everything. Under Octane the framework boots once and workers reuse it. Bootstrap cost drops to near zero, hence the pretty numbers. But every singleton is now shared between requests, and between users.\nclass Cart { public array $items = []; } // AppServiceProvider $this-\u0026gt;app-\u0026gt;singleton(Cart::class); In FPM this is sloppy but harmless. Under Octane the second user gets the first user\u0026rsquo;s items. Nothing crashes. One customer quietly sees another\u0026rsquo;s data. The worst bugs, the invisible kind. Same trap with static properties, memoized \u0026ldquo;per-request\u0026rdquo; caches, an authenticated user stored somewhere too convenient.\nOctane flushes and rebinds a lot of framework internals between requests, and there is config to reset your own services. But the framework cannot guess which of your singletons hold request state. That audit is manual. On an old codebase it is real work.\nTwo more items on the bill. Memory grows across thousands of requests, so a small leak FPM erased for free becomes a graph you must watch, plus a worker restart policy. And with Swoole a blocking call blocks the whole worker, so one slow external HTTP request holds a slot hostage.\nWhether it pays depends on where your time goes. Bootstrap is a big slice, high-RPS API with light endpoints: Octane is close to free performance. p95 is database queries: you made the fast part faster and bought a new class of bugs. Profile before, not after.\nI grepped our project for singleton(. I stopped counting at the one that holds the current user.\n","permalink":"https://useinov.com/posts/2021/octane-shared-state/","summary":"\u003cp\u003eOne customer sees another customer\u0026rsquo;s cart. No crash, no error in the log. That is the bug Laravel Octane brings to an old codebase, and the benchmarks are the least interesting part of the story.\u003c/p\u003e\n\u003cp\u003eOctane is out in beta, Swoole and RoadRunner became first-class overnight. In FPM every request gets a fresh application. Boot, handle, die. The model forgives everything. Under Octane the framework boots once and workers reuse it. Bootstrap cost drops to near zero, hence the pretty numbers. But every singleton is now shared between requests, and between users.\u003c/p\u003e","title":"Octane and state that outlives the request"},{"content":"public/index.php, unchanged since I learned Symfony. Create the request from globals, run the kernel, send the response, terminate. Symfony 5.3 arrives next month, and the Runtime component rewrites this file. It looks like a small refactoring. It is a statement about where PHP is going.\nBaked into those few lines is one big assumption: one process, one request, then we die. FPM made the assumption true for fifteen years, so nobody saw it as an assumption.\nWith Runtime the file stops doing and starts describing:\nrequire_once dirname(__DIR__).\u0026#39;/vendor/autoload_runtime.php\u0026#39;; return function (array $context) { return new Kernel($context[\u0026#39;APP_ENV\u0026#39;], (bool) $context[\u0026#39;APP_DEBUG\u0026#39;]); }; You return a callable that builds the application. How it runs is no longer your business. Under FPM it runs once and dies, as always. Under RoadRunner or Swoole a different runtime keeps the kernel alive and feeds it requests in a loop. Same application code, different process model, chosen by a Composer package.\nThe real price is not in this file. It is in the habits the old model allowed. Static properties as convenient storage. Superglobals read in random places. Services that accumulate state and count on the process dying before it becomes a problem. FPM was a garbage collector for our sins, restarting the world forty times a second. Long-running PHP takes that away.\nSo I read Runtime as a warning shot. The abstraction is ready before most application code is. Check your own project against one question: what breaks here if the process handles two requests instead of one?\nI asked it about ours. I stopped writing the list at the session handler.\n","permalink":"https://useinov.com/posts/2021/symfony-runtime/","summary":"\u003cp\u003e\u003ccode\u003epublic/index.php\u003c/code\u003e, unchanged since I learned Symfony. Create the request from globals, run the kernel, send the response, terminate. Symfony 5.3 arrives next month, and the Runtime component rewrites this file. It looks like a small refactoring. It is a statement about where PHP is going.\u003c/p\u003e\n\u003cp\u003eBaked into those few lines is one big assumption: one process, one request, then we die. FPM made the assumption true for fifteen years, so nobody saw it as an assumption.\u003c/p\u003e","title":"Symfony Runtime: front controller as a callable"},{"content":"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.\nTwo 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.\nThe fix is old and has a name, transactional outbox. Do not talk to the broker inside the request at all. Write the event into a table, same database, same transaction as the data:\n$db-\u0026gt;beginTransaction(); $orderId = $orders-\u0026gt;insert($order); $db-\u0026gt;insert(\u0026#39;outbox\u0026#39;, [ \u0026#39;event\u0026#39; =\u0026gt; \u0026#39;order.created\u0026#39;, \u0026#39;payload\u0026#39; =\u0026gt; json_encode([\u0026#39;order_id\u0026#39; =\u0026gt; $orderId]), ]); $db-\u0026gt;commit(); One atomic unit now. Either the order and its event both exist, or neither does. A separate publisher process polls the outbox, pushes rows to RabbitMQ, marks them sent. If it crashes after publish but before the mark, it publishes again on restart. That is fine and that is the contract: at least once. Consumers must be idempotent. Store processed event ids, or make the operation naturally repeatable. There is no way around this part, so do not fight it.\nTwo operational notes. Polling every second is not elegant and is completely fine for most loads. Resist log tailing on day one. And the table grows, so delete or archive sent rows on schedule, or the outbox becomes your largest table and the poll query your slowest one.\nThe pattern costs one table, one worker and some discipline. The bug it removes cannot be fixed by retries, monitoring or hope.\nWe ran the broken version for a long time. Every time it fired, someone re-sent the event by hand and called it a fluke.\n","permalink":"https://useinov.com/posts/2021/outbox-table/","summary":"\u003cp\u003eAbout once a month an order existed and nobody heard about it. The order service saved the order, then published \u003ccode\u003eorder.created\u003c/code\u003e to RabbitMQ, and once a month the process died between the two. Rare enough to be mysterious. Frequent enough to ruin a weekend.\u003c/p\u003e\n\u003cp\u003eTwo 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.\u003c/p\u003e","title":"The outbox table"},{"content":"Support ticket: a user changed his name, the site still shows the old one. The profile sits in Redis with TTL one hour. Fine, we set five minutes. A week later the hit rate drops and MySQL feels it. This seesaw has no good position. TTL is insurance, not invalidation.\nThe obvious upgrade is cache-aside with explicit delete. Read: try the cache, miss, load from the database, write to the cache. Write: update the row, delete the key. Looks correct. It has a race.\nProcess A gets a miss and reads the old row. Process B updates the row and deletes the key. Then A, a bit slow, writes the old value into the cache. Fresh delete, stale data on top of it, and it will sit there happily. This is why the TTL stays even with explicit invalidation: it limits how long a lost race can hurt you.\nAnother road is versioned keys. Delete nothing, change the address:\n$v = $redis-\u0026gt;get(\u0026#39;user:42:v\u0026#39;) ?: 1; $profile = $redis-\u0026gt;get(\u0026#34;user:42:profile:$v\u0026#34;); // on update: $redis-\u0026gt;incr(\u0026#39;user:42:v\u0026#39;); INCR is atomic, so the write race is gone. Old generations are garbage and expire by TTL. As a bonus, put one version in front of a whole family of keys, bump it once, the whole group is invalid. Poor man\u0026rsquo;s cache tags.\nWrite-through and event-driven invalidation exist too, and they are fine, but they are infrastructure. A consumer that listens to entity-changed events and cleans keys is a service you now operate and get paged for.\nMy default is boring: cache-aside, explicit delete, versioned keys where the race matters, and always a TTL as the last line.\nI still remember that ticket. The user was right and the cache was correct by design. Both were true for one hour.\n","permalink":"https://useinov.com/posts/2021/ttl-not-invalidation/","summary":"\u003cp\u003eSupport ticket: a user changed his name, the site still shows the old one. The profile sits in Redis with TTL one hour. Fine, we set five minutes. A week later the hit rate drops and MySQL feels it. This seesaw has no good position. TTL is insurance, not invalidation.\u003c/p\u003e\n\u003cp\u003eThe obvious upgrade is cache-aside with explicit delete. Read: try the cache, miss, load from the database, write to the cache. Write: update the row, delete the key. Looks correct. It has a race.\u003c/p\u003e","title":"TTL is not an invalidation strategy"},{"content":"composer why-not php 8.0. That is the first command of the migration, and it has nothing to do with syntax.\nPHP 8.0 is six weeks old. Twitter is full of match expressions and constructor promotion. Meanwhile the real upgrade of a working project is a dependency problem. Your code is maybe twenty percent of what runs in production. The rest is the framework, thirty packages and a dozen extensions, and any one of them can be the blocker.\nThe command prints every package whose constraints refuse PHP 8. That list is your backlog. Some packages need one version bump. Some need a major upgrade with its own changelog. One or two are abandoned, and now you have to face it. Better in January than during an incident.\nSecond, CI runs the suite on 7.4 and 8.0 at the same time. Both green, always. The project stays deployable on 7.4 while you fix 8.0 failures one by one. Most failures live deep inside some library, where the engine got stricter.\nThird, before the switch, record baseline numbers. Response times, opcache stats, memory. JIT is loud in benchmarks and quiet in a normal web app, and I want to know what we got, not what the news said.\nAnd the part people skip. After production runs on 8.0, do not touch new syntax for a few more weeks. Named arguments and promoted constructors are a one-way door. The day you merge them, rollback to 7.4 stops being an option. Keep that door open until you are sure you will not need it.\nBoring plan. Upgrades should be boring.\nI merged a match on day two. Then I reverted it, and nobody noticed either way.\n","permalink":"https://useinov.com/posts/2021/php8-dependencies-first/","summary":"\u003cp\u003e\u003ccode\u003ecomposer why-not php 8.0\u003c/code\u003e. That is the first command of the migration, and it has nothing to do with syntax.\u003c/p\u003e\n\u003cp\u003ePHP 8.0 is six weeks old. Twitter is full of match expressions and constructor promotion. Meanwhile the real upgrade of a working project is a dependency problem. Your code is maybe twenty percent of what runs in production. The rest is the framework, thirty packages and a dozen extensions, and any one of them can be the blocker.\u003c/p\u003e","title":"PHP 8 in production: dependencies first"},{"content":"#[Route(\u0026#39;/orders/{id}\u0026#39;, methods: [\u0026#39;GET\u0026#39;])] 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.\nI am for it, with one old scar. We had this debate before, annotations versus YAML config, and the annotation side won on convenience. The cost showed up later: behavior scattered across a hundred class files, no way to see the whole picture. Where is the full route table? Everywhere. Which entities cascade deletes? Open each one and read the comments. Attributes inherit this problem exactly. They just make the scattered metadata syntactically respectable.\nSo the question is what belongs next to the class at all. My current line: metadata that describes the class itself lives on the class. Validation constraints on a DTO, serialization groups, the mapping of a property to a column. This information has no other natural home, and pulling it into config only separates it from the thing it describes.\nDecisions about the application boundary stay out of the class. Which routes are public and which sit behind auth, rate limits, firewall rules, anything security shaped. I want one file that answers \u0026ldquo;what can the outside world reach\u0026rdquo;. An archaeology session across controllers does not answer it. A pentester reads one file. So should you.\nConvenient syntax makes it tempting to hang everything on the class. I already caught myself wanting the firewall rule next to the action this week. Resist a little.\n","permalink":"https://useinov.com/posts/2020/attributes-near-code/","summary":"\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e#[\u003c/span\u003e\u003cspan class=\"nd\"\u003eRoute\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;/orders/{id}\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nx\"\u003emethods\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;GET\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e])]\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"nf\"\u003eshow\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eint\u003c/span\u003e \u003cspan class=\"nv\"\u003e$id\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"nd\"\u003eResponse\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eSymfony 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.\u003c/p\u003e","title":"Attributes: metadata moves in with the code"},{"content":"JIT on, JIT off, the same Symfony endpoint. Difference within noise. Same for a Laravel endpoint. PHP 8.0 came out yesterday and this was the first thing I checked, because every headline is about the JIT.\nNot a scandal. A web request spends its time in I/O, in MySQL, in framework code full of method calls the JIT cannot do much with. JIT is for tight numeric loops, and your controller has none. If you compute fractals in PHP, congratulations. The rest of us can leave it off.\nThe real release is the language. match returns a value, compares strictly, and refuses to fall through or silently match nothing:\n$status = match($code) { 200, 201 =\u0026gt; \u0026#39;ok\u0026#39;, 404 =\u0026gt; \u0026#39;missing\u0026#39;, default =\u0026gt; throw new UnexpectedResponse($code), }; Nullsafe, $user?-\u0026gt;address?-\u0026gt;city, replaces three nested ifs that existed only to avoid a fatal. Constructor promotion removes the ceremony from DTOs: declare the property in the signature and it exists. Union types move int|string from docblock fiction into something the engine checks. Attributes give metadata a native syntax, framework support will take some months.\nMy favorite change is smaller than all of that. Internal functions now throw on a wrong argument type instead of returning null with a warning. Twenty years of errors that walked quietly through three layers and exploded somewhere unrelated. Now they explode where they happen. Louder failures, closer to the cause. That is what maturity looks like.\nUpgrade advice is the usual. Read the migration notes, run the suite on 8.0 in CI now, switch for real when the dependencies say they are ready. The expressiveness is worth it.\nThe JIT can wait for the fractals. I will still turn it on at some point, just to look at the graph. I know myself.\n","permalink":"https://useinov.com/posts/2020/php8-new-baseline/","summary":"\u003cp\u003eJIT on, JIT off, the same Symfony endpoint. Difference within noise. Same for a Laravel endpoint. PHP 8.0 came out yesterday and this was the first thing I checked, because every headline is about the JIT.\u003c/p\u003e\n\u003cp\u003eNot a scandal. A web request spends its time in I/O, in MySQL, in framework code full of method calls the JIT cannot do much with. JIT is for tight numeric loops, and your controller has none. If you compute fractals in PHP, congratulations. The rest of us can leave it off.\u003c/p\u003e","title":"PHP 8.0: ignore the JIT, take the rest"},{"content":"Two hundred thousand rows over the wire to compute forty numbers. That was our monthly report on MySQL 5.7: fetch all transactions for the period, loop in PHP, accumulate a running balance, compare each row with the previous one, rank customers by volume. Classic 5.7 shape, because the database could not say \u0026ldquo;previous row\u0026rdquo; or \u0026ldquo;rank within group\u0026rdquo;.\nWe are finally moving that project to MySQL 8. Two years after GA, which by database standards is reckless haste. The first win had nothing to do with performance. We deleted PHP.\nMySQL 8 has window functions, and the whole loop collapses into SQL:\nSELECT customer_id, amount, SUM(amount) OVER (PARTITION BY customer_id ORDER BY created_at, id) AS balance, amount - LAG(amount) OVER (PARTITION BY customer_id ORDER BY created_at, id) AS delta, ROW_NUMBER() OVER (ORDER BY amount DESC) AS rnk FROM transactions WHERE created_at \u0026gt;= :from AND created_at \u0026lt; :to; The database was reading those rows anyway. Now it also folds them and sends back only what the report shows. Rows over the wire: two hundred thousand became a few hundred. The PHP file with the accumulator loop and its three subtle off-by-one bugs is gone. That file had unit tests. The SQL needs one integration test.\nCheck the plan though. Window functions are not free. Each distinct OVER (...) ordering may cost a sort or a filesort. An index on the partition and order columns, (customer_id, created_at, id) here, keeps it honest. EXPLAIN before celebrating.\nThe principle is old, from before ORMs taught us to fear SQL. Move computation to the data, not data to the computation. Window functions just made it affordable on MySQL. PostgreSQL people have been doing this for a decade and are allowed one smug nod.\nI wrote the original loop. Two of the three off-by-one bugs were mine.\n","permalink":"https://useinov.com/posts/2020/window-functions-mysql/","summary":"\u003cp\u003eTwo hundred thousand rows over the wire to compute forty numbers. That was our monthly report on MySQL 5.7: fetch all transactions for the period, loop in PHP, accumulate a running balance, compare each row with the previous one, rank customers by volume. Classic 5.7 shape, because the database could not say \u0026ldquo;previous row\u0026rdquo; or \u0026ldquo;rank within group\u0026rdquo;.\u003c/p\u003e\n\u003cp\u003eWe are finally moving that project to MySQL 8. Two years after GA, which by database standards is reckless haste. The first win had nothing to do with performance. We deleted PHP.\u003c/p\u003e","title":"Stop building running totals in PHP"},{"content":"-\u0026gt;refundedTwice(). That is the line from Laravel 8 I care about, and it is nowhere on the release page. Jetstream gets the screenshots. Class-based factories and the queue changes get my attention, because both are about production.\nFactories used to be closures registered through a global function. Now they are classes with states:\nOrder::factory() -\u0026gt;paid() -\u0026gt;has(OrderItem::factory()-\u0026gt;count(3)) -\u0026gt;create(); Test data quality decides test quality. Most integration suites on our project test the happy path fifty times, because the default factory returns a fresh, valid, boring record. The bugs live somewhere else. An order refunded twice. A user registered before the migration added the column. A subscription that expired in the middle of renewal. A factory state gives such a monster a name, and once the name exists, people write tests with it. Named ugly data is the cheapest test improvement I know.\nQueues: retries, backoff, retryUntil, and now job batching. The API is fine. The mental shift matters more. A queued job is a small distributed system. It can run twice, run late, or run after the row it references has changed. Laravel 8 gives better tools for the retry choreography. Deciding what \u0026ldquo;safe to run twice\u0026rdquo; means for your order is still your job.\nOne caution. Every version adds conveniences, and conveniences are pleasant right until someone mistakes them for architecture. Facades, magic scopes, implicit bindings: tools. The structure has to survive the day you swap them.\nThe upgrade is calm. Factories need a namespace move, there is a shim package for the transition. Do it before the suite grows another hundred closure factories. Ours has more than I want to count, and I have been saying \u0026ldquo;next sprint\u0026rdquo; since 7.0.\n","permalink":"https://useinov.com/posts/2020/laravel-8-maturity/","summary":"\u003cp\u003e\u003ccode\u003e-\u0026gt;refundedTwice()\u003c/code\u003e. That is the line from Laravel 8 I care about, and it is nowhere on the release page. Jetstream gets the screenshots. Class-based factories and the queue changes get my attention, because both are about production.\u003c/p\u003e\n\u003cp\u003eFactories used to be closures registered through a global function. Now they are classes with states:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003eOrder\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"na\"\u003efactory\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003epaid\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003ehas\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eOrderItem\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"na\"\u003efactory\u003c/span\u003e\u003cspan class=\"p\"\u003e()\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003ecount\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mi\"\u003e3\u003c/span\u003e\u003cspan class=\"p\"\u003e))\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003ecreate\u003c/span\u003e\u003cspan class=\"p\"\u003e();\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eTest data quality decides test quality. Most integration suites on our project test the happy path fifty times, because the default factory returns a fresh, valid, boring record. The bugs live somewhere else. An order refunded twice. A user registered before the migration added the column. A subscription that expired in the middle of renewal. A factory state gives such a monster a name, and once the name exists, people write tests with it. Named ugly data is the cheapest test improvement I know.\u003c/p\u003e","title":"Laravel 8: the interesting parts are not on the marketing page"},{"content":"Average response time: 180 ms. Ticket from support: \u0026ldquo;the API is slow\u0026rdquo;. Both true. The average is a diplomat. It offends nobody and tells you nothing.\nLatency is a distribution. Our 180 ms hides a p50 of 90 ms and a p99 above four seconds. One request in a hundred is terrible, and with thirty requests per page load, most users hit that unlucky one regularly. The people complaining are not imagining things. They live in the tail, and the average never visits there.\nSo we started tracing. Nothing fancy: a correlation id generated at the edge, passed into every log line, every SQL comment, every outgoing HTTP header. Plus timing spans, controller, each query, each Redis call, each external API. When a specific slow request comes in, we pull its trace and see the shape of the time.\nThe shapes were educational. One slow case was a warm path that sometimes missed the cache and rebuilt an expensive aggregate inline, four seconds of SQL nobody remembered writing. Another was an external API that answers in 50 ms except when it answers in three seconds, and our timeout was five. No amount of staring at averages would name these. The trace names them in one screenshot.\nIt also changed the meetings. Before, they sounded like \u0026ldquo;Symfony is slow\u0026rdquo; versus \u0026ldquo;the database is slow\u0026rdquo;, two teams pointing at each other\u0026rsquo;s boxes. Now someone opens a trace, points at span four, and the meeting is over in five minutes.\nSampling keeps it cheap. Trace one percent of everything and one hundred percent of requests slower than a threshold. The slow ones are the whole point.\nIf you monitor one number, make it p99. Ours was the average, on a dashboard nobody had a reason to look at. It was always green.\n","permalink":"https://useinov.com/posts/2020/average-explains-nothing/","summary":"\u003cp\u003eAverage response time: 180 ms. Ticket from support: \u0026ldquo;the API is slow\u0026rdquo;. Both true. The average is a diplomat. It offends nobody and tells you nothing.\u003c/p\u003e\n\u003cp\u003eLatency is a distribution. Our 180 ms hides a p50 of 90 ms and a p99 above four seconds. One request in a hundred is terrible, and with thirty requests per page load, most users hit that unlucky one regularly. The people complaining are not imagining things. They live in the tail, and the average never visits there.\u003c/p\u003e","title":"Average response time explains nothing"},{"content":"@param int|string $id. I grepped one service this morning: dozens of those, and every one is a wish. The docblock says what the signature could not. First alpha of PHP 8 came out last week, and native union types turn the wish into a contract, checked at runtime and by tooling. Half of my phpdoc can go.\nEveryone writes about the JIT. I keep thinking about the less shiny thing: how many of our daily patterns exist only to compensate for a missing language feature, and are about to become legacy.\nAnnotations in docblocks are the big one. Fifteen years we put machine-readable metadata into comments, wrote parsers for the comments, and called it normal. Doctrine mappings, route definitions, whole frameworks configured through strings the engine never sees. Attributes make metadata part of the language. Typos become errors, IDEs and static analyzers finally see it. I will not show the syntax here. They changed it once already during the RFC, and I would not bet against a second time. The idea is what matters.\nNamed arguments are still argued about on internals, but it looks likely. If it lands, the options array dies: doSomething(['force' =\u0026gt; true, 'dryRun' =\u0026gt; false]) becomes real parameters with real types. One catch. Parameter names become public API. Rename $dryRun to $dry in a library and you break callers. Library authors will have to think about names the way they think about method names now.\nNone of this makes old code wrong. But when the language absorbs a workaround, the workaround starts to read as noise.\nI wrote one of those docblock parsers myself, years ago. It is still running somewhere. I hope nobody opens it.\n","permalink":"https://useinov.com/posts/2020/php8-old-patterns/","summary":"\u003cp\u003e\u003ccode\u003e@param int|string $id\u003c/code\u003e. I grepped one service this morning: dozens of those, and every one is a wish. The docblock says what the signature could not. First alpha of PHP 8 came out last week, and native union types turn the wish into a contract, checked at runtime and by tooling. Half of my phpdoc can go.\u003c/p\u003e\n\u003cp\u003eEveryone writes about the JIT. I keep thinking about the less shiny thing: how many of our daily patterns exist only to compensate for a missing language feature, and are about to become legacy.\u003c/p\u003e","title":"PHP 8 will retire some of our workarounds"},{"content":"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.\nSymfony 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:\npublic function register($data) { // what is in $data? read three call sites to find out } versus\npublic function register(RegistrationRequest $request): RegistrationResult The second signature is documentation that cannot rot. RegistrationRequest with typed properties shows the exact shape, and psalm or phpstan tells me at commit time when someone forgets a field, instead of an Undefined index in the logs at 3am. The runtime error and the static error carry the same information. One of them arrives before deploy.\nAutowiring completes the picture. People call it magic. I think it is the opposite. The old magic was container config in YAML, strings pointing at strings. Now the constructor type is the wiring. The class says what it needs, in code, where I am already looking.\nOne warning from our own codebase. Once DTOs feel good, someone generates one per table, one per endpoint, and mappers between all of them. A new field means five files. A DTO should exist because some boundary needs that exact shape: HTTP input, a message payload, a report row. If it is a one-to-one copy of an entity and only carries data between two layers invented last month, delete the layer.\nType the boundaries. Keep the middle simple.\nThe someone with the mappers was me, two projects ago. It looked very clean in the pull request.\n","permalink":"https://useinov.com/posts/2020/typed-code-navigation/","summary":"\u003cp\u003e\u003ccode\u003eOrderRepository\u003c/code\u003e 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.\u003c/p\u003e\n\u003cp\u003eSymfony 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:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"nf\"\u003eregister\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$data\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"c1\"\u003e// what is in $data? read three call sites to find out\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eversus\u003c/p\u003e","title":"Types are for reading, not for the compiler"},{"content":"Error 1213 in the logs, and the chat lights up: the database is broken. It is not. Two transactions locked rows in opposite order, A waits for B, B waits for A, and InnoDB did the only sane thing: picked a victim and killed it. The cycle is gone. This is a feature.\nThe classic shape is a money transfer. One request moves funds from account 1 to account 2, another from 2 to 1, both update the first account and then the second. Opposite order, instant cycle under load. Tests never show it, because tests do not run two of these in the same millisecond.\nThe application has one duty here: know that this error is retryable. MySQL says 1213, PostgreSQL says SQLSTATE 40001. It does not mean \u0026ldquo;your data is wrong\u0026rdquo;. It means \u0026ldquo;try again, you will probably win this time\u0026rdquo;. So catch it, roll back, wait a random 10 to 50 ms, run the whole transaction again. Bounded, three attempts, then give up loudly. The jitter matters. Two retries on a fixed delay collide again, like two people stepping aside in the same direction.\nThe detail people get wrong: retry the whole transaction, from the first read. The victim is rolled back completely, and the values you read before the deadlock may already be stale. A retry that reuses old values from memory is a quiet corruption machine.\nPrevention is one sentence. Lock rows in a consistent order everywhere. For the transfer, sort the two account ids and update the smaller one first. Cycles need opposite orders. One global order, no cycles.\nRetry handles the rare case. Ordering makes it rare. You want both. But if the deadlock graph in SHOW ENGINE INNODB STATUS shows the same pair of queries every day, stop retrying and fix the order. Ours showed the same pair for weeks before anyone opened the graph.\n","permalink":"https://useinov.com/posts/2020/deadlock-not-bug/","summary":"\u003cp\u003eError 1213 in the logs, and the chat lights up: the database is broken. It is not. Two transactions locked rows in opposite order, A waits for B, B waits for A, and InnoDB did the only sane thing: picked a victim and killed it. The cycle is gone. This is a feature.\u003c/p\u003e\n\u003cp\u003eThe classic shape is a money transfer. One request moves funds from account 1 to account 2, another from 2 to 1, both update the first account and then the second. Opposite order, instant cycle under load. Tests never show it, because tests do not run two of these in the same millisecond.\u003c/p\u003e","title":"A deadlock is the database doing its job"},{"content":"Three external calls on one page: prices, stock, delivery estimate. Each answers in about 300 ms. The page waits a full second, because we call them one after another. PHP is synchronous, what can you do.\nTurns out, something. Symfony HttpClient is lazy. request() sends and returns immediately. The waiting happens when you read the response. So start all three, read later:\n$prices = $client-\u0026gt;request(\u0026#39;GET\u0026#39;, $pricesUrl); $stock = $client-\u0026gt;request(\u0026#39;GET\u0026#39;, $stockUrl); $delivery = $client-\u0026gt;request(\u0026#39;GET\u0026#39;, $deliveryUrl); $data = [ \u0026#39;prices\u0026#39; =\u0026gt; $prices-\u0026gt;toArray(), \u0026#39;stock\u0026#39; =\u0026gt; $stock-\u0026gt;toArray(), \u0026#39;delivery\u0026#39; =\u0026gt; $delivery-\u0026gt;toArray(), ]; Under the hood it is curl multi. Three requests fly at once, wall time is the slowest one instead of the sum. Our second became 350 ms. No swoole, no reactphp, no async rewrite. Same boring controller.\nTwo things to get right.\nTimeouts. Sequential, one slow service made the page slow. Parallel, one hanging service still hangs the page, only now with company. Set a per-request timeout that fits the total budget, and decide what the page shows when the delivery estimate did not come. Usually an empty block, and nobody dies.\nFan-out. The first time this trick works, someone applies it to a loop over two hundred items and fires two hundred concurrent requests at a partner API. The partner notices. Batch the loop, or use stream() and keep a fixed number in flight.\nSequential external calls are the cheapest latency win I know right now. Open your slowest endpoint, count the calls that do not depend on each other. Overlap them.\nThe someone in the fan-out paragraph was me. On a Friday.\n","permalink":"https://useinov.com/posts/2020/parallel-http-requests/","summary":"\u003cp\u003eThree external calls on one page: prices, stock, delivery estimate. Each answers in about 300 ms. The page waits a full second, because we call them one after another. PHP is synchronous, what can you do.\u003c/p\u003e\n\u003cp\u003eTurns out, something. Symfony HttpClient is lazy. \u003ccode\u003erequest()\u003c/code\u003e sends and returns immediately. The waiting happens when you read the response. So start all three, read later:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$prices\u003c/span\u003e   \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nv\"\u003e$client\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003erequest\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;GET\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003e$pricesUrl\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$stock\u003c/span\u003e    \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nv\"\u003e$client\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003erequest\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;GET\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003e$stockUrl\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$delivery\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nv\"\u003e$client\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003erequest\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;GET\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003e$deliveryUrl\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$data\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"s1\"\u003e\u0026#39;prices\u0026#39;\u003c/span\u003e   \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nv\"\u003e$prices\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003etoArray\u003c/span\u003e\u003cspan class=\"p\"\u003e(),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"s1\"\u003e\u0026#39;stock\u0026#39;\u003c/span\u003e    \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nv\"\u003e$stock\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003etoArray\u003c/span\u003e\u003cspan class=\"p\"\u003e(),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"s1\"\u003e\u0026#39;delivery\u0026#39;\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nv\"\u003e$delivery\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003etoArray\u003c/span\u003e\u003cspan class=\"p\"\u003e(),\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e];\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eUnder the hood it is curl multi. Three requests fly at once, wall time is the slowest one instead of the sum. Our second became 350 ms. No swoole, no reactphp, no async rewrite. Same boring controller.\u003c/p\u003e","title":"Waiting for three APIs, one at a time"},{"content":"Airlock lived for about a week. Laravel 7 shipped it this month, a trademark scare followed, and it became Sanctum. Fast rename, same idea. And the idea is good, because it names a problem people solve badly.\nThe problem: your own SPA needs to talk to your own API. For years the reflex was OAuth2. Install Passport, stand up an authorization server, issue JWTs to a frontend on the same domain as the backend. All that machinery to authenticate first-party code against itself. OAuth is a delegation protocol, it lets a third party act for a user. When there is no third party, you are running a passport office for your own family.\nSanctum gives two smaller tools. For the SPA, plain session cookies plus CSRF. The browser already knows how to store a cookie, no token in localStorage waiting for the first XSS. For mobile apps and scripts, personal access tokens: a random string in the database with a list of abilities. Revocable, per device, boring.\nMy decision now. Same-domain SPA, session. Mobile app or CLI, token. A real third party integrating on behalf of your users, only then OAuth2, and you will know, because someone external will ask for a client id.\nOne habit worth keeping from the OAuth world: scope the tokens. A token that can do everything is a password with extra steps. Sanctum abilities are primitive next to OAuth scopes, but ['orders:read'] on a reporting integration still saves you the day that token leaks.\nAuth has levels. Pick the lowest one that covers your threat model.\nI installed Passport on a same-domain SPA in 2018. It is still there. Nobody wants to be the one who touches auth.\n","permalink":"https://useinov.com/posts/2020/sanctum-auth-level/","summary":"\u003cp\u003eAirlock lived for about a week. Laravel 7 shipped it this month, a trademark scare followed, and it became Sanctum. Fast rename, same idea. And the idea is good, because it names a problem people solve badly.\u003c/p\u003e\n\u003cp\u003eThe problem: your own SPA needs to talk to your own API. For years the reflex was OAuth2. Install Passport, stand up an authorization server, issue JWTs to a frontend on the same domain as the backend. All that machinery to authenticate first-party code against itself. OAuth is a delegation protocol, it lets a third party act for a user. When there is no third party, you are running a passport office for your own family.\u003c/p\u003e","title":"Sanctum and choosing how much auth you need"},{"content":"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.\nThe 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:\n$token = bin2hex(random_bytes(16)); $ok = $redis-\u0026gt;set(\u0026#39;lock:order:\u0026#39;.$orderId, $token, [\u0026#39;nx\u0026#39;, \u0026#39;ex\u0026#39; =\u0026gt; 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.\nNow the bug no code fixes. The work takes longer than the lease. GC pause, slow external API, anything. TTL expires, another worker takes the lock, and two processes are inside the critical section, both sure they are alone. You can renew the lease from the worker. Renewal is also code, and code can pause. A lock with a TTL is honest about exactly one thing: mutual exclusion most of the time.\nSo before Redis I now ask a different question. Can the database do it? A unique constraint on (order_id, operation) makes the second insert fail, atomically, no lease, no clock. SELECT ... FOR UPDATE serializes two transactions on the same row. Boring, and correct.\nThe Redis lock keeps one honest job: cutting duplicate work when duplicates are merely expensive. Two workers rebuilding the same cache entry, fine, we wasted CPU. Two workers charging the same card, not fine, and no TTL value makes it fine.\nDecide which case you have. Then decide if you need the lock.\nThere is still one SETNX on our side guarding a payment. It has not failed yet. I know how that sentence sounds.\n","permalink":"https://useinov.com/posts/2020/redis-lock-honesty/","summary":"\u003cp\u003eTwo workers, one order, processed twice. Every project gets this day. Someone writes \u003ccode\u003eSETNX\u003c/code\u003e, calls it a distributed lock, closes the ticket. I want to slow down here, because the ticket is not closed.\u003c/p\u003e\n\u003cp\u003eThe 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:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$token\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003ebin2hex\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003erandom_bytes\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"mi\"\u003e16\u003c/span\u003e\u003cspan class=\"p\"\u003e));\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$ok\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nv\"\u003e$redis\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003eset\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;lock:order:\u0026#39;\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003e$orderId\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003e$token\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;nx\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;ex\u0026#39;\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"mi\"\u003e30\u003c/span\u003e\u003cspan class=\"p\"\u003e]);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eAnd 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.\u003c/p\u003e","title":"A Redis lock is a promise you cannot fully keep"},{"content":"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.\nI 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.\nKeyset fixes it, but only if the sort is deterministic. ORDER BY created_at is not enough, timestamps collide. Add the primary key as a tiebreaker:\n$sql = \u0026#39;SELECT * FROM events WHERE (created_at, id) \u0026lt; (:ts, :id) ORDER BY created_at DESC, id DESC LIMIT 50\u0026#39;; The cursor is the (created_at, id) of the last row, base64-encoded into one opaque string. Opaque matters. The moment clients learn the cursor is a timestamp, they start building cursors by hand, and your internal sort order is a public API forever. Encode it, and put a version byte inside while you are there. Future you will want to change the sort key.\nOne contract question people skip: does the cursor survive? If the last row on the page gets deleted, the keyset query still works, it continues from where that row would have been. Offset cannot promise even that.\nSo my rule for new list endpoints this year: cursor by default, (created_at, id) with a matching composite index, opaque token in the response. Offset stays for admin panels, where a human clicks page numbers and nobody cares about a shifted row.\nThe endpoint that taught me this still runs on OFFSET. Every client would have to change at once. The bug is documented instead.\n","permalink":"https://useinov.com/posts/2020/cursor-pagination-contract/","summary":"\u003cp\u003eSomeone 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.\u003c/p\u003e\n\u003cp\u003eI 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.\u003c/p\u003e","title":"Cursor pagination is part of your API contract"},{"content":"One static array, from one of our projects:\nclass Settings { private static $cache = []; public static function get($tenantId, $key) { if (!isset(self::$cache[$tenantId])) { self::$cache[$tenantId] = self::load($tenantId); } return self::$cache[$tenantId][$key] ?? null; } } A reasonable per-request cache. Under PHP-FPM it died with the process. In a queue worker it lives forever.\nPHP had one great architectural feature nobody put in the manual: the process died after every request. Leak memory, cache nonsense in a static, forget to close things. Did not matter. The dying process forgave everything.\nQueue workers take that forgiveness away. A worker boots the framework once and processes jobs for hours in the same process. Code written under the old model misbehaves in ways that are hard to even describe in a bug tracker.\nWith the Settings class above: admin changes a setting, the worker keeps serving the old value for hours. Worse, memory grows with every tenant that passes through, until the process dies at the memory limit somewhere in the middle of a job. Two bugs from one innocent static.\nSame story with less obvious state. An authenticated user object left in the container by job one, visible to job two. A database connection that timed out on the server side during a quiet hour, so the first job of the morning fails with \u0026ldquo;MySQL server has gone away\u0026rdquo;. An entity manager that accumulates every entity it ever saw.\nWhat worker-safe means to me now. Job code takes dependencies explicitly and keeps state in the job, not in statics and not in singletons. Whatever per-request state the framework has is reset between jobs, most queue libraries have a hook for this. Connections are ping-checked or reconnected on error, never assumed alive. The memory limit on the worker is a safety net, and restarting workers on every deploy is not optional, because an old worker runs old code.\nTest the suspicious job by running it twice in the same process. Not twice in two processes. Twice in one. The difference is exactly where these bugs live.\n","permalink":"https://useinov.com/posts/2019/worker-static-state/","summary":"\u003cp\u003eOne static array, from one of our projects:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eSettings\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eprivate\u003c/span\u003e \u003cspan class=\"k\"\u003estatic\u003c/span\u003e \u003cspan class=\"nv\"\u003e$cache\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[];\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003estatic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"nf\"\u003eget\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$tenantId\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"nv\"\u003e$key\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"o\"\u003e!\u003c/span\u003e\u003cspan class=\"nx\"\u003eisset\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"nv\"\u003e$cache\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"nv\"\u003e$tenantId\u003c/span\u003e\u003cspan class=\"p\"\u003e]))\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e            \u003cspan class=\"nx\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"nv\"\u003e$cache\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"nv\"\u003e$tenantId\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"na\"\u003eload\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$tenantId\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"k\"\u003ereturn\u003c/span\u003e \u003cspan class=\"nx\"\u003eself\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"nv\"\u003e$cache\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"nv\"\u003e$tenantId\u003c/span\u003e\u003cspan class=\"p\"\u003e][\u003c/span\u003e\u003cspan class=\"nv\"\u003e$key\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e \u003cspan class=\"o\"\u003e??\u003c/span\u003e \u003cspan class=\"k\"\u003enull\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eA reasonable per-request cache. Under PHP-FPM it died with the process. In a queue worker it lives forever.\u003c/p\u003e\n\u003cp\u003ePHP had one great architectural feature nobody put in the manual: the process died after every request. Leak memory, cache nonsense in a static, forget to close things. Did not matter. The dying process forgave everything.\u003c/p\u003e","title":"Static state outlives the job"},{"content":"Two lines of a class, and half of my docblocks can be deleted:\nclass Money { private int $amount; private string $currency; } PHP 7.4 came out yesterday, and this is the part I start using on Monday. Those docblocks existed only to say @var int. Now the language says it, and unlike the docblock, it checks. Assign a string, get a TypeError at the assignment, not a strange bug three layers later.\nThere is one new state to learn. A typed property without a default is uninitialized, and reading it before the first write throws. This is the feature. \u0026ldquo;Object exists but is not filled yet\u0026rdquo; finally fails loudly instead of pretending to be null.\nPreloading is the other thing in 7.4, and here I want to cool the excitement. The idea is good: opcache.preload points to a script that loads your classes once at FPM start, and they sit in memory permanently, linked and ready, shared by all workers. No stat calls, no autoloader work on the hot path.\nBut look at your baseline first. On a normal 7.3 setup with opcache warm and composer dump-autoload -O, the autoloader is a small slice of the request. Preloading shaves a few percent for a real operational price. The preload script is code you now maintain. A fatal error in it means FPM does not start at all. And preloaded files ignore your deploy until FPM restarts. That last one is sneaky. Deploy scripts that reload FPM keep working. Deploys that relied on opcache timestamp checks serve old classes, and you spend an evening learning why.\nOptimization without a baseline is decoration.\nSo the plan: typed properties now, everywhere new code is written. Preloading after I profile a real application under real traffic and see the autoloader actually cost something. I have not profiled it yet. The config line stays commented out.\n","permalink":"https://useinov.com/posts/2019/php74-preloading/","summary":"\u003cp\u003eTwo lines of a class, and half of my docblocks can be deleted:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eMoney\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eprivate\u003c/span\u003e \u003cspan class=\"nx\"\u003eint\u003c/span\u003e \u003cspan class=\"nv\"\u003e$amount\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003eprivate\u003c/span\u003e \u003cspan class=\"nx\"\u003estring\u003c/span\u003e \u003cspan class=\"nv\"\u003e$currency\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003ePHP 7.4 came out yesterday, and this is the part I start using on Monday. Those docblocks existed only to say \u003ccode\u003e@var int\u003c/code\u003e. Now the language says it, and unlike the docblock, it checks. Assign a string, get a \u003ccode\u003eTypeError\u003c/code\u003e at the assignment, not a strange bug three layers later.\u003c/p\u003e\n\u003cp\u003eThere is one new state to learn. A typed property without a default is uninitialized, and reading it before the first write throws. This is the feature. \u0026ldquo;Object exists but is not filled yet\u0026rdquo; finally fails loudly instead of pretending to be null.\u003c/p\u003e","title":"PHP 7.4: typed properties first, preloading later"},{"content":"grep -rn \u0026quot;WITH \u0026quot; src/ was the first thing I ran after upgrading to PostgreSQL 12 this month. I was looking for a trick that had stopped working.\nFor years WITH was an optimization fence. The planner materialized the CTE first and only then ran the outer query. Everyone used this both ways. As a bug: you wrap a subquery into a CTE for readability, the planner stops pushing conditions inside, a fast query becomes a scan of half a table. As a feature: you write a CTE on purpose, to pin evaluation order and stop the planner from being creative. Half of the CTE advice on the internet is really advice about the fence.\nIn 12 the fence is gone. A CTE referenced once with no side effects gets inlined, predicates get pushed inside, the planner treats it like the subquery it always looked like. Queries of the first kind got faster for free. Queries of the second kind, the ones that used WITH as a hint, silently lost their hint.\nThe behavior is now explicit, which I like:\nWITH stats AS MATERIALIZED ( SELECT user_id, count(*) AS cnt FROM events GROUP BY user_id ) SELECT ... MATERIALIZED gives the old fence back. NOT MATERIALIZED forces inlining even when the CTE is referenced twice. No keyword, the planner decides. If a query depends on the fence, say so in the query. A hint that lives only in the author\u0026rsquo;s head does not survive an upgrade.\nThe grep found every WITH that touches a big table. I re-ran EXPLAIN on each. Two queries got faster, one report needed MATERIALIZED back. Twenty minutes.\nPlanner knowledge has a version attached. A recipe learned on 9.4 is a fact about 9.4, not about SQL. I had been giving that recipe in code reviews for years. Now I give it with a version number.\n","permalink":"https://useinov.com/posts/2019/postgres12-cte/","summary":"\u003cp\u003e\u003ccode\u003egrep -rn \u0026quot;WITH \u0026quot; src/\u003c/code\u003e was the first thing I ran after upgrading to PostgreSQL 12 this month. I was looking for a trick that had stopped working.\u003c/p\u003e\n\u003cp\u003eFor years \u003ccode\u003eWITH\u003c/code\u003e was an optimization fence. The planner materialized the CTE first and only then ran the outer query. Everyone used this both ways. As a bug: you wrap a subquery into a CTE for readability, the planner stops pushing conditions inside, a fast query becomes a scan of half a table. As a feature: you write a CTE on purpose, to pin evaluation order and stop the planner from being creative. Half of the CTE advice on the internet is really advice about the fence.\u003c/p\u003e","title":"PostgreSQL 12 inlines your CTE"},{"content":"Symfony 5.0 is 4.4 minus everything deprecated. That one sentence is the whole upgrade plan. Both land in November, and if the application runs on 4.4 with zero deprecation warnings, the major is a version bump. Two months is enough to get there in small chunks, with no freeze and no heroic branch that lives for six weeks.\nThe order that works for me.\nFirst, get to 4.3 and make the deprecation report visible. The PHPUnit bridge prints the summary after the test run. In dev, the profiler collects the same warnings per request. You cannot clean what you do not see. The first report will be depressing. Fine. It is a todo list, not a verdict.\nSecond, sort the list by who owns the warning. Your own code you fix directly, most items are mechanical renames and take minutes. Warnings from vendors you fix with composer update of the direct dependencies, somebody upstream usually did the work already. What remains is the honest problem: abandoned bundles that will never see a 5.0 release. Every legacy project has two or three. For each one the choice is fork, replace, or inline the ten percent of it you actually use. Decide now. Not in November with a broken composer update on the screen.\nThird, before touching anything, cover the critical paths with tests. Not full coverage. Checkout, login, the money flows. Deprecation fixes are supposed to be behavior-neutral. \u0026ldquo;Supposed to\u0026rdquo; is doing a lot of work in that sentence.\nAnd a scar. An upgrade branch attracts refactoring. You rename a method and suddenly want to redesign the whole service. I did that once. Mandatory changes go in the upgrade, desirable changes go in the backlog. Mixing them is how two months become six.\n","permalink":"https://useinov.com/posts/2019/symfony5-deprecations/","summary":"\u003cp\u003eSymfony 5.0 is 4.4 minus everything deprecated. That one sentence is the whole upgrade plan. Both land in November, and if the application runs on 4.4 with zero deprecation warnings, the major is a version bump. Two months is enough to get there in small chunks, with no freeze and no heroic branch that lives for six weeks.\u003c/p\u003e\n\u003cp\u003eThe order that works for me.\u003c/p\u003e\n\u003cp\u003eFirst, get to 4.3 and make the deprecation report visible. The PHPUnit bridge prints the summary after the test run. In dev, the profiler collects the same warnings per request. You cannot clean what you do not see. The first report will be depressing. Fine. It is a todo list, not a verdict.\u003c/p\u003e","title":"Cleaning up before Symfony 5"},{"content":"Route /health, returns ok, everyone happy. That is the first version of every health endpoint on the project we are moving to Kubernetes. Then somebody pastes the same path into livenessProbe and readinessProbe, and a thirty second database hiccup becomes a long evening.\nThe two probes ask different questions.\nReadiness asks: should this pod get traffic right now. Here it is correct to check dependencies. Database unreachable, cache cold, migrations still running: answer no. Kubernetes takes the pod out of the Service, traffic goes to the others, the pod returns when the world improves. Failing readiness is cheap and reversible. A polite \u0026ldquo;not now\u0026rdquo;.\nLiveness asks: is this process beyond saving. The only honest yes is the process itself being stuck, deadlocked, or broken in a way a restart fixes. Liveness must not check the database. Play it through. Database blinks for thirty seconds, liveness fails on every pod at once, Kubernetes restarts the whole fleet, every PHP-FPM comes up with cold opcache and empty connection pools, all of them hit the recovering database, it goes down again. A restart storm, self-inflicted. A database outage is not a problem a PHP restart can solve, so it has no place in liveness.\nFor PHP-FPM the honest liveness is almost free: ping.path exists for exactly this. Process answers ping, process is alive. For our long-running workers we write a heartbeat timestamp and let liveness compare it with the clock, because a hung worker still has a listening socket.\nAnd give readiness sane timeouts. Checking the database with a two second timeout while the normal query timeout is thirty means the probe declares death long before the application notices anything.\nA restart is a hammer. Liveness decides when to swing it. Be very sure.\n","permalink":"https://useinov.com/posts/2019/kubernetes-probes-php/","summary":"\u003cp\u003eRoute \u003ccode\u003e/health\u003c/code\u003e, returns \u003ccode\u003eok\u003c/code\u003e, everyone happy. That is the first version of every health endpoint on the project we are moving to Kubernetes. Then somebody pastes the same path into \u003ccode\u003elivenessProbe\u003c/code\u003e and \u003ccode\u003ereadinessProbe\u003c/code\u003e, and a thirty second database hiccup becomes a long evening.\u003c/p\u003e\n\u003cp\u003eThe two probes ask different questions.\u003c/p\u003e\n\u003cp\u003eReadiness asks: should this pod get traffic right now. Here it is correct to check dependencies. Database unreachable, cache cold, migrations still running: answer no. Kubernetes takes the pod out of the Service, traffic goes to the others, the pod returns when the world improves. Failing readiness is cheap and reversible. A polite \u0026ldquo;not now\u0026rdquo;.\u003c/p\u003e","title":"200 OK is not health"},{"content":"Ten rows in the dev database, on the same machine, 0.1 ms per query. Ten extra queries is one millisecond. The page feels instant, the code ships. Production has a thousand rows and the database one network hop away. Round trip is about a millisecond even in a good datacenter. A thousand queries is a second of pure network waiting. Not slow SQL. Each query is fast. The plural is slow.\nI wrote about N+1 five years ago, in the Kohana times. The bug did not age. Only the reason it survives code review became clearer to me: locally it costs nothing.\nThis is also why you cannot find it in a slow query log. No single query is slow. You find it in a trace of the endpoint, where it looks like a barcode: the same SQL shape repeated hundreds of times with different ids. Take a trace from production, or at least count queries per request in staging with production-sized data. Count, not time. I said it in 2014 and I repeat it now: time depends on the machine, count is a property of the code.\nThe fix is the same menu as always. Eager loading when the ORM does it well. A join when you need a flat list and can give up the object model. Two queries with WHERE id IN (...) when the join multiplies rows. Any of the three turns a thousand round trips into two.\nThe new part of my routine is a test. After fixing an endpoint, I pin the query count in an integration test: this request performs 4 queries, assert it. The number looks fragile and pedantic. That is the point. When someone adds an innocent $order-\u0026gt;customer-\u0026gt;name into the loop next year, the test fails with 104 instead of 4, and the barcode never reaches production.\nRegressions are silent. A failing assertion with 104 in it is not.\n","permalink":"https://useinov.com/posts/2019/n-plus-one-production/","summary":"\u003cp\u003eTen rows in the dev database, on the same machine, 0.1 ms per query. Ten extra queries is one millisecond. The page feels instant, the code ships. Production has a thousand rows and the database one network hop away. Round trip is about a millisecond even in a good datacenter. A thousand queries is a second of pure network waiting. Not slow SQL. Each query is fast. The plural is slow.\u003c/p\u003e","title":"N+1 hides on your laptop"},{"content":"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.\nBoth diseases have the same cure and it is embarrassingly simple. One use case, one class, one public method.\nfinal 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.\nThe transaction boundary becomes obvious too. The use case is the unit of work, so the transaction wraps handle() and nothing else. With OrderManager you never know. Method A opens a transaction, method B assumes one exists, method C is called from both. I spent a day on exactly that bug.\nIs this CQRS, hexagonal, clean architecture? I do not care. No buses, no interfaces for classes with one implementation. Just a folder of verbs: PlaceOrder, CancelOrder, RefundOrder. A new developer opens that folder and reads what the system does, like a table of contents.\nThin controllers were always the goal. Nobody said where the meat goes. This is where.\n","permalink":"https://useinov.com/posts/2019/application-services/","summary":"\u003cp\u003eFour hundred lines in a checkout action, half of them inside one try. That is one way a project dies. The other is \u003ccode\u003eOrderManager\u003c/code\u003e: thirty methods, six injected services, and nobody can say what the class is for, because it is for everything about orders.\u003c/p\u003e\n\u003cp\u003eBoth diseases have the same cure and it is embarrassingly simple. One use case, one class, one public method.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003efinal\u003c/span\u003e \u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003ePlaceOrder\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"fm\"\u003e__construct\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nx\"\u003eOrderRepository\u003c/span\u003e \u003cspan class=\"nv\"\u003e$orders\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nx\"\u003ePaymentGateway\u003c/span\u003e \u003cspan class=\"nv\"\u003e$payments\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nx\"\u003eEventDispatcher\u003c/span\u003e \u003cspan class=\"nv\"\u003e$events\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"o\"\u003e...\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"nf\"\u003ehandle\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003ePlaceOrderCommand\u003c/span\u003e \u003cspan class=\"nv\"\u003e$command\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"o\"\u003e:\u003c/span\u003e \u003cspan class=\"nx\"\u003eOrderId\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"c1\"\u003e// the whole story, top to bottom\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe 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 \u003ccode\u003ePlaceOrder\u003c/code\u003e suddenly needs the mailer you see it and can ask why. What comes out: one result. Reading \u003ccode\u003ehandle()\u003c/code\u003e from top to bottom tells the whole story of placing an order. No chapter hidden in a base class or a trait.\u003c/p\u003e","title":"One use case, one class"},{"content":"A log full of the same stack trace, every second, all night. That is what a queue looks like when a handler throws on a malformed payload and the retry policy is \u0026ldquo;forever\u0026rdquo;. Symfony 4.3 is out this week, and Messenger in it finally has an answer for that night.\nRetry is good for transient errors. Network blinked, deadlock, remote API returned 503. Wait, try again, it passes. But some errors are permanent. Malformed payload, entity deleted, a bug in the handler. Retrying those forever means a worker grinding the same poison message until morning. Infinite retry is not persistence, it is denial.\nThe setup I converge to:\nframework: messenger: failure_transport: failed transports: async: dsn: \u0026#39;%env(MESSENGER_TRANSPORT_DSN)%\u0026#39; retry_strategy: max_retries: 3 delay: 1000 multiplier: 2 failed: \u0026#39;doctrine://default?queue_name=failed\u0026#39; Three attempts with growing delay eats most transient failures. After that the message goes to the failure transport and stops hurting the workers. 4.3 ships the tooling for the morning after: messenger:failed:show to look at the corpses, messenger:failed:retry to replay them once the bug is fixed, messenger:failed:remove for the hopeless ones. Manual replay sounds primitive. It is exactly right. A human decided the cause is gone, a human pushes the button.\nTwo alerts turn this from a graveyard into a system. Size of the failure queue, because growth means a new class of error appeared. And age of the oldest failed message, because a message sitting there three days is a business operation nobody finished and nobody noticed. The second alert fires more often than you expect. Always some rare flow. Always something with money or email.\nClassify the error, bound the retries, keep the corpses where you can see them. The rest is YAML.\n","permalink":"https://useinov.com/posts/2019/messenger-failure-queue/","summary":"\u003cp\u003eA log full of the same stack trace, every second, all night. That is what a queue looks like when a handler throws on a malformed payload and the retry policy is \u0026ldquo;forever\u0026rdquo;. Symfony 4.3 is out this week, and Messenger in it finally has an answer for that night.\u003c/p\u003e\n\u003cp\u003eRetry is good for transient errors. Network blinked, deadlock, remote API returned 503. Wait, try again, it passes. But some errors are permanent. Malformed payload, entity deleted, a bug in the handler. Retrying those forever means a worker grinding the same poison message until morning. Infinite retry is not persistence, it is denial.\u003c/p\u003e","title":"Messenger retries and the failure transport"},{"content":"Users logged out at random. Not all, not always. Only sometimes, only in the afternoon. Afternoon is when traffic peaks. Traffic peaks fill the cache. The cache lived in the same Redis as the sessions, the instance hit maxmemory, and the eviction policy was allkeys-lru. Redis did exactly what we asked: threw away the least recently used keys, and some of them were sessions of people who went for lunch.\nEviction policy is not an infrastructure knob. It is application behavior. noeviction means writes start failing with OOM when memory is full, so your code better be ready for a failing SET. The LRU and LFU policies mean any key can vanish at any moment, so everything in that instance must be a cache by contract. Habit does not count. volatile-* sounds safe until nobody set TTLs, Redis has nothing it is allowed to evict, and you are back to failing writes.\nSo, one instance, one contract. Cache lives where eviction is allowed and expected. Sessions and queues live behind noeviction with monitoring, because a silently evicted job is the worst bug you will never find.\nThree numbers to watch in INFO. used_memory against maxmemory, to see the ceiling coming. evicted_keys, which must stay zero on the data instance. And hit rate from keyspace_hits and keyspace_misses, because a cache that misses half the time is a delay with extra steps.\nWe split the instances in one evening. The logouts stopped. The config diff was four lines. The allkeys-lru line was mine, from the time the instance held only cache.\n","permalink":"https://useinov.com/posts/2019/redis-maxmemory/","summary":"\u003cp\u003eUsers logged out at random. Not all, not always. Only sometimes, only in the afternoon. Afternoon is when traffic peaks. Traffic peaks fill the cache. The cache lived in the same Redis as the sessions, the instance hit \u003ccode\u003emaxmemory\u003c/code\u003e, and the eviction policy was \u003ccode\u003eallkeys-lru\u003c/code\u003e. Redis did exactly what we asked: threw away the least recently used keys, and some of them were sessions of people who went for lunch.\u003c/p\u003e","title":"What Redis does when memory ends"},{"content":"Statement three of a migration fails. Column name typo. On PostgreSQL the first two ALTERs roll back with it, the schema returns to the exact state before the migration, you fix the typo and run again. On MySQL the first two ALTERs are already permanent.\nSame up() method, same php artisan migrate, same green output. The migration tool gives one abstraction over two very different databases. The abstraction covers syntax. It does not cover what happens on failure.\nPostgres runs DDL inside transactions. This is a luxury, and most people who grew up on Postgres do not know it is one.\nMySQL is another world. Almost every DDL statement performs an implicit commit. The transaction your migration tool politely opened is silently ended before the ALTER even starts. So when statement three fails, the migrations table says the migration did not run. The schema says half of it did. Re-run fails with \u0026ldquo;column already exists\u0026rdquo;. Now you are editing the schema by hand on a live database at eleven in the evening. Everyone who has run MySQL migrations knows this exact state. MySQL 8.0 made single statements atomic, which is nice, but it still cannot roll back two committed ALTERs.\nWhat I do about it.\nOn MySQL, one DDL statement per migration file. It looks pedantic. It means a failed deploy leaves the database in a state the tool can reason about: this migration ran, that one did not. No halves.\nData changes and schema changes never share a migration. Different failure modes, different rollback stories.\nBig migrations get a rehearsal. Take a dump with production-sized data, run the migration on staging, note the time and the locks. On Postgres also check that the migration is in fact transactional. A few operations there still are not.\nThe migration tool is a good clerk. It records what ran and in what order. It does not free you from knowing your database. Nothing does.\n","permalink":"https://useinov.com/posts/2019/transactional-ddl/","summary":"\u003cp\u003eStatement three of a migration fails. Column name typo. On PostgreSQL the first two ALTERs roll back with it, the schema returns to the exact state before the migration, you fix the typo and run again. On MySQL the first two ALTERs are already permanent.\u003c/p\u003e\n\u003cp\u003eSame \u003ccode\u003eup()\u003c/code\u003e method, same \u003ccode\u003ephp artisan migrate\u003c/code\u003e, same green output. The migration tool gives one abstraction over two very different databases. The abstraction covers syntax. It does not cover what happens on failure.\u003c/p\u003e","title":"Transactional DDL is not portable"},{"content":"Three thousand welcome emails. A colleague ran a user import on one project last month: loop over a CSV, $user-\u0026gt;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.\nLaravel 5.8 came out yesterday, and reading the changelog brought that evening back, so here is the note.\nThe real cost of model events is readability. You look at $user-\u0026gt;save() and see one line. The actual control flow is this line plus every observer registered somewhere in a service provider, plus whatever those observers trigger. To know what save() does, you grep the whole project. The code lies about its own price.\nI measured out of curiosity. One save() on that model: eleven SQL queries, two HTTP calls to the search service, one mail dispatched. From one line that looks like a single UPDATE.\nMy rule after that evening. Model events may only touch the model itself. Fill a slug, normalize a phone number, set a UUID. Things where the model is both the cause and the subject. Everything that reaches outside the row (mail, indexes, other aggregates, cache) goes through an explicit application event:\nevent(new UserRegistered($user)); The difference looks cosmetic. It is not. UserRegistered is fired from a specific place in a specific use case. Registration fires it. Import does not. Admin edit does not. With observers you get no such choice, every save() is every save, and you end up with unsetEventDispatcher() or a static $importMode flag. A switch like that says the side effects live in the wrong place.\nObservers are comfortable. So was the import script.\n","permalink":"https://useinov.com/posts/2019/eloquent-events-cost/","summary":"\u003cp\u003eThree thousand welcome emails. A colleague ran a user import on one project last month: loop over a CSV, \u003ccode\u003e$user-\u0026gt;save()\u003c/code\u003e, 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.\u003c/p\u003e\n\u003cp\u003eLaravel 5.8 came out yesterday, and reading the changelog brought that evening back, so here is the note.\u003c/p\u003e","title":"Eloquent observers and hidden control flow"},{"content":"Charge the card, send the receipt, ack the job. The worker lost its Redis connection between step one and step three. The queue delivered the job again. The customer paid twice. Support learned some new words from him.\nRetries are not an edge case. Laravel retries failed jobs by design, and you want that, because networks blink. So every job with a side effect must answer one question: what happens if this runs twice. \u0026ldquo;It will not run twice\u0026rdquo; is not an answer. It will.\nThe popular fix is a lock. Cache::lock() around the job body, done. But a lock solves a different problem. It stops two copies from running at the same moment. It does not stop the second copy from running after the first one died halfway. Overlap protection and idempotency are two separate things, and the second one is the one that saves money.\nThe boring fix is an operation key.\nDB::table(\u0026#39;payments\u0026#39;)-\u0026gt;insert([ \u0026#39;operation_key\u0026#39; =\u0026gt; \u0026#39;order:\u0026#39;.$order-\u0026gt;id.\u0026#39;:charge\u0026#39;, \u0026#39;amount\u0026#39; =\u0026gt; $order-\u0026gt;total, \u0026#39;status\u0026#39; =\u0026gt; \u0026#39;pending\u0026#39;, ]); Unique index on operation_key. First attempt inserts the row, does the work, writes the result into the same row. Second attempt hits the constraint, catches the exception, reads the existing row and returns its result. From outside both attempts look identical.\nThe receipt email is the same story, smaller. Sending mail is not idempotent, so record the fact of sending under its own key before you call the mailer. Worst case you record it and crash before sending. One lost email is a support ticket. One duplicate charge is a refund and an angry man.\nDerive the key from business meaning, not from the job id. A retry of the same job and an accidental double dispatch must collapse into one key. Job ids differ every time. Order id does not.\nOne column, one index, one try/catch. Cheaper than the refund.\n","permalink":"https://useinov.com/posts/2019/laravel-job-idempotency/","summary":"\u003cp\u003eCharge the card, send the receipt, ack the job. The worker lost its Redis connection between step one and step three. The queue delivered the job again. The customer paid twice. Support learned some new words from him.\u003c/p\u003e\n\u003cp\u003eRetries are not an edge case. Laravel retries failed jobs by design, and you want that, because networks blink. So every job with a side effect must answer one question: what happens if this runs twice. \u0026ldquo;It will not run twice\u0026rdquo; is not an answer. It will.\u003c/p\u003e","title":"One payment is enough"},{"content":"A trailing comma after the last argument of a function call. PHP 7.3 came out at the start of the month, and this is the feature I noticed first, because I meet the missing comma in every second diff:\n$this-\u0026gt;logger-\u0026gt;info( \u0026#39;order created\u0026#39;, [\u0026#39;order_id\u0026#39; =\u0026gt; $order-\u0026gt;id], ); A tiny thing that kills a whole class of noisy diffs, the ones where adding an argument touches the previous line too. Arrays got this years ago. Calls only now.\nFlexible heredoc is the change I waited for longest. The closing marker can be indented, so a SQL query in a heredoc no longer crawls to the left edge of the file and ruins the indentation of the whole method. Cosmetic, and I meet it every day.\nAnd JSON_THROW_ON_ERROR. Until now json_decode returned null on garbage input, and null is also a valid decode result, so proper error handling needed json_last_error(), which nobody called. Now you can ask for an exception and handle failure the normal way. A bug class removed from the language, if you opt in.\nNone of this justifies an upgrade by itself, and that is the actual point. No headline feature, nothing for a conference slide. The yearly PHP upgrade is cheap when it is yearly. Add 7.3 to the CI matrix next to 7.2 in January, watch it, fix the couple of deprecations, switch production in spring. A few days of calendar time, hours of real work. I have also walked the other path, 5.6 to 7.x on an old codebase, and that was a project with a budget and meetings.\nThe cost of an upgrade grows faster than linearly with the distance. Keep the distance short. Put the new version in CI while it is still boring.\n","permalink":"https://useinov.com/posts/2018/php-73-small-steps/","summary":"\u003cp\u003eA trailing comma after the last argument of a function call. PHP 7.3 came out at the start of the month, and this is the feature I noticed first, because I meet the missing comma in every second diff:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$this\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003elogger\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003einfo\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"s1\"\u003e\u0026#39;order created\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;order_id\u0026#39;\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nv\"\u003e$order\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e],\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eA tiny thing that kills a whole class of noisy diffs, the ones where adding an argument touches the previous line too. Arrays got this years ago. Calls only now.\u003c/p\u003e","title":"PHP 7.3 and the value of small releases"},{"content":"SYMFONY_DEPRECATIONS_HELPER='max[total]=20' ./bin/phpunit. Twenty is the number of deprecation warnings we had on the day we started counting. The rule is that it only goes down.\nSymfony 4.2 came out yesterday, on the six month schedule. With this cadence deprecation warnings stop being an event and become weather. Every minor release brings a new batch, teams train themselves not to see them, and that is a mistake, because a deprecation is the next major upgrade delivered early, in small pieces, with instructions.\nThe phpunit-bridge already prints the report after the test run. The trick is to turn the number into a hard limit instead of a report nobody scrolls to. A pull request that adds deprecated calls fails CI, same as a broken test. Once a sprint someone spends an hour and lowers the limit. No heroics, no dedicated upgrade week, just a limit that moves one way.\nThe bridge splits the count into self, direct and indirect, and this split is who owns what. Self means our code calls deprecated APIs, we fix it now. Direct means a vendor package does, we check for a release or file an issue. Indirect means dependencies talking to each other, mostly wait. Without the split people argue about warnings they cannot fix. With it every warning has an owner or an excuse.\nThe goal is zero before the next major. Then the upgrade to 5.0, whenever it comes, is a composer.json edit and a green build. I have done the other kind of major upgrade, the archaeology kind, and I still have the branch. It was never merged.\n","permalink":"https://useinov.com/posts/2018/deprecation-budget/","summary":"\u003cp\u003e\u003ccode\u003eSYMFONY_DEPRECATIONS_HELPER='max[total]=20' ./bin/phpunit\u003c/code\u003e. Twenty is the number of deprecation warnings we had on the day we started counting. The rule is that it only goes down.\u003c/p\u003e\n\u003cp\u003eSymfony 4.2 came out yesterday, on the six month schedule. With this cadence deprecation warnings stop being an event and become weather. Every minor release brings a new batch, teams train themselves not to see them, and that is a mistake, because a deprecation is the next major upgrade delivered early, in small pieces, with instructions.\u003c/p\u003e","title":"Deprecations are a to-do list, not noise"},{"content":"jit = on, restart, run the API test suite. Same numbers as before. That was my first evening with PostgreSQL 11, released last week, and it was the correct result.\nJIT is the loud feature of this release and the misunderstood one. Postgres can now compile expression evaluation into machine code through LLVM. People read \u0026ldquo;compilation\u0026rdquo; and expect their endpoints to get faster. They will not. A primary key lookup takes a fraction of a millisecond. There is nothing in it worth compiling, and the compilation itself costs more than the whole query. JIT is for the other kind of query: an aggregate chewing through millions of rows, where the same expression runs so many times that generating machine code for it pays back.\nThis is why it sits behind planner cost thresholds like jit_above_cost, and why in 11 it ships disabled. The planner estimates the query cost and decides if compilation is worth its price. When it fires, EXPLAIN ANALYZE shows a new JIT section with generation and inlining timings. You see what you paid and what you got.\nPartitioning is the quiet feature and the useful one. Version 10 gave us declarative partitioning, honest but thin: no primary keys on partitioned tables, no default partition, pruning only at plan time. Version 11 fills the gaps. Hash partitioning, a default partition for rows that match nothing, unique constraints across partitions, pruning at execution time, so prepared statements and joins skip partitions too. My advice on native partitioning used to be \u0026ldquo;wait\u0026rdquo;. Now it is \u0026ldquo;try\u0026rdquo;.\nThe JIT part generalizes past Postgres. An optimization with a fixed upfront cost must know when not to run. The cost model is the feature. The compiler is just the part that gets the headlines.\n","permalink":"https://useinov.com/posts/2018/postgres-eleven-jit/","summary":"\u003cp\u003e\u003ccode\u003ejit = on\u003c/code\u003e, restart, run the API test suite. Same numbers as before. That was my first evening with PostgreSQL 11, released last week, and it was the correct result.\u003c/p\u003e\n\u003cp\u003eJIT is the loud feature of this release and the misunderstood one. Postgres can now compile expression evaluation into machine code through LLVM. People read \u0026ldquo;compilation\u0026rdquo; and expect their endpoints to get faster. They will not. A primary key lookup takes a fraction of a millisecond. There is nothing in it worth compiling, and the compilation itself costs more than the whole query. JIT is for the other kind of query: an aggregate chewing through millions of rows, where the same expression runs so many times that generating machine code for it pays back.\u003c/p\u003e","title":"PostgreSQL 11: partitions grow up, JIT arrives"},{"content":"email_verified_at, a timestamp. Laravel 5.7 came out this month with email verification built in, and this column is the first thing I noticed. A boolean would cost the same and answer less. A timestamp answers not only whether, but when, and when a support ticket arrives half a year later, \u0026ldquo;when\u0026rdquo; is the question.\nThe tutorials say: implement MustVerifyEmail, put the verified middleware on routes, done. True, and boring. The interesting part is how the feature is put together. It is a small example of a cross-cutting feature done right.\nThe link is a signed URL. Route parameters signed with the application key, expiration inside the URL itself. So there is no tokens table, nothing to store, nothing to clean up. The server checks its own past promise by signature alone. Signed URLs are the most underused tool in this framework, and this feature shows why they exist.\nThen the middleware. Verification is enforced at the routing layer. No if checks sprinkled through controllers. That is what makes it cross-cutting instead of a pile of conditions.\nTwo places where you still think for yourself. Double clicks: the user clicks the link twice and two requests race. Nothing breaks, because setting a timestamp that is already set is harmless. The operation is idempotent, so the race is boring. That is the general recipe. Make the action idempotent and the race goes away.\nEmail change. A verified user changes the address, and verified status must reset, and this part is on you. Forget it and \u0026ldquo;verified\u0026rdquo; quietly means \u0026ldquo;verified some other address, once\u0026rdquo;. I forgot it on the first project where I wired verification by hand. The column was set. It was true about an address nobody used anymore.\n","permalink":"https://useinov.com/posts/2018/email-verification-anatomy/","summary":"\u003cp\u003e\u003ccode\u003eemail_verified_at\u003c/code\u003e, a timestamp. Laravel 5.7 came out this month with email verification built in, and this column is the first thing I noticed. A boolean would cost the same and answer less. A timestamp answers not only whether, but when, and when a support ticket arrives half a year later, \u0026ldquo;when\u0026rdquo; is the question.\u003c/p\u003e\n\u003cp\u003eThe tutorials say: implement \u003ccode\u003eMustVerifyEmail\u003c/code\u003e, put the \u003ccode\u003everified\u003c/code\u003e middleware on routes, done. True, and boring. The interesting part is how the feature is put together. It is a small example of a cross-cutting feature done right.\u003c/p\u003e","title":"Anatomy of email verification"},{"content":"Validate input, reserve stock, create the order, charge the card, fire events. Five steps in one checkout action, and the fat model versus fat controller argument offers only two rooms for them. Both rooms are wrong.\nPut it all in the controller and you cannot run checkout from anywhere except HTTP. No console command, no queue job, no test without the kernel. Put it in the Order model and the model now knows about payments, stock and notifications, a strange set of friends for an Eloquent class.\nThe third room is a plain use-case class. No DDD, no layers, no ceremony. One class, one public method, dependencies in the constructor:\npublic function store(CheckoutRequest $request, Checkout $checkout) { $order = $checkout-\u0026gt;handle( $request-\u0026gt;user()-\u0026gt;id, $request-\u0026gt;validated() ); return redirect()-\u0026gt;route(\u0026#39;orders.show\u0026#39;, $order); } The form request checks shape at the HTTP boundary: fields present, types correct, address not empty. Checkout::handle() owns the business part: stock, payment, order creation, in what order, what happens on failure. Persistence stays where it was, in Eloquent.\nThe payoff shows in tests. A use-case class is constructed and called directly. No HTTP kernel, no routes, no middleware. Fast tests for every branch of the discount logic, plus a couple of slow feature tests through HTTP to check the wiring. Testing every discount edge case through full requests is how a suite gets to twenty minutes. Ours did.\nOne caution against the opposite religion. A controller that loads a model and returns a view does not need a service. Extracting ShowOrderService with one line inside is cargo cult, and I have written that class. The use-case class earns its place when there is orchestration to own. No orchestration, no class.\n","permalink":"https://useinov.com/posts/2018/thin-controllers-laravel/","summary":"\u003cp\u003eValidate input, reserve stock, create the order, charge the card, fire events. Five steps in one checkout action, and the fat model versus fat controller argument offers only two rooms for them. Both rooms are wrong.\u003c/p\u003e\n\u003cp\u003ePut it all in the controller and you cannot run checkout from anywhere except HTTP. No console command, no queue job, no test without the kernel. Put it in the Order model and the model now knows about payments, stock and notifications, a strange set of friends for an Eloquent class.\u003c/p\u003e","title":"Where the checkout logic goes"},{"content":"Using index in the Extra column of EXPLAIN. That is the cheapest read MySQL can do, and you often get it almost for free.\nSELECT user_id, created_at FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20; With an index on (user_id, created_at) everything the query needs is in the index leaves. The table is never touched. A composite index finds rows fast, a covering index answers the query on its own. Postgres calls this Index Only Scan, with one condition: the visibility map must be fresh, so a table that vacuum never visits quietly falls back to heap fetches.\nNow change one thing. SELECT * instead of the two columns. The plan degrades at once: the index still finds the twenty rows, but each of them now needs a lookup into the table for the rest of the columns. Twenty random reads that were not there before. People say SELECT * is bad style. Here it is a more expensive query for the same result set.\nSmall InnoDB detail: secondary indexes carry the primary key in their leaves. So (user_id, created_at) covers id as well, and SELECT id, created_at WHERE user_id = ... is index-only too.\nThe wrong conclusion is tempting: stuff more columns into indexes until everything is covered. Every column in an index is paid on the write path, on every INSERT and every UPDATE of that column, plus buffer pool space that now holds fat index pages instead of data. Cover the two or three hottest queries, name the columns in SELECT, and stop.\nI did not stop, once. Six columns in one index and the report query flew. Inserts on that table got slower the same week, and it took me a month to connect the two.\n","permalink":"https://useinov.com/posts/2018/covering-index/","summary":"\u003cp\u003e\u003ccode\u003eUsing index\u003c/code\u003e in the Extra column of EXPLAIN. That is the cheapest read MySQL can do, and you often get it almost for free.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eSELECT\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003euser_id\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ecreated_at\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eFROM\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eorders\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eWHERE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003euser_id\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"mi\"\u003e42\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eORDER\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eBY\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ecreated_at\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eDESC\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eLIMIT\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"mi\"\u003e20\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eWith an index on \u003ccode\u003e(user_id, created_at)\u003c/code\u003e everything the query needs is in the index leaves. The table is never touched. A composite index finds rows fast, a covering index answers the query on its own. Postgres calls this Index Only Scan, with one condition: the visibility map must be fresh, so a table that vacuum never visits quietly falls back to heap fetches.\u003c/p\u003e","title":"Covering index and the price of SELECT *"},{"content":"$bus-\u0026gt;dispatch(new SendWelcomeEmail($user-\u0026gt;getId())); and then, in the same request, a handler:\nclass SendWelcomeEmailHandler { public function __invoke(SendWelcomeEmail $message) { // load user by id, send the email } } That is Messenger in Symfony 4.1, still marked experimental. By default everything is synchronous, so at first it is a function call with extra steps.\nThe extra steps are for the transport. Change configuration, route this message class to AMQP, and the same handler runs in a worker process. The calling code does not change. Start synchronous, go async when you need it. I like this order much more than \u0026ldquo;install RabbitMQ on day one\u0026rdquo;.\nTwo rules follow from the design, and both bite when ignored.\nThe message travels, so it gets serialized. Put ids in it, never Doctrine entities. An entity in a message looks fine in sync mode and explodes the day you flip the transport, or worse, quietly serializes a detached object graph.\nA failed message goes back to the queue and the handler runs again. Sending the welcome email twice is embarrassing. Charging twice is a disaster. Make handlers idempotent from the start, while everything is still synchronous and it looks unnecessary.\nAnd the thing no config flag hides: async changes the consistency model. After dispatch() returns, in async mode nothing has happened yet. Code below that line cannot assume the email exists, the projection is updated, the PDF is generated. The framework makes sync-to-async a one-line change. Your assumptions are not switched by config.\nFlip one message to async early, in staging, to find out which assumptions you have. I flipped one last week. I had four.\n","permalink":"https://useinov.com/posts/2018/messenger-sync-first/","summary":"\u003cp\u003e\u003ccode\u003e$bus-\u0026gt;dispatch(new SendWelcomeEmail($user-\u0026gt;getId()));\u003c/code\u003e and then, in the same request, a handler:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eclass\u003c/span\u003e \u003cspan class=\"nc\"\u003eSendWelcomeEmailHandler\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"fm\"\u003e__invoke\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eSendWelcomeEmail\u003c/span\u003e \u003cspan class=\"nv\"\u003e$message\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"c1\"\u003e// load user by id, send the email\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThat is Messenger in Symfony 4.1, still marked experimental. By default everything is synchronous, so at first it is a function call with extra steps.\u003c/p\u003e\n\u003cp\u003eThe extra steps are for the transport. Change configuration, route this message class to AMQP, and the same handler runs in a worker process. The calling code does not change. Start synchronous, go async when you need it. I like this order much more than \u0026ldquo;install RabbitMQ on day one\u0026rdquo;.\u003c/p\u003e","title":"Messenger: in-process today, queue tomorrow"},{"content":"The row in users. Then orders, with the delivery address and phone. Then nginx access logs with emails inside GET parameters of an old unsubscribe endpoint. That is how far I got in the first ten minutes of drawing where one user\u0026rsquo;s data lives on one project, and the map did not fit on one page.\nMay 25 is close and everyone discusses consent banners. The lawyers can have the banners. The engineering part is the right to erasure. \u0026ldquo;Delete the user\u0026rdquo; sounds like one DELETE statement until you sit down with a pen.\nThe rest of my map: application logs where somebody dumped whole request bodies \u0026ldquo;for debugging\u0026rdquo;. Redis queue payloads, serialized with name and email inside. The analytics events table. A denormalized customer_name column in orders, copied at purchase time so old orders keep the name from that moment. Backups of all of the above. The mail provider with its own delivery logs.\nIt helps to classify: source of truth, derived data, audit records. For derived data the answer is usually simple, delete it or let it expire. Audit and financial records are the opposite case. Accounting law requires keeping orders for years, so the user is anonymized and the transaction stays. Which also means no naive ON DELETE CASCADE from users, or one erasure request eats order history you are legally required to keep. Foreign keys do not know about accounting law.\nBackups are the honest edge case. You cannot edit last month\u0026rsquo;s dump. The workable answer is retention: backups expire after N days, so erasure is fully complete after N days. Write that into the policy instead of pretending backups do not exist.\nDraw the map before the first real request arrives. It is an afternoon of work when nobody is waiting. The \u0026ldquo;for debugging\u0026rdquo; log line, by the way, was mine, from 2016. It is still there.\n","permalink":"https://useinov.com/posts/2018/gdpr-data-map/","summary":"\u003cp\u003eThe row in \u003ccode\u003eusers\u003c/code\u003e. Then orders, with the delivery address and phone. Then nginx access logs with emails inside GET parameters of an old unsubscribe endpoint. That is how far I got in the first ten minutes of drawing where one user\u0026rsquo;s data lives on one project, and the map did not fit on one page.\u003c/p\u003e\n\u003cp\u003eMay 25 is close and everyone discusses consent banners. The lawyers can have the banners. The engineering part is the right to erasure. \u0026ldquo;Delete the user\u0026rdquo; sounds like one DELETE statement until you sit down with a pen.\u003c/p\u003e","title":"GDPR: where the user actually lives"},{"content":"@row := @row + 1. I have a report in production that stands on that trick, and the trick was never guaranteed to work. Evaluation order of user variables in SELECT is undefined, it just happened to behave. MySQL 8.0 went GA last week, and for me the release is about SQL. Window functions and CTEs, the things Postgres people stopped noticing years ago, are here.\nThe classic task: top three orders per customer. In 5.7 you had a self-join nobody could read a month later, or the variable trick. Now:\nSELECT customer_id, order_id, amount FROM ( SELECT customer_id, order_id, amount, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY amount DESC ) AS rn FROM orders ) t WHERE rn \u0026lt;= 3; Boring, standard, readable. Exactly what a report query should be.\nCTEs are the second gift. WITH lets you name the steps of a report instead of nesting subqueries five levels deep. And WITH RECURSIVE closes an old wound: category trees. Everyone who stored a hierarchy in MySQL either did nested sets or walked the tree in PHP with a query per level. A recursive CTE does it in one statement.\nOne warning before you rewrite everything. More expressive SQL is not cheaper SQL. A window function over the whole orders table with no useful index on the partition and order columns is still a full scan plus a sort. The query got shorter, the work did not. Read the EXPLAIN like before.\nMigration note: 8.0 defaults to utf8mb4. Good default, but check connection settings and column collations before the upgrade surprises you with mixed collation errors in joins.\nThe report with the variable trick is still in production. I know how to fix it now. It has worked for years, and that is exactly why nobody will let me touch it.\n","permalink":"https://useinov.com/posts/2018/mysql-eight-sql/","summary":"\u003cp\u003e\u003ccode\u003e@row := @row + 1\u003c/code\u003e. I have a report in production that stands on that trick, and the trick was never guaranteed to work. Evaluation order of user variables in SELECT is undefined, it just happened to behave. MySQL 8.0 went GA last week, and for me the release is about SQL. Window functions and CTEs, the things Postgres people stopped noticing years ago, are here.\u003c/p\u003e\n\u003cp\u003eThe classic task: top three orders per customer. In 5.7 you had a self-join nobody could read a month later, or the variable trick. Now:\u003c/p\u003e","title":"MySQL 8.0: finally, modern SQL"},{"content":"A .env file committed to git, \u0026ldquo;temporarily\u0026rdquo;. Database password, API keys, mailer credentials, all in one file. Symfony 4 moved configuration to environment variables, and this is the second project this month where I see the same thing.\n.env is a developer convenience. It exists so local setup does not require exporting fifteen variables by hand before running the app. That is the whole job of this file. .env.dist goes to git with placeholder values, .env stays in .gitignore with your local ones. This part is not negotiable.\nProduction is a different world. There the variables come from the environment for real: from the systemd unit, from the container definition, from whatever orchestration you have. Not from a file that PHP parses on every request. The Dotenv component says this in its docs, but nobody reads that far. A file with production secrets in the project root, readable by the web server user, one misconfigured vhost away from being served as text.\nThere is also a build-time versus runtime distinction that bites. Container compilation and cache warmup happen once, at deploy. %env(DATABASE_URL)% is resolved at runtime, later, and that is exactly why it exists. Warm the cache on a build server and runtime resolution is what lets the same artifact run in staging and in production. Bake the value into the compiled container and you lose that.\nNow the committed password from the first line. Removing the commit is not enough, git history remembers. A secret that has ever been in git is a former secret. Rotate it today, then fix the gitignore. In that order.\nThe second project was mine. The file is gone, the password is rotated, and the commit is still there if you know where to look.\n","permalink":"https://useinov.com/posts/2018/env-not-secrets/","summary":"\u003cp\u003eA \u003ccode\u003e.env\u003c/code\u003e file committed to git, \u0026ldquo;temporarily\u0026rdquo;. Database password, API keys, mailer credentials, all in one file. Symfony 4 moved configuration to environment variables, and this is the second project this month where I see the same thing.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003e.env\u003c/code\u003e is a developer convenience. It exists so local setup does not require exporting fifteen variables by hand before running the app. That is the whole job of this file. \u003ccode\u003e.env.dist\u003c/code\u003e goes to git with placeholder values, \u003ccode\u003e.env\u003c/code\u003e stays in \u003ccode\u003e.gitignore\u003c/code\u003e with your local ones. This part is not negotiable.\u003c/p\u003e","title":".env is not a secrets store"},{"content":"Search for -\u0026gt;fetch( in a project with old Kohana code and count the hits. A method call on a model, a comment, a line in a test fixture, an unrelated class that happens to have a method with the same name. That is what regex refactoring looks like. Regex does not know a method call from a string literal.\nRector does. A young tool I found this month: it parses PHP into an AST with nikic/php-parser, applies transformation rules, prints the code back. Rename a class across the whole project. Change a method call, add an argument everywhere. The AST sees that this fetch is called on that type and touches only those places. Mechanical change becomes exact.\nThe workflow that makes sense to me: run one rule, run the tests, review the diff. The review of a machine-made change is boring, and boring is what you want here. You review the rule once, not a thousand changed lines one by one. Attention goes to the two places where the rule did something unexpected.\nI think about this from the position of someone with Kohana and Symfony code still in production. A framework upgrade is maybe ninety percent mechanical renames and signature changes. That ninety percent is why upgrades get postponed for years. If a machine takes the mechanical part, what remains is the actual decisions, and that part was never the problem.\nThe tool is early and rough. Rules cover a narrow set of cases, you will hit bugs, and I have not run it on anything that matters yet. Fear of regex was my reason for years. Fear of a new tool is a different reason with the same result.\nThe upgrade I keep postponing is still postponed. The excuse just got weaker.\n","permalink":"https://useinov.com/posts/2018/rector-machine-refactoring/","summary":"\u003cp\u003eSearch for \u003ccode\u003e-\u0026gt;fetch(\u003c/code\u003e in a project with old Kohana code and count the hits. A method call on a model, a comment, a line in a test fixture, an unrelated class that happens to have a method with the same name. That is what regex refactoring looks like. Regex does not know a method call from a string literal.\u003c/p\u003e\n\u003cp\u003eRector does. A young tool I found this month: it parses PHP into an AST with nikic/php-parser, applies transformation rules, prints the code back. Rename a class across the whole project. Change a method call, add an argument everywhere. The AST sees that this \u003ccode\u003efetch\u003c/code\u003e is called on that type and touches only those places. Mechanical change becomes exact.\u003c/p\u003e","title":"Rector: refactoring by machine"},{"content":"redis-cli llen queues:default and hope. That was my queue monitoring for years. Horizon replaces it with a real dashboard, and I did not know how much I needed one until I saw it.\nThe mental shift matters more than the UI. dispatch() is the beginning of the work, not the end. The job still has to wait in Redis, run, maybe fail, maybe retry. All of that was invisible. Now it is on one screen: throughput, wait time per queue, failed jobs with the full payload and the exception.\nConfiguration is one file. I split queues by priority:\n\u0026#39;production\u0026#39; =\u0026gt; [ \u0026#39;supervisor-1\u0026#39; =\u0026gt; [ \u0026#39;connection\u0026#39; =\u0026gt; \u0026#39;redis\u0026#39;, \u0026#39;queue\u0026#39; =\u0026gt; [\u0026#39;payments\u0026#39;, \u0026#39;default\u0026#39;, \u0026#39;emails\u0026#39;], \u0026#39;balance\u0026#39; =\u0026gt; \u0026#39;auto\u0026#39;, \u0026#39;processes\u0026#39; =\u0026gt; 10, ], ], balance =\u0026gt; auto moves worker processes to the queue that falls behind. Sounds like magic. Works fine, at least on the one project I watch daily now.\nThe most useful metric turned out to be wait time. Throughput tells you how busy you are. Wait time tells you how much your users suffer. A payment confirmation job that waits ninety seconds behind a pile of newsletter jobs is a bug, even when every job succeeds.\nSecond lesson. At some point more workers stop helping. We went from ten processes to twenty and throughput did not move. Jobs were spending their time waiting on MySQL and one slow external API, and twenty workers means twenty connections waiting in parallel. The dashboard shows this honestly: jobs per minute flat, wait time still growing. When you see that picture, the fix is somewhere else than the queue config.\nFor years I answered \u0026ldquo;how is the queue\u0026rdquo; with a number from redis-cli. I was answering a different question.\n","permalink":"https://useinov.com/posts/2018/laravel-horizon-queues/","summary":"\u003cp\u003e\u003ccode\u003eredis-cli llen queues:default\u003c/code\u003e and hope. That was my queue monitoring for years. Horizon replaces it with a real dashboard, and I did not know how much I needed one until I saw it.\u003c/p\u003e\n\u003cp\u003eThe mental shift matters more than the UI. \u003ccode\u003edispatch()\u003c/code\u003e is the beginning of the work, not the end. The job still has to wait in Redis, run, maybe fail, maybe retry. All of that was invisible. Now it is on one screen: throughput, wait time per queue, failed jobs with the full payload and the exception.\u003c/p\u003e","title":"Horizon, or the queue becomes visible"},{"content":"PHP 7.2 came out on the last day of November. Mcrypt is out of core, libsodium is in. That trade alone makes it a good release.\nSearch any forum for \u0026ldquo;php encrypt\u0026rdquo; and you find the same folk recipe: openssl_encrypt with AES-256-CBC, an IV made from who knows what, no authentication of the ciphertext. Every choice in that recipe is a place to be wrong, and CBC without a MAC is wrong in a way that has published attacks. The developer is not careless. The API hands an application developer decisions that belong to a cryptographer.\nSodium takes the decisions away. One task, one function, the right parameters inside:\n$key = sodium_crypto_secretbox_keygen(); $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $cipher = sodium_crypto_secretbox($message, $nonce, $key); No cipher to pick, no mode to pick, authentication built in. Tampered ciphertext fails to open instead of decrypting into garbage that your code then trusts.\nThree tasks, three tools, and mixing them is the classic mistake. Passwords go through password_hash, and 7.2 adds Argon2i there. It is one-way on purpose, you never need the password back. Data you must read again is secretbox, or crypto_box when two parties are involved. Proving authorship without hiding anything is sodium_crypto_sign. If you are encrypting passwords or hashing things you need back, stop and re-read the task.\nWhat sodium does not solve is keys. A key in the repo protects nothing. Keep it in the environment or a secrets store, apart from the data it protects, and decide on rotation before the incident.\nAbove all: use primitives made by people who break them for a living. The moment you design your own scheme out of hash functions and XOR, you have already lost. You just have not been told yet.\n","permalink":"https://useinov.com/posts/2017/php-sodium/","summary":"\u003cp\u003ePHP 7.2 came out on the last day of November. Mcrypt is out of core, libsodium is in. That trade alone makes it a good release.\u003c/p\u003e\n\u003cp\u003eSearch any forum for \u0026ldquo;php encrypt\u0026rdquo; and you find the same folk recipe: \u003ccode\u003eopenssl_encrypt\u003c/code\u003e with AES-256-CBC, an IV made from who knows what, no authentication of the ciphertext. Every choice in that recipe is a place to be wrong, and CBC without a MAC is wrong in a way that has published attacks. The developer is not careless. The API hands an application developer decisions that belong to a cryptographer.\u003c/p\u003e","title":"Sodium in PHP 7.2 core"},{"content":"A kernel, an index.php, a config folder with almost nothing in it. That is a Symfony 4 project on day one, and 4.0 came out today together with 3.4 LTS. The code changes are the smaller story. How a project starts is the bigger one.\nThe Standard Edition is gone. You start from the empty skeleton and ask for what you need. composer require orm pulls Doctrine, and the recipe writes the config files, registers the bundle in bundles.php, adds the variables to .env. You watch your own repo grow file by file as you add dependencies.\nThis inverts the old model. Standard Edition gave you everything and you deleted what you did not need, which in practice was never. Now you get nothing and add. A microservice stays a hundred lines of config. A monolith grows to exactly its own size.\nAbout the magic, because recipes are magic: code from the internet edits your project at install time. My worry lasted until I looked at the result. A recipe runs once and leaves plain files in the repo, and after that Flex does not own them, you do. You read them, edit them, commit them. Magic that leaves a paper trail is the acceptable kind. The runtime kind, the one that does things on every request where you cannot see, is the one to fear.\nMigration. 4.0 is 3.4 minus everything deprecated. Same features, same behavior. So the path is: upgrade to 3.4, turn on deprecation warnings, fix them until the log is silent, then 4.0 is a version bump in composer.json. Anyone planning a direct jump from 3.2 is planning a long weekend.\nI have one project on 2.8. For that one I am not planning anything yet.\n","permalink":"https://useinov.com/posts/2017/symfony-flex/","summary":"\u003cp\u003eA kernel, an \u003ccode\u003eindex.php\u003c/code\u003e, a config folder with almost nothing in it. That is a Symfony 4 project on day one, and 4.0 came out today together with 3.4 LTS. The code changes are the smaller story. How a project starts is the bigger one.\u003c/p\u003e\n\u003cp\u003eThe Standard Edition is gone. You start from the empty skeleton and ask for what you need. \u003ccode\u003ecomposer require orm\u003c/code\u003e pulls Doctrine, and the recipe writes the config files, registers the bundle in \u003ccode\u003ebundles.php\u003c/code\u003e, adds the variables to \u003ccode\u003e.env\u003c/code\u003e. You watch your own repo grow file by file as you add dependencies.\u003c/p\u003e","title":"Symfony 4: the framework as recipes"},{"content":"DROP TABLE events_2017_10. That is how you delete October now, and it is the reason I am reading PostgreSQL 10 release notes on a Thursday evening instead of waiting a year like usual.\nPartitioning existed before, through inheritance, CHECK constraints and an insert trigger you wrote yourself and hoped was right. Now the database owns it:\nCREATE TABLE events ( id bigserial NOT NULL, created_at timestamptz NOT NULL, payload jsonb ) PARTITION BY RANGE (created_at); CREATE TABLE events_2017_10 PARTITION OF events FOR VALUES FROM (\u0026#39;2017-10-01\u0026#39;) TO (\u0026#39;2017-11-01\u0026#39;); The win is the data lifecycle. An events table grows forever, and deleting a year of history with DELETE is a night of I/O plus a bloated table in the morning. With partitions, retiring a month is one DROP. Instant. And a query that filters by created_at visits only the partitions in range, the planner skips the rest.\nLogical replication is the second thing. Streaming replication copies the whole cluster byte by byte, same major version on both ends. Logical works per table: a publication on one server, a subscription on another, only the tables you named. So you can feed the two hot tables to a reporting server and let the analysts run their monster queries far from production. Or replicate across versions, which makes the next major upgrade less of a cliff.\nNow the cold shower. Partitioning is not a speed switch. If your queries do not filter by the partition key, every query now visits every partition, and you made it slower and the schema stranger. A unique constraint across partitions does not exist in this version at all, and a row with no matching partition simply fails to insert, so somebody has to create November before November. The test I use: for each frequent query, can you say which partitions it touches? If the answer is \u0026ldquo;all of them\u0026rdquo;, you do not need partitions. You need an index.\n","permalink":"https://useinov.com/posts/2017/postgres-ten/","summary":"\u003cp\u003e\u003ccode\u003eDROP TABLE events_2017_10\u003c/code\u003e. That is how you delete October now, and it is the reason I am reading PostgreSQL 10 release notes on a Thursday evening instead of waiting a year like usual.\u003c/p\u003e\n\u003cp\u003ePartitioning existed before, through inheritance, CHECK constraints and an insert trigger you wrote yourself and hoped was right. Now the database owns it:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eCREATE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eTABLE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eevents\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"n\"\u003eid\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ebigserial\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eNOT\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eNULL\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"n\"\u003ecreated_at\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003etimestamptz\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eNOT\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eNULL\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"n\"\u003epayload\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ejsonb\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ePARTITION\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eBY\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eRANGE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"n\"\u003ecreated_at\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eCREATE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eTABLE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eevents_2017_10\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ePARTITION\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eOF\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eevents\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"k\"\u003eFOR\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eVALUES\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eFROM\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;2017-10-01\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eTO\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;2017-11-01\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe win is the data lifecycle. An events table grows forever, and deleting a year of history with DELETE is a night of I/O plus a bloated table in the morning. With partitions, retiring a month is one DROP. Instant. And a query that filters by \u003ccode\u003ecreated_at\u003c/code\u003e visits only the partitions in range, the planner skips the rest.\u003c/p\u003e","title":"PostgreSQL 10: partitions I can finally use"},{"content":"Level 0, a legacy codebase, a few hundred errors on the first run. That was my week with PHPStan. Most of the output was noise about magic the tool cannot see. But in the first hour of reading I found three real bugs, live in production for months.\nOne: a repository method returns an entity or null, and a caller chains a method right on the result. The not-found branch was never written. It survived because that path needs a deleted record, and deleted records are rare. Rare is not never.\nTwo: a branch reading $row['user_name'] from a query that stopped selecting user_name half a year ago. Dead code pretending to be alive, waiting for someone to \u0026ldquo;fix\u0026rdquo; something next to it.\nThree: a call passing three arguments to a function that takes two. PHP quietly drops the extra one. The extra one was the important one.\nNone of these needed a type system to exist. They needed someone to read every path, and tests only cover the paths somebody thought of. Static analysis reads all of them and does not get bored.\nFor legacy the strategy is incremental. Level 0 in CI, fix what it found, hold the line. Raise the level when the current one is clean. For the hopeless corners there is ignoreErrors with a regex in the config. Write a comment next to each one saying why, or the ignore list becomes a second legacy.\nYou do not have to type the whole codebase first. Add phpdoc where inference fails and PHPStan works with the dynamic mess as it is. It checks the contract that already exists, it does not demand a new one.\nTwo of the three bugs were in diffs I had reviewed. I read them. I approved them.\n","permalink":"https://useinov.com/posts/2017/phpstan-first-run/","summary":"\u003cp\u003eLevel 0, a legacy codebase, a few hundred errors on the first run. That was my week with PHPStan. Most of the output was noise about magic the tool cannot see. But in the first hour of reading I found three real bugs, live in production for months.\u003c/p\u003e\n\u003cp\u003eOne: a repository method returns an entity or null, and a caller chains a method right on the result. The not-found branch was never written. It survived because that path needs a deleted record, and deleted records are rare. Rare is not never.\u003c/p\u003e","title":"First run of PHPStan on legacy code"},{"content":"Laravel 5.5 came out yesterday, an LTS: two years of bug fixes, three of security fixes. Good news for projects that outlive the hype cycle, which is most of them.\nThe feature in every screenshot is package auto-discovery. A package declares its service provider in its own composer.json, and after composer require it is registered. No editing of config/app.php, no provider line copied from the readme.\nConvenient. Now look at what got deleted. Installing a package used to be two steps, and the second step was consent: a line in my own config saying this code runs inside my application at boot. Now composer require is the whole ceremony. The list of code that runs at boot lives in vendor/, spread over other people\u0026rsquo;s files. php artisan package:discover shows it, and dont-discover in composer.json switches it off per package. But the default flipped from explicit to implicit.\nFor a debugbar I do not care. For anything that registers middleware, listens to events or wraps the exception handler, I want the registration in my repo, in a place where a diff will show it.\nThere is a quieter question under this one. Half of the packages I see are twenty lines of code wrapped in a provider, a facade and a config file. If the useful part is one class, take the one class. A dependency you can read in five minutes is cheaper than a convenience you have to trust.\nAuto-discovery removes the friction. Friction was annoying. It was also the moment where somebody asked whether the package is needed at all. I did not like that moment either, and I will miss it.\n","permalink":"https://useinov.com/posts/2017/laravel-auto-discovery/","summary":"\u003cp\u003eLaravel 5.5 came out yesterday, an LTS: two years of bug fixes, three of security fixes. Good news for projects that outlive the hype cycle, which is most of them.\u003c/p\u003e\n\u003cp\u003eThe feature in every screenshot is package auto-discovery. A package declares its service provider in its own \u003ccode\u003ecomposer.json\u003c/code\u003e, and after \u003ccode\u003ecomposer require\u003c/code\u003e it is registered. No editing of \u003ccode\u003econfig/app.php\u003c/code\u003e, no provider line copied from the readme.\u003c/p\u003e\n\u003cp\u003eConvenient. Now look at what got deleted. Installing a package used to be two steps, and the second step was consent: a line in my own config saying this code runs inside my application at boot. Now \u003ccode\u003ecomposer require\u003c/code\u003e is the whole ceremony. The list of code that runs at boot lives in \u003ccode\u003evendor/\u003c/code\u003e, spread over other people\u0026rsquo;s files. \u003ccode\u003ephp artisan package:discover\u003c/code\u003e shows it, and \u003ccode\u003edont-discover\u003c/code\u003e in composer.json switches it off per package. But the default flipped from explicit to implicit.\u003c/p\u003e","title":"Laravel 5.5 and package auto-discovery"},{"content":"720 MB. That was our production PHP image, and most of it was composer, git, unzip, build headers and a pile of apt cache. None of it runs in production. It was there because composer install needs it, and a Dockerfile was one linear script. People worked around this with two Dockerfiles and a shell script gluing them. Ugly, and everyone\u0026rsquo;s glue was different.\nDocker 17.05 brought multi-stage builds. The first Docker feature in a while that fixes a problem I actually had. Now it is one file:\nFROM composer:1.4 AS vendor COPY composer.json composer.lock ./ RUN composer install --no-dev --optimize-autoloader --no-scripts FROM php:7.1-fpm-alpine COPY --from=vendor /app/vendor /var/www/app/vendor COPY src/ /var/www/app/src/ The first stage has composer and does the heavy lifting. The final image never sees it. Only vendor/ crosses the border, through COPY --from.\nThe new image is 84 MB. Nice, but size is the least interesting part. There is no composer and no git inside the production container now. Whatever an attacker manages to do in there, he does it without a package manager and without a toolchain. Smaller image is mostly a faster pull. Smaller toolset is a smaller playground.\nOne more effect. The image is a complete artifact: code plus dependencies, built once, immutable. The same image goes to staging and production. No composer install on the server, no \u0026ldquo;it resolved different versions on prod\u0026rdquo; mystery. The build happens in the build. That sounds like a tautology and took us years.\nCopy composer.json and the lock file before the sources, like above. Then editing PHP code does not invalidate the dependency layer, and rebuilds take seconds. I checked the 720 MB number twice before writing it down. I had stopped noticing.\n","permalink":"https://useinov.com/posts/2017/docker-multi-stage/","summary":"\u003cp\u003e720 MB. That was our production PHP image, and most of it was composer, git, unzip, build headers and a pile of apt cache. None of it runs in production. It was there because \u003ccode\u003ecomposer install\u003c/code\u003e needs it, and a Dockerfile was one linear script. People worked around this with two Dockerfiles and a shell script gluing them. Ugly, and everyone\u0026rsquo;s glue was different.\u003c/p\u003e\n\u003cp\u003eDocker 17.05 brought multi-stage builds. The first Docker feature in a while that fixes a problem I actually had. Now it is one file:\u003c/p\u003e","title":"Multi-stage builds for PHP images"},{"content":"LIMIT 50 OFFSET 500000. Page 10001 of an events table, from a paginator someone wrote in an afternoon. Postgres has no shortcut to row 500000. It walks the index through half a million entries, fetches them, throws them away, and returns fifty. Page one is fast. Page ten thousand is slow, and every page after it is slower. Run EXPLAIN ANALYZE on both: the plans are identical, the numbers are not.\nKeyset pagination stops counting and starts seeking. The client sends the position of the last row it saw instead of a page number:\nSELECT id, created_at, payload FROM events WHERE (created_at, id) \u0026lt; (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 50; With an index on (created_at, id) this is one descent into the index and fifty rows out. Page one and page ten thousand cost the same.\nThe ordering must be total. That is why id is there: created_at alone has duplicates, and a duplicate on a page boundary means a lost row or a repeated one. The pair from the last row is the cursor. Base64 it into the next-page link, nothing more is needed. A side effect I did not expect to like: inserts no longer shift the pages. With OFFSET a new row pushes everything down and page four shows you a row from page three. Keyset does not notice.\nThe limitation is known. No jump to page 7000, only next and previous. Before calling this a problem, grep the access logs of the paginator you have. On the project where I did this nobody went past page nine in a month. People do not browse to page 7000. Robots do, and robots I am happy to slow down.\n","permalink":"https://useinov.com/posts/2017/keyset-pagination/","summary":"\u003cp\u003e\u003ccode\u003eLIMIT 50 OFFSET 500000\u003c/code\u003e. Page 10001 of an events table, from a paginator someone wrote in an afternoon. Postgres has no shortcut to row 500000. It walks the index through half a million entries, fetches them, throws them away, and returns fifty. Page one is fast. Page ten thousand is slow, and every page after it is slower. Run EXPLAIN ANALYZE on both: the plans are identical, the numbers are not.\u003c/p\u003e","title":"Keyset pagination instead of OFFSET"},{"content":"$container-\u0026gt;get('app.mailer') stopped working this week. Symfony 3.3 is out, and in the new configuration services are private by default. The container refuses the call. Everyone is writing about autowiring. This is the change I like more.\nThat call was always a smell. A class that pulls dependencies from the container by string id is a class with secrets. Its constructor says nothing, and to test it you boot half the framework, because it may ask for anything at any moment.\nConstructor injection makes the same code boring, in the good sense:\npublic function __construct(Mailer $mailer, LoggerInterface $logger) { $this-\u0026gt;mailer = $mailer; $this-\u0026gt;logger = $logger; } The signature is the documentation. The test is new UserNotifier($mailerMock, new NullLogger()). No kernel, no container, no fixtures for things you do not use.\nErrors move to a better place too. A wrong service id used to explode in production, on the one request that hit that branch. Now a missing dependency fails when the container compiles. Cache warmup dies on the deploy server, traffic never sees it. A loud deploy beats a quiet incident.\nThe bigger point is what the container becomes: a wiring machine that runs before your code and then gets out of the way. An implementation detail.\nI still have $container-\u0026gt;get() in a few old console commands. They work, the old config is untouched, the container still hands them whatever they ask for. I am just no longer proud of them.\n","permalink":"https://useinov.com/posts/2017/private-services/","summary":"\u003cp\u003e\u003ccode\u003e$container-\u0026gt;get('app.mailer')\u003c/code\u003e stopped working this week. Symfony 3.3 is out, and in the new configuration services are private by default. The container refuses the call. Everyone is writing about autowiring. This is the change I like more.\u003c/p\u003e\n\u003cp\u003eThat call was always a smell. A class that pulls dependencies from the container by string id is a class with secrets. Its constructor says nothing, and to test it you boot half the framework, because it may ask for anything at any moment.\u003c/p\u003e","title":"Private services: the container steps back"},{"content":"Forty million rows, three application instances behind a balancer, one migration that renames a column. The deploy script runs migrations first, then rolls the code instance by instance. For a few minutes old code was writing to a column that no longer existed. The error rate graph looked like a wall. I had approved that migration. It was one line.\nMigration tools trained us to think a schema change is a code change. Write a file, run it, done. A schema change on a big table takes time, takes locks, and while it runs the previous version of the application is still serving traffic. Both versions must survive both schemas. That is the whole discipline, and it has a name: expand and contract.\nExpand first. Add the new column, nullable, no default that rewrites the table. Cheap. Old code ignores the new column and nothing breaks.\nThen deploy code that writes both columns and reads the old one. Then backfill history in batches, a few thousand rows per query, with pauses between them. One big UPDATE is the same outage as one big ALTER in different clothes.\nThen switch reads to the new column. Watch it for a day.\nContract last. Dropping the old column is a separate deploy, days later, when you are sure nothing reads it. A rename as a single operation does not exist in this world. A rename is add plus drop, with a week between.\nYes, this turns one line of DDL into four deploys. That is the price. pt-online-schema-change helps with locking, and MySQL 5.7 does online DDL better than 5.5 did, but no tool closes the compatibility window. Only ordering does.\nRehearse on a copy of production data. Staging with a thousand rows tells you nothing about locks. The ALTER that is instant there runs forty minutes on the real table, and you want to learn that number on a Tuesday afternoon.\nA migration is not a file. It is a sequence of deploys.\n","permalink":"https://useinov.com/posts/2017/zero-downtime-migrations/","summary":"\u003cp\u003eForty million rows, three application instances behind a balancer, one migration that renames a column. The deploy script runs migrations first, then rolls the code instance by instance. For a few minutes old code was writing to a column that no longer existed. The error rate graph looked like a wall. I had approved that migration. It was one line.\u003c/p\u003e\n\u003cp\u003eMigration tools trained us to think a schema change is a code change. Write a file, run it, done. A schema change on a big table takes time, takes locks, and while it runs the previous version of the application is still serving traffic. Both versions must survive both schemas. That is the whole discipline, and it has a name: expand and contract.\u003c/p\u003e","title":"ALTER TABLE is part of the deploy"},{"content":"Our services file repeats every class name three times. Once as the id, once as the class, and again in the arguments of every service that needs it. Add a constructor parameter, edit YAML, clear cache, repeat. Symfony 3.3 lands in May and promises to delete most of that file:\nservices: _defaults: autowire: true autoconfigure: true AppBundle\\: resource: \u0026#39;../../src/AppBundle/*\u0026#39; Autowiring and autoconfiguration on by default, the whole source folder registered in five lines. I have mixed feelings. Sorted, they look like this.\nThe good part is obvious. What we do by hand today is typing. With autowiring the type-hint is the configuration. The constructor was already the honest list of dependencies. Now the container reads it.\nThe uncomfortable part. Today, when I ask \u0026ldquo;where does this mailer come from\u0026rdquo;, the answer is a line in a file. In May the answer is \u0026ldquo;from the type-hint, resolved by the container at compile time\u0026rdquo;. Correct, and nowhere to look. Interfaces make it sharper. Two implementations of one interface is an ambiguity, and you resolve it with an alias, which is explicit config again. So the explicit config did not disappear. It shrank to the places where a decision exists.\nMaybe that is the right way to see it. Wiring with only one possible answer should be automated, because writing it by hand adds no information. Wiring that is a choice, which of two mailers, must stay written down. Config should be a list of decisions, not an inventory.\nMy plan is to turn it on for new code and leave the old definitions alone. I know myself: if I start converting the old file, I will spend a week on it and call it refactoring.\n","permalink":"https://useinov.com/posts/2017/symfony-autowiring/","summary":"\u003cp\u003eOur services file repeats every class name three times. Once as the id, once as the class, and again in the arguments of every service that needs it. Add a constructor parameter, edit YAML, clear cache, repeat. Symfony 3.3 lands in May and promises to delete most of that file:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003eservices\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003e_defaults\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"nt\"\u003eautowire\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"nt\"\u003eautoconfigure\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"kc\"\u003etrue\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e\u003cspan class=\"nt\"\u003eAppBundle\\\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e        \u003c/span\u003e\u003cspan class=\"nt\"\u003eresource\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;../../src/AppBundle/*\u0026#39;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eAutowiring and autoconfiguration on by default, the whole source folder registered in five lines. I have mixed feelings. Sorted, they look like this.\u003c/p\u003e","title":"Autowiring is coming to Symfony 3.3"},{"content":"Cache hit rate 98 percent. The database still fell over. Both facts are true, and the second one does not care about the first.\nWhat happened. One key holds the result of a heavy query for the main page. TTL one minute. At some second the key expires. In that second a few hundred requests all get a miss, and all of them go to recompute the same heavy query. Redis is fine. MySQL is not. This is a cache stampede, and hit rate will not warn you, because hit rate is an average. The stampede lives in the worst second.\nThe straightforward fix is a lock. On miss, SET lock:key 1 NX EX 10. Whoever wins recomputes. Fine, but now decide what the losers do. If they wait in a sleep loop, you moved the pile-up from the database into PHP-FPM workers, and those run out faster.\nBetter: keep serving the old value while one process refreshes it. Store the data with a long Redis TTL and put the logical expiry inside the value. Expired logically, still present physically. One request takes the lock and refreshes, everyone else eats slightly stale data. For a main page, ten seconds of stale is nothing.\nThere is also a probabilistic trick: each request may volunteer to refresh a bit before expiry, with probability growing as the deadline comes closer. No lock at all, and the expiry moment stops being a cliff.\nOne warning. The lock is now part of your failure model. The process that took it can die. So the lock must have a TTL, and the code must survive the case where nobody refreshed in time. Do not skip that branch. It fires at night, and the first version of mine did not have it.\n","permalink":"https://useinov.com/posts/2017/cache-stampede/","summary":"\u003cp\u003eCache hit rate 98 percent. The database still fell over. Both facts are true, and the second one does not care about the first.\u003c/p\u003e\n\u003cp\u003eWhat happened. One key holds the result of a heavy query for the main page. TTL one minute. At some second the key expires. In that second a few hundred requests all get a miss, and all of them go to recompute the same heavy query. Redis is fine. MySQL is not. This is a cache stampede, and hit rate will not warn you, because hit rate is an average. The stampede lives in the worst second.\u003c/p\u003e","title":"Cache stampede: the hot key problem"},{"content":"Laravel 5.4 came out on Tuesday, and the first thing I installed was Dusk. Browser tests without a Selenium server: it talks to ChromeDriver directly, the API is fluent, and a failed test leaves a screenshot behind.\nMy first test was the login path:\n$this-\u0026gt;browse(function ($browser) { $browser-\u0026gt;visit(\u0026#39;/login\u0026#39;) -\u0026gt;type(\u0026#39;email\u0026#39;, \u0026#39;user@example.com\u0026#39;) -\u0026gt;type(\u0026#39;password\u0026#39;, \u0026#39;secret\u0026#39;) -\u0026gt;press(\u0026#39;Log in\u0026#39;) -\u0026gt;assertPathIs(\u0026#39;/home\u0026#39;); }); It passed on the third run. The first run failed because the button on the test box had different text. The second failed because a JS animation was slower than the wait. This is the normal life of a browser test. It sees the real application, and the real application moves.\nSo writing them is solved, Dusk made that easy. How many to write is the question. My answer is very few. A browser test costs seconds where a unit test costs milliseconds, and it goes red when a designer renames a button. Cover every branch of business logic this way and the suite cries wolf every week, and after a month nobody looks at red.\nI keep the pyramid boring. Many unit tests for logic. Some integration tests for queries and services. Browser tests only where a failure costs money: registration, login, checkout, the payment callback. On one project I counted such paths honestly. Eight. Not eighty.\nAnd do not assert markup in Dusk. Assert what the user gets: assertPathIs, assertSee on the text that matters. The deeper you look into the HTML, the more you test the template, and the template is not the contract.\n","permalink":"https://useinov.com/posts/2017/laravel-dusk/","summary":"\u003cp\u003eLaravel 5.4 came out on Tuesday, and the first thing I installed was Dusk. Browser tests without a Selenium server: it talks to ChromeDriver directly, the API is fluent, and a failed test leaves a screenshot behind.\u003c/p\u003e\n\u003cp\u003eMy first test was the login path:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$this\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003ebrowse\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$browser\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nv\"\u003e$browser\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003evisit\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;/login\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003etype\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;email\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;user@example.com\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003etype\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;password\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;secret\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003epress\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;Log in\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003eassertPathIs\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;/home\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e});\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eIt passed on the third run. The first run failed because the button on the test box had different text. The second failed because a JS animation was slower than the wait. This is the normal life of a browser test. It sees the real application, and the real application moves.\u003c/p\u003e","title":"Laravel Dusk and what browser tests are for"},{"content":"// returns User or false, see wiki public function findByEmail($email) Every legacy codebase has this method. The comment is the type system. Half the callers check for false, some check for null because a sister method returns null, one caller checks nothing and works by luck.\nPHP 7.1 came out last week. Nullable types, void, iterable, multi-catch. Small features, but together they continue the direction 7.0 started: less implicit agreement, more signature. The one I care about is ?Type:\npublic function findByEmail(string $email): ?User Now \u0026ldquo;maybe there is no user\u0026rdquo; is a fact the engine enforces. ?User is honest in a way that magic false never was. void does the same for commands: it declares \u0026ldquo;do not use my return value\u0026rdquo; instead of returning whatever the last line happened to produce.\nThe practical question is how to add types to an existing project without a religious rewrite. My approach, from a project we are moving through 7.x now. All new code is fully typed, no exceptions. Old code gets types only when touched for other reasons, and from the leaves inward: value objects and small services first, core classes last, because a type on a core method ripples into every caller and you do not want that ripple inside an unrelated bugfix. Where old code returns false, changing to ?Type is a behavior change, so it gets its own commit and a look at every caller.\nEach added type is a small piece of tribal knowledge turned into a check. The wiki page lies eventually. The signature cannot.\nType the boundaries first. The middles can wait. The wiki page can stay as it is. Nobody reads it anyway.\n","permalink":"https://useinov.com/posts/2016/php71-nullable-types/","summary":"\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// returns User or false, see wiki\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"nf\"\u003efindByEmail\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$email\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eEvery legacy codebase has this method. The comment is the type system. Half the callers check for \u003ccode\u003efalse\u003c/code\u003e, some check for \u003ccode\u003enull\u003c/code\u003e because a sister method returns null, one caller checks nothing and works by luck.\u003c/p\u003e\n\u003cp\u003ePHP 7.1 came out last week. Nullable types, \u003ccode\u003evoid\u003c/code\u003e, \u003ccode\u003eiterable\u003c/code\u003e, multi-catch. Small features, but together they continue the direction 7.0 started: less implicit agreement, more signature. The one I care about is \u003ccode\u003e?Type\u003c/code\u003e:\u003c/p\u003e","title":"PHP 7.1: nullable is honest"},{"content":"if ($order-\u0026gt;status == 'paid') in the shipping module. Another one in the refund module. A slightly different one in the admin panel. Then someone cancels a shipped order from an admin button, because the button just writes the string, and the warehouse learns about it a week later.\nSymfony 3.2 is out this week and ships a Workflow component. Finally a framework says out loud what every order table has been whispering for years: status is a state machine, not a string column you assign wherever convenient.\nThe state machine view asks one question first: which transitions are legal? Which states exist, everybody knows. Transitions are the question.\ntransitions: pay: from: new to: paid ship: from: paid to: shipped cancel: from: [new, paid] to: cancelled Ten lines, and they hold a business decision: a shipped order cannot be cancelled, it can only be refunded, and refund is a different process. With the component, an illegal transition throws. Without the component the same idea costs one small class with a whitelist of transitions and a single method that changes status. The model matters more than the library.\nYou also get transitions as events. \u0026ldquo;Order became paid\u0026rdquo; is a real moment where things happen: reserve stock, notify, start the shipping clock. Scattered status writes have no such moment, so the logic ends up duplicated near every write.\nMy test: try to draw the order lifecycle on a whiteboard, from the code alone. On the project with the admin button I could not. The graph existed anyway. Nobody controlled it.\n","permalink":"https://useinov.com/posts/2016/order-status-graph/","summary":"\u003cp\u003e\u003ccode\u003eif ($order-\u0026gt;status == 'paid')\u003c/code\u003e in the shipping module. Another one in the refund module. A slightly different one in the admin panel. Then someone cancels a shipped order from an admin button, because the button just writes the string, and the warehouse learns about it a week later.\u003c/p\u003e\n\u003cp\u003eSymfony 3.2 is out this week and ships a Workflow component. Finally a framework says out loud what every order table has been whispering for years: status is a state machine, not a string column you assign wherever convenient.\u003c/p\u003e","title":"Order status is a graph"},{"content":"The job called the payment provider. The call succeeded. Then the worker timed out before it marked the job done. The queue did what queues do: it retried. The provider did what it was asked: it charged again. The customer did what customers do and wrote an angry email.\nNobody made a mistake here. The queue promises at-least-once delivery, and \u0026ldquo;at least\u0026rdquo; is written in the contract. Network partitions, worker crashes, deploy restarts. Sooner or later every job runs twice, and the jobs that hurt are exactly the ones with external effects: payments, emails, webhooks, API calls.\nExactly-once delivery does not exist in practice. Exactly-once business effect is achievable, and it is your job, not the queue\u0026rsquo;s. The tool is an idempotency key.\nBefore touching the outside world, the job writes an operation row with a unique constraint on the key:\nINSERT INTO operations (idempotency_key, status) VALUES (\u0026#39;charge-order-1042\u0026#39;, \u0026#39;started\u0026#39;); The second execution of the same job hits the duplicate key error and knows the story: this operation already ran or is running. Read its status. Finished, then quietly exit. Started long ago and never finished, then you are in the genuinely hard case, and you must ask the external system what actually happened before retrying.\nIf the provider accepts an idempotency key of its own, always pass one. Then even the ugly timeout case is safe, because the provider refuses the duplicate charge itself. The good payment APIs support this, and it is a serious argument when choosing one.\nThe unique constraint does the real work here. Do not replace it with a SELECT check before insert, that is a race, the same one I wrote about in January. Let the database enforce uniqueness. It is the only participant that can.\nEvery job will run twice. I write them that way now. It took one angry email.\n","permalink":"https://useinov.com/posts/2016/idempotent-jobs/","summary":"\u003cp\u003eThe job called the payment provider. The call succeeded. Then the worker timed out before it marked the job done. The queue did what queues do: it retried. The provider did what it was asked: it charged again. The customer did what customers do and wrote an angry email.\u003c/p\u003e\n\u003cp\u003eNobody made a mistake here. The queue promises at-least-once delivery, and \u0026ldquo;at least\u0026rdquo; is written in the contract. Network partitions, worker crashes, deploy restarts. Sooner or later every job runs twice, and the jobs that hurt are exactly the ones with external effects: payments, emails, webhooks, API calls.\u003c/p\u003e","title":"The queue will run your job twice"},{"content":"38 seconds to 11. Same query, same forty million rows, no index added, no SQL changed. The only difference is PostgreSQL 9.6, released yesterday, and one setting.\nThe query is a typical report: count and sum over an events table, grouped by day, three months of data. On 9.5 the plan is one process grinding through the table. On 9.6 with parallelism enabled:\nFinalize HashAggregate -\u0026gt; Gather Workers Planned: 4 -\u0026gt; Partial HashAggregate -\u0026gt; Parallel Seq Scan on events Four workers scan their own chunks, aggregate partially, the leader merges. Just more hands. For years we optimized SQL as if the database had exactly one worker per query. That assumption expired yesterday.\nIt is off by default. Set max_parallel_workers_per_gather above zero and the planner starts considering parallel plans. Start small and watch, because workers are not free. Each one is a process to launch and a share of work_mem to spend, and the planner knows it. That is why your OLTP queries will not change at all: fetch a user by primary key, and the index lookup finishes before a worker would even start. Parallelism pays only when there are millions of rows to chew. Analytics, reports, batch jobs.\nThat is also the caution. If your reports run on the same server as production traffic, four extra busy cores per report is CPU taken from user requests. The feature makes heavy queries cheaper. It does not make them free.\nThe hardware has had many cores for a decade. Nice to see the database finally allowed to use them. I wrote the caution paragraph for myself.\n","permalink":"https://useinov.com/posts/2016/postgres-parallel-query/","summary":"\u003cp\u003e38 seconds to 11. Same query, same forty million rows, no index added, no SQL changed. The only difference is PostgreSQL 9.6, released yesterday, and one setting.\u003c/p\u003e\n\u003cp\u003eThe query is a typical report: count and sum over an events table, grouped by day, three months of data. On 9.5 the plan is one process grinding through the table. On 9.6 with parallelism enabled:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eFinalize HashAggregate\n  -\u0026gt;  Gather\n        Workers Planned: 4\n        -\u0026gt;  Partial HashAggregate\n              -\u0026gt;  Parallel Seq Scan on events\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eFour workers scan their own chunks, aggregate partially, the leader merges. Just more hands. For years we optimized SQL as if the database had exactly one worker per query. That assumption expired yesterday.\u003c/p\u003e","title":"Parallel query in PostgreSQL 9.6"},{"content":"Mail::send() inside the checkout controller. A second copy inside the API controller. Then someone adds the chat webhook to one copy and forgets the other. Every project has this code. I have written it more than once.\nLaravel 5.3 came out this week, and the two big pieces, Notifications and Passport, both point the same way. The application is no longer a thing that renders HTML. It is a core that talks to browsers, mobile clients and third parties, and HTML is one of the outputs.\nPassport is a full OAuth2 server in a package. If you ever hand-rolled token auth for a mobile app, you know how many small decisions there are to get wrong. Now it is a boring dependency. Auth is the code I most want to be boring.\nNotifications interest me more, because they force a separation people skip. There is a business fact: an order was paid. And there are delivery details: send an email, write a row for the in-app bell, ping a chat channel. Different layers. The checkout code should announce the fact and stop:\nevent(new OrderPaid($order)); A listener catches it and decides about delivery. The notification class lists channels in via(), and one more channel later is a change in that one class. Checkout does not know chat exists.\nThe test I apply: if marketing asks for SMS tomorrow, how many files change? The right answer is one.\nYou do not need Laravel 5.3 for the idea. An event and a listener exist in any stack, even in our old Kohana project. The release just makes the good structure the lazy option. On a Friday evening that is the only option people take, me included.\n","permalink":"https://useinov.com/posts/2016/laravel-53-notifications/","summary":"\u003cp\u003e\u003ccode\u003eMail::send()\u003c/code\u003e inside the checkout controller. A second copy inside the API controller. Then someone adds the chat webhook to one copy and forgets the other. Every project has this code. I have written it more than once.\u003c/p\u003e\n\u003cp\u003eLaravel 5.3 came out this week, and the two big pieces, Notifications and Passport, both point the same way. The application is no longer a thing that renders HTML. It is a core that talks to browsers, mobile clients and third parties, and HTML is one of the outputs.\u003c/p\u003e","title":"Laravel 5.3: the app is not a website anymore"},{"content":"Fifty rows. Three indexes on the table, one per column. Still slow. The query is the standard one from any multi-tenant application:\nSELECT * FROM orders WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC LIMIT 50; \u0026ldquo;Add an index\u0026rdquo; is the typical reaction, and one index on each column is the typical result. The number of indexes is not the point. The order of columns inside one index is.\nA B-tree index on (tenant_id, status, created_at) is sorted by tenant first, inside a tenant by status, inside that by date. MySQL jumps straight to the (tenant_id, status) slice, and inside the slice the rows already lie in created_at order. No filesort. Read fifty rows, done. EXPLAIN shows a short and quiet plan.\nChange the order to (created_at, tenant_id, status) and the same index is nearly useless for this query. The tree is sorted by date first, your tenant\u0026rsquo;s rows are scattered through the whole thing. Leftmost prefix is the rule: the index helps only while you consume its columns from the left, equality matches first.\nThat is also why three single-column indexes are not one composite. MySQL usually picks one of them, filters the rest row by row, then sorts. It can sometimes merge two indexes, but the merge is rarely the plan you hoped for. Three narrow indexes cost three structures on every write and still lose on read.\nMy ordering rule, stolen from the SQL indexing literature and checked on our data: equality columns first, then the range or sort column. And run EXPLAIN on production-sized data, never on the ten rows in your dev database. The optimizer changes its mind with volume.\nDesign the index from the query. The table does not know what you are going to ask it.\n","permalink":"https://useinov.com/posts/2016/composite-index-order/","summary":"\u003cp\u003eFifty rows. Three indexes on the table, one per column. Still slow. The query is the standard one from any multi-tenant application:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eSELECT\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e*\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eFROM\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eorders\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eWHERE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003etenant_id\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e?\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eAND\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003estatus\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e=\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"o\"\u003e?\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eORDER\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eBY\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003ecreated_at\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eDESC\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eLIMIT\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"mi\"\u003e50\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e\u0026ldquo;Add an index\u0026rdquo; is the typical reaction, and one index on each column is the typical result. The number of indexes is not the point. The order of columns inside one index is.\u003c/p\u003e","title":"Column order in a composite index"},{"content":"Row sixty thousand. Allowed memory size exhausted. The import script on one project died there, and the code was the obvious loop: read a row, persist() an entity, next row, flush() at the end. On a hundred test rows it worked perfectly. It was my loop.\nThe reason is the Unit of Work. Doctrine keeps every managed entity in memory, plus a snapshot of its original data for change tracking. Persist a hundred thousand entities and you hold a hundred thousand objects twice. This is not a bug. It is the price of the ORM\u0026rsquo;s main feature, and on a normal web request the price is invisible because the request dies young.\nThe standard fix is batching:\nforeach ($reader-\u0026gt;rows() as $i =\u0026gt; $row) { $em-\u0026gt;persist($this-\u0026gt;makeEntity($row)); if ($i % 500 === 0) { $em-\u0026gt;flush(); $em-\u0026gt;clear(); } } $em-\u0026gt;flush(); flush() writes, clear() detaches everything and lets memory go. The memory graph turns from a ramp into a sawtooth.\nTwo traps. After clear() every previously loaded entity is detached, so references you kept across the border are stale now, re-fetch them. And in dev, disable the SQL logger. It quietly keeps every executed query in an array:\n$em-\u0026gt;getConnection()-\u0026gt;getConfiguration()-\u0026gt;setSQLLogger(null); If the import inserts plain rows with no business logic per entity, skip the ORM and use DBAL with multi-row inserts. On our data it was about ten times faster. Also think about transaction size. One transaction around a hundred thousand rows holds locks for the whole run, one per row is slow, one per batch is the sane middle.\nAn ORM is built for the request cycle. An import is not a request. Doctrine can still do it, but you manage its memory by hand, and past some volume it is simpler to write the SQL. I wrote the SQL.\n","permalink":"https://useinov.com/posts/2016/doctrine-batch-memory/","summary":"\u003cp\u003eRow sixty thousand. \u003ccode\u003eAllowed memory size exhausted\u003c/code\u003e. The import script on one project died there, and the code was the obvious loop: read a row, \u003ccode\u003epersist()\u003c/code\u003e an entity, next row, \u003ccode\u003eflush()\u003c/code\u003e at the end. On a hundred test rows it worked perfectly. It was my loop.\u003c/p\u003e\n\u003cp\u003eThe reason is the Unit of Work. Doctrine keeps every managed entity in memory, plus a snapshot of its original data for change tracking. Persist a hundred thousand entities and you hold a hundred thousand objects twice. This is not a bug. It is the price of the ORM\u0026rsquo;s main feature, and on a normal web request the price is invisible because the request dies young.\u003c/p\u003e","title":"Doctrine and the 100 000 row import"},{"content":"A folder of cron scripts, each one a .php file with hand-parsed $argv. That is what CLI looked like on our legacy Kohana application until last month. One line fixed it:\ncomposer require symfony/console Now each script is a small Command class with named options, --help for free, and exit codes that cron can actually check. The framework around it did not notice anything. Console does not care who serves your HTTP.\nSymfony is not only the full-stack framework. It is also a shelf of separate components, and you can take one without the rest. People know this in theory. In practice I rarely see it used, which is a pity, because it is the cheapest way to get modern code into an old project.\nSame trick works elsewhere. HttpFoundation gives you a sane Request object instead of digging in superglobals. EventDispatcher gives you events without inventing your own observer. Each component is one require line and one boundary in the code.\nThe boundary is the important part. Legacy code calls the new library. Never the reverse. The Command class may use our services, but no service should import a Kohana class to serve the console. Keep the arrow pointing one way and you can migrate piece by piece for years without a big-bang rewrite.\nOne risk, and I know it from the inside. Take Console, then DI, then Routing, then HttpKernel, and congratulations, you have assembled your own framework. Nobody documents it, nobody else has debugged it, every new hire learns it from you personally. If you catch yourself wiring five components together, stop and ask whether you are avoiding a framework or writing one.\nComponents are for boundaries. The glue should stay thin. Mine is at three components and I am watching it.\n","permalink":"https://useinov.com/posts/2016/symfony-by-piece/","summary":"\u003cp\u003eA folder of cron scripts, each one a \u003ccode\u003e.php\u003c/code\u003e file with hand-parsed \u003ccode\u003e$argv\u003c/code\u003e. That is what CLI looked like on our legacy Kohana application until last month. One line fixed it:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ecomposer require symfony/console\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eNow each script is a small Command class with named options, \u003ccode\u003e--help\u003c/code\u003e for free, and exit codes that cron can actually check. The framework around it did not notice anything. Console does not care who serves your HTTP.\u003c/p\u003e","title":"Symfony by the piece"},{"content":"Two days. That is how long the new colleague spent last month getting one project to run on his laptop. The README is two pages: PHP with a specific set of extensions, Nginx, MySQL, Redis, and a paragraph that starts with \u0026ldquo;on OS X it is a bit different\u0026rdquo;. Every laptop in the team is a slightly different snowflake.\nSo I finally tried Docker for local development. The compose file:\nversion: \u0026#39;2\u0026#39; services: php: build: ./docker/php volumes: - .:/var/www/app nginx: image: nginx:1.9 ports: - \u0026#34;8080:80\u0026#34; volumes: - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf mysql: image: mysql:5.6 environment: MYSQL_DATABASE: app volumes: - dbdata:/var/lib/mysql volumes: dbdata: The php image is a small Dockerfile: official php:5.6-fpm plus docker-php-ext-install for the extensions from that README paragraph. The paragraph is code now. It cannot drift.\nThings I learned the hard way. State does not live in containers. Database files go to a named volume, otherwise docker-compose down eats your test data and you learn about volumes emotionally. Configuration goes through environment variables, and a file edited inside a running container is gone after the next restart. A container is a process. Not a small server you SSH into and garden.\nThe ugly part is file permissions on Linux. The code is bind-mounted, PHP-FPM writes cache files as its own UID, and suddenly your host user cannot delete them. We pass the host UID into the build and create a matching user. Not elegant. Works.\nOne warning. The dev image mounts the code from the host. A production image should copy the code in, so the artifact is complete and immutable. Do not ship the dev compose file to a server and call it deployment.\nSetup for the next person: twenty minutes, most of it downloading images. The README now says docker-compose up. I am about two years late to this.\n","permalink":"https://useinov.com/posts/2016/docker-for-php/","summary":"\u003cp\u003eTwo days. That is how long the new colleague spent last month getting one project to run on his laptop. The README is two pages: PHP with a specific set of extensions, Nginx, MySQL, Redis, and a paragraph that starts with \u0026ldquo;on OS X it is a bit different\u0026rdquo;. Every laptop in the team is a slightly different snowflake.\u003c/p\u003e\n\u003cp\u003eSo I finally tried Docker for local development. The compose file:\u003c/p\u003e","title":"Docker instead of a setup README"},{"content":"Kohana 3.3, on a project that earns money every day. The framework is effectively finished. The repository barely moves, the community left years ago. Nobody will approve a rewrite, and I have stopped asking. This is a normal situation and it deserves a better plan than \u0026ldquo;someday we migrate\u0026rdquo;.\nThe plan we settled on is a freeze.\nPin the exact framework version and vendor it. Not \u0026ldquo;3.3.*\u0026rdquo;, the exact commit. The build must be reproducible in five years, when the original download link is dead.\nKeep our own fork. It accepts one kind of change: security fixes. No features, no refactoring, no cleanup. Every patch in the fork is a liability during any future migration, so the list must stay short enough to read in one sitting.\nWrite tests around the places where our code touches the framework hardest. Routing, the ORM, the request cycle. These tests are the safety net for PHP upgrades, because PHP keeps moving even if Kohana does not, and something will break on 7.0.\nAnd the main rule, the one that changes daily work: new business code must not depend on Kohana_* classes when it can avoid it. New logic goes into plain PHP services, wired through Composer autoload, and controllers stay thin adapters. I wrote about this before, and the freeze is where it pays off. Every class that does not know about Kohana is a class that survives Kohana.\nThe point of a freeze is that the frozen part stops costing attention. A dead framework with a fixed boundary around it is just old code, and old code that works is fine. The dangerous thing is a dead framework that keeps leaking into every new file you write.\n","permalink":"https://useinov.com/posts/2016/freezing-kohana/","summary":"\u003cp\u003eKohana 3.3, on a project that earns money every day. The framework is effectively finished. The repository barely moves, the community left years ago. Nobody will approve a rewrite, and I have stopped asking. This is a normal situation and it deserves a better plan than \u0026ldquo;someday we migrate\u0026rdquo;.\u003c/p\u003e\n\u003cp\u003eThe plan we settled on is a freeze.\u003c/p\u003e\n\u003cp\u003ePin the exact framework version and vendor it. Not \u0026ldquo;3.3.*\u0026rdquo;, the exact commit. The build must be reproducible in five years, when the original download link is dead.\u003c/p\u003e","title":"Freezing the Kohana layer"},{"content":"An IP whitelist check placed after auth. That was the whole bug. Every scanner bot on the internet was going through session start and a user lookup just to be told to go away. The fix was to move one line up in the Kernel.\nLaravel 5.2 makes that line easier to see. Middleware groups, and the request pipeline is finally written down in one place:\nprotected $middlewareGroups = [ \u0026#39;web\u0026#39; =\u0026gt; [ \\App\\Http\\Middleware\\EncryptCookies::class, \\Illuminate\\Session\\Middleware\\StartSession::class, \\App\\Http\\Middleware\\VerifyCsrfToken::class, ], \u0026#39;api\u0026#39; =\u0026gt; [ \u0026#39;throttle:60,1\u0026#39;, \u0026#39;bindings\u0026#39;, ], ]; Before 5.2, sessions and CSRF were global middleware. They ran for everything, including API routes that have no use for cookies. Now web and api are two separate pipelines, and you can read each one top to bottom.\nThe reading matters. Middleware is an ordered list. The request enters at the top, any element can return a response and stop the chain, and then nothing below it runs, not even the controller. That is the feature. Throttling that returns 429 before authentication touches the database is throttling that works. Cheap checks first, broad checks first.\nThe other question is what belongs in middleware at all. My rule: middleware may know about HTTP, it must not know about the business. Rate limits, locale, auth, request logging, fine. \u0026ldquo;Can this user cancel this order\u0026rdquo; is an application service, because tomorrow the same rule is needed from a console command where there is no HTTP at all.\nIf you are on 5.2, open your Kernel and read the pipeline out loud. It should sound boring. Surprises there are the expensive kind. The whitelist line, for the record, was mine.\n","permalink":"https://useinov.com/posts/2016/laravel-middleware-groups/","summary":"\u003cp\u003eAn IP whitelist check placed after auth. That was the whole bug. Every scanner bot on the internet was going through session start and a user lookup just to be told to go away. The fix was to move one line up in the Kernel.\u003c/p\u003e\n\u003cp\u003eLaravel 5.2 makes that line easier to see. Middleware groups, and the request pipeline is finally written down in one place:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eprotected\u003c/span\u003e \u003cspan class=\"nv\"\u003e$middlewareGroups\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"s1\"\u003e\u0026#39;web\u0026#39;\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nx\"\u003e\\App\\Http\\Middleware\\EncryptCookies\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"na\"\u003eclass\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nx\"\u003e\\Illuminate\\Session\\Middleware\\StartSession\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"na\"\u003eclass\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nx\"\u003e\\App\\Http\\Middleware\\VerifyCsrfToken\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"na\"\u003eclass\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e],\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"s1\"\u003e\u0026#39;api\u0026#39;\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"p\"\u003e[\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"s1\"\u003e\u0026#39;throttle:60,1\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"s1\"\u003e\u0026#39;bindings\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e],\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e];\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eBefore 5.2, sessions and CSRF were global middleware. They ran for everything, including API routes that have no use for cookies. Now \u003ccode\u003eweb\u003c/code\u003e and \u003ccode\u003eapi\u003c/code\u003e are two separate pipelines, and you can read each one top to bottom.\u003c/p\u003e","title":"Middleware groups in Laravel 5.2"},{"content":"$row = $db-\u0026gt;fetchOne(\u0026#39;SELECT id FROM counters WHERE name = ?\u0026#39;, array($name)); if ($row) { $db-\u0026gt;execute(\u0026#39;UPDATE counters SET value = value + 1 WHERE name = ?\u0026#39;, array($name)); } else { $db-\u0026gt;execute(\u0026#39;INSERT INTO counters (name, value) VALUES (?, 1)\u0026#39;, array($name)); } Works on the laptop. In production two requests arrive in the same millisecond. Both SELECT, both see nothing, both INSERT. One dies with a duplicate key error. Or worse, there is no unique constraint, and now you have two rows and a bug report you cannot reproduce. The window between SELECT and INSERT is tiny, so it fires once a week, always for someone else.\nPostgreSQL 9.5 is out, and the feature I waited for is INSERT ... ON CONFLICT. Real upsert, at last. One statement:\nINSERT INTO counters (name, value) VALUES (\u0026#39;emails_sent\u0026#39;, 1) ON CONFLICT (name) DO UPDATE SET value = counters.value + 1; The database resolves the race. That is its job, it has locks and it knows how to use them. There is also DO NOTHING, perfect for idempotent writes: insert the event, and if it is already there, fine, move on.\nOne detail I like. ON CONFLICT requires a unique index to conflict on. So the feature pushes you to declare the constraint. \u0026ldquo;Counter names are unique\u0026rdquo; is a business rule, and a unique constraint is the only place where that rule cannot be bypassed by a careless script or a second code path.\nBefore 9.5 we had advisory locks, retry loops around the duplicate error, and a wiki page explaining why. That code can go now. Deleting it is the best part of the upgrade. I wrote most of it. I am still going to enjoy the delete.\n","permalink":"https://useinov.com/posts/2016/postgres-upsert/","summary":"\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$row\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nv\"\u003e$db\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003efetchOne\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;SELECT id FROM counters WHERE name = ?\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003earray\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$name\u003c/span\u003e\u003cspan class=\"p\"\u003e));\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eif\u003c/span\u003e \u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$row\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nv\"\u003e$db\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003eexecute\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;UPDATE counters SET value = value + 1 WHERE name = ?\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003earray\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$name\u003c/span\u003e\u003cspan class=\"p\"\u003e));\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e \u003cspan class=\"k\"\u003eelse\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nv\"\u003e$db\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003eexecute\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;INSERT INTO counters (name, value) VALUES (?, 1)\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003earray\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$name\u003c/span\u003e\u003cspan class=\"p\"\u003e));\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eWorks on the laptop. In production two requests arrive in the same millisecond. Both SELECT, both see nothing, both INSERT. One dies with a duplicate key error. Or worse, there is no unique constraint, and now you have two rows and a bug report you cannot reproduce. The window between SELECT and INSERT is tiny, so it fires once a week, always for someone else.\u003c/p\u003e","title":"Upsert in PostgreSQL 9.5"},{"content":"One endpoint. The heaviest catalog page of one project, same data, same opcache settings, PHP 5.6 against 7.0. That is the only benchmark I trust. 7.0 has been out for three weeks, everyone has seen the hello-world numbers, and I do not believe hello-world numbers on principle.\nResponse time dropped about 40 percent. Memory per request, more than half. No code changes. I ran it again because I did not believe the first numbers either. The engine rewrite is real: smaller internal structures, cheaper function calls, and typical framework code is exactly that, thousands of small calls and arrays.\nSpeed is the free part. The language is the part you have to earn.\ndeclare(strict_types=1); function monthlyPrice(int $amount, float $rate): float { return $amount * $rate / 12; } $name = $_GET[\u0026#39;name\u0026#39;] ?? \u0026#39;guest\u0026#39;; Scalar types on boundaries turn a class of quiet data bugs into loud TypeErrors. ?? deletes a thousand isset() ternaries. Both are opt-in. Old code runs as before.\nThe thing that will bite migrating projects is the error hierarchy. Fatal errors are throwable now, as Error. catch (Exception $e) does not catch them, they only share Throwable. Every global handler and every too-clever try/catch wrapper needs a review, or your handled errors turn into white screens.\nThe checklist for the legacy fleet is short. Kill remaining mysql_* calls, the extension is gone. Rename PHP 4 style constructors. Run the tests and read the deprecation output. We did most of this in summer on the alphas, so December is calm here.\nStaying on 5.x now has a price measured in hardware. Strange position for a language that was declared dying every year since I started writing it. I intend to enjoy it for a while.\n","permalink":"https://useinov.com/posts/2015/php7-measured/","summary":"\u003cp\u003eOne endpoint. The heaviest catalog page of one project, same data, same opcache settings, PHP 5.6 against 7.0. That is the only benchmark I trust. 7.0 has been out for three weeks, everyone has seen the hello-world numbers, and I do not believe hello-world numbers on principle.\u003c/p\u003e\n\u003cp\u003eResponse time dropped about 40 percent. Memory per request, more than half. No code changes. I ran it again because I did not believe the first numbers either. The engine rewrite is real: smaller internal structures, cheaper function calls, and typical framework code is exactly that, thousands of small calls and arrays.\u003c/p\u003e","title":"PHP 7 measured on real code"},{"content":"Symfony 2.8 and 3.0, released today, both at once, on purpose. The pairing is the whole message. 2.8 is the last of the 2.x line and an LTS. 3.0 is roughly 2.8 with the deprecated code deleted. Same features, cleaner body.\nThis turns a scary major upgrade into two steps.\nStep one, move to 2.8. Minor upgrade, low risk. Then make the deprecation log empty. The phpunit bridge prints every deprecated call your code and tests touch. Burn the list down during normal sprints, item by item, each fix small and releasable on its own.\nStep two, when the log is silent, change the constraint to 3.0. If step one was honest, step two is an afternoon.\nCompare with the old style of major migration: one heroic branch that lives for two months, diverges from everything, and merges as a single terrifying diff. I have owned such a branch. I do not want another.\nOne discipline makes or breaks this. Do not refactor business code in the same commits. During migration you see ugly code all day, and the temptation to fix it while you are here is strong. Resist. A migration commit should answer one question in review: does this change behavior? No. A refactoring commit answers a different question. Mix them and the reviewer can answer neither, and when something breaks in production you cannot tell which intention broke it.\nThe framework tells you the future years in advance and lets you walk there in small steps. All you have to do is read the warnings. In most projects I have seen, that is the hard part.\n","permalink":"https://useinov.com/posts/2015/symfony-two-step/","summary":"\u003cp\u003eSymfony 2.8 and 3.0, released today, both at once, on purpose. The pairing is the whole message. 2.8 is the last of the 2.x line and an LTS. 3.0 is roughly 2.8 with the deprecated code deleted. Same features, cleaner body.\u003c/p\u003e\n\u003cp\u003eThis turns a scary major upgrade into two steps.\u003c/p\u003e\n\u003cp\u003eStep one, move to 2.8. Minor upgrade, low risk. Then make the deprecation log empty. The phpunit bridge prints every deprecated call your code and tests touch. Burn the list down during normal sprints, item by item, each fix small and releasable on its own.\u003c/p\u003e","title":"Symfony 3.0 is not a rewrite"},{"content":"ALTER TABLE users ADD profile JSON; MySQL 5.7 went GA this week, and this line is now legal. Native JSON type: validation on insert, binary storage, functions to read paths. The question is not whether it works. It is what belongs in it.\nClassic case: user profile with flexible metadata. Marketing wants a new field every other week. The old options were bad in familiar ways. Forty nullable columns and an ALTER for every idea. Or EAV, key-value rows, where every read is a self-join festival and nothing has a type.\nJSON is the third option, and for this data it is honest.\nThe trap is stopping there and treating it as MongoDB inside MySQL. The optimizer cannot index a path inside the document. The moment you write WHERE JSON_EXTRACT(profile, '$.country') = '\u0026quot;DE\u0026quot;', you get a full table scan with a modern haircut.\n5.7 gives the way out, generated columns:\nALTER TABLE users ADD country CHAR(2) AS (JSON_UNQUOTE(JSON_EXTRACT(profile, \u0026#39;$.country\u0026#39;))) STORED, ADD INDEX idx_users_country (country); The value lives in JSON, the searchable projection lives in a real indexed column.\nWhich gives the rule. If you filter by it, join by it, sort by it, or business logic depends on its type, it is a column. Real schema, real constraint, real index. JSON gets the long tail: display-only attributes, per-user settings, payloads you store today and will understand later. Data whose shape you honestly do not control.\nI know the trap from the inside. Two years ago, on 5.5, I put the same kind of profile into a TEXT column with serialize() and told myself it was temporary. It is still there. Now at least the database can read it.\nSchema is the database knowing what your data means. Indexes and constraints are made from that knowledge. JSON is a fine place for data that means nothing to SQL. Keep it there.\n","permalink":"https://useinov.com/posts/2015/json-not-schema/","summary":"\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003eALTER\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eTABLE\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eusers\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"k\"\u003eADD\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eprofile\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"n\"\u003eJSON\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eMySQL 5.7 went GA this week, and this line is now legal. Native JSON type: validation on insert, binary storage, functions to read paths. The question is not whether it works. It is what belongs in it.\u003c/p\u003e\n\u003cp\u003eClassic case: user profile with flexible metadata. Marketing wants a new field every other week. The old options were bad in familiar ways. Forty nullable columns and an ALTER for every idea. Or EAV, key-value rows, where every read is a self-join festival and nothing has a type.\u003c/p\u003e","title":"JSON column is not a schema"},{"content":"The test suite went green on the PHP 7 release candidate on the second try. I expected worse. Then I opened modules/ and the mood changed.\nThe core mostly works. The community is patching compatibility in the 3.3 branch on GitHub right now, and the fixes are small: deprecated PHP 4 style constructors, changed engine behavior in dark corners. Whoever says the framework is dead is not reading the commit log. It is not dead. It is volunteers.\nThe modules are the real problem. Our project carries a dozen third-party Kohana modules, and half of them saw their last commit around 2012. Nobody will make them PHP 7 ready. If we want them, we patch them ourselves.\nWhich means \u0026ldquo;does Kohana support PHP 7\u0026rdquo; is the wrong question. The right one: who supports our copy of it. And the answer quietly became \u0026ldquo;we do\u0026rdquo; some time ago. In practice we maintain a private fork of a framework plus a museum of modules attached to it.\nThat can still be a rational choice. Patching a small, stable framework costs a few days a year. But this cost curve only goes up: every new PHP version, every security finding, every new hire who has to learn a framework with no future. Migration cost, meanwhile, stays roughly flat.\nSo I keep a simple ledger now. Hours spent this quarter keeping the framework alive. When that line grows past the boring, steady estimate for a strangler-style migration, the decision makes itself. No drama. Just arithmetic.\nThe ledger has one weakness. I fill it in, and I like patching the framework more than I like the migration. The hours in it are probably a bit low.\nFor now the tests pass on the RC. We buy another year.\n","permalink":"https://useinov.com/posts/2015/kohana-on-php7/","summary":"\u003cp\u003eThe test suite went green on the PHP 7 release candidate on the second try. I expected worse. Then I opened \u003ccode\u003emodules/\u003c/code\u003e and the mood changed.\u003c/p\u003e\n\u003cp\u003eThe core mostly works. The community is patching compatibility in the 3.3 branch on GitHub right now, and the fixes are small: deprecated PHP 4 style constructors, changed engine behavior in dark corners. Whoever says the framework is dead is not reading the commit log. It is not dead. It is volunteers.\u003c/p\u003e","title":"Kohana on PHP 7: whose problem is it"},{"content":"Two lines from redis.conf on one project:\nmaxmemory 2gb maxmemory-policy allkeys-lru Correct for a cache. Then sessions moved into the same instance, because Redis was already there. Then the job queue, same reason. One process, three tenants. This works until the day it does not.\nCache data is disposable by definition, and those two lines embrace that. Memory fills up, Redis evicts the coldest keys, the application rebuilds them on demand. Persistence is optional. After a restart a cold cache is an inconvenience, not an incident.\nNow put a job queue next to it. A job is a promise: the email will be sent, the invoice will be generated. With allkeys-lru a memory spike can evict the queue key itself. The jobs do not fail. They vanish. No error, no log entry, just customers asking where the email is. Silent loss that looks exactly like a healthy system.\nA queue wants noeviction, so writes fail loudly when memory is full, and AOF persistence, so a restart does not erase the promises. Sessions sit in the middle: losing them logs everyone out, unpleasant but survivable. Decide per project.\nThe fix is almost embarrassing. Two Redis instances. One for cache, LRU, no persistence. One for queues, noeviction, AOF. Redis is a single process that starts in milliseconds. A second instance costs one config file.\nBefore putting anything into Redis, ask whether you can lose it. Both answers are fine, as long as the config matches the answer.\nI knew all of this when I put the queue on the cache instance. It was faster that day.\n","permalink":"https://useinov.com/posts/2015/redis-two-jobs/","summary":"\u003cp\u003eTwo lines from redis.conf on one project:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003emaxmemory 2gb\nmaxmemory-policy allkeys-lru\n\u003c/code\u003e\u003c/pre\u003e\u003cp\u003eCorrect for a cache. Then sessions moved into the same instance, because Redis was already there. Then the job queue, same reason. One process, three tenants. This works until the day it does not.\u003c/p\u003e\n\u003cp\u003eCache data is disposable by definition, and those two lines embrace that. Memory fills up, Redis evicts the coldest keys, the application rebuilds them on demand. Persistence is optional. After a restart a cold cache is an inconvenience, not an incident.\u003c/p\u003e","title":"Redis cache and Redis queue are different databases"},{"content":"Three lines in .travis.yml. That is the whole cost of knowing in July what will break on PHP 7 in December.\nphp: - 5.6 - nightly matrix: allow_failures: - php: nightly Alpha builds are out, release is planned for this autumn, and production is on 5.6 and will stay there for a while. The build stays green on 5.6. The nightly job fails quietly in the corner and produces a list of future problems while nobody is under pressure to read it.\nOurs came back with about thirty. Sorting them was more useful than fixing them.\nPart of the list is language. PHP 4 style constructors are deprecated, and Kohana-era code is full of classes where the constructor carries the class name. Part is extensions: ext/mysql is gone, every mysql_* call dies, and that alone is a small project. Then the framework itself, which for us means patching our own copy of Kohana. Then third-party libraries, where you file an issue upstream and wait.\nEach group has a different owner and a different deadline. A constructor rename is an afternoon. An abandoned library may be forever, and it is much better to learn that in July than in December.\nNobody asks to run 7 in production this year. But when it ships and the benchmarks make management curious, there are two possible answers. \u0026ldquo;We need three months just to assess.\u0026rdquo; Or \u0026ldquo;here is the list, half is already done.\u0026rdquo;\nHalf of ours is done. The other half is waiting on people I have never met.\n","permalink":"https://useinov.com/posts/2015/php7-early-tests/","summary":"\u003cp\u003eThree lines in \u003ccode\u003e.travis.yml\u003c/code\u003e. That is the whole cost of knowing in July what will break on PHP 7 in December.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-yaml\" data-lang=\"yaml\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003ephp\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e- \u003cspan class=\"m\"\u003e5.6\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e- \u003cspan class=\"l\"\u003enightly\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nt\"\u003ematrix\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e  \u003c/span\u003e\u003cspan class=\"nt\"\u003eallow_failures\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"w\"\u003e    \u003c/span\u003e- \u003cspan class=\"nt\"\u003ephp\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e\u003cspan class=\"w\"\u003e \u003c/span\u003e\u003cspan class=\"l\"\u003enightly\u003c/span\u003e\u003cspan class=\"w\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eAlpha builds are out, release is planned for this autumn, and production is on 5.6 and will stay there for a while. The build stays green on 5.6. The nightly job fails quietly in the corner and produces a list of future problems while nobody is under pressure to read it.\u003c/p\u003e","title":"Run your tests on PHP 7 now"},{"content":"PHP 5.5.9. That is the minimum for Laravel 5.1, which came out this week, the first Laravel with LTS: two years of bug fixes, three of security fixes. Two weeks ago I wrote the same about Symfony 2.7. Two frameworks arriving at the same idea in one summer is no coincidence. PHP applications got old enough to have a lifecycle.\nDraw the ladder for your project once. OS at the bottom. Then the PHP version the OS ships, or the one you build. Then the framework. Then the packages on top. Every rung has its own end-of-life, and the rungs are coupled. If production sits on some old distro with 5.4, the 5.1 upgrade is suddenly an ops project. And above you, half the packages will require 5.1 in their next major, so staying behind slowly cuts you off from fixes.\nThe failure mode I keep seeing is the frozen stack. Nobody upgrades anything for three years because it works. Then one security issue, or one library you badly need, and the whole ladder must move at once. I watched such a project. Six weeks, everything changed at the same time, and when the regressions came, everything was a suspect.\nI was not only watching. The freeze was partly my decision: \u0026ldquo;it works, why touch it\u0026rdquo;, said in a planning meeting two years earlier.\nThe alternative is boring and cheap. Upgrade something small every month or two. One minor version, one package major, one PHP point release. Each step is small enough to test and small enough to revert. You never get the giant migration because you never let the distance accumulate.\nFramework version used to be a line in composer.json. Now it is part of the architecture, with a calendar attached.\n","permalink":"https://useinov.com/posts/2015/dependency-ladder/","summary":"\u003cp\u003ePHP 5.5.9. That is the minimum for Laravel 5.1, which came out this week, the first Laravel with LTS: two years of bug fixes, three of security fixes. Two weeks ago I wrote the same about Symfony 2.7. Two frameworks arriving at the same idea in one summer is no coincidence. PHP applications got old enough to have a lifecycle.\u003c/p\u003e\n\u003cp\u003eDraw the ladder for your project once. OS at the bottom. Then the PHP version the OS ships, or the one you build. Then the framework. Then the packages on top. Every rung has its own end-of-life, and the rungs are coupled. If production sits on some old distro with 5.4, the 5.1 upgrade is suddenly an ops project. And above you, half the packages will require 5.1 in their next major, so staying behind slowly cuts you off from fixes.\u003c/p\u003e","title":"Laravel 5.1 and the dependency ladder"},{"content":"Three years of bug fixes. Four years of security fixes. Symfony 2.7 came out this weekend, and that is the line from the announcement I care about. The changelog has new things in it, but the important word is on the label: LTS.\nFor a pet project this is boring. For a product with paying users and a team, this is the actual feature.\nA big application does not upgrade for fun. Every framework upgrade is testing time, regression risk, and a sprint that produces nothing visible. Business asks a fair question: what do we get. \u0026ldquo;Newer version\u0026rdquo; is not an answer. \u0026ldquo;We keep receiving security fixes until 2018 without touching anything\u0026rdquo; is.\nSo latest versus LTS is a bet on how often you want to pay the upgrade tax, and taste has little to do with it. Small team, product that changes every week: ride the latest, keep the upgrades small and frequent. Large codebase, careful release process: sit on LTS and batch the pain.\nOne more thing in 2.7 I like. It reports a deprecation warning for everything that dies in Symfony 3. The upgrade path is announced years ahead. You can burn the list down slowly, during normal work, instead of discovering it all at once on migration day.\nThe practical part is one line. Pin \u0026quot;symfony/symfony\u0026quot;: \u0026quot;2.7.*\u0026quot; in composer.json and stop thinking about it. Then put the 3.x migration into the plan now, as a real task with real hours.\nEnd of LTS has a date on it. That date arrives whether you planned or not. So far, for me, mostly not.\n","permalink":"https://useinov.com/posts/2015/symfony-lts/","summary":"\u003cp\u003eThree years of bug fixes. Four years of security fixes. Symfony 2.7 came out this weekend, and that is the line from the announcement I care about. The changelog has new things in it, but the important word is on the label: LTS.\u003c/p\u003e\n\u003cp\u003eFor a pet project this is boring. For a product with paying users and a team, this is the actual feature.\u003c/p\u003e\n\u003cp\u003eA big application does not upgrade for fun. Every framework upgrade is testing time, regression risk, and a sprint that produces nothing visible. Business asks a fair question: what do we get. \u0026ldquo;Newer version\u0026rdquo; is not an answer. \u0026ldquo;We keep receiving security fixes until 2018 without touching anything\u0026rdquo; is.\u003c/p\u003e","title":"Symfony 2.7: LTS is the feature"},{"content":"$order-\u0026gt;getCustomer()-\u0026gt;getName() inside a foreach. Every time I open a slow Doctrine endpoint, I find some version of this line within ten minutes. The PHP is rarely slow. The shape of the data access is.\nSame few places every time.\nLazy loading. The classic N+1. Doctrine proxies make it invisible: you touch the relation in a loop and every touch is a query. The profiler query count tells the truth. The code does not.\nCaches that are not configured. Doctrine parses mapping metadata and translates DQL on every request unless you give it a cache. On dev nobody notices. In production without APC-backed metadata and query cache you pay this tax on each request. Fifteen minutes of configuration, permanent discount.\nHydration. Turning rows into full entities is expensive. Loading five thousand entities to render a table means five thousand tracked objects in the unit of work. For read-only lists ask for array hydration, or go straight to DBAL. A report does not need entities. It needs rows.\nInheritance. Class table inheritance looks clean in the model and produces JOIN fans in SQL. Every query on the base class drags the children in. Sometimes worth it. Measure before you commit, because leaving it later is a schema migration.\nEager fetch on everything. The panic reaction to N+1, and it only moves the cost. You haul the whole object graph to show one column. This one was my reaction, on my first Doctrine project, and it made the list page slower than the N+1 it replaced.\nThe pattern behind all five: convenience is a loan. The ORM lets you not think about SQL, and mostly that is fine. But the SQL still executes. When an endpoint gets slow, open the profiler, read the actual queries, count them. Then decide per query: fix the fetch, change the hydration, or write the SQL by hand.\nDBAL is right there. Using it is knowing when the abstraction stops paying for itself.\n","permalink":"https://useinov.com/posts/2015/doctrine-latency/","summary":"\u003cp\u003e\u003ccode\u003e$order-\u0026gt;getCustomer()-\u0026gt;getName()\u003c/code\u003e inside a \u003ccode\u003eforeach\u003c/code\u003e. Every time I open a slow Doctrine endpoint, I find some version of this line within ten minutes. The PHP is rarely slow. The shape of the data access is.\u003c/p\u003e\n\u003cp\u003eSame few places every time.\u003c/p\u003e\n\u003cp\u003eLazy loading. The classic N+1. Doctrine proxies make it invisible: you touch the relation in a loop and every touch is a query. The profiler query count tells the truth. The code does not.\u003c/p\u003e","title":"Where Doctrine spends your milliseconds"},{"content":"Same repository, three ways to get it in Laravel 5.\n// 1. Constructor injection public function __construct(OrderRepository $orders) { $this-\u0026gt;orders = $orders; } // 2. Facade $order = Orders::find($id); // 3. Service locator $orders = App::make(\u0026#39;App\\Repositories\\OrderRepository\u0026#39;); All three work. The container resolves everything either way. The difference is in what the class tells you about itself.\nWith constructor injection the dependencies are in the signature. You open the class, you read the constructor, you know what it needs. A test passes a mock and never touches the container.\nThe facade hides the dependency, but at least it is greppable and visible at the call site. In a controller I can live with it. Controllers are framework territory anyway.\nApp::make() deep inside domain code is the one I fight. It is a service locator. The class claims to need nothing and secretly needs everything. You learn its real dependencies at runtime, one exception at a time. And a test suddenly needs a bootstrapped container just to construct the object.\nThe rule I use: business code takes dependencies through the constructor, always. Facades are allowed in controllers and views. App::make() is allowed in exactly two places, service providers and factories, because building objects is their job.\nAnd the smell test. If a constructor takes seven dependencies, injection did not fail you. The class does seven jobs. The container will happily hide that behind App::make(). Injection makes it hurt, and it should hurt.\nI know the smell because it was mine. On a previous project I had App::make() in every second method of the billing code, because the constructor was getting long. The constructor was telling me something. I told it to be quiet.\n","permalink":"https://useinov.com/posts/2015/container-or-facade/","summary":"\u003cp\u003eSame repository, three ways to get it in Laravel 5.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// 1. Constructor injection\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003epublic\u003c/span\u003e \u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"fm\"\u003e__construct\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nx\"\u003eOrderRepository\u003c/span\u003e \u003cspan class=\"nv\"\u003e$orders\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nv\"\u003e$this\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003eorders\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nv\"\u003e$orders\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// 2. Facade\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$order\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003eOrders\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"na\"\u003efind\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$id\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"c1\"\u003e// 3. Service locator\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$orders\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"nx\"\u003eApp\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"na\"\u003emake\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;App\\Repositories\\OrderRepository\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e);\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eAll three work. The container resolves everything either way. The difference is in what the class tells you about itself.\u003c/p\u003e\n\u003cp\u003eWith constructor injection the dependencies are in the signature. You open the class, you read the constructor, you know what it needs. A test passes a mock and never touches the container.\u003c/p\u003e","title":"Three ways to get a dependency in Laravel"},{"content":"app/models is gone. Laravel 5 came out at the start of the month, and of everything in the release the folder I notice most is the one that no longer exists.\nThe old skeleton had app/models and app/controllers. You knew where things go because the framework told you. Laravel 5 removes that. There is app/, it maps to the App namespace through PSR-4, and inside it you organize classes however you want.\nComing from Kohana this is a bigger shift than it sounds. In Kohana the class name dictates the file path. Model_Post lives in classes/Model/Post.php and that is that. Laravel 5 says: the namespace is the contract, the layout is your decision.\nWhich means teams now have to decide. And folder discussions eat weeks.\nMy take. Sorting classes by type, a folder for models, a folder for services, a folder for repositories, is fine for a small application. It answers \u0026ldquo;what is this class\u0026rdquo;. It does not answer \u0026ldquo;what is this application about\u0026rdquo;. When the project grows, sorting by feature works better: App\\Billing, App\\Catalog, and inside each one the models and services that belong to it. Open the folder, see the domain.\nHonestly, either works. What does not work is arguing about it for days. Pick one scheme, write it in the readme, follow it. The autoloader does not care. The container does not care. Only people care, and people mostly want the debate to be over.\nPSR-4 in the default skeleton also means one more framework quietly on the shared standard. Five years ago every framework had its own autoloading religion. Good riddance.\nWe spent two days on our layout. I argued for by-feature, lost, and the by-type layout has been fine since. The debate was the only expensive part.\n","permalink":"https://useinov.com/posts/2015/laravel-five-structure/","summary":"\u003cp\u003e\u003ccode\u003eapp/models\u003c/code\u003e is gone. Laravel 5 came out at the start of the month, and of everything in the release the folder I notice most is the one that no longer exists.\u003c/p\u003e\n\u003cp\u003eThe old skeleton had \u003ccode\u003eapp/models\u003c/code\u003e and \u003ccode\u003eapp/controllers\u003c/code\u003e. You knew where things go because the framework told you. Laravel 5 removes that. There is \u003ccode\u003eapp/\u003c/code\u003e, it maps to the \u003ccode\u003eApp\u003c/code\u003e namespace through PSR-4, and inside it you organize classes however you want.\u003c/p\u003e","title":"Laravel 5 and the death of the models folder"},{"content":"require APPPATH.\u0026#39;../vendor/autoload.php\u0026#39;; One line in bootstrap.php. This month it became the whole strategy for a project we decided to keep on Kohana.\nThe framework is quiet, the project is alive. These two facts have to coexist somehow. The plan: nothing new gets written the Kohana way. New dependencies come through Composer, and Kohana\u0026rsquo;s autoloader and Composer\u0026rsquo;s autoloader live together fine.\nFirst candidate was the HTTP client. We talk to two external APIs, and the code around Request_Client_External was not pretty. Guzzle is better in every way. But Guzzle does not spread through the codebase. There is an interface:\ninterface Http_Client { public function get($url, array $options = array()); public function post($url, array $options = array()); } and one adapter class with Guzzle inside. Application code sees only the interface.\nTwo things for the price of one small wrapper. Unit tests need no network and no Kohana, you pass a fake client and test the logic. And the day we leave Kohana, this code does not notice. It has no idea what framework it runs in.\nMailer is next. Then the logger.\nI do not call this a migration. Nobody approved a migration, there is no budget for a rewrite, and rewrites of working systems mostly end badly anyway. Every month a little more of the application depends on packages and interfaces, and a little less on Kohana. If the framework dies completely, the surface we have to replace is shrinking while we do normal feature work.\nThe honest part: I wanted the rewrite. The wrapper felt like giving up. A month in, the wrapper is the only thing that shipped.\nStrangle it slowly. The framework will not mind.\n","permalink":"https://useinov.com/posts/2015/kohana-meets-composer/","summary":"\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003erequire\u003c/span\u003e \u003cspan class=\"nx\"\u003eAPPPATH\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;../vendor/autoload.php\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eOne line in \u003ccode\u003ebootstrap.php\u003c/code\u003e. This month it became the whole strategy for a project we decided to keep on Kohana.\u003c/p\u003e\n\u003cp\u003eThe framework is quiet, the project is alive. These two facts have to coexist somehow. The plan: nothing new gets written the Kohana way. New dependencies come through Composer, and Kohana\u0026rsquo;s autoloader and Composer\u0026rsquo;s autoloader live together fine.\u003c/p\u003e\n\u003cp\u003eFirst candidate was the HTTP client. We talk to two external APIs, and the code around \u003ccode\u003eRequest_Client_External\u003c/code\u003e was not pretty. Guzzle is better in every way. But Guzzle does not spread through the codebase. There is an interface:\u003c/p\u003e","title":"Composer in a legacy Kohana project"},{"content":"$link = \u0026#39;https://\u0026#39;.$_SERVER[\u0026#39;HTTP_HOST\u0026#39;].\u0026#39;/reset/\u0026#39;.$token; I wrote this line. More than once. It builds the link for a password reset email, and it looks like it reads something about the server. It does not. Host comes from the client, same as any other header.\nKohana 3.3.4 shipped this month with a fix around exactly this, and the topic is bigger than one framework.\nThe attack is short. Someone requests a password reset for your email address and puts his own domain into the Host header. The application trusts the header, builds the link, sends it. You receive a real email from a real site with a link to evil.example and your real token in the path. You click. He collects the token. Host header poisoning, and no framework protects you from it out of the box.\nTwo fixes.\nHardcode the base URL in config and build every absolute link from it. Simplest, works everywhere, my default now.\nOr validate: a whitelist of trusted hosts, reject everything else. Kohana has trusted_hosts for this since 3.3.4, Symfony has a trusted_hosts setting too. And if you sit behind a proxy, X-Forwarded-Host plays the same role and lies just as easily. Put it under the same check.\nThe rule is older than any of this. Everything in the HTTP request is user input. The URL, the cookies, the headers, all of it. Host just hides better than the others: it describes what the client claims about your server.\nI grepped our projects for HTTP_HOST after the release notes. Four hits. Three of them mine.\n","permalink":"https://useinov.com/posts/2014/host-header-is-user-input/","summary":"\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nv\"\u003e$link\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u003c/span\u003e \u003cspan class=\"s1\"\u003e\u0026#39;https://\u0026#39;\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003e$_SERVER\u003c/span\u003e\u003cspan class=\"p\"\u003e[\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;HTTP_HOST\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e]\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;/reset/\u0026#39;\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"nv\"\u003e$token\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eI wrote this line. More than once. It builds the link for a password reset email, and it looks like it reads something about the server. It does not. \u003ccode\u003eHost\u003c/code\u003e comes from the client, same as any other header.\u003c/p\u003e\n\u003cp\u003eKohana 3.3.4 shipped this month with a fix around exactly this, and the topic is bigger than one framework.\u003c/p\u003e\n\u003cp\u003eThe attack is short. Someone requests a password reset for your email address and puts his own domain into the \u003ccode\u003eHost\u003c/code\u003e header. The application trusts the header, builds the link, sends it. You receive a real email from a real site with a link to \u003ccode\u003eevil.example\u003c/code\u003e and your real token in the path. You click. He collects the token. Host header poisoning, and no framework protects you from it out of the box.\u003c/p\u003e","title":"Host header is user input"},{"content":"dump() instead of var_dump(). Symfony 2.6 came out at the end of November, and this is the visible gift: VarDumper. Collapsible output, clickable class names, works inside the toolbar. A small thing, used fifty times a day. Tools like this improve life more than big features do.\nBut the important story in 2.6 is not a feature. The release notes read like a preparation checklist for Symfony 3: deprecations, everywhere.\nI want to praise this, because it changes how a major upgrade feels.\nThe usual way: live quietly for three years, then a new major version drops and you get a migration project. Two weeks of pain, a frozen feature branch, everyone angry.\nThe deprecation way: the framework tells you today which APIs die tomorrow. You turn on deprecation logging in dev, you see the list, you fix items one by one between normal tasks. Each fix is small and safe, the current version still supports both ways. When the major release arrives, you bump the version and nothing happens.\nAn upgrade where nothing happens is the best possible upgrade.\nThis works only if someone reads the log. Make it visible: fail the test suite on deprecated calls from your own code, or at least go through the log once a week. Otherwise the log is noise. I had E_DEPRECATED in the log of one project for a year. Nobody looked, me included.\nWe started doing this on one Symfony project this month. Twenty minutes a week. I expect the Symfony 3 upgrade to be a non-event.\nI will report when it happens. Or not, if there is nothing to report.\n","permalink":"https://useinov.com/posts/2014/symfony-26-vardumper/","summary":"\u003cp\u003e\u003ccode\u003edump()\u003c/code\u003e instead of \u003ccode\u003evar_dump()\u003c/code\u003e. Symfony 2.6 came out at the end of November, and this is the visible gift: VarDumper. Collapsible output, clickable class names, works inside the toolbar. A small thing, used fifty times a day. Tools like this improve life more than big features do.\u003c/p\u003e\n\u003cp\u003eBut the important story in 2.6 is not a feature. The release notes read like a preparation checklist for Symfony 3: deprecations, everywhere.\u003c/p\u003e","title":"Symfony 2.6: dump() and boring upgrades"},{"content":"One AppBundle. That is the line in the official Symfony Best Practices book I read twice. For years the answer to \u0026ldquo;how many bundles\u0026rdquo; was \u0026ldquo;as many as you have features\u0026rdquo;, and here the framework\u0026rsquo;s own book says: one.\nThe book came out this autumn. Worth reading even if you are not on Symfony, because the interesting part is the change of tone.\nThe old Symfony way: everything is a bundle. Your application is bundles, reusable, configurable, with their own extensions and semantic configuration. Very flexible. And for a normal business application, mostly ceremony. You write a configuration class for code that will never leave this one project. I wrote such classes. Three of them. None was ever reused.\nThe book quietly admits it. One AppBundle. Controllers may extend the base controller. Annotations for routing are fine. Constructor injection, thin controllers, sensible defaults. The application is allowed to be just an application.\nI like this direction. But the book mixes two things I want to keep apart.\nFramework conventions: where files live, how routes are declared, how config is organized. Here just follow the defaults. Every debate about folder structure is an hour of life you do not get back. Defaults end debates. That is their main value.\nArchitecture principles: thin controllers, explicit dependencies, business logic in services. These are not Symfony conventions. They are portable. They were true in Kohana and they will be true in whatever we all use in 2020.\nSo: conventions as law, principles as principles, and do not confuse the two. When you deviate from a default, you should be able to say why. If the answer is \u0026ldquo;this project really needs it\u0026rdquo;, fine. If the answer is \u0026ldquo;I prefer it\u0026rdquo;, follow the default.\nI preferred three bundles. The default was right.\n","permalink":"https://useinov.com/posts/2014/symfony-best-practices/","summary":"\u003cp\u003eOne AppBundle. That is the line in the official Symfony Best Practices book I read twice. For years the answer to \u0026ldquo;how many bundles\u0026rdquo; was \u0026ldquo;as many as you have features\u0026rdquo;, and here the framework\u0026rsquo;s own book says: one.\u003c/p\u003e\n\u003cp\u003eThe book came out this autumn. Worth reading even if you are not on Symfony, because the interesting part is the change of tone.\u003c/p\u003e\n\u003cp\u003eThe old Symfony way: everything is a bundle. Your application is bundles, reusable, configurable, with their own extensions and semantic configuration. Very flexible. And for a normal business application, mostly ceremony. You write a configuration class for code that will never leave this one project. I wrote such classes. Three of them. None was ever reused.\u003c/p\u003e","title":"Symfony best practices: defaults, not laws"},{"content":"/_profiler on a live site. Type it after any Symfony domain and sometimes it answers. This autumn the story went around, and I will not retell it. The lesson is bigger than one framework.\nThink what a profiler actually stores. Every SQL query with parameters. Cookies and session data. Routes, controller names, request headers. Sometimes config values. A full X-ray of your application, nicely formatted, with search.\nNow count how many sites have /_profiler or a debugbar open to the world because someone deployed with app_dev.php, or left debug = true in the production config. Not a rare exotic mistake. I saw it. I made it once, on a staging server that stayed reachable from outside longer than anyone planned.\nThe fix is not \u0026ldquo;restrict by IP\u0026rdquo;. IP filters are the second step. The first step is config separation that cannot fail silently. Production config must not contain the profiler at all. Not disabled. Absent. In Symfony terms: WebProfilerBundle is registered only for the dev environment in AppKernel. In Laravel terms: debugbar in require-dev, never in require.\nSecond step, the deploy checks it. One line in the deploy script that curls /_profiler and fails the deploy if it answers 200. Cheap and honest.\nAnd the principle behind both. Diagnostics are for operators, not for the whole internet. You need production debugging, everyone does, so give it a separate door: logs, metrics, an internal admin behind auth. Not the developer toolbar on the public page.\nThe profiler is a great tool. On localhost.\n","permalink":"https://useinov.com/posts/2014/profiler-in-production/","summary":"\u003cp\u003e\u003ccode\u003e/_profiler\u003c/code\u003e on a live site. Type it after any Symfony domain and sometimes it answers. This autumn the story went around, and I will not retell it. The lesson is bigger than one framework.\u003c/p\u003e\n\u003cp\u003eThink what a profiler actually stores. Every SQL query with parameters. Cookies and session data. Routes, controller names, request headers. Sometimes config values. A full X-ray of your application, nicely formatted, with search.\u003c/p\u003e\n\u003cp\u003eNow count how many sites have \u003ccode\u003e/_profiler\u003c/code\u003e or a debugbar open to the world because someone deployed with \u003ccode\u003eapp_dev.php\u003c/code\u003e, or left \u003ccode\u003edebug = true\u003c/code\u003e in the production config. Not a rare exotic mistake. I saw it. I made it once, on a staging server that stayed reachable from outside longer than anyone planned.\u003c/p\u003e","title":"Debug toolbar in production is a gift to strangers"},{"content":"A nightly cron failed silently for two days. That was our PHP 5.6 upgrade.\n5.6 is out since the end of August, and we moved one production project this month. The syntax part is pleasant and minor. Variadics:\nfunction log_all($level, ...$messages) { } Argument unpacking with ...$args on the call side. Constant expressions in defaults. use function for importing functions. All nice. None of it changes your architecture. You will use variadics maybe twice a year and be happy both times.\nThe part that breaks production is TLS. Since 5.6 PHP verifies peer certificates by default on every encrypted stream. Before, file_get_contents('https://...') and friends accepted any certificate unless you configured verification yourself. Almost nobody did. I did not.\nSo after the upgrade every outgoing https call in your code becomes strict. The old script that talks to some partner API through a self-signed certificate, or through a host with an incomplete chain, starts throwing. Ours was in the nightly cron. No monitoring on the cron, no email on failure, two days of missing data.\nMy checklist for this upgrade, in order of importance. Grep the code for outgoing http calls and test each one against the real endpoint, not a mock. Run the test suite on 5.6 and watch the deprecation warnings. Check json_decode edge cases if you parse third-party JSON. Only then look at the new syntax.\nCertificate verification by default is the right decision, years late. Right decisions also break things.\nI read the migration guide before the upgrade. The TLS section was there. I skipped it because it looked like a security paragraph.\n","permalink":"https://useinov.com/posts/2014/php-56-upgrade/","summary":"\u003cp\u003eA nightly cron failed silently for two days. That was our PHP 5.6 upgrade.\u003c/p\u003e\n\u003cp\u003e5.6 is out since the end of August, and we moved one production project this month. The syntax part is pleasant and minor. Variadics:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003efunction\u003c/span\u003e \u003cspan class=\"nf\"\u003elog_all\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"nv\"\u003e$level\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"o\"\u003e...\u003c/span\u003e\u003cspan class=\"nv\"\u003e$messages\u003c/span\u003e\u003cspan class=\"p\"\u003e)\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eArgument unpacking with \u003ccode\u003e...$args\u003c/code\u003e on the call side. Constant expressions in defaults. \u003ccode\u003euse function\u003c/code\u003e for importing functions. All nice. None of it changes your architecture. You will use variadics maybe twice a year and be happy both times.\u003c/p\u003e","title":"PHP 5.6: small syntax, big TLS surprise"},{"content":"120 lines in one action. Order confirmation: load the order, charge the card, send the email, render the page. It ran fine for a year. Then a console script needed the same operation, and the action turned out to be glued to SMTP, to the database and to the HTTP request. All at once.\nSymfony people talk about dependency injection so much it sounds like religion. It is one simple idea: a class receives its dependencies and does not create them.\nIn Kohana we usually do the opposite. new Mailer() right in the action. ORM::factory('Order') wherever we need it. It works. Until the day you want the logic somewhere without a request.\nSo I took that action apart, one piece at a time.\nThe mailer goes behind an interface and comes in through the constructor. Order loading goes into a repository class. The business operation itself, confirm, charge, notify, becomes one service class with one public method. The controller keeps maybe ten lines: read input, call the service, render.\nNothing here needs Symfony. The container only removes the boring wiring code. You can do the same wiring by hand in any framework, in one factory file. I did it in Kohana, by hand, in an afternoon.\nOne thing I disagree with in most DI articles. They sell testability as the main goal. For me testability is a side effect. The real goal is that dependencies are visible: you open the constructor and you see what this class touches. No surprise on line 87.\nExplicit is better than convenient. Most days.\n","permalink":"https://useinov.com/posts/2014/services-instead-of-fat-controllers/","summary":"\u003cp\u003e120 lines in one action. Order confirmation: load the order, charge the card, send the email, render the page. It ran fine for a year. Then a console script needed the same operation, and the action turned out to be glued to SMTP, to the database and to the HTTP request. All at once.\u003c/p\u003e\n\u003cp\u003eSymfony people talk about dependency injection so much it sounds like religion. It is one simple idea: a class receives its dependencies and does not create them.\u003c/p\u003e","title":"Services instead of fat controllers"},{"content":"1.8 seconds for one registration request. I put a timer around it on a project last month. 1.5 of those seconds was the welcome email going out over SMTP.\nThe user waits almost two seconds and looks at a spinner, for a handshake with a mail server he will never hear about.\nLaravel 4.2 makes the fix one line:\nQueue::push(\u0026#39;SendWelcomeEmail\u0026#39;, array(\u0026#39;user_id\u0026#39; =\u0026gt; $user-\u0026gt;id)); The controller returns in 300 ms. A worker picks the job up and sends the email. php artisan queue:listen to start, beanstalkd or Redis behind it, the failed_jobs table for jobs that died.\nInfrastructure fashion is not the point. The execution model of the application changes. Before: everything happens inside the request. After: the request only records an intent, and the slow work happens later.\n\u0026ldquo;Later\u0026rdquo; has consequences, and I learned each one the hard way.\nThe HTTP response is gone when the job runs. You cannot show the user an error. The job handles its own failures: retries, logging, alerting.\nThe job can run twice. Worker dies after sending but before the ack, the queue redelivers. Every job must be safe to repeat. Check a flag before sending, or make the operation idempotent some other way. One user got the welcome email twice before I believed this.\nPass ids, not objects. The user row can change between push and execution. The job loads fresh data.\nQueues are easy to add and hard to add correctly. Start with email: slow, non-critical, nobody dies if it arrives a minute late.\n","permalink":"https://useinov.com/posts/2014/laravel-queues/","summary":"\u003cp\u003e1.8 seconds for one registration request. I put a timer around it on a project last month. 1.5 of those seconds was the welcome email going out over SMTP.\u003c/p\u003e\n\u003cp\u003eThe user waits almost two seconds and looks at a spinner, for a handshake with a mail server he will never hear about.\u003c/p\u003e\n\u003cp\u003eLaravel 4.2 makes the fix one line:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"nx\"\u003eQueue\u003c/span\u003e\u003cspan class=\"o\"\u003e::\u003c/span\u003e\u003cspan class=\"na\"\u003epush\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;SendWelcomeEmail\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e,\u003c/span\u003e \u003cspan class=\"k\"\u003earray\u003c/span\u003e\u003cspan class=\"p\"\u003e(\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;user_id\u0026#39;\u003c/span\u003e \u003cspan class=\"o\"\u003e=\u0026gt;\u003c/span\u003e \u003cspan class=\"nv\"\u003e$user\u003c/span\u003e\u003cspan class=\"o\"\u003e-\u0026gt;\u003c/span\u003e\u003cspan class=\"na\"\u003eid\u003c/span\u003e\u003cspan class=\"p\"\u003e));\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThe controller returns in 300 ms. A worker picks the job up and sends the email. \u003ccode\u003ephp artisan queue:listen\u003c/code\u003e to start, beanstalkd or Redis behind it, the \u003ccode\u003efailed_jobs\u003c/code\u003e table for jobs that died.\u003c/p\u003e","title":"Queues in Laravel 4.2: the user should not wait for your SMTP"},{"content":"One line in bootstrap.php, above Kohana::init():\nrequire DOCROOT.\u0026#39;vendor/autoload.php\u0026#39;; That is the whole integration. Kohana keeps its modules and its underscore autoloader. Composer brings Packagist. Two autoloaders in one process, and they do not collide: Composer packages use namespaces, Kohana classes have underscores, the lookups never meet.\n{ \u0026#34;require\u0026#34;: { \u0026#34;guzzlehttp/guzzle\u0026#34;: \u0026#34;~5.0\u0026#34; } } This setup runs in production on one of my projects for a few months. Zero problems from the autoloading side. The problems, when they came, were from me.\nBecause the technique is trivial and the discipline is not. My rule: Kohana code may call Composer libraries. Composer-level code never calls Kohana. One direction only. In the first week I broke it myself, a Guzzle call straight from a controller, because it was faster. It was faster for a day.\nSo now every library gets a door. For HTTP there is a small adapter class with my own interface, and the adapter is the only file that knows Guzzle exists. Same for the mailer. Controllers see the interface.\nTwo reasons for the ceremony. Tests: an adapter behind an interface is replaced by a fake in one line. And the future: if this project ever leaves Kohana, everything behind such interfaces moves for free. The migration becomes a list of adapters instead of a big bang.\nIn 2014 new PHP libraries live on Packagist. Kohana module repositories are where old ones stay. Start with one library and one adapter. The old module system will not be offended.\n","permalink":"https://useinov.com/posts/2014/composer-next-to-kohana/","summary":"\u003cp\u003eOne line in \u003ccode\u003ebootstrap.php\u003c/code\u003e, above \u003ccode\u003eKohana::init()\u003c/code\u003e:\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-php\" data-lang=\"php\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"k\"\u003erequire\u003c/span\u003e \u003cspan class=\"nx\"\u003eDOCROOT\u003c/span\u003e\u003cspan class=\"o\"\u003e.\u003c/span\u003e\u003cspan class=\"s1\"\u003e\u0026#39;vendor/autoload.php\u0026#39;\u003c/span\u003e\u003cspan class=\"p\"\u003e;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThat is the whole integration. Kohana keeps its modules and its underscore autoloader. Composer brings Packagist. Two autoloaders in one process, and they do not collide: Composer packages use namespaces, Kohana classes have underscores, the lookups never meet.\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" class=\"chroma\"\u003e\u003ccode class=\"language-json\" data-lang=\"json\"\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"nt\"\u003e\u0026#34;require\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"p\"\u003e{\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e        \u003cspan class=\"nt\"\u003e\u0026#34;guzzlehttp/guzzle\u0026#34;\u003c/span\u003e\u003cspan class=\"p\"\u003e:\u003c/span\u003e \u003cspan class=\"s2\"\u003e\u0026#34;~5.0\u0026#34;\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e    \u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan class=\"line\"\u003e\u003cspan class=\"cl\"\u003e\u003cspan class=\"p\"\u003e}\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003eThis setup runs in production on one of my projects for a few months. Zero problems from the autoloading side. The problems, when they came, were from me.\u003c/p\u003e","title":"Composer next to Kohana bootstrap"},{"content":"The commit log of kohana/kohana on GitHub. I opened it in the summer and counted: a few merges a month, then fewer, then nothing. This year the official development basically stopped. Our projects did not stop working on that day. But the question came to my inbox three times since, so here is the answer.\nWhat to do with a working Kohana product depends on which of four situations you are in.\nA small site in support mode. Nothing. It will run for years. Watch PHP compatibility, apply security fixes when the community makes them, that is all.\nAn active product. The hard case. A full rewrite is expensive and dangerous, and the business will not wait a year to get the same features back. The plan that looks sane to me: keep the framework, write all new domain code framework-agnostic. Plain PHP classes. No ORM::factory() inside business logic. Controllers stay thin adapters. Every month the framework is a smaller detail.\nA big legacy. Same as above, plus a risk people forget: hiring. Nobody young learns Kohana in 2014. In three years this will hurt more than any missing feature.\nA new project. No. Laravel or Symfony, and move on.\nThe real risks are boring. Security patches, which volunteers still make, for now. New PHP versions. And the ecosystem: every year more libraries assume Composer and PSR, and fewer people write Kohana modules.\nI had the rewrite proposal half written in September. Then I looked at how much of our code actually touches the framework. Less than I expected. The rest is our own mess, and a new framework would carry it over untouched.\nRewriting from scratch because of one announcement is emotion, not engineering. Measure your dependency. Then reduce it step by step.\n","permalink":"https://useinov.com/posts/2014/kohana-is-over-now-what/","summary":"\u003cp\u003eThe commit log of kohana/kohana on GitHub. I opened it in the summer and counted: a few merges a month, then fewer, then nothing. This year the official development basically stopped. Our projects did not stop working on that day. But the question came to my inbox three times since, so here is the answer.\u003c/p\u003e\n\u003cp\u003eWhat to do with a working Kohana product depends on which of four situations you are in.\u003c/p\u003e","title":"Kohana is not developed anymore. Now what"},{"content":"Kohana::find_file(). One function, maybe forty lines. If I could keep only one thing from Kohana, this is the one.\nIt looks for every file in order: application/, then modules, then system/. First found wins. Config, views, classes, i18n, everything goes through the same lookup.\nIn practice: a module ships a config file, you put a file with the same name into application/config/, change one key. The module does not know. Nothing under modules/ is touched.\nClasses work the same way through transparent extension. Real code lives in Kohana_Encrypt, and Encrypt next to it is an empty stub. You put your own Encrypt extends Kohana_Encrypt into application, override one method, and the whole framework picks it up. No fork. No patch in vendor. Upgrade is a git pull in the submodule.\nCompare with the usual way: fork the module, patch it, never update it again. I did this. Twice. One of those forks still runs somewhere.\nThe dark side. An override is invisible. A colleague opens the module, reads it, debugs it for an hour, and the real behavior sits in application/classes where nobody looked. I lost that hour myself last month. On my own override from spring.\nSo we have a rule now. Every override goes into a small OVERRIDES.md in the project root. One line per file, with the reason. Boring. It works.\nHMVC gets the conference talks. The filesystem does the work.\n","permalink":"https://useinov.com/posts/2014/cascading-filesystem/","summary":"\u003cp\u003e\u003ccode\u003eKohana::find_file()\u003c/code\u003e. One function, maybe forty lines. If I could keep only one thing from Kohana, this is the one.\u003c/p\u003e\n\u003cp\u003eIt looks for every file in order: \u003ccode\u003eapplication/\u003c/code\u003e, then modules, then \u003ccode\u003esystem/\u003c/code\u003e. First found wins. Config, views, classes, i18n, everything goes through the same lookup.\u003c/p\u003e\n\u003cp\u003eIn practice: a module ships a config file, you put a file with the same name into \u003ccode\u003eapplication/config/\u003c/code\u003e, change one key. The module does not know. Nothing under \u003ccode\u003emodules/\u003c/code\u003e is touched.\u003c/p\u003e","title":"Cascading filesystem: the best idea in Kohana"},{"content":"Fifty one queries. That is what the SQL panel of the Kohana profiler showed for one catalog page. Fifty posts on the page.\nNothing changed in the code. The page took two seconds because there was more data than in spring, and the code was written for spring.\n$post-\u0026gt;author-\u0026gt;name in a loop. One query for the list of posts, then one more for the author of every post, inside the foreach. The line looks innocent, and that is the problem: lazy loading hides the price. You write the relation, you go home early, and the bill comes in six months.\nThree ways out.\n$posts = ORM::factory(\u0026#39;Post\u0026#39;)-\u0026gt;with(\u0026#39;author\u0026#39;)-\u0026gt;find_all(); with() builds one JOIN and hydrates both objects. Works well for belongs_to. For has_many it is worse, the JOIN multiplies rows and you get the same post ten times.\nSecond, a manual join with plain result rows. You lose the object model. You get exactly the SQL you wrote, and nothing else. For heavy list pages I prefer this one now.\nThird, two queries. Load the posts, collect ids, load all authors with IN (...), map by hand. More code, but predictable. And it works where JOIN does not.\nI am not against the ORM. A convenient object model has a price, and the price is written in the SQL log, where the code does not show it. So keep the profiler open while you develop. And watch the query count, not the query time. Time depends on your laptop. Count will be the same in production.\nMine was.\n","permalink":"https://useinov.com/posts/2014/kohana-orm-n-plus-one/","summary":"\u003cp\u003eFifty one queries. That is what the SQL panel of the Kohana profiler showed for one catalog page. Fifty posts on the page.\u003c/p\u003e\n\u003cp\u003eNothing changed in the code. The page took two seconds because there was more data than in spring, and the code was written for spring.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003e$post-\u0026gt;author-\u0026gt;name\u003c/code\u003e in a loop. One query for the list of posts, then one more for the author of every post, inside the \u003ccode\u003eforeach\u003c/code\u003e. The line looks innocent, and that is the problem: lazy loading hides the price. You write the relation, you go home early, and the bill comes in six months.\u003c/p\u003e","title":"Kohana ORM and my first real N+1"},{"content":"Request::factory('widgets/stats') inside a controller. A colleague looked at this line last week and asked the question I hear most often about Kohana: you already have a request, why make another one?\nI keep answering it at the desk. So I will write it down once. Short notes, mostly backend, mostly PHP. This is the first.\nThe answer is composition. Take a dashboard. A stats block, a recent orders block, a notifications block. Each one needs its own data and its own logic. You can put all of it into one action. It works. In a month it is 300 lines and nobody wants to open it.\nWith HMVC each block is a small controller, and the page controller only collects them:\n$stats = Request::factory(\u0026#39;widgets/stats\u0026#39;)-\u0026gt;execute()-\u0026gt;body(); The internal request goes through normal routing and the normal lifecycle. The block does not know it is internal. Tomorrow the same block renders alone, for AJAX, without a single change. This is the good part.\nThe bad part. It is very easy to start calling controllers like functions. Ten internal requests per page, each with its own queries, each with the full routing overhead. This is not composition anymore. This is a service layer hiding behind URLs.\nI know because the ten-request page was mine. It felt clean while I wrote it.\nMy rule now: HMVC is for blocks that are also pages, or at least AJAX endpoints. If nobody ever asks for the thing over HTTP, it is a plain class. Not everything needs a route.\n","permalink":"https://useinov.com/posts/2014/kohana-hmvc/","summary":"\u003cp\u003e\u003ccode\u003eRequest::factory('widgets/stats')\u003c/code\u003e inside a controller. A colleague looked at this line last week and asked the question I hear most often about Kohana: you already have a request, why make another one?\u003c/p\u003e\n\u003cp\u003eI keep answering it at the desk. So I will write it down once. Short notes, mostly backend, mostly PHP. This is the first.\u003c/p\u003e\n\u003cp\u003eThe answer is composition. Take a dashboard. A stats block, a recent orders block, a notifications block. Each one needs its own data and its own logic. You can put all of it into one action. It works. In a month it is 300 lines and nobody wants to open it.\u003c/p\u003e","title":"HMVC in Kohana: a request inside a request"}]