Skip to content

Modern and legacy PHP

Status: Complete. Last reviewed 2026-08-28.

Modernizing PHP is a compatibility program, not a syntax contest. The safe target is a supported runtime, dependency graph, language contract, and operating model that can be changed repeatedly. New features help express intent, but migration succeeds by discovering behavior, controlling deprecations, preserving external contracts, and deploying reversible increments.

Inventory every runtime that executes PHP: web/FPM pools, queue workers, schedulers, CLI tooling, test images, local development, and one-off maintenance hosts. Record exact PHP builds, extensions, INI differences, SAPIs, Composer platform requirements, framework/package support, architecture, and process lifetime. A passing CLI command does not prove FPM has the same extensions or configuration.

PHP branches receive active support and then security-only support before end of life. Use the dated version guide and official support table rather than memory. The target version must also be supported by Laravel, test/static-analysis tools, extensions, and infrastructure images. A language upgrade that forces several major package changes is a dependency program requiring explicit sequencing.

Capture current production signals before changing the runtime: error/deprecation rates, request/job latency, memory/RSS, failure/retry counts, serialization/wire formats, and critical business outcomes. Without a baseline, teams can declare migration success while shifting failures into logs or workers.

Deprecations announce behavior that may change or disappear. Enable and collect them in development and CI, exercise representative production-like paths, group them by owning package/code, and reduce them continuously. Suppressing every deprecation until the target upgrade makes the eventual change set larger and less attributable.

Do not automatically convert all deprecations into production exceptions. That can turn advisory diagnostics into user outages. Route them to owned telemetry, fail new/changed code in CI where practical, and maintain a declining baseline for legacy findings. Vendor deprecations require package upgrades, patches, or replacement rather than edits under vendor/.

Read every intermediate migration guide. A jump from an old branch accumulates changes in comparison, error severity, internal function signatures, resources becoming objects, reflection, serialization, extensions, and diagnostics. Tests should cover behavior named by those guides, not only application happy paths.

Docblock-only parameters and properties are common in older code. Native types improve runtime contracts, reflection, and tool agreement, but adding them can expose coercive callers, uninitialized properties, invalid persisted data, and incompatible overrides.

Start at new code and well-tested boundaries. Run static analysis to find callers, add parsing at external edges, then tighten internal contracts. Do not mechanically replace every docblock: generics, array shapes, and refined scalar types still need analysis annotations. Preserve docblocks that add information and remove those that merely repeat native declarations.

Replacing sentinel false|null results with exceptions or typed outcomes is an API change. Search all consumers and preserve distinctions between absence, invalid input, and infrastructure failure. For public libraries, use major-version policy or compatibility shims.

Replace accidental dynamic behavior deliberately

Section titled “Replace accidental dynamic behavior deliberately”

Dynamic properties are deprecated from PHP 8.2 except for defined exceptions such as stdClass, #[AllowDynamicProperties], or magic access. Most occurrences are typos, undeclared model state, or metadata attached to foreign objects. Declare real properties, use a contained map/magic abstraction for genuinely open records, or use WeakMap for metadata associated with objects you do not own.

#[AllowDynamicProperties] is a migration escape hatch, not a default fix. It preserves weak contracts and applies to descendants. Before removal, inspect serialization and hydration behavior because frameworks or old ORMs may depend on field injection.

Legacy relative callable forms and dynamically assembled callback strings should move toward first-class callables, closures, or invokable objects. Verify visibility and serialization behavior; queue payloads should carry data and a named job type, not serialized executable callbacks.

Move metadata from annotations with ownership

Section titled “Move metadata from annotations with ownership”

Attributes are structured language metadata discoverable through reflection. They replace many parser-based docblock annotations for routes, mapping, validation, tests, and dependency configuration when the owning framework/tool supports them. A staged migration can support both formats temporarily, but duplicate metadata risks conflicting sources of truth.

Choose one owner per metadata category, confirm cache/build behavior, and migrate vertical slices with tests. Attributes do not make configuration correct; they move its representation. Keep descriptive documentation in docblocks and use attributes for machine-consumed metadata with stable classes and constructor contracts.

Enums can replace string-constant sets when the vocabulary is truly closed. Readonly properties/classes can protect stable construction, and constructor promotion can reduce declaration duplication. These features should follow domain decisions, not precede them: enum cases affect protocols and exhaustive matches; readonly is shallow; promoted public state can still be poor encapsulation.

Modernize tests and analysis without rewriting for fashion

Section titled “Modernize tests and analysis without rewriting for fashion”

PHPUnit class-style tests and Pest can coexist. Pest is a test API running on the PHPUnit ecosystem, not a separate confidence model. Migrate syntax only when it improves readability or team workflow; prioritize missing behavioral, integration, concurrency, and failure coverage.

Upgrade test tooling before or alongside the runtime so it can report deprecations and understand new syntax. Remove annotations/listeners/configuration that the new major no longer supports. Keep production and test dependency changes reviewable rather than combining runtime, framework, assertion style, and architecture rewrites into one untraceable branch.

Introduce PHPStan or Psalm by selecting a sustainable level, baselining existing findings, preventing new violations, and shrinking the baseline. Analysis helps expose unreachable code, wrong calls, nullable confusion, and array shape drift before native types are tightened. It does not replace execution against real databases, extensions, serialization, or concurrency.

  1. Inventory and compatibility graph: runtimes, extensions, packages, tools, deploy topology, and unsupported branches.
  2. Observe: enable deprecations and representative tests on the old runtime; establish production baselines.
  3. Prepare dependencies: upgrade or replace blockers while still on a compatible current runtime where possible.
  4. Dual-run in CI: test the lowest supported and target PHP versions, including static analysis and platform checks.
  5. Repair behavior: address migration-guide changes, boundary coercion, serialization, extension, and error-policy differences.
  6. Canary: deploy the target runtime to controlled traffic/workers with comparable metrics and logs.
  7. Expand and retire: increase exposure, retain rollback/roll-forward artifacts, then remove old compatibility paths with evidence.

Mixed-version deployments require wire/database/queue compatibility. An enum case, serialized object shape, job payload, or newly required field can reach an older worker. Prefer scalar/versioned payloads and expand/contract changes. Restart long-lived workers so “runtime upgraded” does not mean only web traffic changed.

  • Do not turn every array into a class without a durable schema or invariant.
  • Do not replace every switch/string constant with an enum controlled by an external party.
  • Do not add readonly where legitimate staged mutation exists.
  • Do not convert ordinary alternatives into exception-driven branching.
  • Do not rewrite PHPUnit tests into Pest without increasing clarity or confidence.
  • Do not adopt every new minor feature while the supported production floor excludes it.

Prioritize security support, removed/deprecated behavior, correctness, operational lifetime, and dependency health before cosmetic syntax. Record the reason and reversal trigger for compatibility shims.

  • CLI CI passes on the target version while FPM lacks an extension or uses a different INI policy.
  • Deprecations are suppressed until an upgrade turns them into errors across an unmeasured path.
  • New enum/job payloads reach old workers during rolling deployment.
  • Declaring typed properties breaks hydration of legacy rows containing invalid null/string data.
  • Attribute and annotation metadata both remain active and register routes/mappings twice.
  • A broad upgrade changes PHP, Laravel, dependencies, tests, and architecture, leaving failures unattributable.
  • Current: PHP 8.5 is the repository baseline; 8.2 remains security-supported only until its published end date, while 8.3–8.4 remain relevant maintained estates. Recheck official dates before planning.
  • Common: Mixed PHP 8.2–8.4 fleets, docblock/native hybrid typing, PHPUnit/Pest coexistence, and static-analysis baselines are normal migration states.
  • Legacy: Dynamic properties, annotations, relative callables, native serialization hooks, old extension resources, and weak error policy should be recognized and replaced according to risk rather than mocked or rewritten indiscriminately.