PHP Modernization

Pin Legacy PHP Behaviour Before You Upgrade It

An upgrade without a pinned picture of current behaviour is a rewrite you did not budget. Characterization tests record what the system does now, including the bugs you still depend on.

Teams postpone a PHP upgrade because they cannot tell a behaviour change from a bugfix. The suite they have, if they have one, covers the classes someone was proud of in 2019. Checkout, the invoice PDF, the webhook that marks an order paid, and the worker that sends the dunning email are exercised by a person on staging, once, on a Thursday, if the person remembers.

Characterization tests exist for that gap. They do not describe what the code should do. They record what it does today, and they fail when an upgrade changes that recording. Michael Feathers named the technique for legacy code that has no specification. A PHP monolith on an unsupported runtime is the usual specimen.

This is not a substitute for a decision about whether to upgrade, refactor, or extract. It is how the upgrade becomes a diff you can read. If the runtime is still PHP 7.4 in production, or a Symfony major is pinned by abandoned packages, or a Laravel bump is stuck on the money path, the missing artefact is usually this kind of test, not another architecture slide.

What you are pinning

A characterization test calls the system the way production does and asserts the observable result.

For an HTTP boundary that means method, path, a realistic payload, the status, and a stable projection of the body. For a worker it means the message in and the rows, emails, or further messages out. For a report it means the query inputs and a checksum of the rows, not a screenshot of a spreadsheet.

The assertion is allowed to include behaviour you dislike. If a refund endpoint returns 200 and an empty body when the payment provider times out, the test records that. You may hate it. You do not “fix” it in the same commit as the PHP bump, because then you cannot tell which change broke staging. Write the ugly assertion. File the bug. Upgrade. Fix the bug in a later commit that has its own review.

That is the whole discipline. Mix a drive-by refactor into the upgrade branch and the suite stops meaning anything. The branch should go green on the old runtime first, then go green on the new one, with a diff that is mostly composer.lock, type errors, and a short list of intentional behaviour changes you wrote down.

Where to spend the first week

Do not characterize the application. Characterize the paths that move money, change a legal record, or page a human when they fail.

A practical first list for a product company:

  • Create an order, or whatever your equivalent of “the customer committed” is.
  • Take payment, including the decline and the timeout, not only the happy card.
  • Refund or cancel, including the partial case.
  • The webhook or IPN that confirms payment, replayed twice.
  • Issue an invoice or a credit note.
  • The nightly or hourly job that no one owns and everyone fears.
  • Login, password reset, and session expiry if account takeover is the incident you cannot have.
  • One admin action that support uses every day and that has no UI test.

Ten to thirty tests of that shape beat four hundred tests of pure functions. The pure functions are fine later. They do not tell you whether PHP 8.3 changed a string cast in a place that reaches a customer.

If you cannot name those paths, you are not ready to write the tests, and you are not ready to upgrade. The naming is the useful part of the week. A Migration Readiness Audit that cannot list them is a code tour.

How to record without freezing the world

Legacy suites fail for boring reasons: time, randomness, remote HTTP, and a database nobody can reset.

Freeze the clock. Pass a clock into the code if you can do it without a redesign. If you cannot, set the clock in the test process and document that the test is not parallel-safe. An assertion that the invoice date is “today” will go red every midnight and teach the team to ignore red.

Replace randomness at the boundary. An order number that includes random_int is not a snapshot. Assert the shape, or inject the generator. Do not assert the full body if one field is a UUID unless you stub the UUID.

Do not call the payment provider. Record a fixture of the request you send and the response you accept, or put a fake on the port the application already uses. If the application constructs a Guzzle client in a static method with no seam, the first characterization task is a seam, not a snapshot. One constructor argument is a smaller change than a rewrite, and it is the change that makes the rest possible.

Use a database you can rebuild. A transaction rolled back per test is enough for many Symfony and Laravel apps. It is not enough when the code under test opens a second connection, runs COMMIT, or talks to Postgres through a queue worker in another process. For those, a template database you clone, or a schema you migrate and truncate, is the honest setup. Sharing one dev database and hoping is how the suite becomes order-dependent.

Prefer asserting a projection. Sort lists. Drop fields you do not care about. Store the expected JSON next to the test if the payload is large, and review the file in the pull request. A 2,000-line dump that nobody reads is not a test. It is a reason to type --no-verify later.

For workers, run the handler in-process with the serialized payload production would have written. Assert the rows. If the handler publishes another message, assert that message. You do not need RabbitMQ in the test until you are characterizing the broker integration itself. Moving those workers to Go later is much easier if the PHP handler already has this kind of fixture: the fixture is the contract.

What not to pin

Do not characterize:

  • Copy, labels, and CSS. Those change on purpose and the failure teaches nothing about the runtime.
  • Wall-clock performance. A test that fails because CI was slow is a flaky test. Performance belongs in a profile, which is the subject of what to measure before a rewrite.
  • Exact SQL text, unless the SQL is the product. Assert the result given a fixture. Query shape changes during an upgrade when an ORM version changes lazy loading. You want to see the behaviour change, then decide if the new queries are acceptable. A snapshot of every SELECT will go red for reasons that are sometimes improvements.
  • Third-party HTML you scrape. Pin your parsing result, not their markup, and accept that the test is a canary.
  • Everything behind a feature flag that is off in production. Pin the flag state production uses. A test of the unreleased path is a different suite.

Also refuse to “improve” assertions during the first green run on the new runtime. If a test fails, classify it: intentional fix, accidental behaviour change, or test that was coupled to the old runtime (a warning that became an exception, a deprecated call, a change in internal array order). Only the third category gets a test edit in the upgrade commit. The second category gets a decision.

Framework bumps and the same suite

PHP version, Symfony major, and Laravel major are three upgrades that teams like to fuse. Do not fuse them.

Land the characterization suite on the runtime you have. Then move PHP, keeping the framework if the constraint solver allows it. Then move the framework. Each step should have a green suite and a production bake. Risky PHP releases stay risky if the only signal is a human checklist. The suite is the signal that lets you ship on a Thursday.

Laravel Shift and Rector will edit files. They will not tell you that a cast from null to string changed between PHP 7.4 and 8, or that a Symfony security voter now receives a different subject, or that Eloquent’s default eager-load behaviour shifted under a relation your invoice screen depends on. The tool is a patch generator. The suite is the review.

Type errors are a gift. A characterization test that still passes while the type checker screams is a sign you are executing a path that never hits the new signature. Add the missing case. Do not silence the error to get the branch green.

Deprecations deserve a log you read, not a wall of noise you hide. On the old runtime, turn deprecations into test failures for the paths you characterized, or at least into a report the upgrade branch must shrink. An upgrade that “passes” with four thousand deprecations is an upgrade you will do again next year under worse conditions.

When this is the wrong first week

Characterization is the wrong first investment when you cannot run the code at all.

No staging that resembles production, no database snapshot you are allowed to use, secrets only on one server, a deploy that is a manual copy: write those down and fix the smallest one that lets a test process boot. A suite that runs only on a laptop with a hand-built .env will not be trusted, and it should not be.

It is also the wrong investment when the behaviour you would pin is already known to be wrong and the business has scheduled the correction. Write the test for the new behaviour and ship the correction on the current runtime. Then upgrade. Pinning a bug you are about to remove is ceremony.

And it is the wrong sole investment when the pain is not the upgrade. If every request is slow because of a sequential scan, tests will not move p99. If two teams write the same tables, tests will not give you a service boundary. The suite makes change safe. It does not choose the change. That choice is still upgrade, refactor, extract, or leave it.

What “done” looks like

Done is not 80% coverage. Done is a short list of paths, a suite that fails for a reason a reviewer can explain, and an upgrade branch that is green for those paths on the target runtime.

Keep the list in the repository next to the tests. When someone wants to know whether the refund flow was in scope, the answer should be a file, not a memory. When a later extraction to Go is proposed, the same fixtures are the equivalence check. You run the PHP path and the new path against the fixture and diff the outputs. Without the fixture, “equivalent” is a meeting.

If you want that list written against your routes, workers, and the places the suite cannot boot, that is part of a Migration Readiness Audit at €1,200. The document is allowed to say the next month is tests and a PHP bump, not a new language. PHP modernisation after that is €402/day. PHP to Go from €402/day stays optional until something you measured says the runtime is the constraint.

An upgrade you can explain is a smaller project than an upgrade you hope is fine. Characterization tests are how you get the explanation before customers do.