PHP 8.4 deprecated parameters like Customer $customer = null; why, and how do you clean them up across a large CRM codebase?
answer
- null default silently widened the type
- deprecation raised at compile time
- fix: ?Customer $customer = null
- followed by a required parameter: drop the default
- promoted parameters never got the widening
basics
~20 sA null default used to make the type nullable silently, so Customer $customer = null accepted null while the type said Customer. PHP 8.4 deprecates that; fix each site by writing ?Customer or Customer|null explicitly, found with a static scan rather than runtime logs.
solid answer
~50 sBefore 8.4, a parameter whose default was `null` had its type **implicitly widened** to nullable: `Customer $customer = null` accepted `null` though the declaration said `Customer`. PHP 8.4 deprecates it with `Implicitly marking parameter $customer as nullable is deprecated, the explicit nullable type must be used instead`. The mechanical fix is `?Customer $customer = null` or `Customer|null $customer = null`; behaviour is identical. The one special case is such a parameter **followed by a required one**, where the default never made it optional: the fix there is `?Customer $customer` with the default removed. Because the deprecation is raised when the file is **compiled**, not when the function runs, a static scan or an automated fixer finds every site reliably, while counting runtime log lines undercounts. Roll it out as one mechanical change, verified by the test suite, before the next major version turns deprecations into errors.
code
php · 15 lines<?php
declare(strict_types=1);
final class CrmService
{
// before (PHP 8.4+: Deprecated: Implicitly marking parameter $owner as nullable ...)
// public function assign(Customer $owner = null): void {}
// after: same behaviour, explicit type
public function assign(?Customer $owner = null): void {}
// before: public function merge(Customer $from = null, int $intoId): void {}
// after: the default never made $from optional, so drop it
public function merge(?Customer $from, int $intoId): void {}
}go deeper
Recall that a null default used to make a typed parameter nullable, and that since PHP 8.4 the ? must be written explicitly.
Explain why the implicit form was deprecated, the union and default-before-required variants of the fix, and why mixed parameters are unaffected.
Run the cleanup as a static-scan, mechanical change with tests and a CI guard, knowing compile-time deprecations make log counts unreliable.
Treat deprecations as a scheduled upgrade budget: prioritise them across packages and vendors before a major version turns them into breakage.
## What the old form did Since PHP 5, a parameter with a class type and a `null` default could receive `null`: ```php <?php function assignOwner(Customer $customer = null) { /* ... */ } ``` The engine treated the `null` default as permission to widen the type, so the real parameter type was `?Customer`. This is the **implicitly nullable** parameter. It predates `?T` (PHP 7.1) and union types (PHP 8.0), which is why so much older code uses it. ## Why PHP 8.4 deprecates it - **The declaration lies.** A reader sees `Customer`; the function accepts `null`. Tooling and humans have to know a special rule to read the signature correctly. - **Two concerns are fused.** "Optional" (has a default) and "nullable" (accepts null) are independent ideas; the implicit form couples them, so changing the default changes the type. - **Inheritance surprises.** The manual notes that if a child class changes the default, a type compatibility violation is raised because `null` then has to be added to the declared type. The message, in PHP 8.4 and 8.5: ```text Deprecated: assignOwner(): Implicitly marking parameter $customer as nullable is deprecated, the explicit nullable type must be used instead ``` ## The fixes | Old declaration | Replacement | Why | |---|---|---| | `Customer $c = null` | `?Customer $c = null` | same behaviour, explicit type | | `int\|string $key = null` | `int\|string\|null $key = null` | `?` cannot prefix a union | | `Customer $c = null, int $id` | `?Customer $c, int $id` | a default before a required parameter never made it optional | | `mixed $x = null` | unchanged | `mixed` already includes `null` | | `?Customer $c = null` | unchanged | already explicit | The third row deserves a note. A parameter with a default that is followed by a required parameter is effectively required anyway (PHP 8.1 made that explicit). A `null` default in that position was kept for a long time purely as the implicit-nullable trick, so the honest replacement drops the default and keeps the `?`. Constructor-promoted parameters were never implicitly widened, so a promoted `public Customer $owner = null` is a compile error rather than a deprecation; only ordinary parameters need the fix. ## Finding every site in a large codebase The deprecation is emitted **when a file is compiled**, not each time the function is called. That has two consequences: 1. A file that is never loaded in production — a rarely used admin screen, a CLI command — never logs it. 2. With an opcode cache serving already-compiled scripts, the notice appears when a file is compiled, not on each request, so log volume says little about how many sites exist. So do not plan the cleanup from runtime logs. Instead: - run a **static search** over the source (a static analyser or an automated code-style fixer can detect and rewrite the pattern); - make the change as **one mechanical commit** per package, separate from behaviour changes, so review is easy; - handle the **"default before required"** cases separately, since their fix removes the default, and run the **test suite** on the result; - add a **CI rule** so new implicit-nullable parameters cannot come back. ## A worked example A CRM service written years ago might contain: ```php <?php function logActivity(string $type, Customer $customer = null, array $meta = []): void {} function linkAccounts(Customer $primary = null, int $secondaryId): void {} ``` The first function becomes `?Customer $customer = null`: callers that omit the argument, pass `null` or pass a `Customer` all behave as before. The second function's `$primary` was never optional, because a required parameter follows it; callers always had to pass something, often `null`. It becomes `?Customer $primary` with no default. Both edits are pure declarations, so the diff is easy to review and the test suite confirms nothing else moved. ## Vendor code Dependencies installed with Composer may trigger the same deprecation. Those are fixed by upgrading the package, not by editing `vendor/`; until then, the notices are noise to filter by path, not a reason to lower the error-reporting level for your own code. ## Why not leave it A deprecation is PHP's advance notice that a later major version may remove the behaviour. When that happens, every remaining implicit-nullable parameter will stop accepting `null` or stop compiling. The fix is cheap now, mechanical and verifiable; later it becomes an upgrade blocker spread across every package that still uses it.
- In PHP 8.4+, why is counting 'Implicitly marking parameter' lines in production logs a poor measure of remaining work?The deprecation is raised when a file is compiled, not per call. Files never loaded in production never log it, and cached compiled scripts do not repeat it on each request. A static scan of the source finds every site, including rarely used code paths, and gives a stable count to track.
- In PHP 8.4, does rewriting Customer $c = null as ?Customer $c = null change behaviour for callers?No. Both accept a `Customer`, accept `null`, and may be omitted, with `$c` defaulting to `null`. Reflection reports the same nullable type. The only difference is that the explicit form no longer emits the deprecation, which is why the change can ship as a mechanical commit.
saying these in an interview costs you the question
- Believes Customer $c = null rejects null because the type says Customer.
- Plans the cleanup by counting deprecation lines in production logs.
- Fixes the deprecation by lowering error_reporting for the whole application.
- Thinks ?Customer $c = null behaves differently from the old implicit form.
- Keeps the null default on a parameter that is followed by a required parameter.