[{"content":"I\u0026rsquo;m a senior software developer based in central Denmark. I\u0026rsquo;ve been around computers since age six and writing software professionally for seventeen years.\nThe path has been broad rather than narrow — I\u0026rsquo;ve worked across the Microsoft enterprise stack (ASP.NET, C#, WCF, IIS Smooth Streaming, WMDRM/PlayReady, Transact-SQL), the LAMP world (PHP, MySQL, Apache, Nginx), and modern web tooling (React, Vue, ES6, Docker, AWS), with a side curiosity in F#, Python, and a little assembly. A consistent thread runs through every role: optimization and modernization.\nI\u0026rsquo;m a Danish vocational-trained practitioner (EUD Web-integrator), MySQL DBA-certified, and equally comfortable in three SQL dialects (MySQL, T-SQL, PostgreSQL). Formal sysadmin experience still shapes how I approach infrastructure questions — I don\u0026rsquo;t treat infra as someone else\u0026rsquo;s problem. My work tends to leave the codebase a little more modern than I found it.\nCurrently Software developer at Mobilplan, a Danish multi-tenant workforce management platform serving 200+ customers. I own the e-conomic accounting integration, lead modernization tracks (PHP 8.4 + Rector, MySQLWrapper PSR-4, PHPUnit→Pest), and help run the team\u0026rsquo;s developer tooling.\nEarlier Years Where What 2009 – 2018 SiteVision (consultancy) C# webshops, news/content sites, streaming + DRM, sysadmin 2018 – 2020 Deltaplan PHP modules + ES6 frontends 2020 – 2021 Karlog-IT (consultancy) WordPress plugins, Dinero/e-conomic integrations 2021 – Mobilplan Where I am now Elsewhere GitHub: evaldnet LinkedIn: evaldnet Email: evaldnet@gmail.com ","permalink":"https://evaldnet.dk/about/","summary":"\u003cp\u003eI\u0026rsquo;m a senior software developer based in central Denmark. I\u0026rsquo;ve been around computers since age six and writing software professionally for seventeen years.\u003c/p\u003e\n\u003cp\u003eThe path has been broad rather than narrow — I\u0026rsquo;ve worked across the Microsoft enterprise stack (ASP.NET, C#, WCF, IIS Smooth Streaming, WMDRM/PlayReady, Transact-SQL), the LAMP world (PHP, MySQL, Apache, Nginx), and modern web tooling (React, Vue, ES6, Docker, AWS), with a side curiosity in F#, Python, and a little assembly. A consistent thread runs through every role: \u003cstrong\u003eoptimization and modernization\u003c/strong\u003e.\u003c/p\u003e","title":"About"},{"content":"Senior Software Developer · Allingåbro, Denmark\nevaldnet@gmail.com · +45 42 59 90 98 · github.com/evaldnet · linkedin.com/in/evaldnet\nDownload as PDF\nProfile Senior software developer with 17 years of professional experience and over 23 years of building software, spanning the Microsoft enterprise stack, the LAMP world, and modern web tooling. A consistent thread across every role has been optimization and modernization — query tuning, page-speed work, server migrations, and refactoring legacy systems toward modern standards while keeping production stable. Danish vocational-trained practitioner, MySQL DBA-certified, with formal sysadmin background that informs how I approach infrastructure.\nSelected Skills Backend \u0026amp; languages PHP (current daily driver) · C# .NET · ASP.NET · Classic ASP · Ruby on Rails · Python · F# (personal) · Bash · some assembly\nDatabases (production work in three SQL dialects) MySQL (DBA-certified) · Microsoft SQL Server (Transact-SQL) · PostgreSQL · LINQ to SQL · Database design\nFront-end HTML5 · CSS / SCSS · JavaScript · jQuery · AJAX · XML / XSLT · JSON · React · Vue · ES6 modules\nInfrastructure \u0026amp; DevOps Linux (Ubuntu, Debian, CentOS) · Apache · Nginx · Docker · AWS · Git / GitLab · Linux system administration\nMicrosoft enterprise stack .NET · LINQ · Visual Studio · WCF · IIS · IIS Smooth Streaming · WMDRM 10 · PlayReady DRM · SOAP\nCMS \u0026amp; integrations WordPress (custom plugin development) · WooCommerce · Drupal · e-conomic · Dinero · RabbitMQ\nLinkedIn Skill Assessments passed: PHP · WordPress · MySQL · Linux · Git · Backend-webudvikling\nProfessional Experience Software Developer · Mobilplan Sep 2021 – present · In-house product · Multi-tenant Danish workforce management platform serving 200+ customers\nOwned the e-conomic accounting integration end-to-end, building on prior Danish ERP/accounting integration experience from before joining the company. Led major modernization tracks: PHP 8.4 + Rector upgrade, MySQLWrapper PSR-4 v2 refactor, PDO wrapper, PHPUnit → Pest test framework migration, permissions refactor, display module consolidation. Drove a long-running customer migration from heavy customizations back to the standard product — coordinating core feature additions with customer-side override removals. Built shared developer tooling and conventions for the team — codebase guidelines, review templates, workflow scripts, AI-assisted development integration. Modernization work delivered alongside steady support throughput; refactor-to-fix ratio in commit history runs higher than typical for a senior IC. Web Developer (Consultant) · Karlog-IT ApS May 2020 – Jul 2021 · Consultancy · WordPress plugins, integrations, and JavaScript for client engagements\nBuilt MIQID-Core / MIQID-Elementor / MIQID-Woo — three-plugin suite bridging WordPress, Elementor, and WooCommerce with the MIQID.com platform. WordPress plugins syncing orders with Dinero and e-conomic — integration with the two major Danish accounting/ERP systems. ES6 custom JavaScript, RabbitMQ service updates, ReactNative (Expo) mobile app maintenance. System Developer · Deltaplan ApS Aug 2018 – Apr 2020 · In-house product\nCustom PHP modules and matching ES6 frontend modules for the Deltaplan platform. System Administrator · SiteVision ApS Jan 2017 – Jul 2018 · Promoted from System Developer to manage server infrastructure\nServer management across Windows, Linux, and FreeBSD. WLAN network management and backup scripting. LinkedIn skills tagged for this role: Optimering, Netværksinfrastruktur, Databaser, Databasedesign, Systemeffektivitet, Systemarkitektur, Ubuntu. System Developer · SiteVision ApS May 2009 – Dec 2016 · Consultancy · Client projects across e-commerce, news/content, streaming/DRM, and comparison engines\nC# webshops with LINQ to SQL — country/language-based, SEO and cache optimized. WordPress setup, plugin development, and news synchronization. Classic ASP maintenance on legacy systems. Custom JavaScript solutions tied to project requirements. Sales / First Assistant (Part-time) · Netto Mar 2008 – Jul 2009 · Retail role overlapping with vocational education\nNotable Projects Mobilplan (2021 – present) e-conomic integration — owned end-to-end across the platform. PHP 8.4 + Rector modernization — repo-wide upgrade and code modernization. MySQLWrapper PSR-4 v2 and PDO wrapper — database-layer refactors. PHPUnit → Pest migration — test framework switch across the codebase. Karlog-IT (2020 – 2021) MIQID plugin suite — three-plugin WordPress/Elementor/WooCommerce extension for the MIQID.com platform. SiteVision (2009 – 2018) Travel: BeachTours.dk · DubaiTours.dk · TemaTours.dk · Timeoff Travel (custom-built booking system)\nE-commerce in C#: Huuray.no · Huuray.se · SendEnTanke.dk · Billigvoks.dk — country/language-based webshops with SEO and cache optimization\nNews / content: Kendte.dk · Newsbreak.dk (Drupal → WordPress migration with live news import)\nEnterprise streaming, VOD and DRM:\nMoveeBox — IIS Smooth Streaming, MMS Streaming, Microsoft WMDRM 10, PlayReady DRM. Web player/shop and settop-box integration. FIDD — VOD system on Windows Communication Foundation and Adobe Flash Media Service. Multiple whitelabel deployments. Comparison engine: Telepristjek.dk — cellphone plan comparison with a complex statistics and live calculation engine in Transact-SQL.\nServer / infrastructure: Lionive.com / Lionivepartners.com — server migration, Nginx setup, theme rework.\nEducation Years Where What 2008 – 2009 Roskilde Tekniske Skole Web-integrator (Danish vocational program / EUD) 2008 Aalborg Tekniske Skole IT-supporter foundation 2004 – 2005 Den Jyske Håndværkerskole Electrician foundation 2001 – 2003 Tjele Efterskole 9th and 10th grade (Danish residential equivalent of folkeskole) 2000 – 2001 Efterskolen Billeshave 8th grade Certifications MySQL Database Administrator — DBA-certified LinkedIn Skill Assessments passed — PHP, WordPress, MySQL, Linux, Git, Backend-webudvikling Languages Danish — native (read, write, speak) English — fluent (read, write, speak) Interests Self-hosted infrastructure, smart home automation, language paradigms (functional programming as a side curiosity), and developer tooling that scales beyond the individual.\nReferences available on request.\n","permalink":"https://evaldnet.dk/cv/","summary":"\u003cp\u003e\u003cstrong\u003eSenior Software Developer\u003c/strong\u003e · Allingåbro, Denmark\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"mailto:evaldnet@gmail.com\"\u003eevaldnet@gmail.com\u003c/a\u003e · +45 42 59 90 98 · \u003ca href=\"https://github.com/evaldnet\"\u003egithub.com/evaldnet\u003c/a\u003e · \u003ca href=\"https://www.linkedin.com/in/evaldnet/\"\u003elinkedin.com/in/evaldnet\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"/Lars-Andersen-CV.pdf\"\u003eDownload as PDF\u003c/a\u003e\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"profile\"\u003eProfile\u003c/h2\u003e\n\u003cp\u003eSenior software developer with 17 years of professional experience and over 23 years of building software, spanning the Microsoft enterprise stack, the LAMP world, and modern web tooling. A consistent thread across every role has been \u003cstrong\u003eoptimization and modernization\u003c/strong\u003e — query tuning, page-speed work, server migrations, and refactoring legacy systems toward modern standards while keeping production stable. Danish vocational-trained practitioner, MySQL DBA-certified, with formal sysadmin background that informs how I approach infrastructure.\u003c/p\u003e","title":"CV"},{"content":"A list page in our admin had a problem nobody quite knew how to file. On a customer with a busy month it would sometimes finish in three seconds, sometimes in twenty, and occasionally just crash with a 500 because PHP\u0026rsquo;s max_execution_time of thirty seconds had run out. Reload, try again, and you might get the page. Or you might not.\nPages with this shape are the worst kind to optimize. The slow case is real but not reliable. Profiling locally returns \u0026ldquo;everything is fast.\u0026rdquo; Adding microtime(true) calls around suspected blocks confirms whatever you thought before you added them.\nWhat broke the impasse was Clockwork, which a colleague had installed in our codebase the week before. Clockwork is a PHP profiler that hangs in the browser dev-tools, showing per-request timings, queries, and custom events. The default install was already giving us \u0026ldquo;how many queries does this page run\u0026rdquo; — useful for the obvious cases, not enough to figure out where the time on this page was actually going.\nI added a small helper, addClockworkEvent($name, $startTime), that wraps Clockwork\u0026rsquo;s event API with a backtrace filter and a consistent naming convention. Then I dropped calls to it at the boundaries I\u0026rsquo;d suspected without evidence — repository constructors, the rule engine, the holiday cache, the schedule lookups, the export builders.\nThe next Clockwork trace was unrecognizable. The fast cases and the slow cases looked completely different — in a way the old \u0026ldquo;how many queries\u0026rdquo; view had hidden.\nWhat the trace showed The slow case wasn\u0026rsquo;t one slow query. It was a thousand small things, none of them individually scary.\nThe same ConfigRepository was instantiated many times per request because every callsite that needed config did new ConfigRepository(...) instead of asking a registry. The holiday repository was loading the year\u0026rsquo;s holidays once per employee. The custom-fields repository was re-reading its definitions on each form render. The rule engine was reconstructing its rule graph for every registration even though the graph only changed when an admin saved a configuration page.\nFIND_IN_SET was used as a filter on a column with hundreds of thousands of rows. Two SQL tables had index orderings that no longer matched the queries hitting them. Inside the employee-registration render, the loop fetched one employee at a time, generating one round trip per employee for what could have been a single batched fetch.\nEach of these was, on a small customer, basically free. On a large customer in a busy month, they added up to the 30-second timeout.\nThe ten fixes Across three weeks I shipped roughly ten changes. None of them were a refactor in the dramatic sense:\nSingleton pattern for ~20 repositories and services. The trait that owns this is fifteen lines. Hit MySQLWrapper, ConfigRepository, RuleCache, RuleBuilder, RuleService, HolidayRepository, ScheduleRepository, Schedule, ModuleService, AssistantService, RegistrationConversionService, and friends. In-memory caching in CustomFieldRepository. First call hits the DB; subsequent calls use a per-request static array. Cached assistant evaluations. The evaluator was deterministic per (employee_id, date); caching it within the request collapsed N calls into one. FIND_IN_SET → IN in three queries in RegistrationRepository. FIND_IN_SET doesn\u0026rsquo;t use indexes; IN does. Recursive query for holidaysInRange. Replaced a per-day fetch loop with one query that returns the whole range. JSON aggregation for worktimeSchedules. Collapsed an N+1 of schedule-day fetches into one query returning a JSON column the app already knew how to read. Index reorganization on two SQL tables. No new indexes — just reordering columns inside existing ones so the leftmost prefix matched the actual queries. Subquery restructure + a deleted flag check in the list query that started this whole thing. The old form re-evaluated the subquery per outer row; the new form pre-filters before the join. Chunked employee rendering — 20 at a time. The render loop used to issue one DB round trip per employee. Now it batches. SystemLogger for batch logging. The audit-log write that fired on every action used to be one INSERT per call. The new logger buffers and writes once per request. Plus one cleanup: the preload limit on the timesheet view rose from 100 to 500, because with the changes above, fetching 500 was cheaper than fetching 100 had been before.\nWhat I almost wrote, and didn\u0026rsquo;t The temptation in the middle of this work was to rewrite the list page entirely — pull out the query, redesign it as a single read with computed columns, lift it into a view. Cleaner, more elegant, lots of demo value.\nIt would also have taken a month and left a long tail of unknown regressions.\nThe ten small fixes took three weeks, each one shipped on its own, each one a measurable win in the Clockwork trace, each one rollback-able on its own merits. The big rewrite would have been one PR I\u0026rsquo;d have been afraid to revert.\nA page that\u0026rsquo;s slow because of one heroic bad query is rare. A page that\u0026rsquo;s slow because of years of accumulated micro-costs is normal. Treat it accordingly.\nThe lesson Two things stuck with me.\nThe instrumentation pass was worth more than any single fix. Without the addClockworkEvent() helper and the targeted calls, every one of the ten changes above was a guess. With it, each fix was a measurable response to a specific line in the trace. The afternoon I spent on instrumentation is the work I\u0026rsquo;d recommend most.\nTrust the trace over your intuition about hotspots. I would have sworn the bottleneck was a slow query. It wasn\u0026rsquo;t. It was a constellation of small ones, plus instantiation costs, plus loop-per-employee fetches. My intuition was wrong by an order of magnitude on which class of fix mattered.\nThe page loads cleanly now. I haven\u0026rsquo;t seen a 30-second timeout since.\n","permalink":"https://evaldnet.dk/posts/what-clockwork-showed-us-about-a-30-second-list-page/","summary":"A list page in our admin was hitting PHP\u0026rsquo;s max_execution_time and crashing. The fix wasn\u0026rsquo;t one big rewrite — it was ten small ones, found by spending an afternoon making Clockwork actually show us where the time went.","title":"What Clockwork showed us about a 30-second list page"},{"content":"The pull request was open from June 16 to October 30. Four and a half months. 138 files. ~12,000 lines of diff. The branch consolidated how the whole admin asks \u0026ldquo;is this user allowed to do this thing\u0026rdquo; — replacing several ad-hoc permission checks with a single, named API.\nIt merged at 12:28. The revert merged at 12:57. The refactor lived twenty-nine minutes on master.\nI think those 29 minutes were the best decision we made that quarter, and I want to write down why.\nWhat the refactor actually was The admin had grown several parallel ways of expressing the same idea:\nif ($user-\u0026gt;hasRight('something')) in some places if (in_array('something', $user-\u0026gt;getRights())) in others A few callsites that constructed the right-name from a feature toggle One module that bypassed the check by re-reading the session A handful of if ($user-\u0026gt;isAdmin()) shortcuts that subtly diverged from \u0026ldquo;has the rights for this thing\u0026rdquo; Five idioms, same intent, slightly different behavior on edge cases. The refactor collapsed them into one: a Permissions::can($user, $action) style API with the per-module right names enumerated in a single registry. New checks could be grepped. Old checks would, eventually, all line up.\nThe PR description called it a refactor. In practice it was a refactor and a bugfix — several of the old idioms had been quietly granting access they shouldn\u0026rsquo;t, or denying it where they shouldn\u0026rsquo;t, and the rewrite tightened those up.\nHow a 12,000-LOC refactor passes review In bits. The PR went through five distinct review rounds between June and October, mostly driven by QA finding behavior differences on specific admin screens. Most rounds were small (\u0026ldquo;the multi-select on the roles screen lost its select-all\u0026rdquo;). A few were bigger (\u0026ldquo;the data-fields page now hides a tab from non-admins that used to be visible to leads\u0026rdquo;). Each round produced more commits, more screenshots, more \u0026ldquo;verify this on customer X.\u0026rdquo;\nBy late October the QA passes had cleared everything that could be clicked through. The test suite was green. The Danger bot warned, accurately, that it was a \u0026ldquo;Large PR detected: ~12000 LOC changed. Consider splitting.\u0026rdquo; We did not split.\nThe bug, and the merge It merged at 12:28 on a Thursday.\nThe bug was in create project — a flow that runs hundreds of times a day. One of the old idioms had implicitly granted a permission that the new registry now required explicitly. Creating a project from one specific entry point hit a path that asked the new API a question the old code never asked, and the new API correctly answered \u0026ldquo;no\u0026rdquo; — which then aborted the create flow with an opaque error.\nTest suite green. Manual QA green. Production: project creation fell off a cliff.\nBy 12:35-ish, support had two tickets and our error tracker had counted ten or twenty unique exceptions.\nWhy we reverted instead of hotfixing The reflex on a fresh merge breaking production is stabilize forward: identify the broken path, push a fix, redeploy. That works when the broken thing is a leaf — a single endpoint, a single template, a single function. It does not work well when the broken thing is one expression of a refactor that touched 138 files.\nThe argument for reverting:\nThe bug was in production, which meant other bugs of the same shape almost certainly existed and hadn\u0026rsquo;t surfaced yet. The test suite couldn\u0026rsquo;t find them; manual QA hadn\u0026rsquo;t found them; the only thing finding them was live traffic. Stabilizing forward means stabilizing one bug at a time, on production, while the codebase is in a state nobody fully understands. The PR had a clean parent commit. The revert was a single click. The customer-facing apps weren\u0026rsquo;t pinned to anything in the new code yet. The team that had built it had been on it for four months. They were not the right people to debug it from cold, mid-incident. The argument against reverting was sunk cost. Four and a half months of work. A coordinated set of PRs across two other repositories. A QA pass that had cleared everything. Reverting felt like throwing it away.\nIt wasn\u0026rsquo;t throwing it away. Reverting bought us the time to re-merge the same diff with the bug fixed, in calm, under code review, on a calendar that wasn\u0026rsquo;t \u0026ldquo;right now.\u0026rdquo;\nBy 12:57, master was back where it was at 12:27.\nThe 29 minutes What actually happened in those 29 minutes:\n12:28 — Merge lands. 12:30-ish — First production exceptions appear. 12:35-ish — Support has two tickets. The team gets pinged. 12:40-ish — We\u0026rsquo;ve reproduced the create-project error locally on the merged branch. Root cause is one missing right in the registry. 12:45-ish — Quick discussion: hotfix or revert. We choose revert. 12:50-ish — Revert PR opened. 12:57 — Revert merged. The decision-to-revert step was the longest. The PR author wanted to hotfix; reverting feels like a personal rejection of the work. The argument that won was: \u0026ldquo;the revert lets you fix this PR. The hotfix doesn\u0026rsquo;t let us fix the next one.\u0026rdquo;\nThe operational muscle this took — being able to revert in under 30 minutes — is the part nobody plans for and everybody benefits from. Specifically:\nThe branch was rebased on master, not merged-with-merge-commits. The revert was one commit. Deploys are fast — under five minutes from merge to production on this repo. We don\u0026rsquo;t have a manual QA gate on master (the manual QA happens pre-merge). So the revert deployed itself, the same way the broken merge had. Nobody had to ask permission to revert. The dev who merged it could revert it. Those four things are not free. They are the result of past investment. If your incident response includes \u0026ldquo;ping the release manager for approval,\u0026rdquo; your revert window is not 29 minutes.\nWhat we changed for v2 The v2 PR opened the next morning. It was the same branch, rebased on the new master (which now didn\u0026rsquo;t contain the reverted merge), with one additional commit: the missing right added to the registry, and a regression test that called the create-project endpoint end-to-end and asserted a non-zero return.\nThat regression test is the actual lesson.\nThe original PR\u0026rsquo;s test coverage was excellent for the new code. It had unit tests on the new Permissions::can() API. It had unit tests on the new registry. It had QA screenshots from manual click-through on the affected admin screens.\nWhat it didn\u0026rsquo;t have was a test that asked, of every important user-facing flow: does this still work end-to-end after the refactor? The create-project flow was hot enough that \u0026ldquo;does this still work\u0026rdquo; should have been a CI gate.\nFor v2 we added a small set of integration tests covering the highest-traffic flows: create-project, create-customer, create-registration, the daily approval cycle. Not exhaustive. Just enough that a regression in any one of them would fail CI before the merge button could go green.\nv2 merged on November 6 and has stayed in. There were two small hotfixes the following week (one typo on a right name, one missed callsite in a rarely-used corner) but no incidents of the create-project shape.\nThe lesson The shape of this story is not \u0026ldquo;we did something wrong and learned a lesson.\u0026rdquo; We did something carefully — four months of review, multiple QA rounds, coordinated cross-repo PRs — and shipped a regression anyway. Test coverage on the new code does not protect you from a refactor\u0026rsquo;s effect on existing call paths. The only thing that does is exercising those call paths end-to-end.\nTwo things I\u0026rsquo;d write down for next time:\nIdentify the top-N hot flows before merging any refactor that touches more than ~50 files. Create-project, create-customer, daily approval — for us, on this codebase, those three are the load-bearing wall. A test that fails when any of them stop working ten minutes from now is worth more than a hundred unit tests that pass on the new abstraction. Optimize for a fast revert, not for never needing one. The 29-minute revert was free for us because of investments in deploy speed, merge hygiene, and a culture where reverting is a tool rather than an admission. If those weren\u0026rsquo;t already in place, this incident would have lasted four hours, not twenty-nine minutes. Review will not save you on a refactor this big. Plan for the revert.\n","permalink":"https://evaldnet.dk/posts/reverting-a-four-month-refactor-in-29-minutes/","summary":"A 138-file permissions refactor we\u0026rsquo;d worked on since June merged at 12:28. By 12:57 it was reverted. The bug was small. The revert was the right call. The version we re-shipped a week later was the same diff plus one fix — and a different way of proving it was safe before the merge button.","title":"Reverting a 4-month refactor in 29 minutes (and shipping it again a week later)"},{"content":"Rector is famous for the dramatic refactors. Add return types across the whole codebase. Convert every closure to an arrow function. Promote constructor properties. Sprinkle readonly over your value objects. The before/after PRs that go viral.\nWe used Rector to upgrade a PHP 7.3 codebase to 8.4. Our config applies none of that. Every rule we picked is a mechanical, semantics-preserving transform that either fixes a forward-compatibility break or removes a syntax form PHP no longer supports. That was the deliberate choice. The boring rules are the ones that get a legacy codebase to 8.4 without setting it on fire.\nThis is what our rector.php looks like, and why.\nThe codebase, briefly Multiple repos sharing a parent directory. One rector.php reaches into all of them. .inc files (legacy PEAR-ish convention) alongside .php. Templates in .tpl for a custom template engine. Thousands of classes. Some still using PHP 4 constructors (function ClassName() instead of __construct). Customer-specific subdirectories that get pulled in dynamically. Almost no type declarations in legacy code. A grep for : void returns dozens of matches across millions of lines. The conventional Rector advice for this kind of codebase is \u0026ldquo;run UP_TO_PHP_84 and fix the breakages.\u0026rdquo; That\u0026rsquo;s the advice you give when you don\u0026rsquo;t have to live with the result.\nWhat the level sets would have done LevelSetList::UP_TO_PHP_84 bundles hundreds of rules. Among them:\nAdd return type declarations everywhere Rector can infer them. Convert closures to arrow functions where possible. Promote constructor properties. Add declare(strict_types=1) to every file. Replace null defaults with nullable parameter types. Convert array_map callbacks into fn() =\u0026gt; form. Every one of those is fine on a codebase you understand fully. Every one of those is a footgun on a codebase the size of ours, because:\nInferred return types are wrong when a legacy function \u0026ldquo;returns string|false\u0026rdquo; but every caller treats false as the empty case. Rector adds : string based on the dominant path, and callers blow up six months later when an edge case hits the false branch through a return type now declared as string. Adding strict_types changes how implicit type coercion works throughout each file. Every '5' that gets passed to an int parameter now throws. The test suite passes if the suite happens to use real ints there; production breaks where the callers passed strings. Promoted constructors are great until you have inheritance and an existing parent signature you don\u0026rsquo;t want to change. The cleanup PR is bigger than the original Rector PR. The unifying problem: those rules change semantics, or at least change what kind of bug a callsite produces. On a codebase nobody fully understands, that\u0026rsquo;s a lot of variance.\nThe rules we did pick share a single property: a passing test suite before the rule means a passing test suite after.\nWhat we actually applied Roughly twenty rules, grouped by what they fix:\nPHP 5.3 / 7.0 forward-compat:\nPhp4ConstructorRector — rename PHP 4-style constructors to __construct. PHP 8 dropped support. ReplaceHttpServerVarsByServerRector — $HTTP_GET_VARS → $_GET. Long gone. EregToPregMatchRector — ereg() was removed in PHP 7. MultiDirnameRector — dirname(dirname($x)) → dirname($x, 2). Trivial, mechanical. PHP 7.4 forward-compat:\nMbStrrposEncodingArgumentPositionRector — the encoding-arg position changed in 7.4. RestoreDefaultNullToNullableTypePropertyRector — property defaults that are null need an explicit nullable type after typed-properties land. PHP 8.1 / 8.2 forward-compat:\nNullToStrictStringFuncCallArgRector — strlen(null) is deprecated; rewrite to strlen((string) $x). VariableInStringInterpolationFixerRector — fixes \u0026quot;${var}\u0026quot; → \u0026quot;{$var}\u0026quot;. The bare ${} form is deprecated. Utf8DecodeEncodeToMbConvertEncodingRector — utf8_decode() / utf8_encode() are deprecated. Style (no semantic change):\nSymplifyQuoteEscapeRector, WrapEncapsedVariableInCurlyBracesRector — string-form normalization. SeparateMultiUseImportsRector, SplitGroupedPropertiesRector, SplitGroupedClassConstantsRector — one declaration per line. ConsistentImplodeRector — implode($sep, $arr) argument order normalization. Dead-code:\nRecastingRemovalRector — (int)(int) $x → (int) $x and similar redundant casts. Safe by definition. That\u0026rsquo;s the entire active list. Twenty-ish rules, every one of them either removes a deprecation or normalizes a syntax form. Not one of them tries to infer types, restructure control flow, or modernize an idiom.\nThe five custom rules we wrote When Rector didn\u0026rsquo;t ship a rule for a pattern we kept hitting, we wrote one. Each is ~100 lines of NodeVisitor code in our app/Rector/ namespace, autoloaded the same way Rector\u0026rsquo;s built-ins are.\nArrayPointerRector — each() was deprecated in 7.2 and removed in 8.0. There are still patterns like while (list($k, $v) = each($arr)) in code older than the test suite. This rule rewrites them to foreach. ArraySortRector — PHP 8 made sort algorithms stable. Callers that relied on the previous unstable order can produce surprising results after the upgrade. The rule rewrites a known set of uksort callsites to make the new ordering explicit. NewArgumentTypeCastRector — declarative per-class argument casting at the callsite. We pass it 'Meta' =\u0026gt; ['string', 'string', 'int'] and it casts every new Meta(...) accordingly. Much safer than adding constructor types and breaking 500 callsites at once; you tighten the boundary at the call end first, then the constructor catches up later. ConvertEnvToGetenvRector — replaces calls to a project-local env() helper with getenv(). We had two competing implementations of env(). Migrating callsites to getenv() consolidated the surface area before we deleted them. TypecastArgsRector — applies known casts to known function arguments based on a config table. PHP 8.1+ deprecates passing null to non-nullable internal function params; the built-in NullToStrictStringFuncCallArgRector catches some, this fills the gap for our own functions. The pattern across all five: target one specific deprecation or anti-pattern, do nothing else. None of them try to be a refactoring tool.\nTwo non-obvious config bits -\u0026gt;withFileExtensions([\u0026#39;inc\u0026#39;, \u0026#39;tpl\u0026#39;, \u0026#39;php\u0026#39;]) Rector defaults to .php only. Our .inc files (legacy class.inc convention) and the .tpl templates contain PHP and execute at runtime, so they have to be in scope or the upgrade is incomplete.\nThen there\u0026rsquo;s a 25-line filesystem walker that identifies files containing PHP 4-style constructors (function ClassName(...) inside a class ClassName). The reason: Php4ConstructorRector operates per-file, and our directory tree is large enough that handing it only the files it could possibly transform cut a Rector run from minutes to seconds. The walker is uglier than it needs to be — it\u0026rsquo;s the kind of code where \u0026ldquo;good enough on first try, never looked at again\u0026rdquo; applied.\nWhat we left for later UP_TO_PHP_84 and its more aggressive cousins are still right there. Once the codebase has typed properties everywhere — once the next round of modernization makes inference reliable — we can add an inference-based rule. That decision happens rule-by-rule, not as a set. The advantage of the cherry-pick is that adding the twenty-first rule is the same shape of decision as adding the first.\nIf your codebase looks anything like ours — .inc files, PHP 4 constructors, untyped legacy — the question is not \u0026ldquo;should I run Rector.\u0026rdquo; The question is which rules are safe enough to run today. That list is shorter than the level sets, and it\u0026rsquo;s the right place to start.\n","permalink":"https://evaldnet.dk/posts/rector-without-the-level-sets/","summary":"Rector ships with version \u0026lsquo;sets\u0026rsquo; that bundle hundreds of rules to take your code from PHP X to PHP Y. We didn\u0026rsquo;t use any of them. Here\u0026rsquo;s the 20-rule cherry-pick we used to upgrade a multi-repo PHP 7.3 codebase to 8.4 — and why hand-picking turned out to be the smaller risk.","title":"Rector without the level sets: upgrading PHP 7.3 → 8.4 the boring way"}]