Object defaults in PHP 8.1
Until PHP 8.0, every default value had to be a constant expression, a new object instance as a default simply was not allowed. Since PHP 8.1, new can stand directly in initializers, in parameters, attribute arguments, and property declarations, making a whole catalog of workarounds unnecessary.
Table of Contents
- 1. The old restriction: default values had to be constant
- 2. What changes with PHP 8.1
- 3. Combining with constructor property promotion
- 4. Object instances as attribute arguments
- 5. Avoiding the null object workaround
- 6. Constraints: which expressions are allowed
- 7. Combining enum cases and constants in the new initializer
- 8. Visibility: private classes as defaults in public signatures
- 9. Practical example: dependency injection without a constructor body
- 10. Summary
- 11. FAQ
1. The old restriction: default values had to be constant
Before PHP 8.1, default values in parameter lists could only be constant expressions: literals, class constants, or combinations of those, but never a call to new. The reason was that default values had to be evaluated at compile time, and constructing an object is fundamentally a runtime operation with possible side effects.
Anyone who still wanted an object as a sensible default for a parameter, say an empty logger or a default configuration, had to fall back on null as a placeholder and explicitly check inside the method body whether the parameter was null, then construct an instance manually. That workaround was boilerplate repeated in every affected method.
This restriction did not just affect constructors, it applied equally to every function and method with optional parameters, regardless of whether it was a public API method or a private helper function. For libraries with many configurable collaborators, that boilerplate added up across dozens of classes into noticeable maintenance overhead.
2. What changes with PHP 8.1
With PHP 8.1, new itself becomes an allowed expression inside an initializer, as long as the constructor arguments are themselves constant expressions. That allows new NullLogger() or new DateInterval('P0D') directly as a parameter default, without the detour through null.
Technically this is not a single, shared instance created once at compile time, it is a fresh object construction on every individual call without an explicitly passed argument. Every call without an argument gets a fresh, independent instance, exactly as with manual construction inside the method body.
final class RequestHandler
{
public function __construct(
private LoggerInterface $logger = new NullLogger(),
) {
}
}
// A fresh NullLogger instance is created for every call without an explicit argument
$handler = new RequestHandler();
3. Combining with constructor property promotion
new in initializers becomes especially useful combined with constructor property promotion, where the default value and the property declaration already collapse into a single line. Instead of a separate line in the constructor body for fallback logic, the default value declaration in the parameter is enough.
That reduces the constructor body to zero extra lines of code in many cases, because both the assignment and the fallback creation are expressed entirely in the signature. For classes with several optional collaborators, that saving adds up noticeably.
final class OrderProcessor
{
public function __construct(
private ClockInterface $clock = new SystemClock(),
private CacheInterface $cache = new ArrayCache(),
) {
}
}
4. Object instances as attribute arguments
new in initializers does not just work for parameters and properties, it also works as an argument inside attributes. An attribute can therefore accept a fully configured object directly instead of only primitive values, for example a preconfigured cache policy or a structured validation rule.
That significantly extends the practical usefulness of attributes, because configuration no longer has to be expressed through nested arrays or strings that then get converted back into an object at runtime. Instead, the finished object already sits right in the attribute declaration itself.
#[Attribute]
final class Cacheable
{
public function __construct(public CachePolicy $policy = new CachePolicy(ttl: 300))
{
}
}
final class ProductController
{
#[Cacheable(new CachePolicy(ttl: 3600))]
public function show(int $id): Response
{
// ...
}
}
5. Avoiding the null object workaround
The classic workaround before PHP 8.1 looked nearly identical in every affected method: declare a parameter with a null default, check for null inside the method body, and construct an instance manually if needed. That pattern had to be repeated at every single place the parameter was used.
With new in initializers, that boilerplate disappears completely from the method body. The fallback logic lives exactly once, in the signature, and the rest of the code can rely on the parameter never being null, which also removes the need for a nullable type on the parameter itself.
// Before PHP 8.1: manual null check inside every method
public function handle(?LoggerInterface $logger = null): void
{
$logger = $logger ?? new NullLogger();
$logger->info('handled');
}
// PHP 8.1+: the fallback lives entirely in the signature
public function handle(LoggerInterface $logger = new NullLogger()): void
{
$logger->info('handled');
}
6. Constraints: which expressions are allowed
The arguments of the new call inside an initializer must themselves be constant expressions again: literals, class constants, enum cases, or nested new calls whose arguments are equally constant. A function call like new Logger(getenv('LOG_PATH')) is not allowed, because getenv is evaluated at runtime and is not a constant expression.
That restriction is deliberate: a default value should still be deterministic and evaluable without external dependencies, based solely on the source code itself. Anyone who needs actual runtime values for a default still has to rely on the classic null check inside the method body.
7. Combining enum cases and constants in the new initializer
Since enum cases are themselves constant expressions, they can be used without issue as an argument inside a new initializer, for example to construct a value object with a fixed enum value as its default. That connects two separate PHP 8.1 features into a single, very readable pattern.
Class constants of the current class or another one can be used as arguments in the same way, making default values configurable without scattering that configuration outside the class definition. An application can maintain its own default policies centrally as constants and still reference them directly in signatures.
8. Visibility: private classes as defaults in public signatures
One detail that is easy to miss: using a private or protected class as a default value in a public method is syntactically allowed, but often triggers a warning from static analysis tools, since the signature reveals a detail that should really stay implementation internal.
For public APIs, it is therefore worth limiting the default type to a publicly visible class or interface, even if the concrete default implementation could stay internal. PHPStan and Psalm reliably flag this kind of visibility inconsistency.
9. Practical example: dependency injection without a constructor body
In practice, the biggest benefit of new in initializers shows up in classes with several optional collaborators, ones that get replaced through a dependency injection container in production code but still need sensible defaults in tests or simple scripts. The constructor body stays entirely empty.
The result is a class fully configured through dependency injection in production, yet instantiable in a simple script or test with no extra configuration at all, because every optional collaborator already comes with a working default right in the signature.
final class ReportGenerator
{
public function __construct(
private ClockInterface $clock = new SystemClock(),
private LoggerInterface $logger = new NullLogger(),
private CacheInterface $cache = new ArrayCache(),
) {
}
}
// Works out of the box in a script or test, no container wiring needed
$report = new ReportGenerator();
| Situation | Before PHP 8.1 | Since PHP 8.1 with new in initializers |
|---|---|---|
| Object as default value | Not allowed, only constant expressions | Allowed, as long as constructor arguments are constant |
| Fallback logic | Manual null check inside the method body | Fully expressed in the signature |
| Nullable typing | Parameter had to be nullable | Parameter can stay non-nullable |
| Attribute arguments | Only primitive values and constants | Configured object instances directly possible |
| Runtime values as default | Not possible | Still not possible, only constant expressions |
| Combination with property promotion | Required a separate constructor body | One single line per parameter is enough |
Mironsoft
PHP modernization, code quality, and legacy refactoring
Grown PHP code nobody wants to touch anymore?
We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.
Legacy Refactoring
Modernize grown PHP code in a structured, low-risk way.
Establishing Code Quality
Anchor PHPStan, coding standards, and CI checks sustainably in the team.
Version Upgrades
Plan and execute PHP major version upgrades safely, without downtime.
10. Summary
new in Initializers
Core idea
new can stand directly as a default value since PHP 8.1, as long as the constructor arguments are constant.
Benefit
The null object workaround with a manual null check in the method body becomes fully unnecessary.
Extension
Attribute arguments may also carry configured object instances instead of only primitive values.
Limit
The arguments of the new call must themselves stay constant expressions, no runtime function calls.