Most teams book a Laravel upgrade and discover, three days in, that they actually booked a PHP upgrade. The framework bump is a composer.json edit and a weekend of test fixes. The runtime bump underneath it touches your Dockerfile, your CI matrix, your PHP-FPM pool sizing, three unmaintained packages, and every function signature written before 2021.
This post is about that hidden first project: moving a Laravel application from PHP 8.1 or 8.2 up through 8.3, 8.4, and 8.5 as a staged, reversible workstream that lands before you touch the framework. I will cover why the PHP floor moves first, what genuinely breaks at each hop, how to wire a version matrix into CI, how to sequence staging to canary to production, and how to roll back when the canary goes red at 2am. If you are looking for the framework side of the story, that lives in Upgrading to Laravel 13: what changed and what breaks. This one is the prerequisite.
Why You Upgrade PHP for Laravel Before You Upgrade Laravel
The reason is mechanical, not philosophical: Composer resolves php as a platform requirement. If your runtime is 8.2 and the framework you want requires ^8.3, the update simply will not resolve. You cannot half-ship it. Which means if you bundle both changes into one branch, you get a single enormous diff where every failure is ambiguous — was that a framework behavior change or a PHP deprecation? — and no clean bisect surface.
Separating them buys you three things:
- Attributable failures. A red test on the PHP branch is a PHP problem. A red test on the framework branch is a framework problem.
- Independent rollback. You can revert a runtime change on a canary node without reverting a week of application code.
- Smaller review surface. Two PRs a reviewer will actually read, instead of one they rubber-stamp.
There is a fourth, more selfish reason. The PHP upgrade delivers value on its own. Each 8.x release has shipped real performance gains and, more importantly, the deprecation warnings you fix on 8.4 are the same ones that would have become hard errors later. You are paying down a debt that only gets more expensive.
The rule I give clients: upgrade PHP as far as your current Laravel version supports, ship it, then upgrade Laravel. That ordering is the same one I use in the broader legacy Laravel migration playbook, and it holds at every scale I have tried it.
Laravel PHP Version Requirements Across 10, 11, 12, and 13
Here is the support matrix as I understand it in September 2026. Treat the PHP ranges as the load-bearing part and verify the dates against the Laravel release notes and support policy before you build a plan on them, because Laravel occasionally extends a supported PHP range within a major line.
| Laravel | Supported PHP | Framework status (Sept 2026) |
|---|---|---|
| 10 | 8.1 – 8.3 | End of life |
| 11 | 8.2 – 8.4 | End of life |
| 12 | 8.2 – 8.5 | Security fixes only |
| 13 | 8.3 – 8.5 | Current |
Read that table as overlapping windows rather than fixed points. The useful question is not "what does Laravel 13 need" but "what is the highest PHP version my current Laravel supports, and does it overlap with the lowest PHP version my target Laravel supports?"
Three common cases fall out:
- On Laravel 12 / PHP 8.2. Laravel 12 supports up to 8.5, Laravel 13 needs 8.3 minimum. You have a generous overlap. Move PHP to 8.3 or 8.4 while staying on Laravel 12, ship, then do the framework hop as a separate release.
- On Laravel 11 / PHP 8.2. Laravel 11 tops out at 8.4. Take PHP to 8.4 on Laravel 11, ship, then go 11 to 12 to 13, and pick up 8.5 afterwards.
- On Laravel 10 / PHP 8.1. Laravel 10 tops out at 8.3. Go to 8.3 first, then start the framework chain. This is the case where the PHP work and the framework work genuinely interleave, and it is the most expensive of the three.
For PHP's own clock, the official supported versions page is the source of truth. The shape of it as of writing: 8.1 is fully end of life, 8.2 is in security-fix-only territory with that window closing around the end of 2026, 8.3 has left active support but retains security fixes into 2027, and 8.4 and 8.5 are the versions with real runway. If your production runtime is 8.2, you are not planning an improvement — you are on a deadline.
One caveat on that runway, because it changes where you should aim. PHP 8.4 leaves active support at the end of 2026 — the same moment 8.2 dies — and then carries security fixes into 2028. So 8.4 is a legitimate destination if your framework caps you there, but it is not a resting place: you will be back in this conversation within a year. PHP 8.5 is the only version with active support running past this year. If nothing in your stack forces 8.4, aim at 8.5 and do the work once.
The Staged Path: 8.3, Then 8.4, Then 8.5
Do not jump 8.2 to 8.5 in one deploy. It is technically possible and I have watched it fail three times for the same reason: when the canary throws, you have three releases' worth of behavior changes and no way to tell which one did it.
Stage the work like this, with each stage getting its own branch, its own green CI run, and its own production soak:
8.2 -> 8.3 mostly free; fixes deprecations you already had
8.3 -> 8.4 the real work; implicit nullables, extension deprecations
8.4 -> 8.5 small; mostly additive, one Laravel-specific gotcha
You do not have to deploy every stage. You do have to commit every stage. If your soak windows are expensive, run 8.3 and 8.4 as sequential commits on the same branch and deploy them together — you keep the bisect surface without three production releases.
Stage 0: Establish a Baseline You Can Trust
Before changing anything, capture where you are. This is the half-day that produces your actual estimate.
# What are you really running? Not what the README says.
php -v
php -m # loaded extensions
php --ini # which ini files are in play
# What blocks the target?
composer why-not php 8.4
composer why-not php 8.5
composer check-platform-reqs
# Dependency health
composer outdated --direct
composer audit
composer why-not php 8.4 is the single most predictive command here. It will name every package whose constraint caps out below your target, and that list — not the framework, not your own code — is usually what determines whether this is a three-day job or a three-week one.
Then pin your static analysis so new findings are attributable:
# phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 6
phpVersion:
min: 80300
max: 80500
paths:
- app
- config
- database
- routes
Setting phpVersion as a range rather than a single value tells PHPStan to report anything that breaks on any version in the window. That is exactly what you want during a staged migration, because your fleet will be mixed for a while.
vendor/bin/phpstan analyse --generate-baseline
git add phpstan-baseline.neon
git commit -m "chore: refresh phpstan baseline pre-php-upgrade"
One more baseline item that teams forget: turn on Laravel's deprecation log channel so runtime deprecations stop being invisible.
// config/logging.php
'deprecations' => [
'channel' => 'deprecations',
'trace' => true,
],
'channels' => [
'deprecations' => [
'driver' => 'daily',
'path' => storage_path('logs/deprecations.log'),
'level' => 'debug',
'days' => 30,
],
],
Deploy that to production on your current PHP version, let it run for a week, and read the file. Static analysis finds what it can see; this finds what your users actually hit.
Stage 1: Getting Clean on PHP 8.3
For most Laravel apps this hop is uneventful, which is exactly why it is worth doing separately. The changes that matter are the ones that turned previously silent behavior into warnings: array_sum() and array_product() now warn on unsupported types, unserialize() emits warnings instead of notices, and range() validates its arguments more strictly.
The work here is not fixing 8.3 — it is fixing everything 8.0 through 8.2 flagged that you ignored. Bump the constraint and clear the noise:
{
"require": {
"php": "^8.3"
},
"config": {
"platform": {
"php": "8.3.0"
}
}
}
That config.platform.php key is the gotcha nobody mentions. It tells Composer what PHP version to resolve against, regardless of what is actually installed. If you leave it pinned at 8.2 while your servers run 8.4, Composer will happily keep resolving old package versions and you will wonder why nothing updates. Bump it in lockstep with your real floor, or remove it entirely and let Composer read the runtime.
Stage 2: The PHP 8.4 Breaking Changes That Actually Bite
This is where the work lives. PHP 8.4 is the hop that generates real diffs in a Laravel codebase.
Implicit Nullable Parameters Are Deprecated
This is the big one, and it is everywhere in older Laravel code. Writing Foo $bar = null implicitly made the type nullable. PHP 8.4 deprecates that; you must write ?Foo explicitly.
// Deprecated in PHP 8.4
public function handle(Request $request, Closure $next, string $guard = null) {}
public function scopeActive(Builder $query, Carbon $since = null) {}
// Correct
public function handle(Request $request, Closure $next, ?string $guard = null) {}
public function scopeActive(Builder $query, ?Carbon $since = null) {}
Every custom middleware, every query scope with an optional filter, every service method with an optional dependency. In a five-year-old codebase, expect hundreds of occurrences. Find them:
# Approximate but effective; excludes already-explicit ?Type params
rg -n --pcre2 '(?<!\?)\b([A-Z][A-Za-z0-9_\\]*|string|int|float|bool|array|iterable|callable|object)\s+\$\w+\s*=\s*null' \
app/ config/ database/ routes/ tests/
Do not fix these by hand. Rector has a PHP 8.4 set that handles it mechanically:
<?php
// rector.php
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/app',
__DIR__ . '/config',
__DIR__ . '/database',
__DIR__ . '/routes',
__DIR__ . '/tests',
])
->withPhpSets(php84: true)
->withImportNames(removeUnusedImports: true);
vendor/bin/rector process --dry-run # always read the diff first
vendor/bin/rector process
Review every hunk. Rector is good, but it is a text transformation and your reviewer is the safety net. Land this as its own commit so the diff is readable.
E_STRICT Is Deprecated
E_STRICT has had no meaning since PHP 8.0 folded it into E_ALL. PHP 8.4 deprecated the constant itself, with removal scheduled for a future major. If your php.ini, Docker entrypoint, or a stray error_reporting() call still contains E_ALL & ~E_STRICT, that is now a deprecation notice on every request.
rg -n "E_STRICT" . --glob '!vendor' --glob '!node_modules'
Replace with E_ALL and move on. Check your deployment configs and container images, not just application code — this one almost always lives in infrastructure.
Session, Date, and DOM Changes
Sessions. PHP 8.4 deprecated the session.sid_length and session.sid_bits_per_character INI settings. If your ops team tuned these years ago for a compliance requirement, they now log deprecations on every boot. Remove them; the defaults are fine and Laravel manages session ID generation through its own session driver anyway.
Dates. Nothing in the date extension will take your app down, but Carbon sits between you and PHP's DateTime, and Carbon 3 tightened several behaviors that Carbon 2 tolerated. If you are on Laravel 11 or earlier and still resolving Carbon 2, that library upgrade should be its own commit inside this stage rather than a surprise inside the framework bump. Anywhere you parse user-supplied date strings, add a test with a deliberately malformed input before you move.
DOM. PHP 8.4 added an entirely new, spec-compliant HTML5 parser under the Dom\ namespace (Dom\HTMLDocument, Dom\XMLDocument). The old DOMDocument API still exists and still works. The risk is not that it breaks — it is that if you scrape, sanitize, or transform HTML anywhere (invoice PDF generation and email templating are the usual suspects), you should snapshot-test that output across the version bump. Parser behavior differences are exactly the kind of change that passes CI and produces a mangled customer-facing PDF.
Extension Deprecations: mysqli and Friends
If any part of your stack still touches mysqli directly — a legacy reporting script, a health check, an inherited import job — PHP 8.4 deprecated a batch of it. mysqli_ping(), mysqli_kill(), and mysqli_refresh() are deprecated, along with several MYSQLI_* constants including MYSQLI_SET_CHARSET_DIR and MYSQLI_STMT_ATTR_PREFETCH_ROWS, among others. Check the official PHP 8.4 migration guide for the complete list rather than trusting any blog summary, including this one.
rg -n "mysqli_|new mysqli|MYSQLI_" app/ scripts/ database/ --glob '!vendor'
The right fix in a Laravel context is almost never "update the mysqli call." It is "move this to the query builder or PDO." A connection-liveness check written with mysqli_ping() in 2016 is a hint that this code path never got the Eloquent treatment. While you are in there, it is worth reading it against the patterns in Laravel database performance: fixing N+1 queries and indexing — legacy raw-SQL corners are usually where the worst query shapes hide.
Also worth noting: PHP 8.4 removed the mhash*() functions outright. If anything in your codebase still hashes with those, it is a removal, not a deprecation, and it fails hard.
The #[\Deprecated] Attribute Is Now Yours to Use
PHP 8.4 added a first-class #[\Deprecated] attribute. This is not a breaking change, it is a tool — and it is the single most useful thing you can adopt during an upgrade project, because it lets you mark internal APIs for removal in a way that shows up in your deprecation log rather than in a wiki nobody reads.
final class LegacyInvoiceService
{
#[\Deprecated(
message: 'use InvoiceService::total() instead',
since: '4.2.0',
)]
public function calculateTotal(Invoice $invoice): float
{
return app(InvoiceService::class)->total($invoice);
}
}
Calling it emits E_USER_DEPRECATED, which flows straight into the deprecations log channel you configured in Stage 0. You now have production evidence of which legacy call sites are actually live. That evidence is worth more than any amount of grepping when you are deciding what to delete.
Adoption Opportunities: Property Hooks and Asymmetric Visibility
PHP 8.4's headline additions are property hooks and asymmetric visibility. Both are genuinely good. Neither belongs in your upgrade PR.
final class ImportBatch
{
public function __construct(
public readonly int $total,
// Readable everywhere, writable only from inside the class
public private(set) int $processed = 0,
) {}
public bool $isComplete {
get => $this->processed >= $this->total;
}
}
Asymmetric visibility deletes a whole category of pointless getters. Property hooks give you computed properties without the __get overhead.
One important caveat for Laravel developers: be careful with property hooks on Eloquent models. Eloquent resolves attributes through __get, which only fires for properties that are not declared on the class. Declare a real, hooked property whose name collides with a database column and you have silently bypassed the attribute system — toArray(), mass assignment, and dirty tracking will not see it. Use hooks on value objects, DTOs, and service classes. For models, keep using Attribute::make() and the casts() method.
Schedule this adoption as a separate refactor after the runtime is stable everywhere. Mixing a language-feature rollout into a version bump is how you lose the ability to answer "what changed?"
Stage 3: PHP 8.5 and Laravel
The 8.4 to 8.5 hop is small. Most of it is additive, and for a codebase that already cleared 8.4 the upgrade is often a constraint bump and a CI run.
There is one Laravel-specific landmine. Laravel 13 depends on symfony/polyfill-php85, which — when running on PHP below 8.5 — defines global array_first() and array_last() functions. If you or laravel/helpers already define functions by those names, you get a collision, and the semantics differ from the historical Laravel helpers. Going to 8.5 actually resolves this, because the polyfill becomes a no-op. But if you are staging your rollout, you will have a window where some nodes are on 8.4 (polyfill active) and some are on 8.5 (polyfill inert). Audit for it before you split the fleet:
rg -n "function (array_first|array_last)" app/ bootstrap/ helpers/
composer show laravel/helpers 2>/dev/null
Use Arr::first() and Arr::last() and the ambiguity disappears entirely.
What You Gain on 8.5
The pipe operator (|>) replaces nested-function soup with left-to-right reading order:
$slug = $title
|> trim(...)
|> strtolower(...)
|> fn (string $s): string => preg_replace('/[^a-z0-9]+/', '-', $s)
|> fn (string $s): string => trim($s, '-');
The right-hand side must be callable, which is why the first-class callable syntax (trim(...)) shows up so often in pipelines.
#[\NoDiscard] marks a function whose return value must not be ignored. This is quietly excellent for immutable value objects and fluent builders, where discarding the result is always a bug:
final class Money
{
#[\NoDiscard('add() returns a new instance; the original is unchanged')]
public function add(self $other): self
{
return new self($this->cents + $other->cents, $this->currency);
}
}
$total->add($shipping); // E_WARNING: return value not used
$total = $total->add($shipping); // correct
(void) $total->add($shipping); // explicit opt-out, if you really mean it
If you build API response objects or DTO pipelines, this catches a class of bug that tests routinely miss. It pairs well with the immutable-resource patterns in Laravel API best practices.
Closures in constant expressions means you can finally use a closure as a property default, a class constant, or an attribute argument:
final class ReportFormatter
{
public function __construct(
private \Closure $format = static fn (float $v): string => number_format($v, 2),
) {}
}
Persistent cURL handles let a long-lived process reuse DNS resolution and TLS connections across requests. For a standard PHP-FPM app this does nothing, because the process dies at the end of the request. For an Octane worker making outbound HTTP calls to a payment gateway or search service on every request, it removes a TLS handshake from the hot path. Measure it before and after; the win is real but workload-dependent.
Fatal error backtraces are the operational headline. Historically, an out-of-memory or max-execution-time fatal gave you a one-line message and no stack. PHP 8.5 emits a backtrace for fatal errors. The first time a queue worker OOMs after this upgrade and the log tells you which Eloquent chunk did it, the upgrade will have paid for itself.
Wiring the Version Matrix Into CI
Your CI should be testing the version you are on and the version you are moving to, simultaneously, before either reaches a server.
# .github/workflows/tests.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.3', '8.4']
include:
- php: '8.5'
experimental: true
continue-on-error: ${{ matrix.experimental == true }}
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping" --health-interval=10s
--health-timeout=5s --health-retries=5
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, intl, bcmath, pdo_mysql, redis, gd, zip
ini-values: error_reporting=E_ALL, display_errors=On, memory_limit=512M
coverage: none
- name: Install dependencies
run: composer update --prefer-dist --no-interaction --no-progress
- name: Static analysis
run: vendor/bin/phpstan analyse --no-progress
- name: Tests (deprecations are failures)
run: vendor/bin/pest --fail-on-deprecation --fail-on-warning
Three details that matter more than the YAML:
fail-fast: false. You want the full picture from every version, not the first failure.continue-on-errorfor the leading edge. Add 8.5 as an advisory job first. It gives you visibility without blocking merges while you are still stabilising 8.4.--fail-on-deprecation. This is the entire point. A deprecation that does not fail the build is a deprecation nobody fixes.
If a dependency has not declared support for the new version yet but you want to test against it anyway, Composer can ignore just the upper bound:
composer update --ignore-platform-req=php+
The + suffix means "ignore the upper limit only" — it will still refuse to install something that genuinely needs a newer PHP than you have. Useful for the advisory job, never for production.
Runtime, Docker, and PHP-FPM
The application-level work is only half of it. The runtime change is where deploys actually fail.
# Pin the patch version. "8.4-fpm-alpine" is a moving target and
# you will not enjoy discovering that during an incident.
FROM php:8.4.11-fpm-alpine
# Use a helper for extensions rather than hand-rolled build deps;
# it handles the version-specific compilation quirks for you.
COPY --from=mlocati/php-extension-installer:latest \
/usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions \
pdo_mysql redis intl bcmath gd zip opcache pcntl
Checklist for the runtime side:
- Extensions are the most common blocker. Third-party extensions (New Relic, Datadog, Blackfire,
ext-imagick,ext-grpc) build per PHP version and their release cadence lags core. Verify these in CI on a real image, not on your laptop. A missing APM extension will not fail your test suite — it will fail your observability, silently, in production. - Opcache must be reconsidered, not just carried over. Compiled opcode is version-specific. If you use
opcache.file_cache, point the new version at a fresh directory or clear it, because a stale cross-version cache produces failures that look like nothing else you have ever seen. If you useopcache.preload, re-test the preload script; preloading is stricter about class resolution than normal autoloading. - Re-tune
pm.max_children. Per-worker memory footprint shifts between versions. Take a real measurement on the new runtime rather than carrying forward a number someone calculated in 2022. - JIT is not a default win. PHP 8.4 rewrote the JIT, but for a typical I/O-bound Laravel app the effect is close to zero and occasionally negative. Leave it off unless you have a benchmark that says otherwise.
- Serverless has its own constraint. On Laravel Vapor and similar platforms, the available PHP versions are whatever the platform's runtime images offer. Confirm the target version is published before you plan the date, and remember that the runtime pin lives in
vapor.yml, not your Dockerfile.
The Rollout Sequence
Same shape every time:
- Local and CI. Green on the target version with
--fail-on-deprecation. Nothing proceeds until this holds. - Staging, with production-shaped data. Run the full runtime — queue workers, scheduler, Horizon, the lot. Deprecation logs must be quiet for a full scheduler cycle, which means at least 24 hours to catch nightly jobs.
- Canary: one node, or one worker pool. This is where the staged approach pays off. Put the new PHP version on a single web node behind your load balancer at low weight, or — often better — on a single queue worker pool first. Queue workers are the ideal canary: they exercise your heaviest code paths, and a failure degrades throughput rather than user-facing availability.
- Soak for a business cycle. Not an hour. Long enough to cover a weekly report, a nightly batch, and a billing run if you have one.
- Ramp. 10 percent, then 50, then 100 percent of web traffic, watching error rate and p95 latency at each step.
- Then, and only then, start the framework branch.
What to Watch During the Soak
# Deprecations should trend to zero, not plateau
tail -f storage/logs/deprecations.log
# Fatals and segfaults - the version-specific failure mode
grep -iE "segmentation fault|zend_mm_heap|allowed memory size" /var/log/php-fpm.log
# Confirm the node is actually running what you think
php -v && php -m | sort
php -r 'echo ini_get("opcache.enable") ? "opcache on\n" : "opcache OFF\n";'
Add a version assertion to your application health endpoint. It is three lines and it has saved me from "the deploy succeeded but the FPM pool never restarted" more than once:
Route::get('/healthz', fn () => response()->json([
'php' => PHP_VERSION,
'laravel' => app()->version(),
'commit' => trim(file_get_contents(base_path('COMMIT'))),
]));
On the metrics side, the numbers that actually move on a PHP upgrade are p95 response time (should improve slightly or stay flat), memory per worker, and error rate. If p95 gets worse, suspect opcache configuration before you suspect the language.
Rollback Plan
Assume you will need it. Design for it before you deploy.
-
Keep both runtimes installed during the canary window. On VMs, that means both
php8.3-fpmandphp8.4-fpmpools present, with nginx pointed at one upstream. Rollback becomes an upstream change and a reload, measured in seconds. On containers, keep the previous image tag pinned by digest and ensure your orchestrator can redeploy it without a rebuild. -
Separate the PHP bump commit from the dependency bump commit. This is the one people get wrong. If you run
composer updateon 8.5 and the resulting lockfile pulls packages that require^8.4, your lockfile is no longer installable on your rollback target. Verify before you ship:# Run this with the OLD php binary against the NEW lockfile composer check-platform-reqsIf it fails, your rollback is not a redeploy — it is a re-resolve under pressure. Fix that in advance by holding dependency updates out of the runtime PR.
-
Tag the pre-upgrade release.
git tag pre-php-84 && git push --tags. Your rollback is a redeploy of a known-good tag, not a revert commit written at 2am. -
Drain queues before switching worker pools. Serialized job payloads written by one runtime get read by another. Let the queue empty, deploy, restart workers with
php artisan queue:restart. -
Know what is not reversible. The runtime itself is fully reversible. Rector-applied code changes are reversible via git. Database migrations are not, so keep schema changes out of this workstream entirely — a PHP upgrade PR should touch zero migrations.
Realistic Effort and Timeline
Ranges from projects I have actually run, assuming one senior developer and a test suite that is green before you start. Calendar time exceeds engineering effort because of review, soak windows, and scheduling.
| Hop | App profile | Engineering effort | Calendar time |
|---|---|---|---|
| 8.2 to 8.3 | Small, modern, good coverage | 2 – 6 hours | 2 – 3 days |
| 8.2 to 8.3 | Mid-size, 30+ packages | 1 – 3 days | 1 week |
| 8.3 to 8.4 | Mid-size, mostly typed code | 2 – 4 days | 1 – 2 weeks |
| 8.3 to 8.4 | Large, legacy, thin coverage | 1 – 3 weeks | 4 – 6 weeks |
| 8.4 to 8.5 | Any well-maintained app | 1 – 3 days | 1 week |
| 8.1 to 8.5 | Full chain, mid-size app | 3 – 5 weeks | 6 – 10 weeks |
| 8.1 to 8.5 | Full chain, large legacy app | 6 – 12 weeks | 3 – 4 months |
Two cost drivers dominate the spread, and neither is your application code. The first is package compatibility — one unmaintained dependency with a hard <8.4 constraint can add a fork-and-maintain decision to the critical path. The second is test coverage, because without it every stage requires manual regression testing that does not scale.
Budget the PHP work as roughly 30 to 50 percent of the total when you are scoping a full modernisation. The commercial framing for the whole exercise, including when a rewrite is the cheaper answer, is in what a Laravel upgrade actually costs.
Key Takeaways
- The PHP floor moves before the framework floor. Composer resolves
phpas a platform requirement, so a Laravel 13 upgrade on PHP 8.2 will not even resolve. Ship the runtime bump as its own release. - Stage it: 8.3, then 8.4, then 8.5. You can deploy them together, but commit them separately. The bisect surface is the whole point.
- PHP 8.4 is where the work is. Implicit nullable parameters are the dominant diff; let Rector's
php84set do the mechanical part and review every hunk. Then clearE_STRICT, the deprecated session INI settings, and directmysqliusage. - PHP 8.5 is small and mostly additive — pipe operator,
#[\NoDiscard], closures in constant expressions, persistent cURL handles, and fatal error backtraces. The one Laravel-specific trap is thesymfony/polyfill-php85global function collision during a mixed-version window. - Treat property hooks and asymmetric visibility as a separate refactor, and keep property hooks off Eloquent models, where they bypass attribute resolution.
- A CI matrix with
--fail-on-deprecationis non-negotiable. A deprecation that does not fail the build never gets fixed. - Design rollback before deploy: both runtimes installed, images pinned by digest, and a lockfile that still passes
composer check-platform-reqson the version you would roll back to.
Verify every version-specific claim here against the PHP migration guides, the PHP supported versions page, and the Laravel release notes. Version support windows shift, and a blog post is a map, not the territory.
Planning Yours?
I have spent 14 years in PHP and Laravel, and a fair share of it running exactly this workstream for teams who would rather not lose a month to it. If you want a second opinion on sequencing, a pre-upgrade audit, or someone to run the runtime migration while your team keeps shipping features, get in touch through the contact form. Send me your current PHP version, your Laravel version, and the output of composer why-not php 8.4 — that is usually enough for a useful first answer.
If the upgrade is bundled with an infrastructure move, the evaluation criteria in my Laravel AWS migration consultant checklist will help you scope the combined project properly rather than discovering the overlap halfway through.

Richard Joseph Porter
Senior Laravel Developer with 14+ years of experience building scalable web applications. Specializing in PHP, Laravel, Vue.js, and AWS cloud infrastructure. Based in Cebu, Philippines, I help businesses modernize legacy systems and build high-performance APIs.
Need Help Upgrading Your Laravel App?
I specialize in modernizing legacy Laravel applications with zero downtime. Get a free codebase audit and upgrade roadmap.
Related Articles
Upgrading to Laravel 13: What Changed and What Breaks
A practical Laravel 13 upgrade guide: the breaking changes that actually bite, paths from Laravel 10, 11, and 12, an audit checklist, and realistic timelines.
What a Laravel Upgrade Actually Costs: Scope, Timeline, and When to Rewrite Instead
A practical Laravel upgrade cost model for CTOs and founders: real hour and dollar ranges, timeline expectations, audit commands, and an upgrade-vs-rewrite scorecard.
Migrate Legacy Laravel Apps: A Staged Upgrade Playbook
Upgrade Laravel 4.x-8.x safely: legacy factories and Laravel 11 compatibility, testing patterns, and staged migration lessons from 14 years of PHP.