{"slug": "porting-ecs-to-go", "title": "Porting ECS to Go", "summary": "Tomas Votruba reported that a Go port of the ECS coding-standard tool runs static analysis on a Symfony repository in 4 seconds with zero cache, versus over 2 minutes for the PHP-based ECS or PHP-CS-Fixer, and that his first proof-of-concept rule converting array() to [] ran 30x faster on one of his internal projects. Votruba said he directed much of his AI subscription toward PHP-to-Go porting, merged the PHP and Go code into a single repository after parallel agents lost context across two repos, and tracked progress against a baseline of 230 PHP fixers versus 1 in Go, working in batches of 5 rules with a missing-go-rules.txt file in the codebase.", "body_md": "# Porting ECS to Go\n\nAfter 4 previous parts in [this series](https://tomasvotruba.com/series/rust-go-in-php), it should be clear why we want Go in our PHP ecosystem. ECS or PHP-CS-Fixer runs over **2 mins** on a [Symfony repository](https://github.com/symfony/symfony).\n\nThe Go version? **[4 seconds](https://github.com/TomasVotruba/need-for-speed#static-analyzers-and-code-modifiers)**. Zero cache, 100 % project.\n\nToday we'll look at the porting.\n\n## Proof of Concept\n\nBefore I put most of my AI subscription to PHP <-> Go porting, I wanted to have a minimal proof of concept to verify the idea. Easy CS (ECS) is a fix-only coding standard tool that includes standard sets like PSR-12 and [PER CS](https://www.php-fig.org/per/coding-style/), and [10 prepared sets](https://github.com/ecsphp/ecs/tree/main/config/set/common) that go beyond the standard, but everyone wants anyway.\n\nWe already have a [php-parser in Go](https://github.com/rectorphp/php-parser-in-go), but we won't touch it today. Here we have to emulate the `token_get_all()` function.\n\nLet's start with PHP 5.4 classic - convert all `array()` to `[]`:\n\nHey agent, can you emulate this `token_get_all()` function and parse a PHP file?\nThen make a simple rule, similar to Fixer, that would fix all `array()` occurrences to `[]`.\n\nAgent made it. So I asked it to compare speed on one of my internal projects.\nI was amazed: **30x faster**.\n\nPOC validated. So I went deeper and added a couple more rules:\n\n- let's always add brackets to a `new X()`\n- add spacing around `if (...)` properly\n- use uppercase constant for `TRUE` ,`FALSE` and`NULL`\n- ...\n\n## 2 repositories for 2 languages?\n\nMore and more ideas about which rules to add to the Go version of ECS - we need a better plan. Let's port existing rules, one by one.\n\nGamify the work! I added a CI check that counted rules that were written in PHP (mostly php-cs-fixer rules + dozens of ECS ones) and how many are in Go.\n\nAt first, the stats were a bit frustrating:\n\n```\nPHP: 230 fixers\nGo: 1\n```\n\nCI kept failing and would for a long time. I wasn't sure how this would go. I never ported one tool to another language in my life, so I shared these stats as amusement on Twitter through August/September. Success is not guaranteed, but excitement is!\n\nAt first, I wanted the ECS repository to be clean and PHP only. So I made another one, private, where the agent worked. 20-30 rules in, it was obvious the work is slow. The agent had to work in the ECS repository and then move to the standalone `ecs-go` directory and do the work on a Go port. The agent was losing context (and so did I), making mistakes, and the repositories soon became out of sync.\n\nSo I decided to merge them into one repository, to improve results and whole workflow.\n\n## Mirror rule and Mirror tests\n\nUsing a single repository came in handy not only for closer context, but also for mirroring work: the rules were in `/src` or `/vendor`. The agent then had to fetch the source for `php-cs-fixer` from Github to see tests as well.\n\nAgent, show me how many rules are not ported yet, pick top 5 to convert.\n\n...\n\nOkay, start with those 5, make a PR for each of those in parallel and make them pass.\n\nI tried to ask agent for 20 rules, but it was reluctant to do it. Either it took much more time, or it was a back-and-forth mess. So I went with 5 rules at a time. Later I added the list of `missing-go-rules.txt` directly to the codebase. That way the agent had context right in the repository, without needing to fetch some memory. Running multiple parallel agents was also easier, as they shared a single source of truth, and could remove the fixers from `missing-go-rules.txt` as they finished them.\n\nThis was a part time project (work, Rector, family, house reconstruction), so I went with low hanging fruit first. It was easier for me to review and for the agent to build.\n\n## Set a \"Done\" Goal\n\nGoal was to have all the sets that are in a typical `ecs.php` and apply exactly 100 % of rules in the exact configuration you had in `ecs.php`. That means the Go version would parse the `ecs.php` and map set + rule configuration to its Golang version.\n\n- ECS Go has sets, has rules, configurable rules with defaults.\n- It doesn't have cache ([duh?](https://tomasvotruba.com/blog/there-is-no-cache) ), it doesn't have @magic PHP CS Fixer sets that are better handled by Rector.\n- It has dry run and `--fix` .\n\nOnce this is done, we can publish a beta release.\n\n## Parity between PHP and Go\n\nSo how do we know it's finished? At first, I added CI criteria of PHP <-> Go parity. That means this table:\n\n```\nPHP: 230 fixers\nGo: 230 fixers\n```\n\nIf a single fixer were missing in Go, that would not be a port, would it? I didn't want to make a brand new tool people would have to learn, understand and figure out how it works differently - the way Mago is a brand new tool next to PHPStan. I want devs/agents to have the same experience as before, just 30x faster. If the port is done correctly, you should only notice the speed improvement, nothing else.\n\nIn 3-4 weeks of afternoon agent prompting, the Go port was nearly done. Sometimes the agent got stuck. This proved to be the most effective nudge:\n\nHey, look at this X fixer, find all its tests in PHP, port it to Golang. Do not fix it yet, just make the Golang Fixer test fail.\n\nOh, I forgot: since the very beginning, tests were a priority. Something between these lines was part of the `CLAUDE.md`:\n\n```\n* every PHP + Go lang rules must be tested\n* test battery should contain example of before/after PHP code\n* make tests complete, but not overly verbose\n```\n\nWe have 230 out of 230 done... ready to publish?\n\nNot yet.\n\n## Parity on Open Source Projects\n\nThe next step is parity on a real project. Not a tiny package/tool, but a big real-life, 10+ year old codebase. I've been working on a Mautic upgrade since June as a hobby project, to see how much one can upgrade with proper Rector/Golang tooling, so it was the first real target for the Go port:\n\nI added a `--blink` CLI option, that would run ECS in Golang mode:\n\n```\n# php\nvendor/bin/ecs \n\n# go\nvendor/bin/ecs --blink\n```\n\nSome things were fixed as expected, others not quite right. Fortunately, the parity goal was simple:\n\nMake both of these commands pass with identical output. They should either both change the same files in the same way, or not touch anything at all.\n\nThe result: the Golang output was 1:1 - the diff format, the spacing, the colors... all on purpose, to make the porting easy for agents. Devs should be able to read the diff the same way they're used to.\n\nAt first, Mautic parity was 25 %. That means the diff of file changes by ECS and the diff by ECS Go was mismatching in 25 % of cases. In a couple of days, it was nearly 1 %. We could push under the 1 %... but agents struggled. To give you an example, this edge case was on 1-2 files with deeply nested bracket spacing and multiple indents.\n\nThere was also a rule here and there that was missed by rule parity. To prevent this, the Golang ECS **reports all the rules and their configurations that were missed on load**.\n\nIt looks like this:\n\n```\nblink: 2 unsupported ECS rule(s) (no blink fixer), skipped:\n    - PhpCsFixer\\Fixer\\Comment\\HeaderCommentFixer\n    - PhpCsFixer\\Fixer\\Import\\GlobalNamespaceImportFixer\n\nblink: 1 configured ECS rule(s) applied with blink's built-in behaviour (config not modelled):\n    - PhpCsFixer\\Fixer\\Phpdoc\\PhpdocAlignFixer\n```\n\nThis is part of the ECS now, so if there is some rule that is configurable in ECS PHP, but not in ECS Go, it will be reported ([make an issue](https://github.com/ecsphp/ecs/issues) and tell me to fix it). The ECS Go is now tested on dozens of projects, so it should be 1:1 parity, but you never know.\n\nThat said: it's a known limitation that when you have a custom local PHP fixer rule, it will not work. I haven't seen that in years, but it might happen. In that case, ask your agent to find a native PHP CS Fixer or ECS rule instead. There is a high chance the rule is already part of ECS sets/rules.\n\n## Avoid Grinding\n\nInstead of grinding the 1 %, there is a more effective way to improve the parity: let's run it on another project. Bigger, more popular and easier to surface weak spots. Like [Laravel codebase](https://github.com/laravel/framework).\n\nNow the parity started at 8 %. It took a few rounds to get under 2 %.\n\nThese are running in CI on every run, to avoid regression. Conflicting parity is a constant that we keep lowering.\n\n## Ready for Public Beta\n\nOn September 30th, the Golang port [was ready for public beta](https://t.co/fCrNSt4ybH).\n\nGive it a go:\n\n```\ncomposer require symplify/easy-coding-standard:^13.3.11\n```\n\nTo run the Go version, just add `--blink` to your command.\n\n```\nvendor/bin/ecs --blink\n```\n\nIf the speed up is smaller than 30x or something is not working right, let me know in [the issues](https://github.com/ecsphp/ecs/issues).\n\nHappy coding!", "url": "https://wpnews.pro/news/porting-ecs-to-go", "canonical_source": "https://tomasvotruba.com/blog/porting-ecs-to-go", "published_at": "2026-11-05 12:43:05+00:00", "updated_at": "2026-11-05 12:55:51.472458+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools"], "entities": ["Tomas Votruba", "ECS", "PHP-CS-Fixer", "Go", "Symfony", "Rector", "php-parser-in-go"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/porting-ecs-to-go", "markdown": "https://wpnews.pro/news/porting-ecs-to-go.md", "text": "https://wpnews.pro/news/porting-ecs-to-go.txt", "jsonld": "https://wpnews.pro/news/porting-ecs-to-go.jsonld"}}