The go-to resource for learning PHP, Laravel, Symfony, and your dependencies.

Handling Named Arguments in PHP 8.0+ Upgrades


In the Serengeti, young elephants learn migration routes from their matriarchs — which water sources to trust during drought, which paths avoid predators, which landmarks signal safety. When drought strikes and landscapes shift, that accumulated memory becomes critical. Lose that knowledge, and a herd can wander for days without water, vulnerable to threats.

When you upgrade a PHP application to version 8.0 or newer, you face a similar challenge. Your codebase carries with it accumulated patterns — some that remain safe, others that will fail in production. Named arguments, introduced in PHP 8.0, offer a powerful new way to call functions, but they expose assumptions in legacy code that rely on positional ordering or functions like func_get_args(). A function call that worked for years can suddenly return empty arrays, throwing errors you never saw in testing.

This guide serves as that migration memory. We’ll show you how to identify which of your existing functions depend on positional argument handling, how to refactor them using modern PHP patterns, and how to verify your changes work correctly before they reach production.

Prerequisites

Before you begin working through this guide, you should have:

  • A PHP codebase running PHP 7.4 or earlier (or you should be planning an upgrade to PHP 8.0+)
  • Basic familiarity with PHP function signatures and parameter handling
  • Access to run grep or similar search tools on your codebase
  • A testing strategy in place (unit tests, integration tests, or manual verification process)

If you’re working on a large codebase, we recommend reading through this article once to understand the scope of the problem before making any changes.

Historical Context

Named arguments arrived in PHP 8.0 as part of a broader set of language improvements that included constructor property promotion, union types, and the nullsafe operator. PHP had, for most of its history, focused on ease of use and flexibility — sometimes at the cost of strictness or clarity.

Originally, PHP functions accepted arguments purely by position. If you wanted to skip an optional parameter, you had to pass all preceding parameters. The language provided func_get_args(), func_get_arg(), and func_num_args() as workarounds, allowing developers to create functions that accepted variable numbers of arguments — though at the cost of clarity and type safety.

Over time, PHP evolved. PHP 5 introduced better object-oriented features. PHP 7 brought performance improvements and type declarations. PHP 8.0, released in November 2020, represented a significant milestone with features like named arguments that made PHP code more explicit and self-documenting.

However, this progress introduced compatibility challenges. Code that relied on func_get_args() or assumed strict positional ordering could break when called with named arguments. Understanding this history helps you see why certain legacy patterns exist and how to transition them to modern approaches.

What are Named Arguments?

Strictly speaking, PHP has always allowed passing arguments in order — but PHP 8.0 gave us the option to pass them by name as well. Before this change, we passed arguments to functions based on their position alone. Consider a typical function like this:

function createUser($name, $email, $isAdmin = false) {
    // function body
}

// Positional arguments
createUser('John Doe', 'john.doe@example.com', true);

With named arguments — a feature introduced in PHP 8.0 — we can specify the argument name explicitly. This makes the order irrelevant and the code more self-documenting. Here’s how we would call the same function using named arguments:

// Named arguments
createUser(name: 'John Doe', email: 'john.doe@example.com', isAdmin: true);

// Order doesn't matter
createUser(isAdmin: true, name: 'John Doe', email: 'john.doe@example.com');

// We can skip optional parameters
createUser(name: 'Jane Doe', email: 'jane.doe@example.com');

Named arguments are particularly valuable when you’re working with functions that have many optional parameters, such as those in database abstraction layers or HTTP clients. They’re also helpful when you or your colleagues read code later — you can immediately see what each value represents without referring back to the function signature.

Of course, there’s a catch: named arguments can expose assumptions in legacy code that rely on positional ordering or introspection functions like func_get_args(). We’ll explore that challenge next.

The Upgrade Challenge: func_get_args() and Friends

The most significant backward compatibility issue with named arguments arises from functions that use func_get_args(), func_get_arg(), and func_num_args(). These functions were often used in older PHP versions to create functions that accept a variable number of arguments before the spread operator (...) became widespread.

When a function is called with named arguments, the values passed via named arguments do not affect func_get_args(). Let’s look at an example.

Consider this function from a legacy codebase:

function logMessage() {
    $args = func_get_args();
    $message = array_shift($args);
    $context = $args;
    // log message with context
}

In PHP 7, you might call it like this:

logMessage('User logged in', ['user_id' => 123]);

func_get_args() would return ['User logged in', ['user_id' => 123]].

Now, imagine after upgrading to PHP 8, a developer calls this function using named arguments for clarity:

logMessage(message: 'User logged in', context: ['user_id' => 123]);

This will not work as expected. Since logMessage has no defined parameters, PHP treats this as a call with zero positional arguments. func_get_args() will return an empty array, and the log message will be lost.

The problem is even more subtle if the function has defined parameters.

function processData($data, $options = []) {
    $allArgs = func_get_args();
    // ... complex logic based on all passed arguments
}

If you call this with processData(data: $myData, options: $myOptions), func_get_args() will only contain $myData and $myOptions if they are passed positionally. If passed by name, func_get_args() behavior changes.

Specifically, named arguments are not populated in func_get_args().

Finding and Fixing the Issues

The first step in addressing these issues is to find all occurrences of func_get_args(), func_get_arg(), and func_num_args() in your codebase. You can use a simple grep command for this:

$ grep -r "func_get_arg" .
src/legacy/Logger.php:function logMessage() {
src/legacy/Logger.php:    $args = func_get_args();
src/legacy/DataProcessor.php:function processData($data, $options = []) {
src/legacy/DataProcessor.php:    $allArgs = func_get_args();

Of course, the exact paths and results you see will vary depending on your project structure. Once you have identified the functions, you need to refactor them. The best approach is to replace the reliance on these functions with modern PHP features — though your specific approach will depend on how each function is used.

Refactoring Strategies

There are several approaches to refactoring functions that use func_get_args() and related functions. Which strategy you choose depends on how the function is used and whether you need to maintain backward compatibility.

Approach 1: Explicit Parameters (Most Robust)

If you can identify the specific parameters that are passed, the safest approach is to define them explicitly:

// Before (PHP 7.x)
function logMessage() {
    $args = func_get_args();
    $message = array_shift($args);
    $context = $args;
    // log message with context
}

// After (PHP 8.0+)
function logMessage(string $message, array $context = []) {
    // log message with context
}

// This works with both positional and named arguments:
logMessage('User logged in', ['user_id' => 123]);           // positional
logMessage(message: 'User logged in', context: ['user_id' => 123]); // named

This approach is the most maintainable and works seamlessly with named arguments, type hints, and IDE autocompletion.

Approach 2: Variadic Parameters

If your function genuinely accepts a variable number of arguments of the same type, use the spread operator:

// Before
function sum() {
    return array_sum(func_get_args());
}

// After
function sum(float ...$numbers): float {
    return array_sum($numbers);
}

// Usage
sum(1.5, 2.5, 3.5);  // works
sum(...[1.5, 2.5, 3.5]);  // also works with unpacking

Note that variadic parameters must be passed positionally; you cannot pass a named argument with the same name as a variadic parameter. For example, sum(numbers: [1, 2, 3]) will not work as expected — you’d need sum(...[1, 2, 3]) instead.

Approach 3: Mixed Parameters

Often, you’ll have one or more required parameters followed by optional variadic ones:

function log(string $level, string $message, mixed ...$context) {
    // ...
}

log('INFO', 'User logged in', ['user_id' => 123]);

Approach 4: Argument Unpacking for Configuration

If your function accepts an associative array of options, you can use argument unpacking with named arguments:

function sendEmail(
    string $to,
    string $subject,
    string $body,
    string $from = 'noreply@example.com',
    bool $html = false
) {
    // ...
}

$options = [
    'to' => 'user@example.com',
    'subject' => 'Welcome!',
    'body' => 'Hello there!',
    'html' => true
];

sendEmail(...$options);  // unpacks as named arguments

This provides a clean way to pass configuration arrays to functions that expect named arguments.

How to Choose

Generally, we recommend:

  1. Use explicit parameters when you know the expected arguments. This is the most maintainable and self-documenting approach.
  2. Use variadics only when the number of arguments is genuinely variable and homogeneous (e.g., all numbers or all strings).
  3. Consider your API’s stability — changing function signatures is a breaking change for positional callers, though not for named argument callers if you use default values appropriately.

Verification and Testing

After you’ve refactored your functions, you need to verify that they work correctly with both positional and named arguments. Here’s how we recommend approaching this:

1. Manual Verification

For each refactored function, try calling it both ways:

// Positional arguments
logMessage('Test message', ['key' => 'value']);

// Named arguments  
logMessage(message: 'Test message', context: ['key' => 'value']);

Check that both calls produce the same result. Of course, if your function has changed semantics significantly during refactoring (e.g., you removed variadic behavior), the two forms may not be equivalent — but they should both work without errors.

2. Automated Testing

If you have a test suite, run it against your refactored code. Ideally, you should:

  • Add specific tests for the refactored functions
  • Test edge cases: empty arrays, missing optional parameters, mixed argument styles
  • Use both positional and named argument calls in your tests

You can use PHPUnit’s data provider feature to test multiple calling conventions:

/**
 * @dataProvider logMessageProvider
 */
public function testLogMessage($callable, $expected) {
    $this->assertEquals($expected, $callable());
}

public function logMessageProvider() {
    return [
        [fn() => logMessage('msg', ['id' => 1]), 'expected'],
        [fn() => logMessage(message: 'msg', context: ['id' => 1]), 'expected'],
    ];
}

3. Static Analysis

Tools like PHPStan and Psalm can help identify potential issues. They may not catch all named argument edge cases, but they can verify that your function signatures are consistent and that default values are used correctly.

$ phpstan analyse src/
$ psalm src/

4. Integration Testing

If you’re working in a larger application, run integration tests that exercise the refactored functions in their actual usage context. This is especially important if the functions are called from many places throughout your codebase.

5. Code Review

Have colleagues review your changes. They might spot edge cases you missed — for instance, functions that rely on func_get_args() in ways that aren’t immediately obvious, like checking argument count before processing.

Troubleshooting

Even with careful refactoring, you might encounter issues. Here are common problems and their solutions.

”Too many arguments” Error

Symptom: After refactoring, you get “Too many arguments to function” errors.

Cause: You likely removed a variadic parameter or changed parameter order. Named arguments require that all named parameters match the function signature exactly.

Solution: Ensure your function signature includes all expected parameters with appropriate default values. If you need truly variable arguments, use a variadic parameter: function foo(string $first, mixed ...$rest).

Unexpected null Values in func_get_args()

Symptom: func_get_args() returns an empty array or missing values when called with named arguments.

Cause: This is the core issue we’re addressing. func_get_args() does not include named arguments in its result — by design in PHP 8.0+.

Solution: Refactor the function to use explicit parameters, as described earlier. There’s no way to make func_get_args() work with named arguments without changing the function signature.

Backward Compatibility with PHP 7.x

Symptom: Your refactored code needs to run on both PHP 7.x and PHP 8.0+.

Cause: Named arguments only exist in PHP 8.0+, but explicit parameters and variadics work in both versions.

Solution: Stick to explicit parameters and variadics. Both approaches are fully backward compatible. Avoid using named arguments in your own code if you need to support PHP 7.x; but the refactored functions themselves will work in both versions.

Performance Concerns

Symptom: You’re concerned about performance impact of changing function signatures.

Cause: Some developers wonder if named arguments or explicit parameters affect performance compared to func_get_args().

Solution: In practice, explicit parameters and variadics are generally as fast or faster than func_get_args(), which has some overhead. The PHP engine optimizes explicit parameters well. Performance differences are typically negligible; focus on correctness and maintainability first.

Conclusion

Named arguments represent a significant addition to PHP, making code more readable and maintainable for many use cases. However, like any major language feature, they can cause issues when you’re upgrading older codebases. The main culprit is the interaction with legacy functions like func_get_args().

To ensure a smooth upgrade to PHP 8.0 and beyond:

  • Audit your codebase for func_get_args(), func_get_arg(), and func_num_args().
  • Refactor these functions to use modern variadics (...) and explicitly defined parameters.
  • Embrace named arguments in your new code to improve clarity and reduce errors.

By proactively addressing these potential pitfalls, you can take full advantage of the power of named arguments and make your PHP codebase more robust and modern.

Sponsored by Durable Programming

Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.

Hire Durable Programming