Scaffolding stands in front of an old building of yellow brick. Arches of red brick curve above its windows, and the low sun casts the shadows of the scaffolding onto the wall.

At the end of September 2026, 37signals released a Rust version of Campfire, their self-hosted group chat. It is a single binary that replaces the Ruby on Rails application while keeping its database, its file storage, and its signed cookies. Coding agents wrote it, and the person who directed them says he never inspected the generated code.

Ports to Go, Elixir, Express, Django, and Laravel followed within a week, and one to C a few days later. In early October, the benchmark table in the repository of Campfire in Go compared seven of these implementations. Rust served the room page at about 36,000 requests per second, Go at about 3,900, and Rails at about 240. On that page, Laravel came last with 164.

If you work with PHP, you know the reflex such a table triggers. Should we worry? Does PHP need an ahead-of-time compiler? Should frameworks ship their hot code paths in Rust?

These are fair questions, and I will get to them. But the most interesting question this experiment raises is a different one: how does anybody know that the Rust version is correct?

Read the benchmark like a test result

A benchmark is a test that reports a number instead of a verdict. It deserves the same questions: what does it exercise, under which conditions, and what would make it fail?

The Campfire numbers come from 37signals' own harness: 16 concurrent clients, four CPU cores per application, one AMD Ryzen machine. That is careful work. The first version of the table, however, compared ports that had received very different amounts of work. The Rust port had been through a series of optimisations, each of them measured before and after. For the Laravel and Django ports, by contrast, finding out how well agents do in each environment without further help was the stated purpose.

The Laravel port shows what that means. Within a day, a pull request against it replaced nginx and PHP-FPM with FrankenPHP and Laravel Octane, cached rendered message lists, and swapped some Eloquent model hydration for plain SQL. In the pull request's own measurements, the room page went from 55 to about 2,000 requests per second, a factor of more than 30. Neither the language nor the framework changed.

Those measurements ran on different hardware, and their author says explicitly that they cannot be compared with the official table. The Rust port's own documentation, however, allows a cleaner separation of effects.

According to its README at the end of September, a straight translation from Ruby to Rust was already 3 to 10 times faster than Rails. Everything beyond that, up to a factor of 95 on the room page in that README's benchmark, came from work such as caching rendered page parts, compressing them once instead of on every request, and adding a database index on room and creation time. The Laravel pull request added the very same index.

Then the table itself changed. Within a few days, 37signals carried what had made the fast ports fast over to all implementations, Rails included: caching complete responses, and the same database queries and indexes. The Rails application also moved to newer versions of Ruby and Puma, and the Laravel pull request was merged. In the table of 8 October, Rails serves the room page at about 4,100 requests per second, 17 times as many as before, and Laravel at about 3,900. Rust serves about 106,000, Go about 53,000, and the new port to C about 137,000. The factor between Rust and Rails shrank from 150 to 26.

So the first version of the table mixed two effects with different causes. One is the language and its runtime: a straight translation was worth a factor of 3 to 10, and with the same caching and queries, Rust still serves 26 times as many requests as Rails. The other is the execution model and the architecture: booting the framework for every request or keeping it resident, rendering everything or caching it, querying the database well or badly. Both are real, and an honest discussion has to keep them apart.

Where does the time go?

Before asking whether something should be rewritten in a faster language, it helps to ask where its time is spent. In the PHP ecosystem, three kinds of programs are worth distinguishing.

Tools that analyse code spend their time in their own computation: tokenising, parsing, inferring types, walking syntax trees. This is where native code pays off, and it has arrived. Mago is a linter, formatter, and static analyser for PHP written in Rust. PHPStan now ships an optional extension, written in C++, that reimplements most of its analysis engine and cuts the time PHPStan needs to analyse its own source code by 58 %.

PHPStan's documentation is unusually candid about where this gain comes from. A call from PHP into C++ costs about as much as a call within PHP, so a native loop that calls back into PHP for every element is as slow as the PHP loop it replaced. A port only pays off when it takes over a whole call tree and returns to PHP once, at the end. The first versions of the extension, released from July 2026 onwards, replaced a handful of classes that each absorbed millions of tiny calls. The big step came with PHPStan 2.3 in October 2026, which moved the analysis engine, some 350 classes, into native code as one unit. Rules, third-party extensions, and the extension API stayed in PHP and work unchanged.

Tools that execute code spend most of their time in the code they execute. PHPUnit is such a tool: how long a test suite takes is mostly a matter of how long its tests take. Rewriting the test runner in a systems language would speed up the part that is not slow. The levers are elsewhere: not doing the same work twice, running tests in parallel, and not running tests that a change cannot affect. That is why my work on PHPUnit concentrates on these levers. Since PHPUnit 13.4, a focused test run no longer has to load every test file, because PHPUnit can cache which tests each file contains. I am currently working on parallel test execution and test impact analysis.

Applications spend their time in the execution model, in I/O, and in the database. The factor of 30 in the Laravel port came from exactly there. For most PHP applications, a faster interpreter is not the first thing that is missing. An ahead-of-time compiler would not change the execution model either: the work of bootstrapping the application would still be repeated for every request, only faster.

How do you know the fast version is correct?

This is the part of the Campfire experiment that I find most interesting, and it has received the least attention.

The Rust port is not trusted because someone reviewed it. It is trusted because it was compared against the Rails application, systematically and without tolerance. The Rails application is pinned as a Git submodule, and the README called it “the oracle for everything”. No expected output was written by hand: golden data, screenshots, and protocol recordings all came from running the Rails application.

A Playwright harness runs both applications side by side on identical seed data with frozen clocks. When the port was finished, the harness covered 225 screen states across three browser engines, four viewports, and light and dark mode, and for each of them it compared the server's HTML, the live DOM, the accessibility tree, every subresource, the WebSocket frames, and a pixel-by-pixel screenshot. Before the first line of Rust existed, the harness had to show that the Rails application matched itself, so that nondeterminism could not hide real differences.

PHPStan's native extension is verified along the same lines. Every class with a native counterpart keeps its PHP implementation, marked with a #[ShadowedByTurboExtension] attribute. A differential test compares the native classes with their PHP twins, and the full test suite runs with the extension loaded. A complete analysis run is also recorded twice, once with the PHP classes and once with the native ones, down to the type of every expression and the scope at every statement, and the two recordings must be identical. For the parser, whose input domain is all PHP code, thousands of files are parsed with both implementations, and the serialised syntax trees must be byte-identical. CI also uses reflection to compare each native class's signatures with those of its PHP twin, and the extension switches itself off when its version does not match the PHPStan release.

Even the agent-generated performance pull request for the Laravel port follows this pattern. Its author did not review the changes. He relied on the test suite and on every page being byte-identical to the previous build.

None of these techniques is new: Software testing research calls the underlying challenge the test oracle problem: how do you decide whether an output is correct? An existing implementation is one of the best oracles there is. Running two implementations on the same input and comparing their results is differential testing, which William McKeeman described in 1998 for testing C compilers against each other. And recording what existing software actually does, before changing it, is what Michael Feathers called characterisation testing.

What is new is the economics: According to the README of the Rust port at the end of September, coordinated agents built it in about a day. When an implementation can be produced that quickly, the implementation is no longer the expensive part. The initiator of the Rust port does not intend to write or read the Rust code; to him, Rust is the target his prompts are compiled into. If that is where we are heading, the source code that matters is the specification against which the generated code is checked. In practice, that specification is a reference implementation plus a test suite.

Rewrite or accelerate?

There are two ways to use a reference implementation, and they differ in what becomes of the oracle afterwards.

The first is the rewrite. The Rails application served as the oracle while the Rust port was built. Now that parity has been reached, the Rust port deliberately diverges from Rails wherever that makes it faster or better. Every divergence is documented, and the harness masks or allowlists it. From that point on, the specification is no longer the Rails application. It is the Rails application minus a growing list of exceptions, and once Rails is retired, it is whatever the tests encode.

The second is acceleration: write the code in PHP, find the parts that are measurably slow, port only those to native code, and keep both. PHPStan follows this path. So does graphql_accelerator, a Rust extension for the parser of webonyx/graphql-php. It does not fork the library. Instead, it provides native implementations of graphql-php's parser and of the node classes of its syntax tree, so that the PHP versions of these classes are never autoloaded, and it reports a speedup of 11 to 16 times.

Its correctness is checked by running graphql-php's own PHPUnit tests for GraphQL\Language, which contains the lexer, the parser, the printer, and the visitor, with the extension loaded: 227 tests with 14,299 assertions; two of the tests are skipped. Its author is candid that the code was largely generated with LLM-based tools, and that development was driven by keeping the original tests green.

The acceleration path keeps the oracle alive. The PHP implementation remains the reference, the fallback when the extension is not available, and the yardstick for every change to the native code. To me, this is the strongest argument for this path: it is the one you can keep verifying. PHPStan's rules for new native ports make the same point from the other direction. A port that cannot demonstrate a measurable gain with identical output is reverted, because “unproven native code is pure maintenance debt”.

What does keeping the oracle alive take in practice?

  • The shared test suite runs in at least two configurations, with and without the native code, as a CI matrix. A test that is skipped because an extension is missing compares nothing.
  • Checks that only make sense for the native code come on top, such as PHPStan's comparison of signatures between a native class and its PHP twin.
  • Example-based tests only cover the inputs someone thought of. Generated inputs, fed to both implementations with their results compared, cover the rest. This is where property-based testing and fuzzing come in, and the reference implementation makes the property trivial to state: same input, same output. The Rust port of Campfire did exactly this for its rich text pipeline, with a corpus of 658 cases, 400 of them fuzzed.

Shipping native code with PHP packages raises a second question: where do the binaries come from? PHPStan already ships prebuilt binaries of its extension for the common platforms in its Composer package. PIE, the PHP Installer for Extensions, can download prebuilt binaries, and Packagist has announced, as a longer-term direction, that it wants to host immutable build artifacts with SLSA build provenance and Sigstore attestations verified by Composer. Native code in PHP packages turns provenance, reproducible builds, and SBOMs from nice-to-haves into requirements. That deserves an article of its own.

The tests that survive are bound to the interface you keep

This brings me to the question in the title: Imagine that your application, or a library you maintain, were reimplemented tomorrow. Which of your tests could run against the new implementation unchanged?

The answer depends on what the replacement preserves. graphql_accelerator preserves graphql-php's PHP classes, their names, and their methods. That is why graphql-php's tests, which test at the level of those classes, could run against the native code unchanged. The Rust port of Campfire preserves something much smaller: HTTP, the database schema, the cookies, and the WebSocket protocol. Only tests at that level would have survived that rewrite, and that is exactly the level at which its harness works.

So the precise answer is: the tests that survive are the tests bound to the interface you keep. Tests that use mocks to verify which methods get called, or that inspect internal state, are bound to one particular implementation. They may be useful while you work on that implementation, but they cannot tell you whether a different one is correct.

This is not an argument against unit tests. It is an argument for knowing which of your tests specify behaviour and which merely describe how the current code happens to work. PHPUnit's own test suite is an example. Its end-to-end tests are PHPT files that run the test runner with a given set of arguments and compare its complete output with an expectation. They depend on little more than the runner's entry point, its arguments, and its output, so they would survive any rewrite that keeps those.

You do not need to change the language to run into this. Moving a Laravel application from PHP-FPM to Octane changes no business logic, but it changes the execution model: long-running workers keep state across requests that PHP-FPM discarded after each one. The performance pull request for the Laravel port shows what that means. One of its commits resets the current user at the start of each request, because otherwise guest pages would have been rendered with the previous request's user. The tests that catch this kind of defect exercise the application through its HTTP interface, across more than one request.

There is a catch, though. Most functional tests in PHP never go through the runtime: they hand a request object to the framework's kernel inside the test process. The lifecycle between two such requests is then whatever the test tooling implements, not what the server you deploy does. Symfony's test client, for example, reboots the kernel for each request by default, which resembles PHP-FPM more than a worker that keeps the application in memory.

So when you change the runtime, at least some of your tests have to run against the runtime you actually deploy. And every runtime you support adds a configuration to that matrix; Laravel Octane alone supports four application servers.

A harness proves agreement, not correctness

A parity harness proves that two implementations agree. It does not prove that either of them is correct.

The Rust port of Campfire is instructive here, too. Its README lists defects in the Rails application that the port fixes on purpose, among them a stored cross-site scripting vulnerability in how URLs in messages are turned into links. A harness that compares against Rails cannot flag such defects; it can only reproduce them faithfully. Fixing them meant deviating from the oracle, deliberately and with documentation.

Nor does a harness give anyone an understanding of the code that passes it. The initiator of the port says he has not read the Rust code. For a chat application with a living reference implementation and a thorough harness, that may be an acceptable trade-off. Once the reference implementation is gone, though, the harness is the only remaining description of what the software is supposed to do, and it was written to compare, not to explain. I have written before about what happens when code is produced faster than it is understood.

On the acceleration path, the remaining risk is divergence wherever the tests are silent. graphql_accelerator passes 14,299 assertions, and its author still calls it not production-ready. That is the right call, because a green test suite is exactly as strong as the test suite.

This strength can be measured. The Campfire harness had to show that Rails matched itself before it was allowed to judge Rust: a test of the oracle's determinism. The oracle's sensitivity deserves the same attention. Mutation testing of the PHP reference implementation tells you how many deliberately introduced faults your tests detect. A test suite that does not notice when the reference implementation is broken will not notice when the native one is broken either. Before you trust a test suite as an oracle, test the oracle.

Tests are not the only deterministic check, though. A test covers the inputs someone thought of; a type checker covers every path through the code, limited only by what its type system can express. A recent article on heise.de argued that this is exactly what makes Rust attractive as a target language for agents: where the tests reach their limits, the compiler takes over.

PHP checks its type declarations at runtime, not before the program runs. Its counterpart to that compiler is the combination of strict type declarations and static analysis with PHPStan, Psalm, or Mago at their strictest settings. These tools are weaker than a compiler: they trust annotations and have to give up where code is dynamic. For code that nobody reads, this second deterministic check is still not a matter of style. It is the only one that covers all inputs rather than a sample of them, and I have described elsewhere where tests end and types begin.

PHP does not have to win a throughput race

Should the PHP community worry about a benchmark table in which Rust is still more than 25 times as fast as Laravel? I do not think PHP has to win a throughput race against Rust. It never tried to. It has to remain fast enough that keeping a PHP code base, or starting a new project in PHP, is the reasonable choice.

A good part of the original distance in that table lay in the execution model and the architecture, not in the language, as the table itself showed a few days later. Where a tool's own computation dominates, native code is the right answer, and the acceleration path shows how to adopt it without giving up the PHP implementation as the reference.

The asset that makes any of these choices possible, however, is not the code. It is the specification against which you can check an implementation: a reference implementation, and tests bound to the interfaces you intend to keep. With it, you can move from PHP-FPM to Octane, accept a native extension, or let an agent build a port, and know whether the result is correct. Without it, no compiler will help you.

So here is a small exercise: Pick ten tests from your test suite at random. For each one, ask whether it would still be meaningful against a reimplementation of the code it tests. The ones that would are your specification; the others are scaffolding. It is worth knowing how many of each you have.