PHP Packagev8.5.2MIT License

Dot Array

Read, write, check and delete values in deeply nested PHP arrays using dot-notation paths (user.profile.name) and * wildcards (users.*.email). It is small and framework-independent. One path parser and one set of wildcard rules are shared by every operation, so get(), set(), has() and delete() always agree on what a path means.

Dot-Notation Paths

get('user.profile.name'), set('a.b.c', 1): missing keys are created on write.

Wildcards

users.*.email reaches the value in every element, nested wildcards included.

Safe Defaults

get('user.phone', 'n/a') falls back for missing and null values; has() still sees a null key.

Escaping

config.app\.name reads the key app.name; items.\* reads the key *.

Reference Mode

setReference($array) writes every change straight to your own array.

ArrayAccess & Countable

$dot['user.name'], isset(), unset(), count(), iteration and json_encode() all work.

Quick Tip

Wrap any array with the dot() helper and chain your writes: dot($data)->set('user.active', true)->delete('user.password').

Installation

Install the package with Composer. There is nothing to register or configure.

Requirements

  • PHP 8.5.x (each 8.x release line targets the matching PHP version)
  • ext-mbstring
  • pharaonic/php-readable ~8.5.0 (installed automatically)

Composer Installation

Terminal
composer require pharaonic/php-dot-array

Composer autoloads the Pharaonic\DotArray namespace and the global dot() helper.

Installation Complete

You're all set! Try dot(['user' => ['name' => 'Raggi']])->get('user.name'), which returns "Raggi".

Basic Usage

Wrap an array with the dot() helper, or create a DotArray yourself. Both accept an array, another DotArray, or nothing for an empty one:

1use Pharaonic\DotArray\DotArray;
2 
3$dot = dot(['user' => ['name' => 'Raggi']]);
4$dot = new DotArray(['user' => ['name' => 'Raggi']]);

The instance works on its own copy of the array. To change your array directly, see Reference Mode.

Reading Values

get() follows the path one key at a time. Numeric indexes are keys like any other:

1$dot = dot([
2 'user' => ['profile' => ['name' => 'Raggi']],
3 'matrix' => [[1, 2], [3, 4]],
4]);
5 
6$dot->get('user.profile.name'); // "Raggi"
7$dot->get('user.profile'); // ["name" => "Raggi"]
8$dot->get('matrix.1.0'); // 3
9$dot->all(); // the whole array
10 
11dot(['first', 'second'])->get(1); // "second" (integer keys work too)

Default Values

The second argument of get() is returned when the path is missing or its value is null. A path is missing when a key doesn't exist, or when a value on the way isn't an array:

1$dot = dot(['user' => ['email' => null, 'name' => 'Raggi', 'active' => false]]);
2 
3$dot->get('user.phone'); // null (missing, no default given)
4$dot->get('user.phone', 'n/a'); // "n/a" (missing)
5$dot->get('user.name.first', 'x'); // "x" ("Raggi" is not an array)
6$dot->get('user.email', 'n/a'); // "n/a" (null)

Other empty values, false, 0, '' and [], are returned as they are:

1$dot->get('user.active', true); // false

To tell a null value from a missing key, use has().

Wildcards and defaults

With a * in the path, the default works per element. See Wildcards.

Writing Values

set() creates missing keys and returns the instance, so calls can be chained:

1$dot = dot();
2 
3$dot->set('user.name', 'Raggi')
4 ->set('user.roles.0', 'admin');
5 
6$dot->all(); // ["user" => ["name" => "Raggi", "roles" => ["admin"]]]

A value that isn't an array and sits in the way of the path is replaced by an array:

1$dot = dot(['a' => 'hello']);
2$dot->set('a.b', 1);
3 
4$dot->all(); // ["a" => ["b" => 1]]

Checking Paths

has() is true when the path exists, even when its value is null:

1$dot = dot(['user' => ['email' => null]]);
2 
3$dot->has('user.email'); // true
4$dot->has('user.phone'); // false

Deleting Values

delete() returns true when something was deleted. pull() returns the value (or the default) and deletes it:

1$dot = dot(['token' => 'abc', 'user' => ['name' => 'Raggi', 'password' => 'secret']]);
2 
3$dot->delete('user.password'); // true
4$dot->delete('user.password'); // false (already gone)
5 
6$dot->pull('token'); // "abc"
7$dot->pull('token', 'none'); // "none"
8 
9$dot->all(); // ["user" => ["name" => "Raggi"]]

Clearing and Counting

1$dot = dot(['a' => [1, 2, 3], 'b' => 'x']);
2 
3$dot->count(); // 2 (top-level items)
4$dot->count('a'); // 3
5$dot->count('b'); // 0 (not an array)
6$dot->isEmpty('a'); // false
7 
8$dot->clear();
9$dot->isEmpty(); // true

An empty path never deletes anything; use clear() to empty the whole array.

Wildcards

A * segment matches every element of the array it is applied to. Every operation supports it:

1$dot = dot([
2 'users' => [
3 ['name' => 'Ahmed', 'profile' => ['email' => '[email protected]']],
4 ['name' => 'Sara', 'profile' => []],
5 ],
6]);
7 
8$dot->get('users.*.name'); // ["Ahmed", "Sara"]
9$dot->get('users.*.profile.email'); // ["[email protected]", null]
10$dot->get('users.*.profile.email', '-'); // ["[email protected]", "-"]
11 
12$dot->has('users.*.name'); // true: every user has a name
13$dot->has('users.*.profile.email'); // false: Sara has no email
14 
15$dot->set('users.*.active', true); // adds "active" => true to every user
16$dot->delete('users.*.profile'); // removes "profile" from every user
17$dot->count('users.*.name'); // 2

Nested Wildcards

Nested wildcards give one flat list, in order:

1$dot = dot([
2 'groups' => [
3 ['users' => [['name' => 'A'], ['name' => 'B']]],
4 ['users' => [['name' => 'C']]],
5 ],
6]);
7 
8$dot->get('groups.*.users.*.name'); // ["A", "B", "C"]
9 
10$dot->set('groups.*.users.*.active', true);
11$dot->get('groups.*.users.*.active'); // [true, true, true]

How Matching Works

The rules are the same for get(), has(), set(), delete() and pull():

  1. The elements reached by the last * are matched. For each of them, the rest of the path is read, written or deleted as usual.
  2. Branches that don't reach the last * are skipped: a missing key, or a value that isn't an array, before it. When keys follow the last *, elements that aren't arrays are skipped too.
  3. A trailing * means the elements themselves.
  4. * is a wildcard only as a whole segment: a.b* is the literal key b*.
1$dot = dot([
2 'groups' => [
3 ['users' => [['name' => 'A']]],
4 ['title' => 'no users'], // skipped: no "users" key
5 ['users' => [['name' => 'B'], 'x']], // "x" skipped: not an array
6 ],
7]);
8 
9$dot->get('groups.*.users.*.name'); // ["A", "B"]
10$dot->has('groups.*.users.*.name'); // true

Results and Defaults

OperationResult with a wildcard
get($path, $default)A list with one entry per matched element: its value, or $default where the rest of the path is missing or null. $default itself when nothing matches. Keys of associative arrays are not kept.
has($path)true when at least one element matches and every matched element has the rest of the path.
set($path, $value)Sets the value in every matched element. Never creates elements for a *.
delete($path)Deletes the key from every matched element; true when at least one was deleted.
pull($path, $default)Returns what get() returns, then deletes like delete().
count($path)The number of entries get() returns, or 0.
1$dot = dot(['users' => [], 'roles' => ['admin' => ['level' => 1], 'editor' => ['level' => 2]]]);
2 
3$dot->get('users.*.name', 'none'); // "none" (nothing matched)
4$dot->get('roles.*.level'); // [1, 2]
5$dot->get('roles.*'); // [["level" => 1], ["level" => 2]]
6 
7$dot->set('users.*.active', true); // nothing to update, nothing created
8$dot->get('users'); // []

Trailing Wildcards

A trailing * reaches the elements themselves:

1$dot = dot(['flags' => ['beta' => true, 'dark' => true], 'tags' => ['a', 'b']]);
2 
3$dot->set('flags.*', false); // every flag becomes false
4$dot->delete('tags.*'); // "tags" becomes []
5$dot->all(); // ["flags" => ["beta" => false, "dark" => false], "tags" => []]
The array itself

delete('tags.*') empties tags but keeps the key. To remove the key, use delete('tags').

Paths & Escaping

A path is a list of keys separated by .. The same parser is used by every method.

Escaping Dots

Use \. for a dot that is part of a key:

1$dot = dot(['config' => ['app.name' => 'Pharaonic']]);
2 
3$dot->get('config.app\.name'); // "Pharaonic"
4$dot->get('config.app.name'); // null (looks for ["app"]["name"])
5 
6$dot->set('hosts.example\.com.port', 443);
7$dot->get('hosts'); // ["example.com" => ["port" => 443]]

Escaping Wildcards

Use \* for a key that is literally *:

1$dot = dot(['items' => ['*' => ['name' => 'Literal Star'], 'x' => ['name' => 'X']]]);
2 
3$dot->get('items.\*.name'); // "Literal Star"
4$dot->get('items.*.name'); // ["Literal Star", "X"]

Backslashes

\\ is a literal backslash. Any other backslash is kept as it is, so class names work as keys:

1$dot = dot(['App\Models\User' => ['table' => 'users']]);
2 
3$dot->get('App\Models\User.table'); // "users"
PHP strings

In single-quoted PHP strings, '\.' and '\*' are already a backslash followed by the character, so you can write the paths exactly as shown. In double-quoted strings, write "config.app\\.name".

Numeric Keys

Numeric segments reach numeric keys. PHP itself stores '1' as the integer key 1, so both spellings reach the same element, while '01' stays a string key:

1$dot = dot([0 => 'A', '1' => 'B', '01' => 'C']);
2 
3$dot->get('1'); // "B"
4$dot->get(1); // "B"
5$dot->get('01'); // "C"
6$dot->get('0'); // "A"

Unusual Paths

PathMeaning
'', '.'The whole array: get() returns it, has() is true, set() and delete() do nothing.
'.user.', ' user 'Leading and trailing dots and spaces are ignored: user.
'user..name'An empty-string key between user and name.
'a\.'The key a. (an escaped trailing dot is kept).

ArrayAccess & Interfaces

DotArray implements ArrayAccess, Countable, IteratorAggregate and JsonSerializable.

ArrayAccess

Array syntax maps directly to the methods, paths and wildcards included:

SyntaxSame as
$dot['user.name']$dot->get('user.name')
$dot['user.name'] = 'Raggi'$dot->set('user.name', 'Raggi')
$dot[] = 'value'Appends to the root array.
isset($dot['user.name'])$dot->has('user.name')
unset($dot['user.name'])$dot->delete('user.name')
1$dot = dot();
2 
3$dot['user.name'] = 'Raggi';
4$dot['user.email'] = null;
5$dot[] = 'appended';
6 
7$dot['user.name']; // "Raggi"
8isset($dot['user.email']); // true (null still exists)
9$dot['users.*.name']; // null (no users yet)
10 
11unset($dot['user.name']);
isset() and null

Unlike a plain array, isset($dot['user.email']) is true when the value is null, because it uses has().

Countable

count($dot) counts the top-level items. $dot->count($path) counts the items at a path, and is 0 when the path is missing or not an array:

1$dot = dot(['users' => [['name' => 'A'], ['name' => 'B']], 'title' => 'x']);
2 
3count($dot); // 2
4$dot->count('users'); // 2
5$dot->count('users.*.name'); // 2
6$dot->count('title'); // 0

Iteration

foreach walks the top-level items:

1foreach (dot(['a' => 1, 'b' => 2]) as $key => $value) {
2 echo "$key=$value "; // a=1 b=2
3}

JSON

toJson() encodes the whole array, or the value at a path, with optional json_encode() flags. json_encode($dot) gives the same result as toJson():

1$dot = dot(['user' => ['name' => 'Raggi', 'url' => 'https://pharaonic.dev']]);
2 
3$dot->toJson(); // the whole array
4$dot->toJson('user.name'); // '"Raggi"'
5$dot->toJson('user', JSON_UNESCAPED_SLASHES); // '{"name":"Raggi","url":"https://pharaonic.dev"}'
6json_encode($dot); // same as toJson()

When encoding fails, toJson() returns an empty string. Pass JSON_THROW_ON_ERROR to get a JsonException instead.

Reference Mode

By default, a DotArray works on its own copy, so your array is never changed:

1$array = ['user' => ['name' => 'Old']];
2 
3$dot = dot($array);
4$dot->set('user.name', 'New');
5 
6$array['user']['name']; // "Old"

setReference() binds the instance to your array instead, so every write lands there:

1use Pharaonic\DotArray\DotArray;
2 
3$array = ['user' => ['name' => 'Old']];
4 
5$dot = (new DotArray())->setReference($array);
6$dot->set('user.name', 'New');
7 
8$array['user']['name']; // "New"

set(), delete(), pull(), clear(), setArray() and ArrayAccess writes, wildcards included, all write to the referenced array. Changes you make to the array yourself are visible through the instance too. Reads never modify it.

1$config = ['users' => [['name' => 'A', 'password' => 'x']]];
2 
3(new DotArray())->setReference($config)
4 ->set('users.*.active', true)
5 ->delete('users.*.password');
6 
7$config; // ["users" => [["name" => "A", "active" => true]]]
setArray() and clear()

After setReference(), setArray($items) replaces the contents of your array and clear() empties it. To stop working on your array, create a new instance.

API Reference

Pharaonic\DotArray\DotArray

MethodDescriptionReturns
__construct(mixed $items = [])Create an instance from an array, another DotArray, or any value castable to an array.
get(string|int $key, mixed $default = null)The value at a path, or $default when it doesn't exist. A list with a wildcard.mixed
set(string $key, mixed $value = null)Set a value, creating missing keys.$this
has(string|int $key)Whether the path exists, even when its value is null.bool
delete(string|int $key)Delete a path.bool (true when something was deleted)
pull(string|int $key, mixed $default = null)Return the value like get(), then delete it.mixed
all()The whole array.array
clear()Empty the array.$this
setArray(mixed $items)Replace the array.$this
setReference(array &$items)Work on the given array by reference.$this
count(string|int|null $key = null)The number of top-level items, or of items at a path (0 when missing or not an array).int
isEmpty(?string $key = null)Whether the array, or the value at a path, is empty.bool
toJson(int|string|null $key = null, int $options = 0)The array, or the value at a path, as JSON ('' on failure).string
getIterator()Iterator over the top-level items.ArrayIterator
jsonSerialize()The array, for json_encode().array
offsetGet() / offsetSet() / offsetExists() / offsetUnset()ArrayAccess: see ArrayAccess & Interfaces.

Helper

FunctionDescriptionReturns
dot(mixed $items = [])Same as new DotArray($items).DotArray

Deprecated

These still work, but will be removed in a future major release:

NameUse instead
isNumericKeys(), array_is_numeric(array $arr)Pharaonic\Readable\Arr::isList($array)
isMultidimensional(), array_is_multidimensional(array $arr)Pharaonic\Readable\Arr::isMultidimensional($array)
isNulledValues(), array_is_null(array $arr)Pharaonic\Readable\Arr::isNull($array)
The third argument of get(), has(), set() and delete() (an array to work on)dot($array) or setReference($array)

Between 8.x Release Lines

Each 8.N.x line targets exactly one PHP version: 8.0.x runs on PHP 8.0, 8.1.x on PHP 8.1, and so on. Moving from one 8.x line to another never changes the public API or results, so no code changes are needed. Composer picks the line that matches your PHP version.

Upgrading from 8.5.0

Patch with result changes

8.5.0 still ran the 2.x engine. 8.5.1 is a patch release, so composer update installs it automatically, but every change below applies. Review them first, or pin 8.5.0 with composer require pharaonic/php-dot-array:8.5.0 until you have.

Upgrading from 2.x

v8.5.2 rebuilt the path engine. Every method and helper from 2.x still exists with the same parameters, and delete() still returns bool, but some results changed. Most of them were bugs.

The full list, with before and after values for every change, is in UPGRADE.md.

What Changed

ChangeBeforeNow
Existing [], [null, null]get('a', 'x') gave 'x'gives the value (null still gives 'x')
Existing nullhas()/delete() ignored ithas() is true, delete() removes it
Wildcard values that are arraysmerged: get('users.*.p') gave ["e" => [1, 2]]one entry per element: [["e" => 1], ["e" => 2]]
Trailing *stripped: delete('users.*') deleted usersthe elements: users becomes []
set() with a * on a missing or empty arraycreated [["active" => true]]does nothing
has() with a * and no matchestrue for an empty arrayfalse
delete() with a *false unless every element had the keytrue when anything was deleted
Path '0'treated as an empty paththe key 0
set('a.b', 1) on ["a" => "hello"], count('missing'), wildcards over scalarsthrew TypeError / Errorwork (see Basic Usage and Wildcards)
\., \*, \\ in a pathliteral backslashesescapes (see Paths & Escaping)
isNumericKeys() on [], isMultidimensional() on ['a' => []]falsetrue (now backed by Pharaonic\Readable\Arr)

New

  • pull($path, $default) returns a value and deletes it.
  • set(), clear(), setArray() and setReference() return the instance, so calls can be chained.

Deprecated

isNumericKeys(), isMultidimensional(), isNulledValues() and the array_is_*() helpers (use Pharaonic\Readable\Arr instead), and the third argument of get(), has(), set() and delete(). See the API Reference for replacements.

Examples

1. Reading an API Response

Pull what you need from a deeply nested JSON response without isset() chains:

app/Services/OrderImporter.php
1$response = dot(json_decode($json, true));
2 
3$orderId = $response->get('data.order.id');
4$currency = $response->get('data.order.currency', 'USD');
5$skus = $response->get('data.order.items.*.sku', []);
6$total = array_sum($response->get('data.order.items.*.price', []));

The [] default keeps array_sum() safe when the order has no items.

2. Cleaning Data Before Output

Remove sensitive fields from every record, then encode the result:

app/Http/Controllers/UserController.php
1$payload = dot(['users' => $users])
2 ->set('users.*.profile.public', true);
3 
4$payload->delete('users.*.password');
5$payload->delete('users.*.remember_token');
6 
7return $payload->toJson('users', JSON_UNESCAPED_UNICODE);

3. Editing a Config Array in Place

Use reference mode to update a configuration array that the rest of the code already holds:

bootstrap/config.php
1use Pharaonic\DotArray\DotArray;
2 
3$config = require __DIR__ . '/../config/app.php';
4 
5$settings = (new DotArray())->setReference($config);
6$settings->set('cache.driver', getenv('CACHE_DRIVER') ?: 'file');
7$settings->set('mail.hosts.smtp\.example\.com.port', 587);
config/app.php
1return [
2 'cache' => ['driver' => 'array'],
3 'mail' => ['hosts' => []],
4];

After this, $config['cache']['driver'] holds the new driver, and the SMTP host is stored under the key smtp.example.com.

4. Validating Nested Input

Check that every line of a submitted form has the fields you need:

app/Forms/InvoiceForm.php
1$input = dot($_POST);
2 
3if (! $input->has('lines.*.product_id') || ! $input->has('lines.*.quantity')) {
4 throw new InvalidArgumentException('Every line needs a product and a quantity.');
5}
6 
7$input->set('lines.*.tax_rate', 0.14);

has() with a wildcard is false when there are no lines at all, and when any line misses the field.

5. Array Syntax in Templates

Pass a DotArray to code that expects array access:

1$view = dot(['page' => ['title' => 'Home', 'meta' => ['og.title' => 'Welcome']]]);
2 
3echo $view['page.title']; // Home
4echo $view['page.meta.og\.title']; // Welcome
5echo isset($view['page.subtitle']) ? 'yes' : 'no'; // no

Troubleshooting

get() returns my default although the key exists

The key holds null, and get() gives the default for null values as well as for missing paths. Use has() to check whether the key exists:

1$dot = dot(['a' => null]);
2 
3$dot->get('a', 'x'); // "x"
4$dot->has('a'); // true

get() returns [] or false instead of my default

The key exists with that value. Only missing paths and null values give the default. Use ?: for a fallback on any empty value:

1dot(['a' => []])->get('a') ?: 'x'; // "x"

A key with a dot in its name can't be found

config.app.name looks for ["config"]["app"]["name"]. Escape the dot: config.app\.name. In a double-quoted PHP string, write "config.app\\.name".

A wildcard read returns the default instead of a list

Nothing matched: the array before the * is missing, empty, or not an array, or no element reached the last *. Pass [] as the default when you want an empty list:

1dot(['users' => []])->get('users.*.name', []); // []

A wildcard read has fewer entries than the array has elements

When keys follow the last *, elements that aren't arrays are skipped. Branches that miss a key before the last * are skipped too. Elements that are arrays but miss the rest of the path are kept, with the default as their value (the same as a null value).

set() with a wildcard didn't add anything

Wildcards only update existing elements; they never create them. Write to an index instead: set('users.0.active', true).

has() with a wildcard is false although some elements have the key

With a *, has() is true only when every matched element has the path. Check the values instead when one is enough:

1$emails = dot($data)->get('users.*.email', []);
2$anyEmail = array_filter($emails, fn ($email) => $email !== null) !== [];

delete('users.*') didn't remove the users key

A trailing * means the elements, so users becomes []. Use delete('users') to remove the key.

My original array didn't change

A DotArray works on its own copy. Use Reference Mode to write to your array.

toJson() returns an empty string

Encoding failed, usually because of invalid UTF-8. Pass JSON_THROW_ON_ERROR to see why:

1$dot->toJson(null, JSON_THROW_ON_ERROR); // throws JsonException with the reason

Contributors

Amazing people who made this package possible

This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Dot Array!

Moamen Eltouny

@MoamenEltouny

58 contributions

Peter Schade

@peterschade

3 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.