Advanced Tricks
This section is for library maintainers and developers of JSON migration tools: config upgraders, repository cleanup commands, package metadata migrations, or tests that need to prove a rewrite changed only what it meant to change.
Contents
- Choose Direct Edits Or Visitors
- Add Values Using The Existing Format
- Collapse Duplicate Keys
- Move Existing Subtrees
- Use LeaveNode After Array Reindexing
- Preserve Special Number Spelling
Choose Direct Edits Or Visitors
Use direct JsonDocument edits when the tool already knows the small part of the file it wants to change. This is a good fit for root-level config migration, duplicate-key cleanup, or moving one known subtree with container helpers.
use Boundwize\JsonRecast\JsonRecast;
use Boundwize\JsonRecast\Node\ObjectNode;
use Boundwize\JsonRecast\Value\JsonValue;
$document = JsonRecast::parse($json);
if ($document->value instanceof ObjectNode) {
$document->value->set('config', JsonValue::from(['sort-packages' => true]));
}
echo JsonRecast::print($document);
Use a visitor when the tool should find matching nodes while walking the document. This is the better fit for nested paths, repeated structures, removals with NodeJsonVisitor::REMOVE_NODE, or edits that depend on traversal timing.
use Boundwize\JsonRecast\JsonRecast;
use Boundwize\JsonRecast\Node\BooleanNode;
use Boundwize\JsonRecast\Node\NodeJson;
use Boundwize\JsonRecast\Node\ObjectItemNode;
use Boundwize\JsonRecast\Node\ObjectNode;
use Boundwize\JsonRecast\NodePath\NodeJsonPath;
use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitorAbstract;
use Boundwize\JsonRecast\Value\JsonValue;
$document = JsonRecast::parse($json);
$result = JsonRecast::traverse($document, new class extends NodeJsonVisitorAbstract {
public function enterNode(NodeJson $node, NodeJsonPath $path): ?NodeJson
{
if (! $node instanceof ObjectNode || ! $path->isRoot()) {
return null;
}
$config = $node->get('config');
if ($config instanceof ObjectItemNode && $config->value instanceof ObjectNode) {
$sortPackages = $config->value->get('sort-packages');
if (
$sortPackages instanceof ObjectItemNode
&& $sortPackages->value instanceof BooleanNode
&& $sortPackages->value->value === true
) {
return null;
}
$config->value->set('sort-packages', JsonValue::from(true));
return $node;
}
if ($config instanceof ObjectItemNode) {
return null;
}
$node->set('config', JsonValue::from(['sort-packages' => true]));
return $node;
}
});
echo JsonRecast::print($result);
In short: direct edits are simplest when the location is already known; visitors are safer when discovery, path checks, or traversal order are part of the work. When a visitor finds the file is already in the intended state, return null; that avoids marking the node as changed on repeated runs. Use leaveNode() when it follows an earlier enterNode() change or when the decision depends on child edits that have already happened.
Add Values Using The Existing Format
Project tools often need to add the same data to files with different local formatting. Build the new value once with JsonValue::from(); the preserving printer uses the parsed document’s indentation and newline metadata when it has to print that new subtree.
use Boundwize\JsonRecast\JsonRecast;
use Boundwize\JsonRecast\Node\ObjectNode;
use Boundwize\JsonRecast\Value\JsonValue;
$document = JsonRecast::parse(<<<'JSON'
{
"name": "acme/demo",
"config": {
"allow-plugins": false
}
}
JSON);
if ($document->value instanceof ObjectNode) {
$document->value->set('config', JsonValue::from([
'allow-plugins' => true,
'sort-packages' => true,
]));
}
echo JsonRecast::print($document);
{
"name": "acme/demo",
"config": {
"allow-plugins": true,
"sort-packages": true
}
}
The migration code does not need to know whether the source uses two spaces, four spaces, tabs, or inline objects. In this example the replacement follows the document’s two-space indentation because that is what was parsed from the surrounding file.
Collapse Duplicate Keys
JSON parsers usually treat the last duplicate object key as the effective value. JsonRecast keeps object items visible, so tooling can clean up duplicates while making the intended value explicit.
use Boundwize\JsonRecast\JsonRecast;
use Boundwize\JsonRecast\Node\ObjectNode;
use Boundwize\JsonRecast\Node\StringNode;
$document = JsonRecast::parse('{"name":"acme/demo","license":"GPL","license":"MIT"}');
if ($document->value instanceof ObjectNode) {
$document->value->set('license', new StringNode('BSD-3-Clause'));
}
echo JsonRecast::print($document);
{"name":"acme/demo","license":"BSD-3-Clause"}
ObjectNode::set() updates the effective value and removes the stale duplicate entries. Use this in migration tools when duplicate keys would otherwise make a config file ambiguous.
Move Existing Subtrees
When a value is already present in the file, move the existing node instead of rebuilding it from PHP data. This keeps useful details such as number spelling, escaped strings, and multiline formatting.
use Boundwize\JsonRecast\JsonRecast;
use Boundwize\JsonRecast\Node\ArrayNode;
use Boundwize\JsonRecast\Node\ObjectItemNode;
use Boundwize\JsonRecast\Node\ObjectNode;
$document = JsonRecast::parse(<<<'JSON'
{
"legacy": [
{
"path": "tests/Fixtures/App"
}
],
"autoload-dev": []
}
JSON);
if ($document->value instanceof ObjectNode) {
$legacyItem = $document->value->get('legacy');
$autoloadDevItem = $document->value->get('autoload-dev');
if (
$legacyItem instanceof ObjectItemNode
&& $legacyItem->value instanceof ArrayNode
&& $autoloadDevItem instanceof ObjectItemNode
&& $autoloadDevItem->value instanceof ArrayNode
) {
if ($legacyItem->value->items !== []) {
$movedNode = $legacyItem->value->items[0]->value;
$legacyItem->value->removeAt(0);
$autoloadDevItem->value->append($movedNode);
}
if ($legacyItem->value->items === []) {
$document->value->remove('legacy');
}
}
}
echo JsonRecast::print($document);
{
"autoload-dev": [
{
"path": "tests/Fixtures/App"
}
]
}
This is handy for project restructures: move a block from an old key to a new key, then remove the old parent when the migration leaves it empty. The empty check also handles files where legacy was already empty before this run.
Use LeaveNode After Array Reindexing
If a migration removes or inserts array items in enterNode(), child values are visited with the updated indexes. Use matchesObjectKeys() to match the array’s object-key path, then leaveNode() with NodeJsonPath::matches() when the next edit should target the reshaped array.
use Boundwize\JsonRecast\JsonRecast;
use Boundwize\JsonRecast\Node\ArrayNode;
use Boundwize\JsonRecast\Node\BooleanNode;
use Boundwize\JsonRecast\Node\NodeJson;
use Boundwize\JsonRecast\Node\ObjectItemNode;
use Boundwize\JsonRecast\Node\ObjectNode;
use Boundwize\JsonRecast\Node\StringNode;
use Boundwize\JsonRecast\NodePath\NodeJsonPath;
use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitorAbstract;
use Boundwize\JsonRecast\Value\JsonValue;
$document = JsonRecast::parse(<<<'JSON'
{
"repositories": [
{"type": "vcs", "url": "https://example.com/old.git"},
{"type": "path", "url": "packages/local"}
]
}
JSON);
$result = JsonRecast::traverse($document, new class extends NodeJsonVisitorAbstract {
private ?int $composerRepositoryIndex = null;
public function enterNode(NodeJson $node, NodeJsonPath $path): ?NodeJson
{
if (! $node instanceof ArrayNode || ! $path->matchesObjectKeys(['repositories'])) {
return null;
}
$oldIndex = null;
foreach ($node->items as $index => $item) {
if (! $item->value instanceof ObjectNode) {
continue;
}
$type = $item->value->get('type');
$url = $item->value->get('url');
if (
$type instanceof ObjectItemNode
&& $type->value instanceof StringNode
&& $type->value->value === 'vcs'
&& $url instanceof ObjectItemNode
&& $url->value instanceof StringNode
&& $url->value->value === 'https://example.com/old.git'
) {
$oldIndex = $index;
}
}
if ($oldIndex === null) {
return null;
}
$node->removeAt($oldIndex);
$this->composerRepositoryIndex = count($node->items);
$node->append(JsonValue::from([
'type' => 'composer',
'url' => 'https://repo.packagist.org',
]));
return $node;
}
public function leaveNode(NodeJson $node, NodeJsonPath $path): ?NodeJson
{
if (
$this->composerRepositoryIndex !== null
&& $node instanceof ObjectNode
&& $path->matches(['repositories', $this->composerRepositoryIndex])
) {
$type = $node->get('type');
$url = $node->get('url');
if (
! $type instanceof ObjectItemNode
|| ! $type->value instanceof StringNode
|| $type->value->value !== 'composer'
|| ! $url instanceof ObjectItemNode
|| ! $url->value instanceof StringNode
|| $url->value->value !== 'https://repo.packagist.org'
) {
return null;
}
$canonical = $node->get('canonical');
if (
$canonical instanceof ObjectItemNode
&& $canonical->value instanceof BooleanNode
&& $canonical->value->value === false
) {
return null;
}
$node->set('canonical', JsonValue::from(false));
return $node;
}
return null;
}
});
echo JsonRecast::print($result);
{
"repositories": [
{"type": "path", "url": "packages/local"},
{
"type": "composer",
"url": "https://repo.packagist.org",
"canonical": false
}
]
}
The repositories array is changed before its children are traversed. The visitor records the index where the new repository was appended, then leaveNode() uses that updated path to add metadata to the reshaped item directly. The early return null branches make the migration repeatable: enterNode() stops when the old repository is already gone, and leaveNode() stops when the appended repository already has the intended canonical value.
Preserve Special Number Spelling
NumberNode stores the raw JSON spelling. If your tool only needs to inspect a number, avoid rebuilding the node, because spellings such as -0, 1.0, and 1e0 may matter to users.
use Boundwize\JsonRecast\JsonRecast;
use Boundwize\JsonRecast\Node\NodeJson;
use Boundwize\JsonRecast\Node\NumberNode;
use Boundwize\JsonRecast\NodePath\NodeJsonPath;
use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitorAbstract;
use RuntimeException;
$document = JsonRecast::parse('{"temperature_delta":-0}');
$result = JsonRecast::traverse($document, new class extends NodeJsonVisitorAbstract {
public function enterNode(NodeJson $node, NodeJsonPath $path): ?NodeJson
{
if ($node instanceof NumberNode) {
$value = $node->toIntOrFloat();
if (
$path->matchesObjectKeys(['temperature_delta'])
&& ($value < -10 || $value > 10)
) {
throw new RuntimeException('temperature_delta must be between -10 and 10.');
}
}
return null;
}
});
echo JsonRecast::print($result);
{"temperature_delta":-0}
Rebuild number nodes only when the migration intentionally changes the number. Otherwise, leave the original node in place and the preserving printer will reuse its original text.