# k2gl/array-reader

> Read nested array data with types, defaults, and clear errors.

Stop writing isset() ladders over decoded JSON and config arrays — pull typed values by path.

## Install

```bash
composer require k2gl/array-reader
```

## Requirements

- PHP >=8.1

## Documentation

# k2gl/array-reader

[![CI](https://img.shields.io/github/actions/workflow/status/k2gl/array-reader/ci.yml?branch=main&label=CI&logo=github)](https://github.com/k2gl/array-reader/actions/workflows/ci.yml)
[![Latest Stable Version](https://img.shields.io/packagist/v/k2gl/array-reader?logo=packagist&logoColor=white)](https://packagist.org/packages/k2gl/array-reader)
[![Total Downloads](https://img.shields.io/packagist/dt/k2gl/array-reader?logo=packagist&logoColor=white)](https://packagist.org/packages/k2gl/array-reader)
[![PHPStan Level](https://img.shields.io/badge/PHPStan-level%209-2a5ea7?logo=php&logoColor=white)](https://phpstan.org)
[![License](https://img.shields.io/packagist/l/k2gl/array-reader?color=yellowgreen)](https://packagist.org/packages/k2gl/array-reader)

Read typed values out of an untyped `array` — query strings, form input, CSV rows, decoded JSON,
config, environment — **without** the `isset(...) && is_string(...) ? ... : null` dance, and
**without** dropping to `mixed` in the eyes of PHPStan / Psalm.

```php
use K2gl\ArrayReader\ArrayReader;

$request = ArrayReader::of($_GET);

$page    = $request->int('page');             // "5"   -> 5   (int)
$active  = $request->bool('active');          // "on"  -> true (bool)
$perPage = $request->intOr('per_page', 20);   // 20 if it is absent or not a valid number
```

Without it you write the same guard for every field and still end up with `mixed`:

```php
$page = isset($_GET['page']) && is_numeric($_GET['page']) ? (int) $_GET['page'] : null;
```

**Why install it:** one tiny, **zero-dependency** class family that turns messy input arrays into
typed values, lets you choose how forgiving the conversion is, fails loudly on missing data, and
keeps static analysis green.

## Install

```bash
composer require k2gl/array-reader
```

Requires PHP 8.1+. No runtime dependencies.

## Pick a reader

There are three readers. They expose the **same methods** and differ only in how they handle a
value whose type doesn't match what you asked for. Here is one input read by each:

```php
$data = ['count' => '42', 'price' => '9.99', 'enabled' => 'yes'];
```

### `ArrayReader` — safe casting (use this by default)

Converts the obvious string/number/bool representations and **rejects anything ambiguous or
lossy**. Ideal for data that arrives as strings: `$_GET`, `$_POST`, CSV rows, environment variables.

```php
use K2gl\ArrayReader\ArrayReader;

$request = ArrayReader::of($data);

$request->int('count');        // 42        — "42" is a whole number
$request->float('price');      // 9.99      — numeric string
$request->bool('enabled');     // true      — "yes"
$request->int('price');        // throws TypeMismatchException — "9.99" is not an integer
$request->intOr('price', 0);   // 0         — the lenient variant returns the default, never throws
```

### `StrictArrayReader` — exact type only

Accepts a value only if it is **already** the requested type (the lone convenience: an `int` may be
read as `float`). Ideal for data you already trust to be well-typed, e.g. a decoded JSON document.

```php
use K2gl\ArrayReader\StrictArrayReader;

$document = StrictArrayReader::of(['count' => 42, 'name' => 'Ada']);

$document->int('count');       // 42
$document->string('name');     // 'Ada'

StrictArrayReader::of(['count' => '42'])->int('count'); // throws — "42" is a string, not an int
```

### `LooseArrayReader` — PHP's native cast

Casts **any scalar** with PHP's own rules and never rejects a scalar — so malformed input passes
through silently. Reach for it only when you explicitly want that behaviour.

```php
use K2gl\ArrayReader\LooseArrayReader;

$loose = LooseArrayReader::of($data);

$loose->int('price');          // 9         — (int) "9.99"
$loose->bool('enabled');       // true
$loose->int('count');          // 42

LooseArrayReader::of(['x' => 'abc'])->int('x'); // 0   — (int) "abc"
$loose->int('missing');        // throws MissingKeyException — a missing key is always an error
```

## Reading values

For every scalar type each reader offers two accessors:

- **strict** `int($key)` — returns the value, or throws: `MissingKeyException` when the key is
  absent, `TypeMismatchException` when the value cannot be produced as the requested type.
- **lenient** `intOr($key, $default = null)` — returns the value, or `$default` when the key is
  absent or the value cannot be produced. Pass a non-null default and the return type is non-null
  too (conditional return types, so PHPStan / Psalm narrow it for you).

```php
$form = ArrayReader::of($_POST);

$email    = $form->string('email');          // string  (throws if missing / not producible)
$nickname = $form->stringOr('nickname');     // ?string (null when absent)
$age      = $form->intOr('age', 0);          // int     (0 when absent / invalid)
$price    = $form->float('price');           // float
$subscribe = $form->boolOr('subscribe', false);
```

## Arrays, lists and nesting

`array()`, `list()` and `nested()` **never cast** — in every reader they only validate shape:

```php
$config = ArrayReader::of($decoded);

$config->array('options');               // array<array-key, mixed>
$config->list('tags');                   // list<mixed> — sequential, 0-based keys
$config->nested('database')->string('host');   // a reader of the same kind over the nested array

$config->arrayOr('options', []);         // lenient variants return the default instead of throwing
$config->listOr('tags', []);
$config->nestedOr('database');           // ?reader
```

Read a list of nested objects (`{"items": [{...}, {...}]}`) as a list of readers with
`nestedList()` / `nestedListOr()`:

```php
$payload = ArrayReader::of(['items' => [['id' => 1], ['id' => 2]]]);

foreach ($payload->nestedList('items') as $item) {
    $item->int('id');                    // each element is a reader of the same kind
}

$payload->nestedListOr('missing');       // null when absent, not a list, or an element is not an array
```

## Typed scalar lists

`ints()`, `strings()`, `floats()` and `bools()` read a list and produce every element through the
same cast pipeline as the scalar accessors, so the reader's mode applies element by element. They
turn `ids[]=1&ids[]=2`, CSV columns or JSON arrays into a `list<int>` / `list<string>` / … without
a manual `array_map()`.

```php
$query = ArrayReader::of(['ids' => ['1', '2', '3'], 'tags' => ['php', 'json']]);

$query->ints('ids');         // list<int>    => [1, 2, 3]   ('1' cast in safe mode)
$query->strings('tags');     // list<string> => ['php', 'json']

// strict variant throws TypeMismatchException if any element cannot be produced;
// lenient *Or returns the default when the key is absent, the value is not a list,
// or any element cannot be produced (all-or-nothing):
$query->intsOr('ids', []);   // list<int>
$query->floatsOr('missing'); // null
```

For lists of enums or dates see the sections below; for any other element type `listOf()` maps
each element of a list through a caster of your own — the "array of objects to DTOs" payload:

```php
$payload = ArrayReader::of(['points' => [['x' => 1], ['x' => 2]]]);

$payload->listOf('points', fn (mixed $p) => Point::fromArray((array) $p));  // list<Point>
$payload->listOfOr('points', $caster, []);   // default when absent / not a list (caster not run)
```

## Enums

`enum()` / `enumOr()` read a **backed enum**: the enum's backing scalar is produced through the
same cast pipeline (so the reader's cast mode applies), then resolved with `BackedEnum::tryFrom()`.
A value that is absent, cannot be produced as the backing type, or is not one of the enum's cases
throws (strict) or returns the default (`enumOr`):

```php
enum Suit: string { case Hearts = 'hearts'; case Spades = 'spades'; }

$card = ArrayReader::of($row);

$card->enum('suit', Suit::class);                 // Suit  (throws if missing / not a valid case)
$card->enumOr('suit', Suit::class);               // ?Suit (null when absent / invalid)
$card->enumOr('suit', Suit::class, Suit::Hearts); // Suit  (Suit::Hearts when absent / invalid)

$deck->enums('suits', Suit::class);               // list<Suit>  (strict: throws on a bad element)
$deck->enumsOr('suits', Suit::class, []);         // list<Suit>  (default when absent / any element invalid)
```

The cast mode applies to the backing scalar: with `ArrayReader` (safe) a numeric string `"2"`
resolves an `int`-backed enum, `StrictArrayReader` requires the exact backing type, and
`LooseArrayReader` coerces any scalar. Only backed enums are supported.

## Dates

`dateTime()` / `dateTimeOr()` read a date/time string into a `DateTimeImmutable`. The value is read
as a string through the cast pipeline, then parsed. Without a format any `DateTimeImmutable`-parsable
string is accepted (ISO-8601, relative, `@timestamp`); pass a format and the input must match it
exactly — surplus characters or parse warnings are rejected.

```php
$row = ArrayReader::of(['created_at' => '2024-01-15T10:30:00+00:00', 'day' => '15/01/2024']);

$row->dateTime('created_at');              // DateTimeImmutable (throws if missing / unparsable)
$row->dateTime('day', 'd/m/Y');            // DateTimeImmutable, strict to the format
$row->dateTimeOr('missing');               // ?DateTimeImmutable (null when absent / unparsable)
$row->dateTimeOr('day', null, 'Y-m-d');    // pass a format as the third argument

$log->dateTimes('timestamps');             // list<DateTimeImmutable> (strict: throws on a bad element)
$log->dateTimesOr('timestamps', []);       // list<DateTimeImmutable> (default when absent / any unparsable)
```

## Nested keys (dot notation)

Every key-based accessor (`has()`, the scalar getters, `enum()`, `dateTime()`, `nested()`, the list
accessors, …) accepts a dot path into nested arrays. A **literal key always wins** — a key that
contains a dot keeps resolving to itself — and the path is only walked when no literal key matches,
so this is fully backward compatible.

```php
$reader = ArrayReader::of(['user' => ['profile' => ['age' => 30]]]);

$reader->int('user.profile.age');     // 30
$reader->has('user.profile.age');     // true
$reader->nested('user.profile')->int('age'); // 30
$reader->intOr('user.profile.missing', 0);   // 0

// A literal "a.b" key still wins over the a -> b path:
ArrayReader::of(['a.b' => 1, 'a' => ['b' => 2]])->int('a.b'); // 1
```

## Lazy defaults and required keys

`stringOrElse()` / `intOrElse()` / `floatOrElse()` / `boolOrElse()` work like the `*Or` accessors,
but the default comes from a callback that runs **only** when the value cannot be produced — handy
when computing the default is expensive.

`require()` asserts that a set of keys (dot paths allowed) is present, failing once with **all** the
missing keys instead of one at a time, and returns the reader for chaining.

```php
$reader->intOrElse('page', fn (): int => $this->countPages());  // callback only runs when 'page' is absent / invalid

ArrayReader::of($payload)
    ->require(['id', 'user.email'])     // throws MissingKeyException listing every missing key
    ->string('user.email');
```

## Helpers and JSON

```php
$config = ArrayReader::of($decoded);
$config->has('debug');                   // bool — is the key present? (true even if its value is null)
$config->toArray();                      // the underlying array<array-key, mixed>

$request = ArrayReader::fromJson($body); // decode a JSON object/array straight into a reader
```

## Error handling

Every exception implements `K2gl\ArrayReader\Exception\ArrayReaderException`, so you can catch the
whole family at once:

```php
use K2gl\ArrayReader\ArrayReader;
use K2gl\ArrayReader\Exception\ArrayReaderException;

try {
    $email = ArrayReader::fromJson($body)->string('email');
} catch (ArrayReaderException $e) {
    // MissingKeyException | TypeMismatchException | InvalidJsonException
}
```

## Safe casting reference (`ArrayReader`)

What `ArrayReader` accepts beyond the exact type — anything else throws (strict) or returns the
default (`*Or`):

| Accessor | Also accepts |
| --- | --- |
| `string` | `int`/`float` → string, `bool` → `"1"`/`"0"`, `Stringable` |
| `int` | integer numeric string (`"5"`, `"-3"`), `bool` → `1`/`0` — rejects floats, `"5.5"`, `"abc"` |
| `float` | `int`, numeric string (`"1.5"`, `"2"`) |
| `bool` | `"1"`/`"true"`/`"on"`/`"yes"` → true, `"0"`/`"false"`/`"off"`/`"no"`/`""` → false, `int` `0`/`1` |

## Zero dependencies

`array-reader` has **no runtime dependencies** — its only `require` is `php` itself. Installing it
pulls nothing else into your dependency tree: no transitive packages to audit, no version conflicts
to resolve, and it is safe to drop into any application or library, including ones that must stay
dependency-light.

## License

MIT © Nick Harin. See [LICENSE](LICENSE).

## API

### K2gl\ArrayReader\AbstractArrayReader (class)

- `__construct(protected readonly array $data)`
- `of(array $data): static`
- `fromJson(string $json): static`
- `has(string|int $key): bool`
- `require(array $keys): static`
- `string(string|int $key): string`
- `stringOr(string|int $key, ?string $default = null): ?string`
- `int(string|int $key): int`
- `intOr(string|int $key, ?int $default = null): ?int`
- `float(string|int $key): float`
- `floatOr(string|int $key, ?float $default = null): ?float`
- `bool(string|int $key): bool`
- `boolOr(string|int $key, ?bool $default = null): ?bool`
- `ints(string|int $key): array`
- `intsOr(string|int $key, ?array $default = null): ?array`
- `strings(string|int $key): array`
- `stringsOr(string|int $key, ?array $default = null): ?array`
- `floats(string|int $key): array`
- `floatsOr(string|int $key, ?array $default = null): ?array`
- `bools(string|int $key): array`
- `boolsOr(string|int $key, ?array $default = null): ?array`
- `stringOrElse(string|int $key, callable $default): string`
- `intOrElse(string|int $key, callable $default): int`
- `floatOrElse(string|int $key, callable $default): float`
- `boolOrElse(string|int $key, callable $default): bool`
- `enum(string|int $key, string $enum): BackedEnum`
- `enumOr(string|int $key, string $enum, ?BackedEnum $default = null): ?BackedEnum`
- `enums(string|int $key, string $enum): array`
- `enumsOr(string|int $key, string $enum, ?array $default = null): ?array`
- `dateTime(string|int $key, ?string $format = null): DateTimeImmutable`
- `dateTimeOr(string|int $key, ?DateTimeImmutable $default = null, ?string $format = null): ?DateTimeImmutable`
- `dateTimes(string|int $key, ?string $format = null): array`
- `dateTimesOr(string|int $key, ?array $default = null, ?string $format = null): ?array`
- `array(string|int $key): array`
- `arrayOr(string|int $key, ?array $default = null): ?array`
- `list(string|int $key): array`
- `listOr(string|int $key, ?array $default = null): ?array`
- `nested(string|int $key): static`
- `nestedOr(string|int $key): ?static`
- `nestedList(string|int $key): array`
- `nestedListOr(string|int $key): ?array`
- `listOf(string|int $key, callable $caster): array`
- `listOfOr(string|int $key, callable $caster, ?array $default = null): ?array`
- `toArray(): array`

### K2gl\ArrayReader\ArrayReader (class)

_no public methods_

### K2gl\ArrayReader\CastMode (enum)

_no public methods_

### K2gl\ArrayReader\Exception\ArrayReaderException (interface)

_no public methods_

### K2gl\ArrayReader\Exception\InvalidJsonException (class)

- `decodeFailed(JsonException $previous): self`
- `notArray(mixed $decoded): self`

### K2gl\ArrayReader\Exception\MissingKeyException (class)

- `forKey(string|int $key): self`
- `forKeys(array $keys): self`

### K2gl\ArrayReader\Exception\TypeMismatchException (class)

- `expected(string $expectedType, string|int $key, mixed $actual): self`

### K2gl\ArrayReader\LooseArrayReader (class)

_no public methods_

### K2gl\ArrayReader\Miss (enum)

_no public methods_

### K2gl\ArrayReader\StrictArrayReader (class)

_no public methods_

## Links

- GitHub: https://github.com/k2gl/array-reader
- Packagist: https://packagist.org/packages/k2gl/array-reader
