lukascivil/treewalker
TreeWalker is a lightweight PHP 8.1+ library for manipulating nested arrays, objects, and JSON strings interchangeably. It offers diffing, recursive walking with edits/deletion, merging, and dynamic key-path get/set/create operations, with configurable array, object, or JSON output.
TreeWalker is a small, framework-agnostic utility (single class, Composer-only, MIT) for three jobs: diffing two nested structures, walking/editing a nested structure in place, and reading/writing/creating deep keys via a dynamic path array. In Laravel it is not a service provider — you new it yourself.
Install
composer require lukascivil/treewalker
Requires PHP 8.1+, so any currently supported Laravel (10/11/12) is fine.
Where to look first
getdiff() — this is the reason the package exists (73 stars, "PHP JSON diff"). Everything else is supporting convenience.*Dynamically* methods — createDynamicallyObjects(), getDynamicallyValue(), setDynamicallyValue() — are data_get/data_set-adjacent helpers you already get free from Laravel; treat them as a fallback, not a draw.grep -r "namespace" vendor/lukascivil/treewalker/src — the README examples omit the use statement.First use case — audit logging on model updates
The most immediately useful day-to-day application is producing a human-readable "what changed" payload for an audit table or a notification:
use Lukascivil\TreeWalker\TreeWalker; // verify namespace in vendor/
$walker = new TreeWalker(['returntype' => 'array', 'debug' => false]);
$diff = $walker->getdiff(
$model->getChanges(), // the "new" side
$model->getOriginal(), // the "old" side
true // nested output
);
Be aware of the direction convention before you ship this — see Gotchas.
The class is cheap, but the returntype is fixed per instance, so pick the dominant format for your app and inject it:
// app/Providers/AppServiceProvider.php
$this->app->singleton(TreeWalker::class, fn () => new TreeWalker([
'debug' => false,
'returntype' => 'array',
]));
Then inject TreeWalker into actions/jobs. If you need JSON output for an API response, either create a second short-lived instance with returntype => 'jsonstring' or json_encode() the array yourself — the latter is clearer and avoids the debug footgun.
Because the API is six loosely-named methods, the readability win from a thin wrapper is large:
final class StructureDiffer
{
public function __construct(private TreeWalker $walker) {}
/** @return array{new: array, removed: array, edited: array} */
public function diff(array $before, array $after): array
{
// Normalise argument order here, once, so call sites can't get it wrong.
return $this->walker->getdiff($after, $before, true);
}
}
Centralising the argument order is the single most valuable thing you can do with this package, since getdiff's new/removed naming does not match intuition (see Gotchas).
The library accepts jsonstring | object | array, but mixing them invites subtle behaviour differences. Convert at the boundary:
$before = json_decode($snapshot->payload, true); // array
$after = $model->toArray(); // array, casts applied
$diff = $applier->diff($before, $after);
Eloquent::toArray() gives you date casts, accessors and hidden-field filtering for free — diffing $model->getAttributes() instead will produce noise from raw DB formats.
A genuinely good fit: comparing a config snapshot across environments or a versioned JSON settings document.
$drift = $walker->getdiff(
json_decode(file_get_contents(base_path('settings.json')), true),
$snapshot->settings,
true
);
if ($drift['new'] || $drift['removed'] || $drift['edited']) {
// surface in an admin panel or a scheduled Slack alert
}
Pass false for flat, slash-delimited keys (cafeina/ss) when you want to stuff the result into a flat table or a log line rather than nested JSON.
walker() for bulk transformation, not getdiff()walker() is the right tool when you want to rewrite a payload — stripping nulls, redacting keys, converting empty strings to null — before comparing or storing:
$walker->walker($payload, function (&$struct, $key, &$value) {
if (in_array($key, ['password', 'api_token', 'secret'], true)) {
unset($struct[$key]);
return;
}
if ($value === '') {
$value = null;
}
});
This is a neat alternative to a recursive array_walk_recursive when you need the parent (to unset) as well as the value.
When a vendor sends full-state payloads, store the previous payload and diff on ingest:
public function handle(TreeWalker $walker): void
{
$previous = $this->subscription->last_payload ?? [];
$diff = $walker->getdiff($this->payload, $previous, true);
if (empty($diff['new']) && empty($diff['removed']) && empty($diff['edited'])) {
return; // no-op webhook, don't dispatch downstream work
}
ChangedPayload::dispatch($this->subscription, $diff);
}
This is the highest-leverage integration: cheap idempotency guard on noisy webhooks using only the nested getdiff output.
structMerge() as a defaults/overrides mergerFirst argument wins, and nested keys are merged rather than replaced:
$config = $walker->structMerge($tenantOverrides, $globalDefaults, true);
That's essentially array_replace_recursive with a different precedence narrative — useful if your team already speaks in TreeWalker terms, otherwise prefer the native function.
getdiff()'s naming is inverted relative to how most people read it. In the README example, struct1 contains b and oi, and those land under new; oi2 (present only in struct2) lands under removed. Likewise edited reports oldvalue from the second argument and newvalue from the first. So the mental model is: argument 1 = new/current, argument 2 = old/baseline. If you pass (original, changes) you will label every edit backwards and still pass a naive test. Write one assertion on a known asymmetric pair before trusting it.
debug => true appends execution time to the output. It will corrupt any array you diff, break json_encode round-trips and poison comparisons. Never enable it outside a throwaway tinker session, and hard-code false in your singleton binding.
Loose vs strict comparison is unspecified. The README example diffs "5" (string) and 5-adjacent values without comment. If your data crosses JSON boundaries, string/int drift ("1" vs 1) can show up as a spurious edited entry — or be silently swallowed. Pin this down with a test on your real payload shapes early; if it bites, normalise types before diffing rather than patching the library.
Numeric arrays diff by index, so reordering is pure noise. Inserting one row at the top of a list marks every subsequent index as edited. For list-shaped data, key the collection before diffing:
$keyed = collect($payload['items'])->keyBy('id')->toArray();
Empty objects and empty arrays collapse. json_decode($json, true) turns {} into [], so a structural change like "object became array" is invisible. If that distinction matters, decode without assoc and compare as objects.
Collections and Eloquent models must be converted. Collection is not one of the three accepted types. Always ->toArray() first, and be explicit about returntype afterwards — the returned value follows the instance config, not the input type.
walker() mutates the parent by reference; don't add keys you intend to visit. The callback gets (&$struct, $key, &$value). Unsetting $struct[$key] mid-walk is the documented pattern, but appending new siblings during traversal has undefined ordering. Two-pass it: collect changes in the first pass, apply after.
returntype is fixed at construction. If you need both a nested array for storage and a JSON string for an HTTP response, don't mutate the config — json_encode() the array, or build a second instance. Sharing one instance across a request and flipping modes mid-flight is a good way to ship a bug.
Extension points are essentially nil. No interfaces, no events, no container hooks. If you need depth limits, path-prefix filtering or strict-mode comparison, you're writing a recursive wrapper around walker() yourself. Given 73 stars and zero dependents, treat this as vendored utility code: pin the version in composer.json, and if the diff semantics become load-bearing, consider a small in-house diff instead so you control the comparison rules.
No depth or size guard. Deep or wide payloads (large API exports, big settings documents) recurse without a limit. Run diff jobs on queue workers with a sane timeout, not in a request cycle, and consider hashing large payloads first to skip the diff entirely when nothing changed.
Debugging edited output: the flat (false) mode gives you cafeina/ss paths, which are the fastest way to eyeball a diff in tinker or a log line. Reach for getdiff($a, $b, false) first, then switch to nested once you're confident in the direction convention.
How can I help you explore Laravel packages today?