The spread operator for associative arrays since PHP 8.1
Up through PHP 8.0, the spread operator in array literals only worked with numeric keys, associative arrays still had to be merged with array_merge. Since PHP 8.1, spread syntax supports string keys directly. We cover the difference from the classic numeric spread, collision behavior, and when switching away from array_merge actually pays off.
Table of Contents
- 1. The difference from the classic numeric spread operator
- 2. Reindexing behavior: numeric keys get reassigned
- 3. Practical use: merging configuration arrays
- 4. Collision behavior with duplicate string keys in detail
- 5. Direct comparison to array_merge: syntax and semantics
- 6. Only one level: spread is not a deep merge
- 7. Performance comparison: spread versus array_merge
- 8. Spread as a function argument versus spread in an array literal
- 9. Decision guide: when to use spread, when array_merge
- 10. Summary
- 11. FAQ
1. The difference from the classic numeric spread operator
The spread operator ... in array literals was introduced back in PHP 7.4, but initially worked only with numerically indexed arrays. If one of the arrays being unpacked contained a string key, PHP up through version 8.0 threw a TypeError at runtime. Until then, array_merge() remained the only tool for associative arrays, which led to an awkward mix of two different syntax styles in code that combined both numeric and associative structures.
PHP 8.1 lifted that restriction. String keys are now allowed to appear during unpacking inside array literals, and their behavior is deliberately modeled on array_merge(), not on the numeric spread behavior. That deliberate alignment is key to understanding the collision rules covered in more detail later in this article.
// Numeric spread, possible since PHP 7.4
$a = [1, 2, 3];
$b = [0, ...$a, 4]; // [0, 1, 2, 3, 4], order preserved
// String-key spread, possible only since PHP 8.1
$defaults = ['timeout' => 30, 'retries' => 3];
$overrides = ['retries' => 5];
$config = [...$defaults, ...$overrides]; // ['timeout' => 30, 'retries' => 5]
2. Reindexing behavior: numeric keys get reassigned
A central difference between numeric and string-key unpacking concerns how the keys themselves are handled. Numeric keys are always reassigned during unpacking, regardless of their original position in the source array, exactly as would happen with a manual foreach loop using [] =, but unlike what a simple array combination with the + operator would do.
String keys, by contrast, are preserved exactly as defined in the source array during unpacking. That's consistent with how array_merge() behaves, where associative keys are likewise carried over unchanged while purely numeric keys are renumbered there too. Anyone unpacking an array with mixed keys should be aware of this different handling for the two key types to avoid surprises.
$source = [5 => 'a', 'name' => 'Anna', 10 => 'b'];
$result = [...$source];
var_dump($result);
// [0 => 'a', 'name' => 'Anna', 1 => 'b']
// numeric keys (5, 10) get reassigned (0, 1)
// the string key "name" is preserved exactly
3. Practical use: merging configuration arrays
The most obvious use case for string-key unpacking is merging configuration arrays, for example a module's default values with project-specific overrides or environment-dependent settings. Spread syntax makes it immediately visible which array takes priority, without having to look up the argument order in a separate function signature the way array_merge() requires.
This becomes especially valuable for multi-tier configuration hierarchies, for example base configuration, environment configuration, and runtime overrides combined in a single expression. The left-to-right reading order maps exactly to priority from low to high, which noticeably improves readability compared to nested array_merge() calls with multiple arguments.
function buildConfig(array $baseConfig, array $envConfig, array $runtimeOverrides): array
{
return [
...$baseConfig,
...$envConfig,
...$runtimeOverrides, // highest priority, listed last
];
}
$config = buildConfig(
baseConfig: ['cache' => true, 'debug' => false],
envConfig: ['debug' => true],
runtimeOverrides: ['cache' => false],
);
// ['cache' => false, 'debug' => true]
4. Collision behavior with duplicate string keys in detail
When the same string key appears in multiple unpacked arrays inside one literal, the value from the array listed later consistently wins, exactly like array_merge(), where later arguments override earlier ones. This rule applies regardless of the order within each source array itself, what matters is exclusively the position of the spread expression in the target literal.
A detail that's often overlooked in practice: this override rule applies strictly sequentially from left to right, even when additional, directly written key-value pairs sit between two spread expressions. A pair written directly in the literal is evaluated at exactly the position where it appears in the code, and can therefore be overridden by both a preceding and a following spread.
$a = ['level' => 'info', 'channel' => 'app'];
$b = ['level' => 'debug'];
$merged = [...$a, 'level' => 'warning', ...$b];
// ['level' => 'debug', 'channel' => 'app']
// 'warning' is immediately overridden by $b['level'] = 'debug',
// because $b is unpacked last
5. Direct comparison to array_merge: syntax and semantics
Semantically, string-key spread and array_merge() behave identically for the plain merging case: associative keys get overridden, numeric keys get renumbered. The difference lies mainly in syntax and placement flexibility. Spread syntax lets you mix unpacked arrays with individual, directly written key-value pairs inside the same literal, which with array_merge() would require an extra, awkward single-element array literal as an argument.
A relevant difference concerns null values inside the arrays being unpacked: spread treats them like any other value and carries them over unchanged, just like array_merge() does. If you want to filter out null values during a merge instead, for example to distinguish genuine value overrides from mere placeholders, you have to handle that explicitly via array_filter() before the actual merge in both cases.
6. Only one level: spread is not a deep merge
A common pitfall when merging configuration arrays is assuming that spread merges nested arrays recursively. In reality, both the spread operator and array_merge() only operate on the top level. If a key exists in both source arrays and itself holds an array as its value, the array value unpacked later completely overwrites the earlier one instead of merging their inner keys individually.
For genuinely recursive merging, array_merge_recursive() remains the appropriate function, though it shows its own, often surprising behavior for numeric keys, since it combines values into a new array instead of overwriting them. Anyone wanting to merge nested configuration structures with spread should either explicitly unpack each nesting level themselves or write a dedicated, clearly documented merge function for the specific use case.
$defaults = ['db' => ['host' => 'localhost', 'port' => 3306]];
$overrides = ['db' => ['port' => 3307]];
$result = [...$defaults, ...$overrides];
// ['db' => ['port' => 3307]]
// the entire "db" sub-array was replaced, "host" is lost!
// Explicitly merging the nested level:
$result = [
...$defaults,
'db' => [...$defaults['db'], ...$overrides['db']],
];
// ['db' => ['host' => 'localhost', 'port' => 3307]]
7. Performance comparison: spread versus array_merge
In benchmarks with realistic configuration arrays, the performance difference between string-key spread and array_merge() sits in the low single-digit percentage range and is irrelevant for the vast majority of use cases. Both mechanisms internally perform a comparable copy operation via copy-on-write semantics, the spread operator merely saves the function-call overhead of array_merge() itself.
The difference only becomes relevant with very frequently repeated merges inside hot code paths, for example building request contexts inside a loop with thousands of iterations. Here the spread operator shows a slight but measurable advantage because the function-call overhead disappears. In practice, though, the choice between the two approaches should primarily be driven by readability, not a marginal performance difference that's only visible under extreme conditions.
// Both variants are semantically identical:
$mergedA = array_merge($defaults, $overrides);
$mergedB = [...$defaults, ...$overrides];
// array_merge wins for a variable argument count from one array:
$allConfigs = [$base, $env, $runtime];
$merged = array_merge(...$allConfigs); // spread AS a function argument
8. Spread as a function argument versus spread in an array literal
An important distinction, easily confused with the topic covered here, concerns the spread operator used as a function argument, for example array_merge(...$configs), where $configs is an array of several config arrays. That unpacks the outer list into individual function arguments, regardless of whether the inner arrays themselves contain numeric or string keys, because here the semantics of the called function apply, not those of array-literal unpacking.
These two use cases, spread in an array literal and spread as a function argument, share the same syntax symbol but follow different rules. For function calls with variadic parameters, there's the additional point that string keys in the unpacked array have been interpreted as named arguments since PHP 8.1, which is a separate topic with its own pitfalls, going beyond plain array merging.
9. Decision guide: when to use spread, when array_merge
For array literals with a fixed, code-visible number of arrays to combine, spread syntax is almost always the more readable choice, because it mixes seamlessly with directly written individual values and visually maps source priority from left to right. That especially applies to configuration merges with two to four sources, as commonly found in dependency-injection contexts or when building HTTP client options.
array_merge() remains the right choice when the number of arrays to combine is variable at runtime and itself lives in an array, for example a dynamic list of plugin configurations of unknown length. In that case the spread operator can still be used inside the array_merge() function call itself, but plain array-literal unpacking with a fixed number of ... expressions isn't syntactically suited for that.
| Feature | Spread (string keys, PHP 8.1+) | array_merge() | Spread (numeric, PHP 7.4+) |
|---|---|---|---|
| Numeric keys | Renumbered | Renumbered | Renumbered |
| String keys | Preserved, last wins | Preserved, last wins | Not supported (before 8.1: TypeError) |
| Mixable with literal values | Yes, directly in same array | No, requires wrapper array | Yes, directly in same array |
| Variable argument count | Not directly, fixed expressions | Yes, via spread as argument | Not directly, fixed expressions |
| Performance | Marginally faster, no function call | Marginally slower, function-call overhead | Marginally faster, no function call |
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
Array unpacking with string keys: the essentials at a glance
What's new
Since PHP 8.1, the spread operator supports string keys directly in array literals.
Semantics
String-key spread behaves like array_merge, later values override earlier ones.
Practice
Ideal for readable configuration hierarchies with a fixed, small number of sources.
Limits
For a variable argument count from an array, array_merge with a spread argument is still required.