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.
- 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.
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 defaultcomposer require safeaccess/inlineRequirements: PHP 8.2+, extensions: json, simplexml, libxml
Optional: ext-yaml for improved YAML parsing performance (a built-in minimal parser is used by default).
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)All public entry points validate input by default. Every key passes through SecurityGuard and SecurityParser before being accessible.
| 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 | 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 |
| 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 |
$guard = new SecurityGuard(extraForbiddenKeys: ['secret', 'internal_token']);
$accessor = Inline::withSecurityGuard($guard)->fromJson($data);$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.
| 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'| 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 |
$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 onlyJSON
$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'$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 ReadonlyViolationExceptionBeyond 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');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);| 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) |
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 | 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 |
// 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.
// 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// 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);| 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 |
| 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 |
| 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 |
| Method | Description |
|---|---|
readonly($flag?) |
Block all writes |
strict($flag?) |
Toggle security validation |
Array · Object · Json · Xml · Yaml · Ini · Env · Ndjson · Any
See CONTRIBUTING.md for development setup, commit conventions, and pull request guidelines.
MIT © Felipe Sauer