A PHP array is an ordered map from integer or string keys to arbitrary values. The same runtime type represents both lists and dictionary-like records.
Keys are normalized, unset() does not reindex integers, and isset() cannot distinguish a missing key from a null value. These rules let valid code silently change data shape.
Decide whether the data must be a list or map, use array_key_exists() for nullable fields, and immediately unset() a by-reference foreach variable.
What it is and why it exists
A PHP array is an ordered map . Each element pairs an integer or string key with a value, and iteration follows insertion order. Values may be scalars, objects, resources, or other arrays, so one container can represent a list, lookup table, or nested record.
PHP does not use separate runtime types for lists and associative maps. An array whose keys are consecutive integers starting at 0 is a PHP list ; an array with gaps or string keys is still an array, but not a list. array_is_list() checks this shape, not the element types.
This design fits request parameters, configuration, database rows, and short-lived data transformations. You can add fields incrementally or look up elements by business identifier without declaring a fixed size. That flexibility also means code or a static analyzer must constrain field spelling, key types, and list shape.
Domain data with fixed fields and invariants is often clearer as a class, enum, or readonly object. Repeated removal from the front, priority queues, and streaming data also have better-fitting SPL structures or generators. An array can fill many roles, but it does not express every role equally well.
How it works
Array literals use [], and a keyed element uses key => value. When the key is omitted, PHP assigns an integer key. Explicit string keys often represent record fields, while explicit integers may represent business identifiers or sparse positions.
$colors = ['red', 'green']creates a list.$profile = ['name' => 'Mina', 'email' => null]creates a map with field names.$states = [10 => 'draft', 20 => 'review']creates a map with nonconsecutive integer keys.
All three values have the runtime type array. Only $colors has list shape. $profile uses field names, and $states does not have consecutive integer keys starting at 0.
Literal syntax alone does not declare an array’s role. The caller’s interpretation of its keys determines whether it is a positional sequence, business map, or temporary record.
Keys and insertion order
An array key ultimately has type int or string. Some other inputs undergo array-key normalization : a valid decimal integer string may become an integer, a Boolean becomes 0 or 1, and null becomes the empty string. If two normalized keys are equal, the later write replaces the earlier value.
An unkeyed append, $array[] = $value, uses the next integer key recorded by the array. It does not search for the smallest available position to fill a gap. Removing an element also does not move the other elements, so the key set and the element count are separate facts.
Arrays retain insertion order; they do not automatically sort by key. Overwriting an existing key replaces its value without moving the element to the end. Sorting and reindexing require explicit array functions, whose key contracts belong in php/array-functions.
Access and existence
Reading $data[$key] requires the key to exist. Otherwise PHP raises an Undefined array key warning and produces null. External input commonly omits fields, so direct indexing should follow validation or an explicit data contract.
$data[$key] ?? $fallback avoids a warning for a missing key and works well when both missing and null mean “use the default.” The operator uses the same existence semantics as isset(), so it cannot preserve the distinction between an existing null value and no key.
array_key_exists($key, $data) tests only whether the key exists, returning true even when its value is null. isset($data[$key]) requires the key to exist and its value not to be null. State the domain rule first and choose the matching check; the two forms are not interchangeable.
Mutation and iteration
Bracket assignment adds or replaces an element, $array[] appends, and unset($array[$key]) removes a key-value pair. count() returns the current number of elements; it does not guarantee that valid keys range from 0 to count() - 1. Only a list can safely use that range for positional access.
foreach ($array as $key => $value) reads keys and values in insertion order. By default, $value receives the current element value, and rebinding it does not update the array. Writing &$value enables in-place changes, but it creates a reference that must be cleared after the loop.
Array assignment has value semantics: after $copy = $original, changing the structure of $copy does not change $original. PHP internally delays the physical copy with copy-on-write , but that optimization does not alter the observable independent-value semantics. Only an explicit & makes two variables aliases for the same array.
| Operation | Resulting keys | Changes original array |
|---|---|---|
$array[$key] = $value | Adds or overwrites the given key | Yes |
$array[] = $value | Uses the next integer key | Yes |
unset($array[$key]) | Removes the key without reindexing others | Yes |
$copy = $array | Retains all keys and order | No |
array_values($array) | Returns a new list starting at 0 | No |
Examples
The four examples build from maps and nullable fields to list shape and value semantics. Every output was produced with the local PHP 8.3.33 CLI.
Build an inventory map with business keys
A product code is a business identifier, not a position. Making it the key directly expresses which product a lookup or update targets, while foreach still emits insertion order.
<?php
$inventory = [
'BK-101' => ['title' => 'PHP Patterns', 'stock' => 3],
'BK-205' => ['title' => 'Web APIs', 'stock' => 0],
];
$inventory['BK-330'] = [
'title' => 'Testing Services',
'stock' => 5,
];
$inventory['BK-101']['stock'] -= 1;
foreach ($inventory as $sku => $book) {
printf("%s | %s | %d\n", $sku, $book['title'], $book['stock']);
}BK-101 | PHP Patterns | 2
BK-205 | Web APIs | 0
BK-330 | Testing Services | 5Updating BK-101 does not change its position in iteration order. A stock value of 0 also remains an ordinary element. Whether the product is sold out is a property of the value, separate from key existence.
A plain list would require the caller to search for the code before updating. The map shape makes uniqueness and direct lookup possible, but code must still validate that external data really supplies unique codes.
Distinguish a missing field from a null value
The email field is explicitly set to null, while phone is absent. array_key_exists() preserves this distinction, but isset() and ?? send both states down the same branch.
<?php
$profile = [
'name' => 'Mina',
'email' => null,
];
$fields = ['email', 'phone'];
foreach ($fields as $field) {
printf(
"%s key=%s isset=%s value=%s\n",
$field,
array_key_exists($field, $profile) ? 'yes' : 'no',
isset($profile[$field]) ? 'yes' : 'no',
$profile[$field] ?? 'fallback',
);
}email key=yes isset=no value=fallback
phone key=no isset=no value=fallbackIf null means that the user deliberately hid their email, access code should first call array_key_exists() and then handle the value. If the product rule treats both states as “no email,” ?? is the concise and accurate expression.
The code never directly reads $profile['phone'], so it raises no Undefined array key warning. Safe access is not one fixed idiom; it means matching the check to the missing-value policy.
Observe list shape after deletion
Calling unset() on the middle of a list leaves a key gap. A later append uses the next integer key rather than filling the deleted key 1.
<?php
$workflow = ['draft', 'review', 'publish'];
unset($workflow[1]);
$workflow[] = 'archive';
echo json_encode($workflow), PHP_EOL;
echo array_is_list($workflow) ? "list\n" : "map\n";
$steps = array_values($workflow);
echo json_encode($steps), PHP_EOL;
echo array_is_list($steps) ? "list\n" : "map\n";{"0":"draft","2":"publish","3":"archive"}
map
["draft","publish","archive"]
listThe first value encodes as a JSON object because its integer keys are not consecutive. array_values() explicitly discards the old keys and creates a new list, so the third line is a JSON array.
Reindex only when keys truly represent positions. If they are order numbers or external identifiers, array_values() deletes information. A better JSON design may retain object shape or put the identifier into a field on each element.
Modify a copy in a by-reference loop
Ordinary assignment creates an independent array value. A by-reference loop can change every amount in the copy, but the loop variable must be detached afterward.
<?php
$prices = [
'BK-101' => 2500,
'BK-205' => 1800,
];
$discounted = $prices;
foreach ($discounted as &$cents) {
$cents -= 500;
}
unset($cents);
$cents = 9999;
echo json_encode($prices), PHP_EOL;
echo json_encode($discounted), PHP_EOL;{"BK-101":2500,"BK-205":1800}
{"BK-101":2000,"BK-205":1300}The original array is unchanged, proving that $discounted is not an alias of $prices. unset($cents) only detaches the variable from the final element; it does not delete an array element. The later assignment to $cents therefore cannot corrupt $discounted.
If an element is an object, copying the array does not clone that object. Corresponding elements in both arrays can still refer to the same object. Code that needs an independent object graph must define an explicit copy policy instead of confusing array value semantics with a recursive deep copy.
Pitfalls
Array mistakes often do not throw immediately. Keys and values may look plausible until serialization, nullable fields, or a later traversal exposes the shape change.
Treating isset() as a key check
Fix: use array_key_exists('email', $row) when those states differ, then inspect the value separately. Use isset() only when “exists and is not null” is exactly the condition you need.
Assuming unset() compacts indexes
Fix: use foreach when you do not need positions. When you genuinely need a new list, call array_values() at a clear boundary where discarding the old keys is intentional.
Ignoring collisions after key normalization
Fix: validate and normalize identifier formats before insertion. If a numeric string must retain textual identity, use an explicit nonnumeric prefix such as user:1, and test collision inputs.
Leaving a by-reference loop variable attached
Fix: write unset($item) immediately after a by-reference loop and keep the loop’s scope short. When keyed assignment is clear, prefer $items[$key] = ... to avoid creating a long-lived reference.
Treating an array copy as a deep copy
Fix: decide whether the container, nested arrays, or the whole object graph needs copying. Explicitly clone or reconstruct objects that require independent identity; do not use a serialization round trip as an undesigned general-purpose deep copy.
Key normalization and list shape
Most of PHP array’s flexibility is concentrated in its key rules. Understanding normalization on write and the later list test explains how overwriting, appending, and JSON shape affect one another.
Which keys are converted
Integers and strings can be keys directly. A valid decimal integer string without a leading + becomes an integer, so '8' and 8 address the same key. '08' is not converted by this rule and remains a string key. Nonnumeric strings with different letter case remain distinct keys.
Boolean keys convert to integers, so false collides with 0 and true with 1. null converts to the empty string and may overwrite an explicit '' key. A float key converts to an integer by truncating its fractional part; since PHP 8.1, a precision-losing conversion produces a deprecation diagnostic, so floats should not be designed as array keys.
Arrays and objects cannot be used directly as array keys; attempting it raises TypeError. When dynamic identifiers come from JSON, forms, or a database, choose the application-level key format and validate it consistently. Relying on implicit conversion hides the collision policy in language details.
Overwriting does not change position
Writing a key that already exists after normalization replaces its value but preserves its original iteration position. Only a new key puts an element at the end of insertion order. Key sorting and insertion order have no automatic connection.
This behavior gives configuration overrides stable order, but it can also hide duplicate input. If duplicates should be errors, the final array is too late to detect them. Validate while parsing before overwriting happens, or preserve the original entry list for duplicate checks.
A list is a shape constraint
array_is_list($array) returns true for an empty array and when the keys are exactly 0, 1, ..., count($array) - 1 in that order. Element values and types do not affect the result. A string key '0' was normally normalized to integer 0 when inserted.
List shape is an implicit requirement at many boundaries. Positional loops, tuple-like destructuring, and JSON arrays depend on consecutive integer keys, while business maps often need nonconsecutive identifiers or string keys. Variable names and return documentation should make that contract visible.
Append uses an internal counter
$array[] = $value relies on a next-integer-key counter rather than scanning from the beginning for a gap. Removing keys normally does not move that counter backward, so appending after deletion continues with a larger key.
Explicitly writing a large integer key also influences later appends. Mixing business identifiers with unkeyed appends makes new keys depend on write history. A record map should normally use explicit business keys consistently.
JSON exposes shape differences
json_encode() encodes only a PHP list as a JSON array. An array with string keys or nonconsecutive integer keys becomes a JSON object, where integer keys appear as string property names.
The encoder is not arbitrarily changing the data; JSON has only array and object container shapes. API tests should assert the decoded structure or complete JSON rather than merely searching for values. If the boundary requires a list, call array_values() explicitly and verify that the discarded keys carry no business meaning.
Value semantics, references, and iteration
Array variables behave as independent values by default, but elements may hold shared objects and explicit references can change the default relationship. A copy review must discuss the outer container, nested arrays, and object identity separately.
Copy-on-write is an implementation strategy
PHP need not copy all storage immediately on every array assignment. Two variables may temporarily share an internal representation until one variable writes, at which point the runtime separates the array being changed. That is copy-on-write.
Application code should depend on value semantics, not guess how long storage remains shared. Micro-optimizations should not turn internal reference counts into an API contract or call copying “free.” Without measurement, there is no basis for a performance claim about a particular array size.
Nested values are not an independent object graph
Scalars and nested arrays inside an array still follow value semantics, so changing a nested array in the copy does not automatically alter the corresponding nested array in the original. Object elements hold object handles; after copying the outer array, both handles still target the same object unless it is explicitly cloned.
Resources also represent external entities rather than pure values that an array copy can duplicate. Copying a container holding a file handle or connection does not create a second independent resource. Such data needs an explicit owner and close policy.
References change ownership relationships
$alias =& $array makes both variables refer to the same array variable, so a write through either name is observable through the other. An & on a function parameter likewise lets the callee modify the caller’s variable. That should be a public and necessary API contract.
An array can also contain reference elements, linking one slot to an outside variable. This shape is difficult to see in a type declaration and makes copying and iteration harder to reason about. Unless the interface specifically requires aliasing, returning a new value is usually easier to test.
foreach value variables and reference variables
The by-value form assigns the current element to the loop variable. Rebinding that variable does not write back to a scalar element, although mutating a property through an object handle still changes that same object.
The by-reference form binds the loop variable to the current slot, so assignment directly updates the array. The loop ending does not detach the final binding, which causes the lingering-reference trap. The fixed idiom is to call unset($value) immediately after the loop rather than cleaning it up much later.
Adding or removing elements during traversal increases the control-flow burden, and by-value and by-reference forms may observe changes differently. Unless the algorithm truly needs structural mutation during iteration, build a result array or collect the keys to update before making changes.
Runtime types and static shapes
The native array function type says only that a value is an array; it cannot express specific key and value types. array<string, int>, list<string>, and array-shape syntax normally belong to PHPDoc and static-analysis tools such as PHPStan or Psalm. They cannot be substituted directly for a native runtime parameter type.
Generated code often puts a documentation type in a native type position or assumes that writing array proves every field exists. A reliable boundary combines the native array type, input validation, precise PHPDoc, and tests. When the fields remain stable, a value object is often clearer than an ever-growing array shape.
Designing clear array boundaries
Arrays most easily lose their constraints at system boundaries. The input layer must verify keys and values, and the return layer must declare list or map shape. Only then can the middle of the program rely on a stable data contract.
Validate before reading
For a request body or decoded JSON value, is_array() alone is insufficient. Code must also check that required keys exist, values have the right types, and unknown keys are rejected, ignored, or preserved according to policy. A series of ?? defaults can disguise a misspelling as an ordinary omission.
A nullable field needs a two-stage test: first use array_key_exists() to determine whether it was supplied, then test for null or the target type. This can report “field absent” separately from “field explicitly empty” and avoids warnings from direct indexing.
Declare the return shape
A native return type of array still does not tell a caller whether to consume by position or by key. The function name, PHPDoc, and tests should jointly specify a list<Order>, an array<string, Order>, or an array shape with fixed fields.
Calling array_values() casually before returning may make the type look tidier while deleting business identifiers. Conversely, leaking gap-filled keys from an intended list into JSON turns the array into an object. Put shape conversions in the boundary function responsible for that protocol.
Test contracts with counterexamples
A tidy ['a', 'b'] cannot expose most key mistakes. List tests should at least cover an empty array, a gap after middle deletion, and a nonzero starting key. Map tests should cover integer-string collisions, null values, and order after overwriting.
Copy and reference tests need to observe two variables. Modify the copy and assert that the original stays unchanged, then repeat with an object element to prove the code distinguishes container independence from shared objects. A by-reference loop test should also reuse the variable outside the loop to catch a lingering binding.
Change types before flexibility becomes ambiguity
When an array shape gains many optional fields, nested alternatives, and cross-field constraints, validation spreads into every consumer. Converting it into a value object that validates at construction centralizes invariants and lets method signatures express intent.
The conversion need not happen during every internal step. Arrays remain useful at parsing boundaries and for temporary transformations, but code should narrow the shape on entry to a stable domain model and explicitly map it back to the list or object required by the output protocol.
Further reading
5 questions · 2 predict-the-output · 1 spot-the-bug