· Jobs Dev 2019 · Prague · given in Czech

Migrating a legacy PHP project to PSR-4 with scripts and Rector

Migrace vašeho legacy projektu na PSR-4 automatickými nástroji. Renaming classes across a thousand files sounds like a search-and-replace job. It isn’t, when the rest of the team keeps committing about 100 times a day to the same code while you do it.

Update, 2026The Rector config below is the YAML format from 2019. Rector dropped YAML configs in 2020 and is configured with a rector.php file today, and its rules have moved to different namespaces. The approach still works; the syntax doesn’t.

The starting point

The project was a big legacy PHP application at GLAMI, built on Nette. PSR-4 is the PHP standard that maps a namespace to a directory, so Glami\Cache\Apc lives in src/Cache/Apc.php and Composer can find it without any index. This code didn’t follow it. It relied on Nette RobotLoader instead.

RobotLoader’s killer feature is that it doesn’t care: it scans your directories and loads a class from whatever file it’s in, whatever the file is called. You can have 20 classes in one file and nothing complains. That’s convenient for years, and then it’s a mess. Our bootstrap had hundreds of lines of require __DIR__ . '/../app/model/...' and a list of single files added to RobotLoader by hand.

Why do it with scripts

I didn’t want to do this by hand, for three reasons:

Step one: RobotLoader to Composer classmap

The path had two stages: RobotLoader, then Composer classmap, then Composer PSR-4. The classmap is the easy first step. Composer scans the listed directories once and writes a lookup table of every class:

"autoload": {
    "classmap": ["app", "libs", "src"]
},
"autoload-dev": {
    "classmap": ["tests", "tests_db", "tests_selenium"]
}

Is there any real performance gain from leaving RobotLoader? People in the Nette community said they never noticed a difference. Our monitoring did show one.

Monitoring chart for 24 April 2019 with average response time (green), average CPU (red) and requests per second (grey). A blue box marks the deploy in the early afternoon, after which both the response time line and the CPU line step down to a lower level and stay there while traffic keeps growing.
The day the classmap went to production: average response time and CPU step down at the deploy.

The catch with a classmap: add a new class and forget to regenerate it, and you get Class 'Glami\MyClass' not found. PSR-4 doesn’t have that problem, which is why the classmap was only a step.

Step two: classmap to PSR-4

The target configuration is short:

"autoload": {
    "psr-4": {
        "App\\": "app",
        "Glami\\": "src"
    },
    "classmap": ["libs"]
},
"autoload-dev": {
    "psr-4": {
        "Tests\\": "tests",
        "TestsDB\\": "tests_db",
        "TestsSelenium\\": "tests_selenium"
    }
}

The naive plan is three bullets: rename class and namespace, replace all usages, profit. Too naive, you are.

The requirements

Plan properly

Most of the work was the plan, written as a numbered checklist before touching any code. In short:

  1. One class per file. Split files that contain more than one class, with Rector.
  2. Name every file after its class.
  3. Rename directories so they start with a capital letter (except templates).
  4. Decide the correct namespace for every class.
  5. Generate a map of old class name to new class name for Rector.
  6. Migrate the classes with Rector.

Moving libs/klarka to src had its own list: fix every __DIR__ and ../ path, then a full-text search and manual fixes in .gitlab-ci.yml, the gettext extract command, the translation scripts, composer.json, phpstan.neon and the entry-point PHP files. One line in the plan was in bold: watch out for how presenters and cron jobs are mapped. The plan also links phpcs-psr4-sniff, a CodeSniffer rule that checks whether files follow PSR-4.

The tools

Rector does instant upgrades and refactoring of PHP code. It parses the code into an abstract syntax tree, so a rename is a real rename, not a string replace. Around the same time we used it to migrate a whole application from Nette to Symfony, which I talked about in a separate talk.

Around Rector I wrote a few small scripts. The first renames files to match the class inside:

$files = Finder::findFiles('*.php')->from(__DIR__ . '/../src');
$renames = [];

foreach ($files as $file) {
    $filePath = $file->getPathname();
    $content = NetteFileSystem::read($filePath);
    $match = Strings::match($content, '/^\w*\s*(class|interface|trait) *(\w*)/m');

    // File does not contain class
    if ($match === null) {
        continue;
    }

    $fileName = $file->getFilename();
    $className = $match[2];
    $correctFileName = $className . '.php';

    // File is already same as class name
    if ($fileName === $correctFileName) {
        continue;
    }

    $renames[$fileName] = $correctFileName;
    $newFilePath = str_replace($fileName, $correctFileName, $filePath);
    NetteFileSystem::rename($filePath, $newFilePath);
}

echo Json::encode($renames, Json::PRETTY);

The second one finds files with multiple classes. It uses RobotLoader itself, because RobotLoader already knows which class lives in which file, and runs Rector in Docker on each of those files to split them, followed by composer dump-autoload.

Then the Rector config, generated from the old-to-new map:

Rector\Rector\Class_\RenameClassRector:
    Glami\libs\Statistics\NormalDistribution: Glami\Statistics\NormalDistribution
    Glami\libs\PHPStan\PHPStan: Glami\PHPStan\PHPStan
    Glami\libs\PageTimeApc: Glami\PageTimeApc
    Glami\libs\Tools\Date\Range: Glami\Tools\Date\Range
    Glami\libs\Cache\Apc: Glami\Cache\Apc
    # ...

And the last script searches the whole project, outside vendor and a few other directories, for any old class name that still appears as a string. Those are the places Rector can’t see: config files, templates, strings.

90% of the time: edge cases

The renames were the easy part. Most of the time went into things like these:

- /** @var  */
+ /** @var */
  private $collation;

- $mockSoapService = $this->getMockBuilder('\Klarka\GpWebPay\SoapService');
+ $mockSoapService = $this->getMockBuilder(SoapService::class);

- $this->expectException('MMDB_Exception');
+ $this->expectException(\Klarka\MMDB\MMDB_Exception::class);

- class DownloaderResult extends Klarka\HTTP\Downloader\Result {}
+ class DownloaderResult extends \Klarka\HTTP\Downloader\Result {}

Broken docblocks, class names hidden in strings, and relative names that only worked because of where the file used to live.

Code review?

The resulting change touched 1,128 files. GitHub stops showing a diff at 1,000 files. Nobody can review that line by line. What you can review is the script that produced it, and the commit message says exactly which script ran. If the script is right, the diff is right, and if master moved in the meantime, you just run it again.

Satisfaction

Terminal output: Rector 0.5.x-dev processing the project, 5,966 of 10,089 files at 59%, then “[OK] Rector is done! 1128 changed files”. Below it a PHPUnit run: 1371 of 1371 tests, time 2.14 minutes, memory 114.50 MB, OK (1371 tests, 3873 assertions).
Rector went through about 10,000 files and changed 1,128. All 1,371 tests passed.

Rector processed around 10,000 files and changed 1,128 of them. The test suite, 1,371 tests and 3,873 assertions, passed in just over two minutes.

All 33 slides
  1. Slide 1: Title: Jan Mikeš, 18.05.2019, Migrace projektu na PSR-4 (migrating a project to PSR-4), with speaker photo
  2. Slide 2: Logos of Entrydo, GLAMI and Péhápkaři.cz
  3. Slide 3: Section title in large letters: PHP
  4. Slide 4: Section title in large letters: PSR-4 autoloading standard
  5. Slide 5: Section title in large letters: Nette RobotLoader
  6. Slide 6: Section title in large blue letters: Why?
  7. Slide 7: Article “Drop RobotLoader and let Composer Deal with Autoloading” and the RobotLoader docs blurb
  8. Slide 8: Code: hundreds of manual require __DIR__ lines loading app model and presenter files
  9. Slide 9: Code: configurator->createRobotLoader() with many addDirectory() calls for single files
  10. Slide 10: Meme: man in a cardigan captioned “It's a mess”
  11. Slide 11: Section title in large blue letters: How?
  12. Slide 12: Plan: RobotLoader → Composer classmap → Composer PSR-4
  13. Slide 13: Composer classmap: autoload classmap for app, libs, src and autoload-dev for tests
  14. Slide 14: Performance: comment saying no negative impact noticed using RobotLoader over PSR-4
  15. Slide 15: Response time graph from monitoring, with the switch point highlighted in a blue box
  16. Slide 16: Error: Class 'Glami\MyClass' not found
  17. Slide 17: Composer classmap → Composer PSR-4
  18. Slide 18: Composer PSR-4 config: App\ → app, Glami\ → src, classmap libs, Tests namespaces for tests
  19. Slide 19: Naive plan: rename class + namespace, replace all usages, profit
  20. Slide 20: Yoda meme: “Too naive you are”
  21. Slide 21: Requirements: non-breaking for team (100 commits/day), repeatable, repeatable by someone else, bugfree
  22. Slide 22: Plan properly: a long checklist of migration steps for libs/klarka to src
  23. Slide 23: GitHub rectorphp/rector repo and article: migrating 50k lines from Nette to Symfony in 17 days
  24. Slide 24: 90% time – edge cases: diffs fixing @var annotations, mock builders and expectException strings
  25. Slide 25: Code review? GitHub showing 1000 changed files, 88116 additions; commit with 1128 files changed
  26. Slide 26: Script “Rename files”: PHP script renaming files to match their class names
  27. Slide 27: Script “Split classes in one file to multiple files” using RobotLoader indexed classes
  28. Slide 28: Rector config for class renames: RenameClassRector mapping old Glami\libs classes to new namespaces
  29. Slide 29: Script “Search for old classes usages” scanning files for old class names in strings
  30. Slide 30: Meme: TV talent show judge slamming the red button in despair
  31. Slide 31: Satisfaction: Rector run finishing with 1128 changed files and all 1371 tests passing
  32. Slide 32: github.com/rectorphp/rector with the GitHub Octocat
  33. Slide 33: Homer Simpson asking “Questions?” and Thank you! janmikes.cz, j.mikes@me.com

Related talks