Skip to content
EdgeServers

Upgrading to PHP 8.3 in production — the migration playbook we run for Laravel, Symfony, and WordPress fleets

PHP 8.3 is mature, fast, and the deprecation surface from 8.1/8.2 is small but sharp. Here is the staged playbook we use to move customer fleets across without an incident.

May 26, 2026 · 10 min · by Sudhanshu K.

Upgrading to PHP 8.3 in production — the migration playbook we run for Laravel, Symfony, and WordPress fleets

PHP 8.3 reached general availability in late 2023 and is now the version we deploy on every new managed PHP environment. 8.1 is in security-fix-only mode and will reach end-of-life by the end of 2025; 8.2 is still active but the writing is on the wall. Across our customer fleet, we've migrated roughly 220 sites from 8.1 or 8.2 to 8.3 over the last 18 months — a mix of Laravel APIs, Symfony admin apps, WordPress stores, and bespoke PHP applications dating back to the early Drupal era.

The migrations have, on the whole, been smooth. PHP 8.3 is the most boring major release in a decade — and "boring" is the highest compliment for a runtime upgrade. But there is a small list of sharp edges, and the runbook below is what we use to surface them before they hit production.

Why bother? The numbers

The performance delta is real but workload-dependent:

WorkloadPHP 8.1 req/sPHP 8.3 req/sMemory delta
Laravel 10 API, JIT off720815-3%
Laravel 10 API, JIT tracing8801015-2%
Symfony admin, heavy Twig195245-5%
WordPress + WooCommerce138152-1%
Custom legacy app (no framework)410446-2%

Roughly 10-20% throughput improvement without code changes, slightly lower per-request memory. The bigger wins come from PHP 8.3-specific language features (json_validate() doesn't decode-then-discard, Randomizer::pickArrayKeys() is faster than userland) that are opt-in.

The security side is the more important reason. Once 8.1 goes EOL, you're either on a paid support track (Zend LTS, Acquia, etc.) or you're running unsupported PHP. Most teams don't realise their stack is unsupported until the next security audit comes back red.

The pre-flight: where deprecations live

PHP 8.3 added deprecations in a few specific areas. Most are minor; a few will break code. The list we check for, in priority order:

1. get_class() without arguments deprecated. A surprising amount of legacy code does get_class() inside a method to get its own class name. In 8.3, this is deprecated; use static::class or self::class.

// PHP 8.1/8.2 - works, no warning
function getClassName() {
    return get_class();
}
 
// PHP 8.3 - deprecation warning
// Fix:
function getClassName() {
    return static::class;
}

2. Date/time methods now throw exceptions instead of warnings. Code that constructs DateTime from an invalid string used to silently produce nonsense. In 8.3, it throws DateMalformedStringException. Wrap your boundary inputs.

// PHP 8.1/8.2
$dt = new DateTime($userInput); // warning + nonsense if invalid
 
// PHP 8.3
try {
    $dt = new DateTime($userInput);
} catch (DateMalformedStringException $e) {
    // handle gracefully
}

3. Highlighter::highlight_string() no longer accepts a non-PHP-code argument. Niche, but bites code that uses highlight_string() for syntax coloring of arbitrary text.

4. assert() with a string argument deprecated. assert("foo == bar") is gone. Use assert(foo == bar) — i.e., an actual expression, not a string-as-code. This catches a lot of older PHPUnit-style assertions in legacy test suites.

5. Implicit nullable parameter types deprecated. A function declared function foo(string $x = null) is implicitly nullable; this is deprecated in 8.3 and will be a fatal error in 9.0. Explicit nullable required:

// PHP 8.2 - works
function foo(string $x = null) {}
 
// PHP 8.3 - deprecation
// Fix:
function foo(?string $x = null) {}

This is the deprecation that produces the largest number of warnings on Laravel/Symfony/WordPress codebases. Use rector to auto-fix:

composer require --dev rector/rector
vendor/bin/rector process app --set-list=DEAD_CODE,CODE_QUALITY,PHP_83

6. Random\Randomizer is now the recommended random API. rand() and mt_rand() still work; new code should use Randomizer. Not deprecated yet but signposted for removal in 9.0.

The new features actually worth using

PHP 8.3 didn't add a long list, but the ones it did are useful:

json_validate() — validates JSON without decoding it. ~5-10x faster than json_decode + check for null for the validation case. We use it in API gateways for early rejection of malformed JSON without allocating the decoded structure.

if (!json_validate($input)) {
    return new Response('Bad Request', 400);
}
$data = json_decode($input, true);

Typed class constants.

class Config {
    const string ENV = 'production';
    const int MAX_RETRIES = 3;
}

Catches the "someone set a constant to a wrong type via a child class" bug. Worth turning on across the codebase.

#[\Override] attribute. Explicit declaration that a method overrides a parent method. If you misspell the method name, you get a compile error instead of a silent shadow.

class Base {
    public function handle(): void {}
}
 
class Child extends Base {
    #[\Override]
    public function handle(): void { /* called */ }
    
    #[\Override]
    public function handel(): void {} // ERROR: not overriding anything
}

This single feature has caught half a dozen production bugs across our fleet during the migration audit pass.

Randomizer::getBytesFromString() — generate cryptographically secure random strings from a custom alphabet. Replaces the awkward bin2hex(random_bytes()) pattern when you want, say, base32 IDs.

Read-only classes / readonly improvements. Already in 8.2 but solidified in 8.3 — you can now clone readonly classes with with* style modifications.

The migration playbook

The five-stage migration we run for every customer:

Stage 1: Audit and inventory (1-2 days)

Spin up a parallel PHP 8.3 container alongside the production runtime. Run the full test suite. Capture every deprecation notice with error_reporting=E_ALL and display_errors=stderr.

docker run --rm -v $(pwd):/app -w /app php:8.3-cli \
    php -d error_reporting=32767 -d display_errors=stderr \
    vendor/bin/phpunit

Collate the deprecation list. Categorise by severity (errors > warnings > notices) and by whether the code is yours, framework code, or a third-party package. The first two are your work; the third is a dependency-upgrade work item.

For Laravel customers on versions older than Laravel 10.x, this stage often surfaces that you need to upgrade Laravel before you can upgrade PHP. Plan accordingly.

For WordPress customers, the dominant finding is plugins with implicit-nullable warnings. Most well-maintained plugins are 8.3-clean by now; the long tail of abandoned plugins is the problem. The audit list goes to the managed WordPress team for remediation (typically: replace with a maintained equivalent, or fork and patch).

Stage 2: CI dual-runs (1-2 weeks)

Add PHP 8.3 as a parallel CI matrix entry. Tests run on both 8.1/8.2 and 8.3. Block merges that pass on the old version but fail on the new.

# .github/workflows/test.yml
strategy:
  matrix:
    php: ['8.2', '8.3']
steps:
  - uses: shivammathur/setup-php@v2
    with:
      php-version: ${{ matrix.php }}
  - run: composer install
  - run: vendor/bin/phpunit

This catches drift as new code is written. Without it, your team will happily add new PHP-8.2-only patterns in the weeks between Stage 1 and Stage 4.

Stage 3: Staging environment full-cycle (1 week)

Provision a staging environment on PHP 8.3 with traffic mirroring from production. We use Provisioning tooling that spins up an identical fleet on a different version, runs read-only traffic mirror from prod for a week, and reports any divergence.

What you're looking for in this stage isn't test failures (those got caught in Stage 1/2). It's:

  • Performance regressions — most PHP 8.3 changes are wins, but extension compatibility can bite (e.g., a custom PHP extension compiled against 8.1 headers won't load on 8.3).
  • Memory profile changes — typically lower, but a worst-case workload might find a new edge.
  • Library behaviour changes — Doctrine, Eloquent, Twig, etc. occasionally have version-specific quirks that only surface under real load.

We run staging for at least 7 days to catch any cron-driven workloads (weekly invoices, end-of-month reports) that don't exercise on shorter cycles.

Stage 4: Canary deploy (3-7 days)

Deploy PHP 8.3 to one host in the production fleet. Route 5-10% of traffic to it via the load balancer. Monitor:

  • Error rate (per endpoint, per status code)
  • p50/p95/p99 latency
  • Memory profile
  • OPcache hit rate
  • Slowlog entries

We do not advance past canary until 72 hours of clean metrics, ideally including a peak-load window (a campaign send, an end-of-business-day rush, whatever the customer's peak pattern is).

The single most common canary finding is around date/time exception handling. Code that used to produce a warning and continue now throws and 500s. The fix is always upstream (wrap the boundary call), but the canary is where you discover which boundary call is the one without a try/catch.

Stage 5: Rolling fleet rollout (1-3 days)

Once canary is clean, roll out to the rest of the fleet one host at a time, with health checks at the load balancer. We use a 30-second drain between hosts and explicit confirmation at 25%, 50%, 75% of the fleet.

# Rolling deploy (excerpt)
for host in $FPM_HOSTS; do
    lb-drain $host
    deploy-php 8.3 $host
    health-check $host || { lb-undrain $host; exit 1; }
    lb-undrain $host
    sleep 30
done

At 100% rollout, watch for 48 hours before declaring done. Roll back capability remains via the per-host installation — we keep the prior PHP package version on disk for 14 days post-migration.

The OPcache and FPM gotcha at upgrade time

A subtle issue we've hit twice now: an upgrade from 8.1 to 8.3 changes the path of the FPM socket from /run/php/php8.1-fpm.sock to /run/php/php8.3-fpm.sock. If your Nginx config has the path hard-coded (most do), Nginx will 502 the moment the old FPM stops.

The fix is to update Nginx config before the FPM swap, with both versions running in parallel for a few minutes:

# 1. Start php8.3-fpm alongside php8.1-fpm
sudo systemctl start php8.3-fpm
 
# 2. Edit Nginx upstream to point at 8.3
sudo sed -i 's|php8.1-fpm.sock|php8.3-fpm.sock|' /etc/nginx/sites-available/*
sudo nginx -t && sudo nginx -s reload
 
# 3. Verify traffic is hitting 8.3
curl -s http://localhost/health | grep "PHP 8.3"
 
# 4. Now stop 8.1
sudo systemctl stop php8.1-fpm

The five-step sequence has zero dropped requests. The naive "stop 8.1, start 8.3" sequence has 30 seconds of 502s.

Post-migration: what to actually monitor

Two weeks of focused monitoring after the migration, on top of normal SLOs:

  • Deprecation warnings in logs — even with the test pass clean, runtime paths reveal new ones. Track and remediate weekly.
  • JIT buffer utilisation — 8.3's JIT behaviour is slightly different from 8.2's; the buffer can fill in workloads it didn't before.
  • Date/time exception rate — the most common 8.3-introduced production exception class.
  • OPcache wasted memory — 8.3 changed internal allocation patterns; you may need to tune opcache.memory_consumption upward by 10-20%.

After two weeks of clean metrics, normal monitoring resumes and the migration is done.

What it doesn't fix

A version upgrade is not an application audit. Slow queries are still slow. Bad caching is still bad. Composer's supply chain risks are still there. PHP 8.3 is a runtime upgrade, not an application upgrade — what it gives you is a faster, supported, more secure foundation on which to fix the actual application issues.

If your fleet is still on 8.1, the time to migrate was last quarter. The next-best time is this quarter. Get in touch if you'd like us to run the playbook above against your environment — it's a process we run weekly, and the rollback path is well-trodden.


Sudhanshu K. is a Senior Site Reliability Engineer at EdgeServers (RemotIQ Pty Ltd, ABN 91 682 628 128). She has personally migrated PHP versions in production from 5.6 onward and considers the 8.3 jump the smoothest in the lineage by some distance.