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, TOML, XML, INI, ENV, NDJSON, CSV/TSV, arrays and objects. Includes a full PathQuery engine with filters, wildcards, slices, and projections, plus optional schema validation. Zero production dependencies.
What's new in 0.2.0
- New formats: TOML (
fromToml) and CSV/TSV (fromCsv,fromTsv). - Optional schema validation:
validate()/assert()with type rules, constraints (min,max,enum,pattern,email,url,uuid), per-itemeach:(...), and*wildcard paths.
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'
TOML
$toml = <<<TOML [database] host = "localhost" port = 5432 [[servers]] name = "alpha" [[servers]] name = "beta" TOML; $accessor = Inline::fromToml($toml); $accessor->get('database.host'); // 'localhost' $accessor->get('servers.1.name'); // 'beta'
Supports tables, arrays of tables, dotted keys, inline arrays and tables, and the scalar types (integer, float, boolean, string). Duplicate keys and redefined tables are rejected; datetimes are preserved as strings.
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'
CSV / TSV
The first row is the header; each subsequent row becomes a record indexed from 0 by position, keyed by the header columns. All values are strings — CSV has no type system, so 007 stays "007". Quoted fields may contain the delimiter, escaped quotes (""), and embedded newlines. Duplicate header columns, rows whose field count differs from the header, and unterminated quotes are rejected. Header columns are validated against forbidden keys.
$csv = "name,age,city\nAlice,30,Porto\nBob,25,Lisbon"; $accessor = Inline::fromCsv($csv); $accessor->get('0.name'); // 'Alice' $accessor->get('1.city'); // 'Lisbon' // Tab-separated $accessor = Inline::fromTsv("name\tage\nAlice\t30"); $accessor->get('0.age'); // '30'
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
Schema validation
Beyond security, validate the shape of the data. A schema maps dot-notation paths to pipe-separated rules: a base type (string, int, float, number, bool, array, object, null, any) followed by optional constraints. A trailing ? marks the path optional. Validation runs against the already-parsed value.
| Constraint | Applies to | Meaning |
|---|---|---|
min:N |
number | value >= N |
min:N |
string / array | length / size >= N |
max:N |
number / string / array | value or length / size <= N |
enum:a,b,c |
string / number | value must be one of the listed options |
pattern:REGEX |
string | must match the (unanchored) regular expression |
email url uuid |
string | common string formats |
each:(RULE) |
array | every element must satisfy RULE (may nest) |
validate() returns a SchemaResult you can inspect; assert() throws SchemaValidationException on failure and returns the accessor for chaining. A path may contain * wildcards to apply a rule to every match. A malformed rule throws AccessorException.
$accessor = Inline::fromJson('{"db":{"host":"localhost","port":5432},"users":[{"email":"a@b.com"}]}'); // Inspect without throwing $result = $accessor->validate([ 'db.host' => 'string|min:1', 'db.port' => 'int|min:1|max:65535', 'db.ssl' => 'bool?', // optional 'users.*.email' => 'string|email', // every user's email ]); $result->isValid(); // true $result->errors(); // SchemaError[] when invalid $result->errorsByPath(); // ['path' => [messages]] grouped per field // Assert and chain (throws SchemaValidationException on failure) $host = $accessor->assert(['db.host' => 'string'])->get('db.host');
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 |
TomlParseException |
InvalidFormatException |
Malformed TOML, duplicate keys, tables |
CsvParseException |
InvalidFormatException |
Malformed CSV/TSV, duplicate columns |
SchemaValidationException |
AccessorException |
assert() when data fails the schema |
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 |
fromToml($data) |
TOML string |
TomlAccessor |
fromIni($data) |
INI string |
IniAccessor |
fromEnv($data) |
dotenv string |
EnvAccessor |
fromNdjson($data) |
NDJSON string |
NdjsonAccessor |
fromCsv($data) |
CSV string |
CsvAccessor |
fromTsv($data) |
TSV string |
TsvAccessor |
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