atoolo / channel-diff
CLI tool to compare two IES publication channels.
Requires
- php: >=8.2
- ext-json: *
- atoolo/resource-bundle: ^1.8
- symfony/console: ^7.4
- symfony/dotenv: ^7.4
- symfony/framework-bundle: ^7.4
- symfony/runtime: ^7.4
- symfony/yaml: ^7.4
Requires (Dev)
- dealerdirect/phpcodesniffer-composer-installer: ^1.2
- infection/infection: ^0.27.11
- overtrue/phplint: ^9.7.1
- phpcompatibility/php-compatibility: ^9.3.5
- roave/security-advisories: dev-latest
- squizlabs/php_codesniffer: ^3.13
Conflicts
This package is auto-updated.
Last update: 2026-08-03 07:22:11 UTC
README
channel-diff
A command-line tool to compare two IES publication channels. It loads the PHP resource files of each channel into their nested arrays and diffs them field-by-field; binary media files are compared by hash.
Typical use cases: verifying a channel after a re-publish, a publisher change, or a migration between environments.
How it works
- Each publication channel is a directory containing SiteKit resource PHP files.
These are loaded via
atoolo/resource-bundleinto their raw nested arrays and compared. - The three channel layouts are detected automatically:
- DOCUMENT_ROOT — everything in one directory (resources and media
intermixed); the SiteKit framework subtree (
WEB-IES/) is excluded. - RESOURCE, URL-based — separate
objects/andmedia/public/trees. - RESOURCE, ID-based — same as above, with an ID-bucketed
objects/tree.
- DOCUMENT_ROOT — everything in one directory (resources and media
intermixed); the SiteKit framework subtree (
- Resources and media are matched by their relative file path. Volatile
fields (
version,created,changed,generated,changedBy,cacheInfo) are ignored by default.
Note: Because matching is by physical path, both channels should use the same layout. Comparing channels of different layouts will report every resource as "only in A / only in B".
Requirements
- PHP >= 8.2 with the
intlandjsonextensions - Composer 2.x
- phive (for the QA tools; only needed for
composer testandcomposer analyse)
Installation
git clone <repository-url> channel-diff cd channel-diff composer install
atoolo/resource-bundle is resolved from Packagist like any other dependency.
composer install runs a post-install-cmd that installs the QA PHAR tools
(PHPUnit, PHPStan, PHP-CS-Fixer, composer-normalize) into tools/ via
phive. phplint is installed as a regular Composer
dev dependency.
Contributors should enable the shared git hooks (Conventional Commit check) once after cloning:
git config core.hooksPath .githooks
Usage
php bin/console channel:diff <channelA> <channelB> [subPath] [options]
Arguments
| Argument | Description |
|---|---|
<channelA>, <channelB> |
Base directories of the two publication channels (required). |
[subPath] |
Restrict the comparison to this sub directory of both channels (optional; default: the whole channel). |
The channel base directories are, for example:
- DOCUMENT_ROOT:
.../publications/<host>/www - RESOURCE:
.../publications/<host>/www/resources
Restricting the comparison to a sub directory
[subPath] is resolved against the channel base directory, so one path
expression addresses the resource tree and the media tree alike — even though
those are separate sub trees in the RESOURCE layout. A sub path that lies
outside one of the two trees simply excludes that tree from the comparison.
For a RESOURCE-layout channel (resourceDir = <base>/objects, mediaDir =
<base>/media/public):
subPath |
Resources compared | Media compared |
|---|---|---|
| (omitted) | all | all |
objects |
all | none |
objects/de/produkte |
objects/de/produkte only |
none |
media/public |
none | all |
media/public/img |
none | media/public/img only |
In the DOCUMENT_ROOT layout, resources and media share one directory, so a sub path narrows both at once.
Only the given sub tree is walked, which also makes a scoped run considerably faster than a full channel comparison.
A sub path that exists in neither channel is an error (exit code 2) — it is
almost always a typo, and reporting it as "identical" would be misleading. A sub
path that exists in only one of the two channels is a real difference and is
reported as such. Sub paths must stay inside the channel; .. segments are
rejected.
Options
| Option | Description |
|---|---|
-f, --format=console|json |
Output format (default: console). |
-i, --ignore=<path> |
Additional dot-notation field path to ignore. Supports wildcards (see below). Repeatable. |
--ignore-config=<file.php> |
PHP file returning a list of dot-notation field paths to ignore. |
--rules=<file.yaml> |
Rule file with accepted differences. Overrides the automatic lookup (see below). |
--no-rules |
Ignore any rule file found next to the channels. |
--float-precision=<n> |
Number of decimal places at which two floats still count as equal. Overrides floatPrecision from the rule file. |
--no-media |
Skip the binary media comparison. |
--strict-null |
Treat a null field and a missing field as different. By default, a field that is null in one channel and absent in the other is considered equal. |
--strict-empty-string |
Treat an empty-string field and a missing field as different. By default, a field that is "" in one channel and absent in the other is considered equal. |
--strict-empty-array |
Treat an empty-array field and a missing field as different. By default, a field that is an empty array in one channel and absent in the other is considered equal. Emptiness is recursive: an array whose (nested) values are all empty counts as empty (e.g. ['features' => ['primary' => []]]). |
--strict-uuid-keys |
Treat UUID array keys as significant. By default, an array whose keys are all UUIDs is matched by the content of its values instead of by the (volatile, regenerated) keys, and inner values equal to the key (e.g. a mirrored id) are neutralized so entries pair up. |
Rule file: recording accepted differences
Differences that have been reviewed and are known to be acceptable can be
recorded in a rule file, so they stop showing up in every subsequent run. Place a
channel-diff.yaml next to the channels:
# .../publications/<host>/www/channel-diff.yaml excludes: # The new publisher writes an explicit "static: false" per source. - '**.sources.*.static' # Focalpoints are stored with 8 decimals, but the 8th digit is not always a # correct rounding of the previous value. floatPrecision: 7
The file is looked up from each channel's base directory upwards, so a single
file above both channels covers both — for example one www/channel-diff.yaml
for www/resources and www/resources.old. The nearest file wins; .yml is
accepted as well. The report lists which rule file was applied, so it is always
visible why a difference is missing.
Use --rules=<file> to point at a specific file instead, or --no-rules to
ignore a discovered one for a single run.
| Key | Meaning |
|---|---|
excludes |
List of dot-notation field paths to exclude, with the same wildcards as --ignore (see below). |
floatPrecision |
Number of decimal places at which two floats still count as equal. Omit it to compare floats strictly. |
floatPrecision: n is applied as a tolerance of 0.5 × 10⁻ⁿ rather than by
rounding both sides, so two values that happen to straddle a rounding boundary
are not reported as a difference. Note that this makes the effective precision
one place coarser than the number of digits you see: values written with 8
decimals whose last digit is not a faithful rounding of the original need
floatPrecision: 7. Only float-to-float pairs use the tolerance — a float
against an int or a numeric string stays a difference, since that is a type
change rather than a precision issue.
Rule files are parsed as data and never executed, unlike --ignore-config.
Ignore path wildcards
Ignore paths may contain wildcard segments:
*matches exactly one key segment. A*in the middle of a path is a field ignore, e.g.a.b.*.idignores theidfield of every child ofa.b.**matches any number of segments (including none), so a structure that occurs at many depths can be addressed once, e.g.**.geo.features.*.- A terminal
*, e.g.**.geo.features.*, is a key-normalization marker: the matched array is compared by the content of its values, not by their keys (useful for arrays keyed by volatile values, when UUID auto-detection does not apply).
Exit codes
| Code | Meaning |
|---|---|
0 |
Channels are identical. |
1 |
Differences were found. |
2 |
An error occurred (e.g. an invalid channel path, or a subPath that exists in neither channel). |
Examples
Compare two channels with human-readable output:
php bin/console channel:diff \
~/ies-environments/site/data/publications/host/www/resources \
~/ies-environments/site/data/publications/host/preview/resources
Compare only one sub directory — here the German product pages, which also skips the media comparison entirely:
php bin/console channel:diff /path/to/A /path/to/B objects/de/produkte
Produce a machine-readable report for CI:
php bin/console channel:diff /path/to/A /path/to/B --format=json > diff.json
Ignore additional fields, both inline and via a config file:
php bin/console channel:diff /path/to/A /path/to/B \
--ignore=ies \
--ignore=base.germanCourse.venue.link \
--ignore-config=ignore.php
Accept the known differences of a re-publish via a rule file, and check what is left:
cat > /path/to/www/channel-diff.yaml <<'YAML' excludes: - '**.sources.*.static' floatPrecision: 7 YAML php bin/console channel:diff \ /path/to/www/resources.old \ /path/to/www/resources \ objects/testseiten
An ignore.php config file simply returns a list of field paths:
<?php return [ 'ies', 'base.germanCourse.venue.addressData.geo', ];
Notes on comparison
- Nested arrays are compared recursively; differences are reported with a
dot-notation path (e.g.
base.metadata.headline). - A field that is
nullin one channel and missing in the other is treated as equal by default; use--strict-nullto report it as a difference. The same applies to an empty string ("") versus a missing field (--strict-empty-string) and an empty array ([]) versus a missing field (--strict-empty-array). - Arrays keyed by UUIDs (which IES regenerates on every publish) are matched by
the content of their values by default, so the changing keys do not show up as
differences; use
--strict-uuid-keysto compare them by key instead. - Floats are compared strictly unless a
floatPrecisionis configured; see Rule file. - PHP objects (e.g.
stdClass) are compared structurally, not by instance. - Closures (e.g. from "code" content sections) are compared by their source code, not by instance.
- In the JSON output, field values are rendered as bounded single-line strings;
closures and objects are shown as
<closure>/<object:Class>.
Development
composer test # run the PHPUnit test suite (with coverage) composer analyse # phplint, PHPStan, PHP-CS-Fixer (check), PHP compatibility composer fix # apply PHP-CS-Fixer fixes composer test:infection # run mutation testing (Infection)