---
title: "Rewritten in PHP"
url: https://www.exakat.io/rewritten-in-php/
date: 2026-08-24
modified: 2026-08-24
author: "dams"
description: "Rewritten in PHP The application was \"rewritten in Rust\" meme runs one direction. Or it also goes to Go, just because it is fun. Here's the traffic going the other..."
categories:
  - "Event"
tags:
  - "php"
  - "rewrite"
image: https://www.exakat.io/wp-content/uploads/2026/08/rebuild.320.jpg
word_count: 1835
---

# Rewritten in PHP

# Rewritten in PHP

The application was "rewritten in Rust" meme runs one direction. Or it also goes to Go, just because it is fun. Here's the traffic going the other way, and why it's less silly than it sounds.

Nobody writes a blog post titled "we ported it to PHP". The genre convention is the opposite: you leave PHP, ideally for Go, and you write 2,000 words about your p99 latency. That narrative is real: PHP → Go/Java/Rust/TypeScript is the more publicized move at large scale, and Facebook's detour into Hack is the interesting exception mostly because it stayed inside the PHP family rather than leaving it.

And also, there is inbound traffic, and it's more structured than you'd expect. It falls into six recognizable shapes. Two of them are old and boring. Two are increasingly interesting. And two are just people doing arithmetic.

## 1. Legacy PHP → modern PHP

By far the biggest bucket, and probably what most people mean when they say that this is a `PHP rewrite`. The language stays; almost everything else changes.

**What it looks like:** PHP 5-era procedural code, or a homegrown framework, or even an abandoned one, such as CodeIgniter 2, Zend Framework 1, CakePHP 2, is rebuilt on PHP 8.x with Laravel, Symfony, Drupal, or Leaf. Typed properties, a DI container, Composer, PSR autoloading, an actual and modern test suite.

**Examples:** Zend Framework 1 apps migrating to Laminas or straight to Symfony. Drupal 7 → 10, which took so long that Drupal 7's end of life became an industry event in its own right. Magento 1 → Magento 2, notorious enough that "M1 to M2" is a line item on agency rate cards.

**Why:**

- **Security EOL is a hard deadline.** Unsupported PHP versions don't get CVE patches, and PCI-DSS auditors know the version table.
- **Hiring.** The pool of people who will cheerfully maintain a bespoke 2011 framework is shrinking and expensive. The pool for Symfony is not.
- **Performance is genuinely different now.** PHP 8 is not PHP 5. Preloading, JIT, and a decade of opcache work mean the "PHP is slow" reflex is calibrated to a language that no longer exists.
- **The ecosystem left without you.** Every useful library assumes Composer. Staying off it means vendoring everything by hand forever.

This bucket is unglamorous but it's where the person-years go.

## 2. Tooling ported into PHP so it can actually parse PHP

This is the newer, more interesting shape, and it's growing fast because of the current wave of AI-adjacent developer tooling, which is overwhelmingly written in TypeScript first.

**The illustration: [`voku/slop-scan`](https://packagist.org/packages/voku/slop-scan).**

The original, [`modem-dev/slop-scan`](https://github.com/modem-dev/slop-scan), is a TypeScript CLI that finds "AI slop" patterns: empty catch blocks, error swallowing, pass-through wrappers, duplicated test mock setup, in JavaScript and TypeScript repositories. It's a Bun/npm project, installed with `npm install -g slop-scan`, and it supports exactly six extensions: `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`.

Lars Moelleken forked it and rewrote it in PHP. The port now targets `.php`, `.phtml`, and `.inc`, runs on PHP 8.3+, and ships as either a Composer package or a standalone PHAR. That can't be more PHP than that.

**Why this couldn't be a config flag:**

The whole tool is AST analysis. Upstream builds its syntax tree with a TypeScript parser; the port builds one with `nikic/php-parser`. There is no adapter layer that makes a TypeScript AST describe a PHP `try`/`catch`. "Detect an empty catch block" is a trivially portable idea and a completely non-portable implementation. Once you accept that the analysis engine has to be rewritten, writing it in the language it analyzes is the path of least resistance: you get the parser, the semantics, and the test fixtures in one ecosystem.

**But the more telling part is what else changed.** The port didn't just translate the rules. It adopted PHP's tooling idioms:

- PHPStan-style `ignoreErrors` in config, because that's the suppression syntax PHP developers already have muscle memory for
- Inline `@slop-scan-ignore` directives, matching the `@phpstan-ignore` / `@psalm-suppress` convention
- Baseline files, because "adopt a linter on a legacy codebase without fixing 4,000 findings first" is a solved problem in PHP and the solution is a baseline
- GitHub annotation output, so it drops into the CI PHP projects already run

That's the actual argument. A cross-language wrapper would have given you PHP file support. It would not have given you a tool that feels like PHP tooling.

**Why:**

- **The AST is the product.** Language-specific analysis can't be genuinely language-agnostic.
- **Distribution matches the audience.** `composer require --dev` or a PHAR beats "install Node to lint your PHP."
- **CI runners already have the runtime.** A PHP project's pipeline has PHP in it. Adding a Node toolchain to lint PHP is a second dependency tree, a second lockfile, and a second thing that breaks.
- **Ecosystem conventions are a real feature.** Config format, suppression syntax, baseline behavior, and output targets are where adoption succeeds or fails.

## 3. Ports where the host language's runtime is the specification

A close cousin of the above, but with a sharper argument, and one that's easy to miss until it bites you.

**The illustration: `phpolylint`** — a utility, inspired by the MegaLinter concept, in PHP:  **[phpolylint](https://codeberg.org/exakat/phpolylint).**

**[MegaLinter](https://megalinter.io/latest/)** is an excellent tool. It's also a Python orchestrator that ships as a Docker image bundling 120+ third-party linters; the full image runs to several gigabytes. For a large polyglot monorepo, that trade is obviously worth it. For a PHP shop, you're pulling gigabytes and standing up a Python runtime and Docker to validate files that PHP is going to read anyway.

**And here's the part that isn't just about convenience.** "Valid INI" and "Valid JSON" sound like universal properties. They aren't. They're properties relative to a parser.

Take INI. PHP's [`parse_ini_file()`](https://www.php.net/manual/en/function.parse-ini-file.php) has its own dialect: `on`, `off`, `yes`, `no`, `true`, `false`, `null`, and `none` are reserved words that get coerced to values, not treated as literal strings. Characters like `?`, `{`, `}`, `|`, `&`, `~`, `!`, `(`, `)`, and `^` have special meaning and need quoting. And the types you get back depend on which scanner mode you pass: `INI_SCANNER_TYPED` gives you booleans and integers where the default gives you strings. A linter built on Python's `configparser` will happily approve a file that `parse_ini_file()` reads differently, or reject one it reads fine. Both tools are correct. Only one of them is correct about your production behavior.

JSON is the same story with a different accent. `json_decode()` has a default nesting depth of 512. Large integers silently become floats unless you pass `JSON_BIGINT_AS_STRING`. Duplicate keys don't error: with PHP, the last one wins, quietly. And the classic: `json_decode('{}')` gives you a `stdClass` while `json_decode('[]')` gives you an array, so an empty PHP array re-encodes as `[]` even when the schema wanted `{}`. A JavaScript-based JSON checker will pass every one of these files. It's validating a different parser than the one that will run.

**Why:**

- **You want to validate against the parser that actually runs in production**, not against an abstract format spec.
- **Runtime and dependency footprint.** No Docker daemon, no multi-gigabyte pull, no second language runtime in CI.
- **Failure output should speak the local dialect.** An error that references `INI_SCANNER_TYPED`is actionable to a PHP developer in a way that a generic parse error isn't.
- **The generic-orchestrator model has a real cost**, and for a single-ecosystem repo it's mostly cost.

The general principle: when a format's behavior is defined by a runtime rather than by a spec everyone implements identically, the checker belongs in that runtime.

## 4. Polyglot stacks consolidating back

**What it looks like:** A team split a monolith into Node and Python services during the microservices enthusiasm years, then folded some of it back into a single Laravel or Symfony app.

**Why:** Almost never a technical argument. It's operational overhead: three deployment pipelines, three dependency ecosystems, three on-call runbooks, three sets of "who knows how this one works." Consolidation usually gets justified on hiring cost and deployment simplicity, and often the honest version is "we were eight people pretending to be eighty".

Worth being clear-eyed: this direction is a correction of a previous over-correction more often than it's a considered choice.

## 5. CMS and e-commerce platform migrations

**What it looks like:** A custom-built storefront or publishing system replaced by Magento, Shopware, WooCommerce, or Drupal, plus the platform-version jumps that are rewrites in all but name.

**Why:** Nobody wants to maintain a bespoke checkout flow, tax engine, or VAT/OSS compliance layer. Buying into a platform means inheriting an extension marketplace, a payment-gateway integration catalog, and someone else's problem list for PSD2 and regional tax rules. The rewrite cost is real; the ongoing compliance cost you're offloading is usually larger.

## 6. Internal tooling and admin panels

**What it looks like:** A bespoke React admin dashboard, talking to a PHP API over REST, replaced by Filament or Laravel Nova running directly against the same models.

**Why:** The CRUD-generation payoff is enormous and the UI requirements are modest. You delete a frontend build pipeline, a separate repo, a state management layer, and an API surface that existed only to serve your own admin panel. Internal tools have maybe forty users and no design requirements: the argument for a bespoke SPA was always thin.

**Related:** cost-driven consolidation off serverless. Teams moving from Lambda-plus-managed-services onto a boring PHP-FPM box because the bill stopped making sense at the scale the project actually operates at. This is arithmetic, not architecture.

---

## Summary

| Shape | Example | Core reason |
| ----- | ------- | ----------- |
| Legacy PHP → modern PHP | Drupal 7→10, Magento 1→2, ZF1→Symfony | Security EOL, hiring, ecosystem access |
| Tooling ported for AST access | `voku/slop-scan` (from TypeScript) | Language-specific analysis can't be language-agnostic |
| Ports for runtime-accurate semantics | `php-linter` (MegaLinter concept) | Validate against the parser that actually runs |
| Polyglot consolidation | Node/Python services → Laravel | Operational overhead, not technical merit |
| CMS / e-commerce platform | Custom storefront → Shopware | Offload compliance and integration burden |
| Admin panel simplification | React admin → Filament | Delete the frontend you didn't need |

---

## When not to do this

Rewriting to PHP is the wrong call when:

- You need real shared-memory concurrency or long-lived stateful connections. PHP has async options, such as Swoole, ReactPHP, or Fibers, but you're swimming upstream, and the request-per-process model is the thing most of the ecosystem assumes.
- The work is CPU-bound numerics or ML. The libraries aren't there yet, and pretending otherwise is how you end up maintaining bindings.
- The feature is run on CLI, as a single, long running process, with lots of files and memory-bound structures. Then, it may be important to check if the time saving is actually worth the conversion.
- The existing system works and the motivation is aesthetic. This applies to every rewrite in every direction, but it bears repeating, because "rewritten in X" posts are a genre that rewards the rewrite and never publishes the null result.

The interesting cases in this list, categories 2 and 3, share a property that the others don't. They're not rewrites of a system. They're re-implementations of an idea in the environment where the idea has to operate. That's a different, and much better, reason to start over.