---
title: "Why Array String Keys Are Not Type-Safe in PHP"
date: 2026-03-19
tags: guides
---

PHP silently casts array keys. This is one of the oldest and most well-known quirks in the language. But I don't think its consequences for type safety have been fully appreciated — until now.

The casting rules
------------------------

The [PHP manual](https://www.php.net/manual/en/language.types.array.php) lists the following automatic key casts:

- **Strings** containing valid decimal integers are cast to `int`. The key `"8"` is stored as `8`. But `"08"` stays as a string because it's not a valid decimal integer.
- **Floats** are truncated to `int`. The key `8.7` becomes `8`. Deprecated since PHP 8.1.
- **Bools** are cast to `int`. `true` becomes `1`, `false` becomes `0`.
- **Null** becomes the empty string `""`. Deprecated since PHP 8.5.

The classic demonstration:

```php
$array = [
    1    => 'a',
    '1'  => 'b',
    1.5  => 'c',
    true => 'd',
];

var_dump(count($array)); // int(1)
```

Four entries, four different literal types — but PHP treats them all as the same key `1`. Only the last value survives.

This isn't just a quirky language feature. It breaks type safety in ways that static analysis has historically been unable to catch.

The strict_types trap
------------------------

Consider this innocent-looking code:

```php
<?php declare(strict_types = 1);

function processKey(string $key): void
{
    // ...
}

/** @param array<string, mixed> $data */
function processData(array $data): void
{
    foreach ($data as $key => $value) {
        processKey($key);
    }
}
```

PHPStan sees `$data` typed as `array<string, mixed>`, so `$key` must be `string`. No errors reported. The code looks perfectly safe.

But at runtime, if `$data` was built with a key like `'123'`, PHP stored it as `int(123)`. When the `foreach` reaches that entry, `$key` is `int`, not `string`. With `strict_types` enabled, `processKey($key)` throws a `TypeError`.

PHPStan told you everything was fine. PHP disagrees. The type annotation `array<string, mixed>` promises something that PHP arrays simply cannot guarantee.

array_merge re-indexes your "string" keys
------------------------

`array_merge()` has different behaviour for integer keys and string keys: integer keys get re-indexed starting from `0`, while string keys are preserved. When your string keys get silently cast to integers, you get re-indexing you didn't ask for:

```php
$a = ['100' => 'foo'];  // Stored as [100 => 'foo']
$b = ['200' => 'bar'];  // Stored as [200 => 'bar']

$merged = array_merge($a, $b);
// Result: [0 => 'foo', 1 => 'bar']
// Not ['100' => 'foo', '200' => 'bar']
```

The developer wrote string keys. They got integer keys. And `array_merge` silently re-indexed them.

Strict key lookups fail
------------------------

If you use `array_keys()` and then search with strict comparison, you'll be surprised:

```php
$data = ['123' => 'value'];
$keys = array_keys($data);
// $keys is [123] — an integer!

in_array('123', $keys, true);  // false!
// Strict comparison: '123' !== 123
```

You put in `'123'` as a string key, you search for `'123'` as a string — and it's not found. Because PHP silently changed the key's type behind your back.

A real-world example
------------------------

Imagine working with postal codes:

```php
/** @return array{'1234aa', '2345bb'} */
function getPostals()
{
    return ['1234aa', '2345bb'];
}

$pc4Set = [];
foreach (getPostals() as $postal) {
    $pc4Set[substr($postal, 0, 4)] = true;
}

$pc4 = array_keys($pc4Set);
\PHPStan\dumpType($pc4[0]); // 1234|2345 (int, not string!)
```

The developer extracted the first 4 characters of each postal code using `substr()` — a function that returns a `string`. But because those strings happen to be `'1234'` and `'2345'`, PHP casts them to integers when they're used as array keys. The resulting `array_keys()` returns integers, not strings.

What PHPStan already does
------------------------

I've been laying the groundwork for solving this problem.

The [online playground](/try) already notifies you about key casts. When you write a literal array or access an array with a key that will be cast, PHPStan tells you:

> Key '1234' (string) will be cast to 1234 (int) in the array access.

[See this example in the playground »](/r/188f1e7b-a708-4f8f-b483-7b812d4ab09c)

Additionally, the [`reportNonIntStringArrayKey`](/config-reference#reportnonintstringarraykey) option (available since PHPStan 2.1.39) can report when non-int/non-string types like booleans, floats, or `null` are used as array keys:

```yaml
parameters:
    reportNonIntStringArrayKey: true
```

```php
$array = [];
$array[true] = 'value';  // Reported: Invalid array key type true.
$array[4.5] = 'value';   // Reported: Invalid array key type float.
$array[null] = 'value';  // Reported: Invalid array key type null.
```

These features catch explicit misuses of non-int/non-string types as keys. But they don't solve the fundamental problem: any `string` might be a decimal integer string, and once it's used as an array key, it silently becomes an `int`. PHPStan couldn't do anything about this — until now.

PHPStan 2.2.0: non-decimal-int-string
------------------------

PHPStan 2.2.0 introduces two new types: `non-decimal-int-string` and `decimal-int-string`.

A *decimal integer string* is a string that PHP considers a valid decimal integer and would cast to `int` when used as an array key — like `'0'`, `'123'`, or `'42'`.

A `non-decimal-int-string` is any string that is *not* one of these — like `'hello'`, `'08'`, `'+1'`, or `'1.5'`.

The key insight: if your array keys are `non-decimal-int-string`, PHP will never cast them. They will always remain strings. This means `array<non-decimal-int-string, mixed>` is genuinely type-safe:

```php
<?php declare(strict_types = 1);

function processKey(string $key): void
{
    // ...
}

/** @param array<non-decimal-int-string, mixed> $data */
function processData(array $data): void
{
    foreach ($data as $key => $value) {
        processKey($key); // Always safe — $key is guaranteed to be string
    }
}
```

No more `TypeError` at runtime. No more surprise re-indexing by `array_merge`. No more failed strict key lookups. The type system finally tells the truth about what your array keys actually are.

You can start using `non-decimal-int-string` in your type annotations today. It makes sense anywhere you need array keys that are guaranteed to stay as strings.

And if you have a plain `string` that you want to narrow down to one of these types, PHPStan understands an `int` round-trip check:

```php
if ((string) (int) $x === $x) {
    // $x is decimal-int-string
} else {
    // $x is non-decimal-int-string
}
```

reportUnsafeArrayStringKeyCasting
------------------------

But you shouldn't have to rewrite all your `array<string, mixed>` annotations by hand. That's where the new [`reportUnsafeArrayStringKeyCasting`](/config-reference#reportunsafearraystringkeycasting) config parameter comes in. It has three values:

**`null`** (default) — behaviour as it is today. No additional checks. [Playground example »](/r/dcbe6433-b2bb-4c1e-b378-2d61190ff253)

**`'detect'`** — typehinted `array<string, mixed>` accepts other `array<string, mixed>`, but when you get a key out of it, the key type will be `int|non-decimal-int-string` instead of `string`. This reveals the potential issues in your code without requiring you to change any type annotations. [Playground example »](/r/170d5184-42cd-4ad1-8cc7-c10d83d0f967)

**`'prevent'`** — typehinted `array<string, mixed>` is essentially treated as `array<non-decimal-int-string, mixed>`. It will only accept another array with safe string keys. Additionally, any `string` being used as an array key is correctly narrowed to `int|non-decimal-int-string`. This is the strictest mode and gives you the most protection. [Playground example »](/r/c05a336c-727e-44ae-81d8-1e63bfaac563)

```yaml
parameters:
    reportUnsafeArrayStringKeyCasting: detect # or: prevent
```

I'd recommend starting with `detect` to see how your codebase is affected, and then moving to `prevent` once you've addressed the findings.

Please note this is highly experimental and things can change as I adjust and make the types more practical and useful.

---

Do you like PHPStan and use it every day? [**Consider supporting further development of PHPStan**](/sponsor). I'd really appreciate it!
