|
| 1 | +# ALTO \ JSON Patch |
| 2 | + |
| 3 | +A strict, auditable [JSON Patch](https://en.wikipedia.org/wiki/JSON_Patch) implementation for PHP 8.3+. This library handles two concerns with precision: |
| 4 | + |
| 5 | +1. **Apply**: A deterministic **[RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902)** engine that replays patches exactly. |
| 6 | +2. **Diff**: A smart diff generator that produces stable, readable patches. |
| 7 | + |
| 8 | +Built for systems where change history matters. |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | + [](https://github.com/PhpAlto/json-patch) |
| 13 | + [](https://github.com/PhpAlto/json-patch/actions) |
| 14 | + [](https://packagist.org/packages/alto/json-patch) |
| 15 | + [](https://github.com/PhpAlto/json-patch) |
| 16 | + [](https://github.com/PhpAlto/json-patch) |
| 17 | + [](./LICENSE) |
| 18 | + |
| 19 | +* **Pure PHP**: Tiny surface area, no heavy dependencies. |
| 20 | +* **Strict Types**: Built for PHP 8.3+ with strict typing. |
| 21 | +* **Deterministic**: Error model designed for auditability. |
| 22 | +* **Smart Diffing**: Supports standard list replacement or smart "by-id" list diffing for readable patches. |
| 23 | + |
| 24 | +## Installation |
| 25 | + |
| 26 | +```bash |
| 27 | +composer require alto/json-patch |
| 28 | +``` |
| 29 | + |
| 30 | +## Why Alto JSON Patch? |
| 31 | + |
| 32 | +**For audit logs**: Deterministic apply means you can verify patch integrity. Store the parent hash, the patch, and the |
| 33 | +result hash. Replaying the patch will always produce the same result. |
| 34 | + |
| 35 | +**For readable diffs**: Generate clean patches that humans can review. Optional identity-based list diffing produces |
| 36 | +granular operations instead of replacing entire arrays. |
| 37 | + |
| 38 | +**For reliability**: Pure PHP with strict types. No magic, no surprises. |
| 39 | + |
| 40 | +## Quick Start |
| 41 | + |
| 42 | +```php |
| 43 | +use Alto\JsonPatch\JsonPatch; |
| 44 | + |
| 45 | +$document = [ |
| 46 | + 'user' => ['name' => 'Alice', 'role' => 'editor'], |
| 47 | + 'status' => 'draft', |
| 48 | +]; |
| 49 | + |
| 50 | +$patch = [ |
| 51 | + ['op' => 'replace', 'path' => '/user/role', 'value' => 'admin'], |
| 52 | + ['op' => 'replace', 'path' => '/status', 'value' => 'published'], |
| 53 | +]; |
| 54 | + |
| 55 | +$result = JsonPatch::apply($document, $patch); |
| 56 | +// ['user' => ['name' => 'Alice', 'role' => 'admin'], 'status' => 'published'] |
| 57 | +``` |
| 58 | + |
| 59 | +## Generate Patches |
| 60 | + |
| 61 | +Create patches automatically by diffing two states: |
| 62 | + |
| 63 | +```php |
| 64 | +$before = ['version' => 1, 'status' => 'draft']; |
| 65 | +$after = ['version' => 2, 'status' => 'published', 'author' => 'Alice']; |
| 66 | + |
| 67 | +$patch = JsonPatch::diff($before, $after); |
| 68 | +// [ |
| 69 | +// ['op' => 'replace', 'path' => '/version', 'value' => 2], |
| 70 | +// ['op' => 'replace', 'path' => '/status', 'value' => 'published'], |
| 71 | +// ['op' => 'add', 'path' => '/author', 'value' => 'Alice'], |
| 72 | +// ] |
| 73 | +``` |
| 74 | + |
| 75 | +## Smart List Diffing |
| 76 | + |
| 77 | +By default, lists are replaced entirely when they differ. For granular control, use identity-based diffing: |
| 78 | + |
| 79 | +```php |
| 80 | +use Alto\JsonPatch\DiffOptions; |
| 81 | + |
| 82 | +$before = [ |
| 83 | + 'items' => [ |
| 84 | + ['id' => 'a', 'qty' => 1], |
| 85 | + ['id' => 'b', 'qty' => 2], |
| 86 | + ], |
| 87 | +]; |
| 88 | + |
| 89 | +$after = [ |
| 90 | + 'items' => [ |
| 91 | + ['id' => 'b', 'qty' => 3], // Modified and reordered |
| 92 | + ['id' => 'c', 'qty' => 1], // Added |
| 93 | + ], |
| 94 | +]; |
| 95 | + |
| 96 | +$options = new DiffOptions(['/items' => 'id']); |
| 97 | +$patch = JsonPatch::diff($before, $after, $options); |
| 98 | +// Generates move, add, remove, and replace operations for individual items |
| 99 | +``` |
| 100 | + |
| 101 | +This produces readable patches where reviewers can see exactly which items changed. |
| 102 | + |
| 103 | +## Utility Methods |
| 104 | + |
| 105 | +```php |
| 106 | +// Get a value at a JSON pointer path |
| 107 | +$name = JsonPatch::get($document, '/user/name'); |
| 108 | + |
| 109 | +// Test if a value matches (returns bool) |
| 110 | +$isAdmin = JsonPatch::test($document, '/user/role', 'admin'); |
| 111 | + |
| 112 | +// Validate patch structure without applying |
| 113 | +$errors = JsonPatch::validate($patch); |
| 114 | +``` |
| 115 | + |
| 116 | +## Audit Trail Example |
| 117 | + |
| 118 | +```php |
| 119 | +class ChangeLog |
| 120 | +{ |
| 121 | + public function recordChange(array $before, array $after): void |
| 122 | + { |
| 123 | + $patch = JsonPatch::diff($before, $after); |
| 124 | + |
| 125 | + $this->store([ |
| 126 | + 'parent_hash' => hash('sha256', json_encode($before)), |
| 127 | + 'patch' => $patch, |
| 128 | + 'result_hash' => hash('sha256', json_encode($after)), |
| 129 | + 'timestamp' => time(), |
| 130 | + ]); |
| 131 | + } |
| 132 | + |
| 133 | + public function verifyIntegrity(string $recordId): bool |
| 134 | + { |
| 135 | + $record = $this->fetch($recordId); |
| 136 | + $parent = $this->reconstructState($record['parent_hash']); |
| 137 | + |
| 138 | + $result = JsonPatch::apply($parent, $record['patch']); |
| 139 | + $computedHash = hash('sha256', json_encode($result)); |
| 140 | + |
| 141 | + return $computedHash === $record['result_hash']; |
| 142 | + } |
| 143 | +} |
| 144 | +``` |
| 145 | + |
| 146 | +## Supported Operations |
| 147 | + |
| 148 | +All RFC 6902 operations: |
| 149 | + |
| 150 | +- `add`: Add a value at a path |
| 151 | +- `remove`: Remove a value at a path |
| 152 | +- `replace`: Replace a value at a path |
| 153 | +- `move`: Move a value from one path to another |
| 154 | +- `copy`: Copy a value from one path to another |
| 155 | +- `test`: Assert a value matches (useful for conditional patches) |
| 156 | + |
| 157 | +## Error Handling |
| 158 | + |
| 159 | +Operations throw `JsonPatchException` with clear messages: |
| 160 | + |
| 161 | +```php |
| 162 | +try { |
| 163 | + JsonPatch::apply($doc, $patch); |
| 164 | +} catch (JsonPatchException $e) { |
| 165 | + // "Operation 0 (replace): path '/missing/path' not found." |
| 166 | + // "Operation 1 (add): invalid path '/items/-1'." |
| 167 | +} |
| 168 | +``` |
| 169 | + |
| 170 | +## Advanced Usage |
| 171 | + |
| 172 | +### Float Comparison |
| 173 | +`JsonPatch` uses strict equality (`===`) for values. Be aware that `json_decode` may treat numbers differently depending on flags. |
| 174 | +For example, `1.0` (float) is not strictly equal to `1` (int). Ensure your input documents use consistent types if strict equality is required. |
| 175 | + |
| 176 | +## Limitations |
| 177 | + |
| 178 | +### `applyJson`: Empty Object vs Array |
| 179 | + |
| 180 | +When using `JsonPatch::applyJson()`, the underlying `json_decode` converts empty JSON objects `{}` into empty PHP arrays |
| 181 | +`[]`. |
| 182 | +Since PHP does not distinguish between empty associative arrays (objects) and empty indexed arrays (lists), an input of |
| 183 | +`{"key": {}}` may result in `{"key": []}` after a round-trip. |
| 184 | +If strictly preserving `{}` vs `[]` is critical, consider using `apply()` with pre-decoded structures where you can |
| 185 | +control the object mapping (e.g. `json_decode($json, false)` for `stdClass`). |
| 186 | + |
| 187 | +## API Reference |
| 188 | + |
| 189 | +### `JsonPatch` |
| 190 | + |
| 191 | +| Method | Description | |
| 192 | +|-------------------------------------------------------------------------|------------------------------------------| |
| 193 | +| `apply(array $doc, array $patch): array` | Apply a patch to a document | |
| 194 | +| `applyJson(string $docJson, string $patchJson, int $flags = 0): string` | Apply patch to JSON string | |
| 195 | +| `diff(array $from, array $to, ?DiffOptions $opts = null): array` | Generate patch from two states | |
| 196 | +| `get(array $doc, string $path): mixed` | Get value at JSON pointer path | |
| 197 | +| `test(array $doc, string $path, mixed $value): bool` | Test if value matches at path | |
| 198 | +| `validate(array $patch): array` | Validate patch structure, returns errors | |
| 199 | + |
| 200 | +### `DiffOptions` |
| 201 | + |
| 202 | +Configure identity-based list diffing: |
| 203 | + |
| 204 | +```php |
| 205 | +$options = new DiffOptions([ |
| 206 | + '/users' => 'id', // Use 'id' field for /users array |
| 207 | + '/items' => 'sku', // Use 'sku' field for /items array |
| 208 | +]); |
| 209 | +``` |
| 210 | + |
| 211 | +## License |
| 212 | + |
| 213 | +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. |
| 214 | +```` |
0 commit comments