Blog
Upgrading PHP 5.6 or older PHP 8 applications to supported PHP versions
Upgrading a legacy PHP application is not one Composer command. A safe migration protects existing behavior with Pest, introduces PHPStan gradually, upgrades the framework and dependencies in stages, and measures before optimizing.
PHP migration in Symfony and Laravel: a technical guide
A PHP upgrade changes an execution environment, not just a version number. A page may load while an import fails under a different CLI binary or a worker cannot read an older message. The useful question is whether the required behaviour still works across the application’s actual execution paths.
This guide covers compatibility investigation, regression protection and release verification. Its examples are illustrative, not accounts of GiSoft client migrations.
Establish the migration environments
Inventory HTTP entry points, console commands, scheduled jobs, queue consumers, imports, exports and maintenance scripts. Record the PHP binary, extensions, configuration, database drivers and external dependencies used by each. PDF generation, image processing, SOAP and email may depend on more than Composer packages.
A PHP 5.6 application may need intermediate combinations of PHP, framework and dependencies. An older PHP 8 application may have fewer gaps. Neither follows a universal version ladder: choose testable combinations from the actual constraints. End-of-life intermediate runtimes are migration tools in isolated environments, not automatically acceptable production destinations.
Check the official PHP support table and the framework’s version-specific upgrade guide when choosing the target. Active support and security-only support are different. Use a maintained patch release with enough remaining support for the project; the code examples below do not select that release for you.
Environment What it establishes
Existing Baseline behaviour with compatible tools
Migration Compatibility of a candidate combination
Target-like HTTP, CLI, workers and deployment behaviour
Static analysis runs on a supported tooling runtime.
It does not replace execution in the target environment.Current Pest and PHPStan cannot simply be installed into PHP 5.6. Keep a compatible PHPUnit suite there, or test the old application externally. Run modern analysis in a separate suitable environment, accounting for bootstrap and autoload compatibility; configure the intended PHP target. Tests that boot the application need dependencies that run in their test environment.
The snippets use modern syntax: typed properties require PHP 7.4, mixed and named arguments PHP 8.0, promoted readonly properties PHP 8.1, and readonly classes PHP 8.2. Those are syntax minima, not the requirements of a particular Pest or framework release.
Use Composer to identify blockers
Review composer.json, composer.lock, extensions and plugins before changing constraints. Set the shell variable PHP_UPGRADE_TARGET to the exact PHP version under investigation; the final two commands are alternatives, not separate migration steps:
composer show --direct
composer outdated --direct
composer why-not php "${PHP_UPGRADE_TARGET:?}"
composer prohibits php "${PHP_UPGRADE_TARGET:?}"show --direct lists direct dependencies; outdated --direct checks for newer releases. why-not and prohibits are aliases that explain declared blockers. They do not prove application compatibility or produce a migration plan.
Use a Composer version compatible with its execution environment. Composer documents the 2.2 LTS line for older PHP; check its command options and plugin compatibility rather than assuming the newest executable will run on PHP 5.6. Review the whole dependency graph, not only direct packages.
A simulated config.platform value helps dependency resolution but does not install that runtime or its extensions. Later, composer check-platform-reqs checks the real CLI environment, ignoring that simulation. Check web and worker environments separately. Ignoring platform requirements is not a deployment fix.
Protect behaviour before changing its implementation
Start with costly failure cases: authentication and permissions, orders, invoices, payment outcomes, imports and public API responses. A small test can protect one rule without reproducing the whole application:
it(
'does not publish after a failed payment',
function (): void {
$payment = Payment::failed();
$invoice = Invoice::forPayment($payment);
$publisher = new InMemoryInvoicePublisher();
$service = new InvoicePublishingService($publisher);
$service->publish($invoice);
expect($invoice->isPublished())->toBeFalse()
->and($publisher->publishedInvoices())->toBeEmpty();
},
);Payment, Invoice, InMemoryInvoicePublisher and InvoicePublishingService are project-specific examples. The test assumes publication is refused without throwing when payment has failed. It checks that rule and the fake publisher, not database persistence or a real delivery service.
Characterisation tests record behaviour that needs investigation before it changes:
it(
'keeps the imported-order rounding result',
function (): void {
$calculator = new LegacyOrderCalculator();
$result = $calculator->calculate(
netAmount: 19.995,
taxRate: 23,
);
expect($result->grossAmount)->toBe(24.59);
},
);This hypothetical baseline assumes the net amount is not rounded first and the gross result is rounded to two decimal places. In decimal arithmetic, 19.995 × 1.23 = 24.59385, giving 24.59 with conventional half-up rounding. Rounding the net to 20.00 first would instead give 24.60. The omitted calculator and its actual rounding policy must be checked in the original environment; the figures are not evidence of real accounting treatment.
The floats deliberately represent a legacy API. Binary floating point cannot exactly represent many decimal amounts, and this assertion is not a design recommendation for financial calculations. A decimal or appropriately scaled integer representation needs explicit precision and rounding rules; change that contract separately from preserving its baseline.
Also cover loose comparisons, numeric strings from database drivers, null versus empty strings, dates and time zones, sorting, JSON and serialisation. A passing characterisation test preserves an observation, not necessarily a correct business rule.
Keep useful tests; choose compatible tooling
Existing PHPUnit tests need not be rewritten to adopt Pest. Pest builds on PHPUnit, but its release, underlying PHPUnit version, plugins and configuration must fit the chosen PHP environment. An old suite does not automatically work with an arbitrary new Pest release. Keeping PHPUnit during the upgrade is a valid choice.
Use unit tests for isolated rules, integration tests for repositories and real dependencies, and functional tests for HTTP behaviour and permissions. Architecture tests enforce only configured rules. Smoke tests confirm a few important paths; they do not replace these deeper checks.
Make type assumptions visible with PHPStan
Start at a useful analysis level, cover relevant callers and dependencies, and resolve high-risk findings before increasing strictness:
vendor/bin/phpstan analyse src --no-progressRun this in the prepared analysis environment. Configure the target PHP version, framework extensions and symbol discovery as needed. A reviewed baseline can separate existing findings from new ones; broad suppressions or repeatedly regenerating it can conceal regressions. The highest analysis level is not a prerequisite for every upgrade.
For a property that really holds a customer or null, broadening its type loses useful information:
private mixed $customer;The narrower declaration documents that contract and initialises the nullable property:
private ?Customer $customer = null;These are class-member excerpts. Do not initialise a required relationship to null merely to silence a warning. Older code can use appropriate PHPDoc until typed properties are supported by its runtime.
Likewise, a repository signature that only promises an array tells callers little:
/**
* @return array
*/
public function findActiveCustomers(): array;A more useful contract states what each row contains:
/**
* @return list<array{
* id: int,
* email: string,
* active: bool
* }>
*/
public function findActiveCustomers(): array;These are alternative interface-method excerpts. The mapper must actually produce the documented integers, strings and booleans; annotations do not convert database values.
A Doctrine collection needs both its element type and initialisation for newly constructed entities:
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
// Members of an illustrative entity.
/** @var Collection<int, Order> */
private Collection $orders;
public function __construct()
{
$this->orders = new ArrayCollection();
}
/** @return Collection<int, Order> */
public function getOrders(): Collection
{
return $this->orders;
}This is an entity fragment, not a complete mapping. Merge the initialisation into the real constructor; adapt the key type if the association uses string keys. PHPDoc describes the collection for analysis, not runtime validation of every inserted object. ORM mappings, hydration and nullable database columns still need integration tests.
Change framework boundaries only where needed
For Symfony, inspect the relevant upgrade guide for service configuration, authentication, argument resolution, forms, validation, Doctrine mappings and serialisation. Group deprecations by owner and dependency; an upstream upgrade may be needed before application code can change. Do not hide all warnings globally.
For Laravel, check the actual versions of service providers, middleware, authentication guards, Eloquent casts, queue serialisation, mail, filesystem integrations and community packages. Replacing every facade or rewriting the architecture is not a runtime requirement.
A controller boundary can help when transport changes would otherwise spread into business logic:
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
final class CreateOrderController
{
public function __construct(
private readonly CreateOrderServiceInterface $service,
) {
}
public function __invoke(
CreateOrderRequest $request,
): JsonResponse
{
$result = $this->service->create($request->toDto());
return new JsonResponse(
data: $result->toArray(),
status: Response::HTTP_CREATED,
);
}
}This PHP 8.1+ Symfony excerpt assumes the project’s CreateOrderRequest is resolved from HTTP input and validated before use. It is not a built-in Symfony request type. Routing, service registration, permission checks, error mapping and transaction handling are omitted. The application service must still enforce its business rules.
In Laravel a Form Request can handle input validation and request authorisation before a service call. In either framework, add an interface only where it serves a real contract or replacement need; do not make a broad architectural rewrite part of the compatibility work by default.
Decide what to do with incompatible packages
A maintained dependency may only need an upgrade. An unused package can be removed after checking indirect use. An abandoned package may need replacement; a difficult integration may need temporary isolation.
For document generation, a project-owned port can limit how far a replacement affects callers:
interface DocumentGeneratorInterface
{
public function generate(DocumentData $data): GeneratedDocument;
}DocumentData and GeneratedDocument are project types. Legacy and replacement adapters would implement this port, with output checked for the required content and format. The interface does not make an incompatible library run on newer PHP, nor make an unsafe library safe. Any separately hosted legacy component still needs an explicit security and retirement plan.
Use performance checks to detect upgrade regressions
Compare compatibility and optimisation separately where practical, even when both belong to the same project. A regression introduced by an ORM or driver upgrade may need an immediate fix; unrelated query or cache redesign can usually wait.
For illustration only, a request to GET /api/en/orders returning 25 records might have this profile. These are invented teaching figures, not GiSoft measurements:
Total response 780 ms SQL queries 54
Database 180 ms Peak memory 110 MB
Hydration 120 ms Response size 1.4 MB
Serialisation 210 ms External HTTP 190 msThe components need not account for all elapsed time and may overlap depending on instrumentation. Compare representative datasets, payloads, cache states and concurrency in equivalent environments. Track latency percentiles, error rates, database time, memory and queue delay without logging sensitive payloads.
Keep a stable read contract while checking whether an ORM update changes types, loading or query count:
final readonly class OrderListItem
{
public function __construct(
public int $id,
public string $number,
public string $customerName,
public string $status,
public \DateTimeImmutable $createdAt,
) {
}
}interface OrderReadRepositoryInterface
{
/**
* @return list<OrderListItem>
*/
public function findPage(
int $page,
int $limit,
): array;
}The DTO is one possible read model, not a mandatory replacement for entities. Its mapper must provide the declared types, including DateTimeImmutable. The repository must define valid page sizes, stable ordering and access boundaries. DTOs alone do not prevent N+1 queries or guarantee faster responses.
An existing query-count check can catch changed loading behaviour:
it(
'bounds queries for an order page',
function (): void {
$this->createOrders(count: 100);
$collector = $this->startApplicationQueryCollection();
$items = $this->orderQuery()->findPage(
page: 1,
limit: 50,
);
expect($items)->toHaveCount(50)
->and($collector->queryCount())->toBeLessThanOrEqual(4);
},
);All three helpers belong to the example project, not Pest or Doctrine. Prepare fixtures, bootstrapping and any authentication before counting. Use a controlled EntityManager or model state and repeatable cache conditions so preloaded relations do not hide queries. Stop or reset the collector after the measured operation. The limit of four queries is illustrative and does not measure their cost.
A response-size check protects a different property:
it(
'bounds the public response size',
function (): void {
$client = static::createClient();
$client->request(
'GET',
'/api/en/orders?page=1&limit=25',
);
self::assertResponseIsSuccessful();
$content = (string) $client->getResponse()->getContent();
expect(strlen($content))->toBeLessThan(300_000);
},
);This assumes Pest is configured with Symfony WebTestCase, a representative fixture and any required authentication. strlen() counts bytes in the test response body, not necessarily compressed bytes on the network. The limit is illustrative; a small response can still contain the wrong data, so retain response-contract assertions too. Avoid exact timing assertions in ordinary shared CI; use controlled performance runs for latency budgets.
Treat cache and messages as deployment contracts
An upgrade can change serialised payloads, cache adapters or PHP extensions. Confirm that old and new readers can consume shared data, or version the format and plan expiry or invalidation. Doctrine’s managed entities and Eloquent’s models have different lifecycle mechanisms, but neither is a portable cache format by default. Even DTOs need stable serialisation contracts.
Keys such as these are only sketches:
settings.public.{locale}
menu.header.{locale}
orders.summary.{customerId}.{month}Include tenant, permissions and data-version context where they affect the result. Test invalidation after committed changes and concurrent reads. Redis, Memcached and filesystem storage are not interchangeable automatic fallbacks; changing backend is not required for a PHP upgrade.
Plan worker restarts or draining, old message compatibility, bounded retries and handling of failed messages. Do not move extra work into queues merely to complete the upgrade. If the application already uses an outbox, business data and its outbox entry must commit in the same database transaction for atomic recording. Publication and consumption can still repeat, so consequential actions need duplicate handling.
Avoid bundling destructive schema changes casually with a runtime release. Where a schema transition is necessary, compatible additions, backfill, validation and later removal can preserve options, but writes during transition must be handled. Dual-write is not mandatory and introduces its own failure cases. Reverting code cannot restore removed data or undo external actions.
Build and release the verified combination
The following is an illustrative Bash/GNU-tools CI job, not a command set verified against this project. It assumes installed, compatible development dependencies and the displayed test directories:
set -euo pipefail
composer validate --strict
composer check-platform-reqs
find src tests -name '*.php' -print0 \
| xargs -0 -r -n 1 php -l
vendor/bin/phpstan analyse --no-progress
vendor/bin/pest tests/Unit
vendor/bin/pest tests/Integration
vendor/bin/pest tests/FunctionalUse vendor/bin/phpunit instead if that is the established compatible runner. A full vendor/bin/pest run can replace the three selected-directory invocations at the acceptance stage; running both is not inherently necessary. Syntax checks do not execute code, and static analysis does not prove business behaviour.
Build a reviewed lock file in a controlled environment; production should install the tested dependencies, not resolve a new set. Check the production dependency set with composer check-platform-reqs --no-dev on its actual runtime. The development tools may have stricter requirements than the application.
Before release, verify HTTP, CLI, scheduled tasks, workers and integrations in production-like conditions. Prepare backups and test restoration. A tenant-based or percentage rollout is useful only if routing and shared data allow it; otherwise choose a tested switch or maintenance window.
Coordinate application cache warm-up and PHP process/OPcache refresh with the deployment model. Resetting CLI OPcache does not refresh a separate PHP-FPM process cache. Monitor errors, latency, failed jobs and critical business outcomes, and define when to stop rollout. Recovery must account for schema, messages, sessions and writes made after release, not just the old image.
After acceptance, remove temporary compatibility layers where no longer needed and record the tested version combination. A completed upgrade should leave a known deployment and verification path for the next change, not a larger collection of unexplained exceptions.
Technical references
Check these sources for the chosen versions; requirements change independently of this article.
- PHP support and migration guides: https://www.php.net/supported-versions.php
- Composer commands and platform checks: https://getcomposer.org/doc/03-cli.md
- Composer runtime requirements: https://getcomposer.org/doc/00-intro.md
- Pest installation requirements: https://pestphp.com/docs/installation
- PHPStan setup and target configuration: https://phpstan.org/user-guide/getting-started and https://phpstan.org/config-reference
- Symfony major upgrades: https://symfony.com/doc/current/setup/upgrade_major.html
- Laravel version-specific guides: https://laravel.com/docs
