safeaccess / inline
Safe nested data access with dot notation for PHP.
Fund package maintenance!
Requires
- php: ^8.2
- ext-json: *
- ext-libxml: *
- ext-simplexml: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- infection/infection: ^0.32.6
- pestphp/pest: ^3.0
- phpstan/phpstan: ^2.0
Suggests
- ext-yaml: For improved YAML parsing performance (optional - a built-in minimal parser is used by default)
README
Safe Access Inline — PHP
PHP library for safe nested data access with security validation on by default — JSON, YAML, XML, INI, ENV, NDJSON, arrays and objects. Includes a full PathQuery engine with filters, wildcards, slices, and projections. Zero production dependencies.
The problem
Reading nested data from external sources requires more than null-safe access. You also need to defend against XXE in XML, anchor bombs in YAML, PHP magic method injection, stream wrapper abuse, superglobal access, and payload size attacks. Without a tool for this, that validation is boilerplate you write manually for every format and every endpoint.
Without this library (XML from an external API):
libxml_disable_entity_loader(true); $xml = simplexml_load_string($input, 'SimpleXMLElement', LIBXML_NOENT); if ($xml === false) { throw new RuntimeException('Invalid XML'); } // validate keys against magic methods, superglobals, stream wrappers... // enforce depth and key count limits... $host = isset($xml->database->host) ? (string) $xml->database->host : null;
With this library:
$host = Inline::fromXml($input)->get('database.host'); // XXE blocked, forbidden keys validated, depth enforced — by default
Installation
composer require safeaccess/inline
Requirements: PHP 8.2+, extensions: json, simplexml, libxml
Optional: ext-yaml for improved YAML parsing performance (a built-in minimal parser is used by default).
Quick start
use SafeAccess\Inline\Inline; $accessor = Inline::fromJson('{"user": {"name": "Alice", "age": 30}}'); $accessor->get('user.name'); // 'Alice' $accessor->get('user.email', 'N/A'); // 'N/A' (default when missing) $accessor->has('user.age'); // true $accessor->getOrFail('user.name'); // 'Alice' (throws if missing) // Immutable writes - original is never modified $updated = $accessor->set('user.email', 'alice@example.com'); $updated->get('user.email'); // 'alice@example.com' $accessor->has('user.email'); // false (original unchanged)
Security
All public entry points validate input by default. Every key passes through SecurityGuard and SecurityParser before being accessible.
What gets blocked
| Category | Examples | Reason |
|---|---|---|
| PHP magic methods | __construct, __destruct, __wakeup, __sleep, __toString, ... |
Prevent PHP magic behavior via data keys |
| Prototype pollution | __proto__, constructor, prototype |
Prevent prototype pollution attacks |
| PHP superglobals | GLOBALS, _GET, _POST, _COOKIE, _SERVER, _ENV, ... |
Prevent superglobal variable access |
| Stream wrapper URIs | php://input, phar://..., data://..., file://... |
Prevent stream wrapper injection |
Format-specific protections
| Format | Protection |
|---|---|
| XML | Rejects <!DOCTYPE — prevents XXE (XML External Entity) attacks |
| YAML | Blocks unsafe tags, anchors (&), aliases (*), and merge keys (<<) |
| All | Forbidden key validation on every parsed key |
Structural limits
| Limit | Default | Description |
|---|---|---|
maxPayloadBytes |
10 MB | Maximum raw string input size |
maxKeys |
10,000 | Maximum total key count |
maxDepth |
512 | Maximum structural nesting depth |
maxResolveDepth |
100 | Maximum recursion for path resolution |
maxCountRecursiveDepth |
100 | Maximum recursion when counting keys |
Custom forbidden keys
$guard = new SecurityGuard(extraForbiddenKeys: ['secret', 'internal_token']); $accessor = Inline::withSecurityGuard($guard)->fromJson($data);
Disabling validation for trusted input
$accessor = Inline::withStrictMode(false)->fromJson($trustedPayload);
Warning: Disabling strict mode skips all validation. Only use with application-controlled input.
For vulnerability reports, see SECURITY.md.
Dot notation syntax
Basic syntax
| Syntax | Example | Description |
|---|---|---|
key.key |
user.name |
Nested key access |
key.0.key |
users.0.name |
Numeric key (array index) |
key\.with\.dots |
config\.db\.host |
Escaped dots in key names |
$ or $.path |
$.user.name |
Optional root prefix (stripped) |
$data = Inline::fromJson('{"users": [{"name": "Alice"}, {"name": "Bob"}]}'); $data->get('users.0.name'); // 'Alice' $data->get('users.1.name'); // 'Bob'
Advanced PathQuery
| Syntax | Example | Description |
|---|---|---|
[0] |
users[0] |
Bracket index access |
* or [*] |
users.* |
Wildcard — expand all children |
..key |
..name |
Recursive descent — find key at any depth |
..['a','b'] |
..['name','age'] |
Multi-key recursive descent |
[0,1,2] |
users[0,1,2] |
Multi-index selection |
['a','b'] |
['name','age'] |
Multi-key selection |
[0:5] |
items[0:5] |
Slice — indices 0 through 4 |
[::2] |
items[::2] |
Slice with step |
[::-1] |
items[::-1] |
Reverse slice |
[?expr] |
users[?age>18] |
Filter predicate expression |
.{fields} |
.{name, age} |
Projection — select fields |
.{alias: src} |
.{fullName: name} |
Aliased projection |
Filter expressions
$data = Inline::fromJson('[ {"name": "Alice", "age": 25, "role": "admin"}, {"name": "Bob", "age": 17, "role": "user"}, {"name": "Carol", "age": 30, "role": "admin"} ]'); // Comparison: ==, !=, >, <, >=, <= $data->get('[?age>18]'); // Alice and Carol // Logical: && and || $data->get('[?age>18 && role==\'admin\']'); // Alice and Carol // Built-in functions: starts_with, contains, values $data->get('[?starts_with(@.name, \'A\')]'); // Alice $data->get('[?contains(@.name, \'ob\')]'); // Bob // Arithmetic: +, -, *, / $orders = Inline::fromJson('[{"price": 10, "qty": 5}, {"price": 3, "qty": 2}]'); $orders->get('[?@.price * @.qty > 20]'); // first order only
Supported formats
JSON
$accessor = Inline::fromJson('{"users": [{"name": "Alice"}, {"name": "Bob"}]}'); $accessor->get('users.0.name'); // 'Alice'
YAML
$yaml = <<<YAML database: host: localhost port: 5432 credentials: user: admin YAML; $accessor = Inline::fromYaml($yaml); $accessor->get('database.credentials.user'); // 'admin'
XML
$xml = '<config><database><host>localhost</host></database></config>'; $accessor = Inline::fromXml($xml); $accessor->get('database.host'); // 'localhost' // Also accepts SimpleXMLElement $accessor = Inline::fromXml(simplexml_load_string($xml));
INI
$accessor = Inline::fromIni("[database]\nhost=localhost\nport=5432"); $accessor->get('database.host'); // 'localhost'
ENV (dotenv)
$accessor = Inline::fromEnv("APP_NAME=MyApp\nAPP_DEBUG=true\nDB_HOST=localhost"); $accessor->get('DB_HOST'); // 'localhost'
NDJSON
Each line is parsed as an independent JSON object and indexed from 0 by its position in the input. Blank lines and trailing newlines are skipped. Security validation is applied to each line individually.
$ndjson = '{"id":1,"name":"Alice"}' . "\n" . '{"id":2,"name":"Bob"}'; $accessor = Inline::fromNdjson($ndjson); $accessor->get('0.name'); // 'Alice' $accessor->get('1.name'); // 'Bob'
Array / Object
$accessor = Inline::fromArray(['users' => [['name' => 'Alice'], ['name' => 'Bob']]]); $accessor->get('users.0.name'); // 'Alice' $accessor = Inline::fromObject((object) ['name' => 'Alice']); $accessor->get('name'); // 'Alice'
Any (custom format via integration)
use SafeAccess\Inline\Contracts\ParseIntegrationInterface; // Requires implementing ParseIntegrationInterface $accessor = Inline::withParserIntegration(new MyCsvIntegration())->fromAny($csvString); $accessor->get('0.column_name');
Dynamic (by TypeFormat enum)
use SafeAccess\Inline\Enums\TypeFormat; $accessor = Inline::from(TypeFormat::Json, '{"key": "value"}'); $accessor->get('key'); // 'value'
Reading & writing
$accessor = Inline::fromJson('{"a": {"b": 1, "c": 2}}'); // Read $accessor->get('a.b'); // 1 $accessor->get('a.missing', 'default'); // 'default' $accessor->getOrFail('a.b'); // 1 (throws PathNotFoundException if missing) $accessor->has('a.b'); // true $accessor->all(); // ['a' => ['b' => 1, 'c' => 2]] $accessor->count(); // 1 (root keys) $accessor->count('a'); // 2 (keys under 'a') $accessor->keys(); // ['a'] $accessor->keys('a'); // ['b', 'c'] $accessor->getMany([ 'a.b' => null, 'a.x' => 'fallback', ]); // ['a.b' => 1, 'a.x' => 'fallback'] $accessor->getRaw(); // original JSON string // Write (immutable - every write returns a new instance) $updated = $accessor->set('a.d', 3); $updated = $updated->remove('a.c'); $updated = $updated->merge('a', ['e' => 4]); $updated = $updated->mergeAll(['f' => 5]); $updated->all(); // ['a' => ['b' => 1, 'd' => 3, 'e' => 4], 'f' => 5] // Readonly mode - block all writes $readonly = $accessor->readonly(); $readonly->get('a.b'); // 1 (reads work) $readonly->set('a.b', 99); // throws ReadonlyViolationException
Configure
Builder pattern
use SafeAccess\Inline\Inline; use SafeAccess\Inline\Security\SecurityGuard; use SafeAccess\Inline\Security\SecurityParser; $accessor = Inline::withSecurityGuard(new SecurityGuard(extraForbiddenKeys: ['secret'])) ->withSecurityParser(new SecurityParser(maxDepth: 5)) ->withStrictMode(true) ->fromJson($untrustedInput);
Builder methods
| Method | Description |
|---|---|
withSecurityGuard($guard) |
Custom forbidden-key rules and depth limits |
withSecurityParser($parser) |
Custom payload size and structural limits |
withPathCache($cache) |
Path segment cache for repeated lookups |
withParserIntegration($integration) |
Custom format parser for fromAny() |
withStrictMode(false) |
Disable security validation (trusted input only) |
Error handling
All exceptions extend AccessorException:
use SafeAccess\Inline\Exceptions\AccessorException; use SafeAccess\Inline\Exceptions\InvalidFormatException; use SafeAccess\Inline\Exceptions\SecurityException; use SafeAccess\Inline\Exceptions\PathNotFoundException; use SafeAccess\Inline\Exceptions\ReadonlyViolationException; try { $accessor = Inline::fromJson($untrustedInput); $value = $accessor->getOrFail('config.key'); } catch (InvalidFormatException $e) { // Malformed JSON, XML, INI, or NDJSON } catch (SecurityException $e) { // Forbidden key, payload too large, depth/key-count exceeded } catch (PathNotFoundException $e) { // Path does not exist } catch (ReadonlyViolationException $e) { // Write on readonly accessor } catch (AccessorException $e) { // Catch-all for any library error }
Exception hierarchy
| Exception | Extends | When |
|---|---|---|
AccessorException |
RuntimeException |
Root — catch-all |
SecurityException |
AccessorException |
Forbidden key, payload, structural limits |
InvalidFormatException |
AccessorException |
Malformed JSON, XML, INI, NDJSON |
YamlParseException |
InvalidFormatException |
Unsafe or malformed YAML |
PathNotFoundException |
AccessorException |
getOrFail() on missing path |
ReadonlyViolationException |
AccessorException |
Write on readonly accessor |
UnsupportedTypeException |
AccessorException |
Unknown accessor class in make() |
ParserException |
AccessorException |
Internal parser errors |
Advanced usage
Strict mode
// Disable all security validation for trusted input $accessor = Inline::withStrictMode(false)->fromJson($trustedPayload);
Warning: Disabling strict mode skips all validation. Only use with application-controlled input.
Path cache
// Implement PathCacheInterface for repeated lookups $cache = new MyPathCache(); $accessor = Inline::withPathCache($cache)->fromJson($data); $accessor->get('deeply.nested.path'); // parses path $accessor->get('deeply.nested.path'); // cache hit
Custom format integration
// Implement ParseIntegrationInterface for custom formats class CsvIntegration implements ParseIntegrationInterface { public function assertFormat(mixed $raw): bool { return is_string($raw) && str_contains($raw, ','); } public function parse(mixed $raw): array { // Parse CSV to associative array return $parsed; } } $accessor = Inline::withParserIntegration(new CsvIntegration())->fromAny($csvString);
API reference
Inline facade
Static factory methods
| Method | Input | Returns |
|---|---|---|
fromArray($data) |
array<array-key, mixed> |
ArrayAccessor |
fromObject($data) |
object |
ObjectAccessor |
fromJson($data) |
JSON string |
JsonAccessor |
fromXml($data) |
XML string or SimpleXMLElement |
XmlAccessor |
fromYaml($data) |
YAML string |
YamlAccessor |
fromIni($data) |
INI string |
IniAccessor |
fromEnv($data) |
dotenv string |
EnvAccessor |
fromNdjson($data) |
NDJSON string |
NdjsonAccessor |
fromAny($data, $integration?) |
mixed |
AnyAccessor |
from($typeFormat, $data) |
TypeFormat enum |
AccessorsInterface |
make($class, $data) |
class-string |
AbstractAccessor |
Accessor read methods
| Method | Returns |
|---|---|
get($path, $default?) |
Value at path, or default |
getOrFail($path) |
Value or throws PathNotFoundException |
getAt($segments, $default?) |
Value at key segments |
has($path) |
bool |
hasAt($segments) |
bool |
getMany($paths) |
array<string, mixed> |
all() |
array<string, mixed> |
count($path?) |
int |
keys($path?) |
list<string> |
getRaw() |
mixed |
Accessor write methods (immutable)
| Method | Description |
|---|---|
set($path, $value) |
Set at path |
setAt($segments, $value) |
Set at key segments |
remove($path) |
Remove at path |
removeAt($segments) |
Remove at key segments |
merge($path, $value) |
Deep-merge at path |
mergeAll($value) |
Deep-merge at root |
Modifier methods
| Method | Description |
|---|---|
readonly($flag?) |
Block all writes |
strict($flag?) |
Toggle security validation |
TypeFormat enum
Array · Object · Json · Xml · Yaml · Ini · Env · Ndjson · Any
Contributing
See CONTRIBUTING.md for development setup, commit conventions, and pull request guidelines.
License
MIT © Felipe Sauer