Upgrading a Laravel App to PHP 8.6: Deprecations, Session Defaults and a CI Checklist

Upgrade to PHP 8.6 without surprises: the deprecations that actually hit Laravel apps, the new session defaults, Rector and CI steps, and a safe rollout order.

Steven Richardson
Steven Richardson
· 9 min read

PHP 8.6 reaches general availability on 19 November 2026, and RC2 is already out. Every minor release triggers the same scramble: one dependency hasn't declared support, Sentry fills with E_DEPRECATED, and the Docker tag you need doesn't exist yet.

This is the ordered checklist I run on Laravel apps. CI first, deprecations second, runtime last — because that order means the painful discoveries happen in a pull request rather than on a production box at 11pm.

Check framework and package support#

Before touching anything else, ask Composer whether your dependency tree will even resolve on PHP 8.6. The why-not command tells you exactly which package is holding the platform constraint down, which is far more useful than a wall of resolver output.

# Which packages block PHP 8.6?
composer why-not php 8.6

# What's lagging behind, direct dependencies only
composer outdated --direct

Laravel 13 declares php: ^8.3, so the framework itself imposes no upper bound and 8.6 is allowed by the constraint from day one. The blockers are almost always further down the tree: a static analysis tool, a PDF library, or an abandoned package pinned to <8.6. For the framework jump itself, the Laravel 12 to 13 upgrade guide is the one to do first — do not stack a framework major and a PHP minor in the same pull request.

If a package blocks you and the fix is already merged upstream, a temporary "config": {"platform": {"php": "8.5.0"}} entry keeps local installs stable while CI tests the real thing.

Add PHP 8.6 to the CI matrix#

Put 8.6 in the matrix as a non-blocking job today, and promote it to required once it's green. shivammathur/setup-php accepts 8.6 and installs a nightly or RC build from the PHP-8.6 branch, so you don't need to wait for GA.

# .github/workflows/tests.yml
jobs:
  tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        php: ['8.3', '8.4', '8.5']
        include:
          - php: '8.6'
            experimental: true
    continue-on-error: ${{ matrix.experimental == true }}
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          # RC builds need ignore-platform-reqs on some transitive deps
          tools: composer:v2
      - run: composer update --prefer-dist --no-interaction --ignore-platform-req=php+
      - run: php artisan test

Note --ignore-platform-req=php+, not the blanket --ignore-platform-reqs. The + suffix only relaxes upper bounds, so a package that genuinely needs PHP 8.4 still fails the build. If your matrix is already more involved than this, I covered the full shape in matrix testing Laravel against multiple PHP and database versions.

Turn deprecations into failing tests#

A deprecation notice that only lands in a log file gets ignored. Convert them into test failures so the matrix job is honest about what's broken.

Laravel's TestCase doesn't do this for you, so add a bootstrap error handler that throws on E_DEPRECATED originating from your own code. Vendor deprecations are somebody else's pull request, so filter them out or you'll never get a green run.

<?php

// tests/Pest.php (or bootstrap/testing.php)

set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
    if ($severity !== E_DEPRECATED) {
        return false; // fall through to PHP's handler
    }

    // Only fail on deprecations in first-party code.
    if (! str_starts_with($file, base_path('app'))
        && ! str_starts_with($file, base_path('src'))) {
        return true; // swallow vendor noise
    }

    throw new ErrorException($message, 0, $severity, $file, $line);
});

Run the suite on 8.6 once with the filter removed and pipe the output to a file. That list is your vendor upgrade backlog — every entry is a package that needs a version bump before GA.

Grep for the deprecated functions#

The Deprecations for PHP 8.6 RFC voted each item separately, so the accepted list is narrower than the proposal. These are the ones that actually turn up in Laravel codebases, and all of them are scheduled for removal in PHP 9.0.

# Object identity — spl_object_id() returns an int and is faster
rg -n 'spl_object_hash\(' app src

# Type-check aliases: is_integer/is_long -> is_int, is_double -> is_float
rg -n 'is_integer\(|is_long\(|is_double\(|doubleval\(' app src

# Oniguruma is EOL: the whole mb_ereg* family is deprecated
rg -n 'mb_ereg|mb_split|mb_regex_encoding' app src

# is_a()/is_subclass_of() with a string subject and $allow_string = false
rg -n 'is_subclass_of\(|is_a\(' app src

The subtle one is return inside a finally block, which now emits a compile-time deprecation. It's deprecated because it silently discards whatever the try was doing — including an exception:

function getConfig(): array
{
    try {
        return loadConfig(); // throws if the file is missing
    } finally {
        return []; // PHP 8.6: Deprecated. The exception vanishes.
    }
}

Also on the list: strcoll(), metaphone(), the SORT_LOCALE_STRING sort flag, spl_classes(), SplFileObject's CSV methods, define() with $case_insensitive, mysqli::stmt_init(), mysqli_get_charset(), the dechunk stream filter, and ReflectionProperty::setValue() when the value doesn't match the declared type. Passing an object where an array is expected — array_walk(), http_build_query(), mb_convert_variables() — is deprecated too, which is the one most likely to bite a legacy service class that leans on ArrayObject.

Run Rector and PHPStan over the codebase#

Rector handles the mechanical renames. The current API reads the PHP version straight out of composer.json, so bump the constraint first and let the config follow.

<?php

// rector.php
use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/app', __DIR__ . '/tests'])
    // Reads the "php" constraint from composer.json — no hardcoded set name.
    ->withPhpSets()
    ->withPreparedSets(deadCode: true);
composer require --dev rector/rector
vendor/bin/rector process --dry-run

Rector will not catch everything. return inside finally needs a human to decide what the correct control flow was, and the is_a() change depends on runtime types. That's where static analysis earns its keep — if you're not at a high level yet, getting a Laravel app to PHPStan level 10 is worth doing before the upgrade rather than during it. For the Rector workflow in more depth, see automating Laravel upgrades with Rector.

Review the new session and stream defaults#

The Secure Session Configuration Defaults RFC passed 27–0 and changes three php.ini defaults:

session.use_strict_mode = 1    ; was 0 — rejects uninitialised session IDs
session.cookie_httponly = 1    ; was 0
session.cookie_samesite = Lax  ; was "" (unset)

Laravel does not use PHP's native session handler. It manages cookies through config/session.php and its own StartSession middleware, so a standard Laravel app sees no behaviour change at all. The risk is in the corners: a legacy admin area still calling session_start(), a third-party SDK that writes to $_SESSION, or a SAML/OAuth callback that relied on cross-site POST working with no SameSite attribute. Lax blocks cookies on cross-site POST, and that is exactly how most SSO assertions arrive.

Also check session_set_save_handler(). Passing a handler object that doesn't implement create_sid() and validateId() is now deprecated.

Separately, the Stream Error Handling Improvements RFC tidies up how stream failures are reported. If you have code doing @fopen(...) followed by error_get_last() to work out what went wrong, read it carefully — that pattern depends on exact message text and is the kind of thing that breaks quietly.

Update Docker images and production runtimes#

Official Docker images already carry RC tags, so you can build against 8.6 in CI today:

# Pin the exact RC while testing, move to php:8.6-fpm-trixie at GA
FROM php:8.6.0RC2-fpm-trixie AS base

Pin the full RC tag rather than the floating 8.6-rc alias — floating tags move under you and turn a reproducible build into a coin flip. If your Dockerfile is a single stage, the upgrade is a good moment to fix that; multi-stage builds for Laravel cut the final image substantially and make the PHP version a one-line change.

Third-party extensions are the usual delay. Check redis, imagick, xdebug, pcov and swoole/openswoole have 8.6-compatible releases before you commit to a date. For managed platforms — Forge, Laravel Cloud, Vapor — PHP minors typically appear within a few weeks of GA, so plan on a December window rather than a November one.

Roll out behind a canary#

Do not ship on GA day. The .0 of a PHP minor reliably produces a handful of regression reports in the first fortnight, and 8.6.1 lands in December. Target that.

When you do roll out, move one node or one container at a time and watch for E_DEPRECATED in the application log, not just fatals. Keep error_reporting at E_ALL in production and route deprecations into your error tracker at a low severity so they're visible but not paging anyone. PHP 8.5's fatal error backtraces make the genuinely bad failures much faster to diagnose when they do appear.

Roll back by flipping the image tag. That's the whole reason the Docker step comes before this one.

Gotchas and Edge Cases#

list() is not deprecated. The proposal to deprecate the list() construct was voted down 23–23 with one abstention, short of the two-thirds threshold. Several write-ups published during the voting period claimed otherwise. list($a, $b) = $array is fine and will stay fine.

trim() now strips form feeds. trim(), ltrim(), rtrim() and chop() include \f in their default character list. Harmless for almost everyone, but if you parse fixed-width files or legacy print streams where \f is a page separator, your parser will quietly start eating delimiters.

array_filter() throws on a bad $mode. Passing an invalid value to the third argument now raises a ValueError instead of being ignored. Code that passed true instead of ARRAY_FILTER_USE_KEY has been silently wrong for years and will now fail loudly — which is the correct outcome, but it will fail in production if you don't find it first.

Deprecation volume is not a severity signal. A single spl_object_hash() call inside a hot loop produces thousands of identical notices. Deduplicate by message and file before you panic about the count.

Vendor deprecations are not your problem to fix in-app. Resist patching vendor/. Open the upstream issue, pin the version, and move on — a Composer patch you forget about is worse than a deprecation notice you can see.

Wrapping Up#

Add the 8.6 matrix job today, run the greps, and let the deprecation backlog accumulate in a pull request for the next six weeks. The work is cheap now and expensive in November.

Once you're on 8.6, the headline feature worth rewriting code for is partial function application — it deletes most of the one-line closures in a typical Laravel app. And if you skipped a version on the way here, the PHP 8.5 deprecations cheat sheet covers the batch that becomes fatal in PHP 9.0 alongside these.

FAQ#

Is PHP 8.6 backwards compatible?

Largely yes. PHP 8.6 is a minor release, so the vast majority of code runs unchanged. The genuine breaks are narrow: array_filter() now throws a ValueError on an invalid $mode argument, the trim() family strips form feeds by default, and the new secure session php.ini defaults can affect code using PHP's native session handling. Everything else on the deprecation list still works — it just emits an E_DEPRECATED notice ahead of removal in PHP 9.0.

What is deprecated in PHP 8.6?

The accepted deprecations include spl_object_hash(), spl_classes(), returning a value from inside a finally block, the type-check aliases is_integer(), is_long(), is_double() and doubleval(), strcoll(), metaphone(), the SORT_LOCALE_STRING flag, SplFileObject's CSV methods, mysqli::stmt_init() and mysqli_get_charset(). The entire mb_ereg* family is deprecated separately because Oniguruma has reached end of maintenance. Note that the proposal to deprecate list() was rejected.

Does Laravel 13 support PHP 8.6?

Laravel 13 declares a platform constraint of php: ^8.3, so PHP 8.6 satisfies it with no change to your composer.json. In practice the blockers are transitive dependencies and PHP extensions rather than the framework. Run composer why-not php 8.6 to see exactly which packages are holding you back, and expect static analysis and testing tools to be the last to declare support.

How do I test my Laravel app on PHP 8.6 before release?

Add 8.6 to your GitHub Actions matrix using shivammathur/setup-php, which installs RC and nightly builds from the PHP-8.6 branch. Mark the job continue-on-error so it reports without blocking merges, and install with composer update --ignore-platform-req=php+ so that upper-bound constraints are relaxed while genuine minimum requirements still fail. Add a test bootstrap error handler that throws on E_DEPRECATED from your own code so deprecations become visible test failures rather than log noise.

Can Rector upgrade my code to PHP 8.6?

Rector handles the mechanical replacements well — the type-check aliases, function renames and signature changes are all straightforward transformations. Configure it with ->withPhpSets(), which reads the PHP constraint from your composer.json rather than needing a hardcoded set name. It will not fix everything: returning from a finally block requires a human decision about the intended control flow, and the is_a() and is_subclass_of() changes depend on runtime types that Rector cannot infer. Pair it with PHPStan or Larastan for the rest.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.