These are the messages you will actually see in the log during a PHP 8 upgrade, with the cause and the fix for each one. Almost all of them come from the same place: PHP 8 stopped being lenient about types, about removed functions and about parameter lists.
The short answer
The errors fall into four groups. Removed functions are fatal the moment the code path runs. Type strictness turns silent conversions into notices and fatals. Signature changes turn invalid parameter lists into compile-time errors. And the language changes around arrays, properties and arguments produce errors that look like bugs in your code but are actually changes in the contract.
The fix is always the same shape: find the call site, decide what the code meant, and make the type explicit. The list below gives the exact change for each message.
The 12 error messages
1. Undefined array key "foo"
What it means: the code read an array key that is not there. Before PHP 8
this was a notice and the value was null; since PHP 8 it is a warning, and the
value is still null.
$name = $user['name']; // undefined array key "name"
$name = $user['name'] ?? ''; // the fix, with a default
$name = $user['name'] ?? null; // or with null as the default
How to fix it: decide whether the key is optional. If it is, use the null coalescing operator. If it is not, the data is missing and the code below it will break, so treat it as an error rather than as a warning.
2. Undefined variable $foo
What it means: the variable was used before it was assigned. It was a notice
in PHP 7 and a warning since PHP 8, and the value is null.
if (!isset($total)) {
$total = 0;
}
How to fix it: find out why it was not set. The usual causes are a renamed
variable, a return that was added in one branch, or a variable that used to be a
parameter and is now missing.
3. Attempt to read property "foo" on null
What it means: something returned null where an object was
expected, and the code immediately read a property from it. It is a warning in PHP 8.
// before
$name = $repository->find($id)->name;
// after
$record = $repository->find($id);
if ($record === null) {
throw new NotFoundException(sprintf('Record %d not found', $id));
}
$name = $record->name;
How to fix it: this one is usually a real bug that PHP 7 hid: a missing row,
a failed lookup, an empty collection. Do not silence it with
->?->name unless a missing value really is an expected outcome.
4. Passing null to parameter #2 ($name) of type string is deprecated
What it means: an internal function was given null for a
parameter that does not accept it. PHP 8.1 deprecated it; it will become an error later.
// before
$length = strlen($input); // $input may be null
// after
$length = strlen($input ?? ''); // if null and empty are the same here
How to fix it: look at what the function does with an empty string. For
strlen, trim and most string functions, an empty string is the right
default. For explode() or a date function, the right default may be different, so
decide per call site rather than blanket replacing.
5. Required parameter $b follows optional parameter $a
What it means: a function declares a required parameter after an optional one. PHP 8 made this a fatal error at compile time, so the file will not even load.
// before
function connect(string $host, int $port = 3306, string $socket) {}
// after, the safe way for a public API
function connect(string $host, string $socket, int $port = 3306) {}
How to fix it: give the trailing parameter a null default and
make it nullable, or reorder the parameters and update every call site. Reordering is the
safer choice for a public API, because it cannot change the meaning of an existing call.
6. Cannot modify readonly property Foo::$bar
What it means: a property declared readonly was written twice, or
was written from outside the scope that declared it.
final class Money
{
public function __construct(public readonly int $amount) {}
public function double(): self
{
$this->amount = $this->amount * 2; // error
return new self($this->amount * 2);
}
}
How to fix it: readonly properties can be written once, from inside the declaring scope. If the value has to change, create a new instance with the new value. If it was written twice from the constructor, the constructor is doing too much.
7. Call to undefined function each()
What it means: each() was removed in PHP 8.0. The call is a
fatal error as soon as that line runs.
// before
while (list($key, $value) = each($items)) {}
// after
foreach ($items as $key => $value) {}
How to fix it: replace it with a foreach loop. The full removal
list is in the
deprecated functions article.
8. Call to undefined function mysql_connect()
What it means: the mysql_* procedural API was removed in PHP
7.0. The code is older than PHP 7.
// before
$result = mysql_query('SELECT id FROM users', $conn);
while ($row = mysql_fetch_assoc($result)) {}
// after
$stmt = $pdo->query('SELECT id FROM users');
while ($row = $stmt->fetch()) {}
How to fix it: migrate the database layer to PDO or MySQLi. This is the
largest single task in most legacy migrations and it deserves its own plan. Note that
mysql_query() returned false on failure while PDO throws by
default, so the error handling has to be revisited as well.
9. Cannot use positional argument after named argument
What it means: PHP 8.0 added named arguments. Once you name an argument, the ones after it must be named too.
// before
array_slice($items, 0, 10, true);
// after
array_slice($items, 0, length: 10, preserve_keys: true);
How to fix it: name the remaining arguments, or move the named one to the end. This error appears when a library started using named arguments in its own calls.
10. Maximum execution time of 30 seconds exceeded
What it means: the script ran longer than max_execution_time.
It is not really a PHP 8 error, but it very often appears during an upgrade because the
upgrade made something slower.
set_time_limit(0); // only for a CLI migration script
How to fix it: look for the real cause before raising the limit. The usual culprits after an upgrade are a query that now uses an index it did not use before, an unbounded loop that used to short-circuit on a removed function, or a retry loop that now retries because the error type changed. Raising the limit hides all three.
11. Class "Foo" not found
What it means: the class could not be autoloaded. After an upgrade this is usually one of three things: a namespace change in a new dependency version, a case sensitivity problem on a case insensitive filesystem, or a class that moved.
// PSR-4 requires the file name to match the class name
// src/Service/OrderProcessor.php -> namespace App\Service; class OrderProcessor
How to fix it: check the autoload map with
composer dump-autoload -o, then compare the namespace in the class with the one
in the use statement. On Linux, a file called orderprocessor.php
instead of OrderProcessor.php works on macOS and fails in production.
12. Array to string conversion
What it means: an array was used where a string was expected. It was a
notice in PHP 7 and a warning since PHP 8, and the result is the literal string
"Array".
// before
echo $order->items; // "Array"
// after
echo implode(', ', $order->items);
echo json_encode($order->items);
How to fix it: decide what the string should be. Often the code wanted a single field from an array of objects, or a JSON representation. This warning is frequently a sign that a function started returning an array where it used to return a scalar.
Reading the log
During an upgrade the log is more useful than the browser, because the errors appear on the routes that only exist in production. Three habits make the difference:
- Turn every notice into a visible line. Make sure
error_reportingincludesE_ALLand thatdisplay_errorsis off in production whilelog_errorsis on. A notice that is swallowed is a bug you will find again in six months. - Read the file and the line, not just the message. The line number tells you which of the twelve categories you are looking at, and the file tells you how old that code is.
- Count by category. Four hundred "Undefined array key" warnings are one problem. Four hundred warnings spread over six categories are six problems, and the order you fix them in should start with the fatal ones.
Work through them in this order
- Fatal errors first: undefined removed functions and the class loading failures. They stop the code from running at all.
- Compile-time errors: the invalid parameter lists. The file does not load until they are fixed.
- Type strictness notices: the null parameters. These are the ones that change behaviour, so they need a decision per call site.
- Property and argument contract changes: readonly properties, the named argument order and the array to string conversions.
- The undefined keys and variables: the largest group, and usually the cheapest per fix. Group them by file and fix them in one pass.
A compatibility scan gives you this grouping directly, because it reports by rule rather than by message. php2shell lists every finding with the file, the line, the PHP version it affects and the recommended change.
Reporting during the migration
Two settings decide how much noise you see while you work:
// in php.ini or the CI configuration
error_reporting = E_ALL;
display_errors = Off; // on a server
log_errors = On;
On a developer machine, display_errors = On is fine and much faster to work
with. On a staging or production server, keep the display off and read the log: a warning
printed into a JSON response breaks the response for the client that called your API.
In PHPUnit, make the deprecations fail the build while you are migrating, so nothing slips through:
vendor/bin/phpunit --display-deprecations --fail-on-deprecation
A summary table
| Message | Severity in PHP 8 | Where it comes from |
|---|---|---|
| Undefined array key | Warning | Missing array key |
| Undefined variable | Warning | Variable never assigned |
| Attempt to read property on null | Warning | A lookup returned nothing |
| Passing null to parameter of type string | Deprecated since 8.1 | Strict internal function types |
| Required parameter follows optional parameter | Fatal at compile time | Invalid signature |
| Cannot modify readonly property | Fatal | Readonly contract |
| Call to undefined function each() | Fatal | Removed in PHP 8.0 |
| Call to undefined function mysql_connect() | Fatal | Removed in PHP 7.0 |
| Cannot use positional argument after named argument | Fatal | Named arguments, new in PHP 8.0 |
| Maximum execution time exceeded | Fatal | Usually a slow path, not a PHP change |
| Class not found | Fatal | Autoload, namespace or file name case |
| Array to string conversion | Warning | A return type changed to an array |
Frequently asked questions
Is 'Undefined array key' a fatal error?
It is a warning in PHP 8 and the value evaluates to null, so the code keeps running until something downstream expects the missing value. Treat it as an error: it almost always points at a missing array key or an unchecked database row.
How do I fix 'Required parameter follows optional parameter'?
Make the parameter list consistent. Either give the optional parameter a null default and make the following ones optional as well, or reorder the parameters and update every call site. Reordering is the safer option for public APIs because it cannot change the meaning of an existing call.
Why do I get 'Cannot modify readonly property'?
The property was declared readonly and has already been initialised. Readonly properties can only be written once, from inside the declaring scope. If your code needs to change the value later, remove the readonly modifier or clone the object and modify the copy.
Where to go next
- How to upgrade PHP 7.4 to 8.3 — the full step-by-step migration plan.
- Deprecated PHP functions that break PHP 8 — every removal with its replacement.
- PHP 8.2 dynamic properties — the deprecation you will meet next.
- The FAQ — plans, credits, licences and how your code is handled.