watchdiff-labs / watchdiff-core
Lightweight web change monitoring - diffs, file/API/webhook alerts. Framework-agnostic PHP core.
Requires
- php: ^8.1
- ext-dom: *
- ext-json: *
- ext-pdo: *
- dragonmantank/cron-expression: ^3.4
- php-http/discovery: ^1.19
- psr/event-dispatcher: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.0 || ^2.0
- psr/log: ^2.0 || ^3.0
- psr/simple-cache: ^2.0 || ^3.0
- sebastian/diff: ^5.1 || ^6.0
- symfony/console: ^6.4 || ^7.0
- symfony/css-selector: ^6.4 || ^7.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.57
- guzzlehttp/guzzle: ^7.9
- nyholm/psr7: ^1.8
- php-http/mock-client: ^1.6
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- ext-pdo_mysql: Required to watch a MySQL table with watchDb().
- ext-pdo_pgsql: Required to watch a PostgreSQL table with watchDb().
- ext-pdo_sqlite: Required to use the SqliteStore backend.
- chrome-php/chrome: Required for browser: true page rendering and screenshotOnChange (drives a real Chrome/Chromium binary).
- guzzlehttp/guzzle: Default PSR-18 HTTP client used by the CLI if no other client is installed.
- phpoffice/phpspreadsheet: Required for exportReportsXlsx() / exportSnapshotsXlsx().
- symfony/http-client: Alternative PSR-18 HTTP client.
- symfony/mailer: Required to send email alerts.
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-26 19:09:08 UTC
README
Framework-agnostic PHP library for web, file, SSL certificate, and database change monitoring: readable diffs and webhook/email/AI alerts.
WatchDiff watches web pages, local files, SSL certificates, and databases - then tells
you exactly what changed, as structured Change objects, via a callback, a webhook, an
email, or an AI-generated summary.
PHP port of watchdiff-core (npm) / watchdiff-core (PyPI). Same concepts, same diff output shape, native PHP implementation.
Use this package directly in any PHP project, WordPress, a script, or a framework that
doesn't have its own adapter yet. For Laravel or Symfony, use
watchdiff-labs/watchdiff-laravel or
watchdiff-labs/watchdiff-symfony
instead - they pull this package in automatically.
- Deterministic - same input always produces the same output
- Human-readable diffs - structured
Changeobjects (added/removed/modified), not a wall of HTML - CSS selectors AND XPath - target any zone of any page
- JSON path targeting - diff only a specific field inside a JSON API response
- 5 diff modes - line, word, json, rss, sitemap
- File monitoring - watch local files with the same diff pipeline as web pages
- API monitoring - the same
watch()pipeline, withdiffMode: JsonandexpectedStatus - SSL certificate monitoring - alert when a cert is about to expire or gets replaced, pure PHP, no extra dependency
- Database monitoring - watch SQLite, PostgreSQL, or MySQL tables for inserts, deletes, updates, schema changes, and aggregate/value thresholds
- RSS / Atom monitoring - item-level diff by guid/id
- Sitemap monitoring - watch a sitemap.xml for added or removed URLs
- AI summaries - optional Claude / OpenAI / Gemini natural-language summary of every diff, via direct HTTP calls, no vendor SDK
- Cookie-based authentication
- HTTP Basic Auth
- POST request support
- User-Agent rotation
- 5 webhook channels - Discord, Slack (Block Kit), Telegram, Microsoft Teams, ntfy.sh, generic POST
- Email alerts - optional, via
symfony/mailer - Retry with exponential backoff, capped by
maxRetryDelayMs - Alert cooldown - minimum delay between two alerts per watcher
- Interval jitter - randomise polling to avoid thundering herds
- minInterval - global or per-watcher floor on how often a watcher can run
- Cron scheduling - fire checks on standard 5-field cron expressions
- Maintenance windows - skip checks during scheduled downtime
- Active hours - restrict monitoring to specific hours/days, with IANA timezone support
- Pause / resume - by stable id, URL, or tag, persisted across cron runs
- Watcher status - checks/changes/errors counters, next/last check, maintenance state
- Failure policy - require N consecutive failures/successes before alerting/recovering
- onRecovery callback - fired when a watcher recovers after a failure streak
- Silence detection - alert when a watcher stops changing
- Change spike detection - alert when something changes too often in a time window
- Flapping detection (
confirmAfter) - re-verify before alerting - Change threshold - require a minimum fraction of content to change before alerting
- minChanges - require a minimum number of discrete changes before alerting
- ignoreSelectors - strip matching elements from the DOM before diffing
- ignorePatterns - strip matching regex patterns from the extracted text before diffing
- ignoreNumbers - strip standalone numbers to ignore counter/date noise
- Dry-run mode - fetch and diff without saving or alerting
- Snapshot pruning - keep only the last N snapshots per watcher
- Retention by age - auto-prune snapshots older than N days
- HTML archiving - save full page HTML to disk on every change
- HTTP status monitoring - alert on status code changes or on an unexpected status
- Response-time tracking - captured on every snapshot
- URL comparison - diff two arbitrary URLs in one call
- CSV / JSON export of reports and snapshots
- XLSX export - optional, via
phpoffice/phpspreadsheet - JS-rendered pages / screenshots - optional, via a real Chrome/Chromium binary
- Opt-in HTTP status server -
/health,/status,/metrics(Prometheus text format) - Stable watcher id + tags - target
pause()/resume()at one watcher or a whole group - alertOnFirstCheck - optionally fire
onChangeon the very first snapshot capture - webhookHeaders - add custom headers to every outgoing webhook POST
- webhookRetries - retry failed webhook deliveries with backoff
- Config validation -
watchdiff validatechecks a config file before deploying - maxWatchers - optional cap on the number of registered watchers per process
- SSRF protection - the resolved host is checked against loopback/link-local/private IP ranges before every request;
allowPrivateUrls: trueopts out for internal monitoring - SQL injection guards - table names are validated as plain identifiers; a custom DB
queryis validated as a singleSELECT - Two storage backends - JSON files or SQLite (PDO), nothing external required
- Zero forced HTTP dependency - bring your own PSR-18 client (Guzzle, Symfony HttpClient, ...)
- Fully typed -
declare(strict_types=1), readonly value objects, native enums
Table of contents
- At a glance
- Install
- Quick start
- How it works
- API
- Feature details
- CSS and XPath selectors
- Ignoring content before diffing
- Diff modes
- JSON path targeting
- Alert cooldown
- Retry with exponential backoff
- Interval jitter
- minInterval
- Pause and resume
- Watcher status
- Silence detection
- Dry-run mode
- Snapshot pruning
- Retention by age
- Change threshold
- minChanges
- ignoreNumbers
- Error callback
- onBeforeCheck hook
- Failure policy and onRecovery
- Webhook channels
- webhookHeaders and webhookRetries
- HTML archiving
- Change spike detection
- HTTP status monitoring
- File monitoring
- API monitoring
- SSL certificate monitoring
- Database monitoring
- RSS / Atom monitoring
- Sitemap monitoring
- AI summaries
- Condition-based alerts
- Cron scheduling
- Flapping detection
- Cookie-based authentication
- HTTP Basic Auth
- POST request support
- User-Agent rotation
- Watcher tags
- alertOnFirstCheck
- Maintenance windows
- Active hours
- URL comparison
- CSV / JSON export
- Human-readable formatting
- XLSX export (optional)
- Email alerts (optional)
- JS-rendered pages / screenshots (optional)
- Status server (opt-in)
- SQLite storage backend
- Config file and CLI
- Custom storage backend
- Types
- Security considerations
- Not implemented
- Development
- License
At a glance
| What you want | How |
|---|---|
| Monitor a URL for changes | new WatchConfig(url: ..., interval: ..., target: ...) + $watchDiff->watch(...) |
| Target a specific element | target: '.price' (CSS) or target: "//span[@class='p']" (XPath) |
| Get notified on change | onChange: fn ($report) => ... or webhooks: ['https://discord.com/...'] |
| Render JS-heavy pages | optional, see JS-rendered pages |
| Avoid notification spam | cooldown: 3600 |
| Retry on fetch errors | retries: 3, retryDelayMs: 1000, maxRetryDelayMs: 30000 |
| Randomise polling interval | jitter: 0.2 |
| Word-level diff | diffMode: DiffMode::Word |
| Diff a JSON API endpoint | diffMode: DiffMode::Json, optionally jsonPath: 'data.price' |
| Monitor an RSS/Atom feed | diffMode: DiffMode::Rss |
| Diff a sitemap for added/removed URLs | diffMode: DiffMode::Sitemap |
| Monitor a local file | new WatchConfig(localPath: '/etc/nginx/nginx.conf') |
| Monitor an SSL certificate | new WatchConfig(certHost: 'example.com', warnDaysBeforeExpiry: 14) |
| Monitor a DB table | new DbWatchConfig(connectionString: 'sqlite://./app.db', table: 'products') |
| Monitor a REST API | diffMode: DiffMode::Json, expectedStatus: 200 |
| AI summary of changes | aiSummary: true, aiProvider: new AiProvider(AiProviderType::Anthropic, $apiKey) |
| Pause / resume a watcher | $watchDiff->pause('my-id') / $watchDiff->resume('my-id') |
| Pause a whole group | tags: ['prod'] then $watchDiff->pause('prod') |
| Check watcher state | $watchDiff->status() |
| Fire checks on a schedule | schedule: '0 9 * * *' (cron expression) |
| Confirm a change before alerting | confirmAfter: 30 |
| Alert on silence | alertIfNoChangeAfter: 86400 |
| Detect change spikes | changeSpikeWindow: 300, changeSpikeThreshold: 5 |
| Monitor HTTP status | alertOnStatusChange: true, onStatusChange: fn ($info) => ... |
| HTTP Basic Auth | auth: new BasicAuth('alice', 'secret') |
| Monitor authenticated pages | cookies: ['session' => 'abc123'] |
| POST to an API | requestBody: ['query' => 'SELECT 1'] |
| Rotate User-Agents | userAgents: ['Mozilla/5.0 ...', 'Mozilla/5.0 ...'] |
| Dry-run (no save/alert) | dryRun: true |
| Limit snapshot storage | maxSnapshots: 100 |
| Auto-prune old snapshots | retentionDays: 30 |
| Ignore counters/dates | ignoreNumbers: true |
| Require significant change | changeThreshold: 0.05 |
| Require several distinct changes | minChanges: 3 |
| Strip ads/widgets before diffing | ignoreSelectors: ['.ad-slot'] |
| Strip matching text before diffing | ignorePatterns: ['/\d{2}:\d{2}:\d{2}/'] |
| Allow watching internal/private URLs | allowPrivateUrls: true |
| Cap the number of registered watchers | new WatchDiff(..., maxWatchers: 1000) |
| Handle errors gracefully | onError: fn ($e, $config) => ... |
| Archive HTML on change | archiveHtml: true |
| Require N failures before alerting | failurePolicy: new FailurePolicy(consecutiveFailures: 3) |
| React when a watcher recovers | onRecovery: fn ($config) => ... |
| Fire a hook before every check | onBeforeCheck: fn ($config) => ... |
| Diff two URLs | $watchDiff->compareUrls($urlA, $urlB) |
| Export history | $watchDiff->exportReportsCsv($key) / exportReportsJson($key) |
| Give a watcher a stable id | id: 'my-watcher' then pause('my-watcher') / resume('my-watcher') |
| Skip checks during scheduled downtime | maintenanceWindows: [new MaintenanceWindow($from, $to)] |
| Restrict monitoring to business hours | activeBetween: new ActiveHours(from: '09:00', to: '18:00', days: ['monday']) |
| Validate config before deploying | watchdiff validate or watchdiff validate --config my.json |
| Add auth headers to webhooks | webhookHeaders: ['Authorization' => 'Bearer token'] |
| List configured watchers | watchdiff list |
| Group watchers by tag | tags: ['prod'] then $watchDiff->pause('prod') |
| Expose status over HTTP | watchdiff serve --port 9090 |
Install
composer require watchdiff-labs/watchdiff-core
The core has no hard dependency on an HTTP client - plug in Guzzle, Symfony HttpClient, or
anything else that implements PSR-18. php-http/discovery picks one up automatically if
installed:
composer require guzzlehttp/guzzle
Quick start
use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; use WatchDiff\Core\Config\WatchConfig; use WatchDiff\Core\Enum\DiffMode; use WatchDiff\Core\Store\JsonStore; use WatchDiff\Core\WatchDiff; $watchDiff = new WatchDiff( new JsonStore('.watchdiff'), Psr18ClientDiscovery::find(), Psr17FactoryDiscovery::findRequestFactory(), Psr17FactoryDiscovery::findStreamFactory(), ); $config = new WatchConfig( url: 'https://example.com/product', target: '.price', diffMode: DiffMode::Word, interval: 60, webhooks: ['https://discord.com/api/webhooks/...'], ); $watchDiff->watch($config); // The first check only stores a snapshot. Run again later (e.g. from cron) to get a report. $report = $watchDiff->checkOnce($config->key());
Several watchers, checked periodically:
$watchDiff ->watch(new WatchConfig(url: 'https://example.com/product', target: '.price', interval: 30)) ->watch(new WatchConfig(url: 'https://news.ycombinator.com', interval: 300, label: 'HN')); // Call this periodically (cron, systemd timer, ...). It only checks watchers that are due. $reports = $watchDiff->tick(); foreach ($reports as $report) { echo "{$report->label}: ".count($report->changes)." change(s)\n"; }
How it works
Every web/file/cert check runs through a fixed pipeline:
Fetcher (HTTP, file read, or TLS handshake) -> Cleaner -> Parser -> DiffEngine -> Store -> dispatch (callback + webhooks)
- Fetcher - downloads the page via your PSR-18 client, retrying with exponential backoff; or reads a local file; or opens a TLS connection and reads the peer certificate
- Cleaner - strips
<script>,<style>, and comments - Parser - extracts the target CSS selector or XPath expression (or the full body), or a
jsonPathfor JSON content - DiffEngine - compares content in line, word, json, rss, or sitemap mode
- Store - persists snapshots, reports, and per-watcher state (counters, pause flag, failure streaks, ...) as JSON files or SQLite
- dispatch - fires
onChangeand webhooks once cooldown/confirmAfter/alertIf all agree the change should actually alert
Database checks run a parallel pipeline through WatchDiff\Core\Db\DbWatcher:
DbFetcher (PDO: SQLite / PostgreSQL / MySQL) -> DbDiffEngine -> Store -> callbacks / webhooks
API
new WatchDiff(...)
new WatchDiff( StoreInterface $store, ClientInterface $httpClient, RequestFactoryInterface $requestFactory, ?StreamFactoryInterface $streamFactory = null, int $minInterval = 0, int $maxWatchers = 0, )
$streamFactory is required for webhooks, AI summaries, and POST request bodies - pass
null only if you need none of those. $minInterval is a global floor (seconds) on how
often any watcher may run. $maxWatchers (0 = unlimited) rejects watch() calls past
that count by throwing OverflowException.
| Method | Description |
|---|---|
watch(WatchConfig $config): self |
Register a watcher. Chainable. |
watchers(): WatchConfig[] |
All registered watchers. |
tick(?DateTimeImmutable $now = null): DiffReport[] |
Check every due, non-paused watcher. |
checkOnce(string $key): ?DiffReport |
Run one watcher immediately, by its key(). |
pause(string $tagOrId): void / resume(string $tagOrId): void |
By id, URL, or tag. |
status(): WatcherStatus[] |
Per-watcher counters and scheduling info. |
history(string $key, ?int $limit = null): Snapshot[] |
Stored snapshots. |
reports(string $key, ?int $limit = null): DiffReport[] |
Stored diff reports. |
clear(string $key): void |
Delete all stored data for a watcher. |
storedKeys(): string[] |
Every watcher key with stored data. |
compareUrls(string $urlA, string $urlB, ?string $target = null, ?DiffMode $diffMode = null): DiffReport |
One-off diff, nothing persisted. |
exportReportsCsv/Json(string $key, ?int $limit = null): string |
|
exportSnapshotsCsv/Json(string $key, ?int $limit = null): string |
|
withArchiveDirectory(string $directory): self |
Override where archiveHtml writes (default .watchdiff/archive). |
new WatchConfig(...)
$watchDiff->watch(new WatchConfig( url: 'https://example.com/product', target: '.price', interval: 30, onChange: fn ($report) => console_log($report->changes), webhooks: ['https://discord.com/api/webhooks/YOUR_WEBHOOK'], retries: 3, cooldown: 3600, ));
Pick exactly one target kind: url, localPath, or certHost. The full option list is
in the feature details below, one section per option.
Feature details
CSS and XPath selectors
target accepts both CSS selectors and XPath expressions. XPath is detected
automatically when the value starts with / or ./.
// CSS selector (default) new WatchConfig(url: 'https://example.com', target: '.product-price'); // XPath expression new WatchConfig(url: 'https://example.com', target: "//span[@class='product-price']"); // More complex XPath new WatchConfig( url: 'https://example.com', target: "//table[@id='results']//tr[position()>1]/td[2]", label: 'Table column 2', );
Ignoring content before diffing
ignoreSelectors strips matching elements from the DOM before diffing (e.g. an ad slot
or a "last updated" widget that isn't the thing you care about); ignorePatterns strips
matching regex patterns from the extracted text afterwards:
new WatchConfig( url: 'https://example.com/article', ignoreSelectors: ['.ad-slot', '#cookie-banner'], ignorePatterns: ['/\d{2}:\d{2}:\d{2}/'], // strip HH:MM:SS timestamps );
Diff modes
| Mode | Best for | Granularity |
|---|---|---|
DiffMode::Line (default) |
General text content | Line by line |
DiffMode::Word |
Short values, prices, status text | Word by word |
DiffMode::Json |
REST APIs, JSON feeds | Nested key path |
DiffMode::Rss |
RSS 2.0 and Atom feeds | Item by item (guid / id) |
DiffMode::Sitemap |
sitemap.xml files |
URL by URL (added/removed) |
Line mode - a Myers line-by-line diff (via sebastian/diff). Each change is one line.
Word mode - word-level diff, useful for dense text where you only care about one value changing:
new WatchConfig(url: 'https://example.com/status', target: '.status-text', diffMode: DiffMode::Word); // change: Modified, before: "operational", after: "degraded"
JSON mode - recursively walks the JSON structure and reports changes by key path:
new WatchConfig( url: 'https://api.example.com/product/42', diffMode: DiffMode::Json, headers: ['Accept' => 'application/json'], label: 'Product API', onChange: function ($report) { foreach ($report->changes as $c) { // $c->context = "price", "stock.available", ... echo "{$c->kind->value} ({$c->context}): {$c->before} -> {$c->after}\n"; } }, );
Falls back to line diff automatically when the content can't be parsed as JSON.
RSS mode - parses RSS 2.0/Atom XML and reports added/removed/modified items by
guid/id instead of a raw XML diff:
new WatchConfig( url: 'https://hnrss.org/frontpage', diffMode: DiffMode::Rss, label: 'Hacker News front page', interval: 300, ); // [added] "Show HN: My project" (context: https://news.ycombinator.com/item?id=42)
Falls back to line diff automatically when no <item>/<entry> elements are found.
Sitemap mode - see Sitemap monitoring below.
JSON path targeting
Extract a sub-value from a JSON response before diffing, instead of diffing the whole payload:
new WatchConfig( url: 'https://api.example.com/price', diffMode: DiffMode::Word, jsonPath: 'data.price', ); // "19.99" -> "24.99", not the whole JSON blob
Alert cooldown
Minimum delay between two alerts for the same watcher - avoids notification storms on pages that change on every check:
new WatchConfig( url: 'https://example.com/live-stats', target: '.visitor-count', interval: 30, cooldown: 3600, // at most one alert per hour );
The change is still recorded in snapshot/report history even when the cooldown
suppresses the alert - only onChange and the webhooks are skipped for that check.
Retry with exponential backoff
new WatchConfig( url: 'https://example.com', retries: 3, // up to 3 extra attempts (4 total) retryDelayMs: 2000, maxRetryDelayMs: 30000, // cap on the backoff delay );
Network errors and persistent 5xx/429 responses are retried; once retries are exhausted,
Fetcher throws and onError fires (it never silently treats a failed fetch as a
successful one).
Interval jitter
new WatchConfig(url: 'https://example.com', interval: 300, jitter: 0.2); // 240-360s
minInterval
A floor on how often a watcher may run, either globally (new WatchDiff(..., minInterval: 30))
or per watcher (new WatchConfig(minInterval: 30)), whichever is higher wins.
Pause and resume
$watchDiff->watch(new WatchConfig(url: 'https://example.com', id: 'my-watcher')); $watchDiff->pause('my-watcher'); // by id $watchDiff->pause('https://example.com'); // or by URL if no id was set $watchDiff->resume('my-watcher');
Paused state is persisted in storage, so it survives between separate watchdiff tick
cron invocations.
Watcher status
foreach ($watchDiff->status() as $s) { echo "{$s->label}: {$s->checksCount} checks, {$s->changesCount} changes, paused=" . ($s->paused ? 'yes' : 'no') . "\n"; }
From the CLI: watchdiff list.
Silence detection
Fires a callback when a watcher hasn't changed in longer than expected - useful for detecting a feed that's gone stale or offline without any content change:
new WatchConfig( url: 'https://example.com/feed', interval: 300, alertIfNoChangeAfter: 86400, // 24 hours onSilence: function (array $info) { // $info = ['url' => ..., 'label' => ..., 'secondsSinceLastChange' => ...] error_log("{$info['label']} has not changed in {$info['secondsSinceLastChange']}s"); }, );
Fires at most once per alertIfNoChangeAfter seconds; the timer resets on the next
detected change.
Dry-run mode
Fetch and diff without saving snapshots/reports or sending alerts:
new WatchConfig( url: 'https://example.com', target: '.price', dryRun: true, onChange: fn ($report) => error_log('Would have alerted: '.json_encode($report->changes)), );
onChange is the one documented exception: it still fires in dry-run mode so you can
preview what would have been alerted, even though nothing else does.
Snapshot pruning
new WatchConfig(url: 'https://example.com', maxSnapshots: 100); // keep the last 100
Retention by age
new WatchConfig(url: 'https://example.com', retentionDays: 30); // auto-delete snapshots older than 30 days
Only snapshots are pruned - stored reports and watcher state (counters, pause flag, ...) are left untouched.
Change threshold
new WatchConfig(url: 'https://example.com/article', changeThreshold: 0.05); // need >= 5% of content to change
Changes below the threshold are detected but not alerted.
minChanges
Require a minimum number of discrete changes (not a fraction of content, see
changeThreshold above) before alerting - useful when a single stray change is noise but
several at once is a real signal:
new WatchConfig(url: 'https://example.com', diffMode: DiffMode::Word, minChanges: 3);
ignoreNumbers
new WatchConfig(url: 'https://example.com/live', ignoreNumbers: true); // "1,234 views updated at 14:52" -> no change detected when only the numbers move
Error callback
new WatchConfig( url: 'https://example.com', retries: 2, onError: function (\Throwable $e, WatchConfig $config) { error_log("[{$config->label()}] {$e->getMessage()}"); }, );
onBeforeCheck hook
new WatchConfig( url: 'https://api.example.com', onBeforeCheck: function (WatchConfig $config) { // e.g. refresh a token, log activity }, );
An exception thrown from onBeforeCheck is caught the same way a fetch failure is - it
increments errorsCount, routes to onError, and checkOnce()/tick() return null
for that watcher instead of crashing the whole run.
Failure policy and onRecovery
Require several consecutive failures/successes before suppressing or restoring change alerts, so a flaky endpoint doesn't flood you the moment it comes back:
use WatchDiff\Core\Config\FailurePolicy; new WatchConfig( url: 'https://example.com', retries: 1, failurePolicy: new FailurePolicy(consecutiveFailures: 3, consecutiveSuccesses: 2), onRecovery: fn ($config) => error_log("Recovered: {$config->label()}"), );
respectRetryAfter makes a 429/503 retry honor the response's Retry-After header
(delta-seconds form only, e.g. Retry-After: 30 - not the HTTP-date form) instead of
the configured exponential backoff, for APIs that tell you exactly how long to wait:
new WatchConfig( url: 'https://api.example.com', retries: 3, failurePolicy: new FailurePolicy(respectRetryAfter: true), );
Webhook channels
Payload format is auto-detected from the URL:
| URL pattern | Channel | Payload |
|---|---|---|
discord.com |
Discord | { "content": "..." } (<= 2000 chars) |
hooks.slack.com |
Slack | Block Kit (header + section + context blocks), <= 3000 chars |
api.telegram.org |
Telegram Bot API | { "text": "..." } |
office.com |
Microsoft Teams | MessageCard JSON |
ntfy.sh / ntfy. |
ntfy.sh | { "message": "..." } |
| anything else | Generic | Full DiffReport as JSON |
// Telegram - format the URL as: // https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<CHAT_ID> new WatchConfig(url: 'https://example.com', webhooks: [ 'https://api.telegram.org/botABC123/sendMessage?chat_id=-1001234567', ]); // ntfy.sh - self-hosted or public push notifications new WatchConfig(url: 'https://example.com', webhooks: ['https://ntfy.sh/my-topic']); // Microsoft Teams - an Incoming Webhook connector URL new WatchConfig(url: 'https://example.com', webhooks: ['https://outlook.office.com/webhook/YOUR/IncomingWebhook/TOKEN']);
webhookHeaders and webhookRetries
new WatchConfig( url: 'https://example.com', webhooks: ['https://my-internal-endpoint.example.com/hook'], webhookHeaders: ['Authorization' => 'Bearer token'], webhookRetries: 3, // exponential backoff on delivery failure );
HTML archiving
Saves the raw fetched content to disk on every detected change:
$watchDiff->withArchiveDirectory('/var/lib/watchdiff/archive'); new WatchConfig(url: 'https://example.com', archiveHtml: true, interval: 300);
Archived files are never pruned automatically - manage retention yourself.
Change spike detection
Alert when a watcher changes too often within a rolling time window - useful for detecting flapping deployments or runaway content updates:
new WatchConfig( url: 'https://example.com/live', interval: 30, changeSpikeWindow: 300, // 5-minute window changeSpikeThreshold: 5, // alert if >= 5 changes in that window onSpike: function (array $info) { // $info = ['url'=>..., 'label'=>..., 'changesInWindow'=>..., 'windowSeconds'=>...] error_log("{$info['label']}: {$info['changesInWindow']} changes in {$info['windowSeconds']}s"); }, );
Fires at most once per changeSpikeWindow seconds, to avoid repeated alerts during the
same spike.
HTTP status monitoring
Detect HTTP-level failures independently of content changes:
new WatchConfig( url: 'https://example.com', interval: 60, expectedStatus: 200, // fires onError if the status differs alertOnStatusChange: true, // fires onStatusChange whenever the status changes between checks onStatusChange: function (array $info) { // $info = ['url'=>..., 'label'=>..., 'previousStatus'=>..., 'currentStatus'=>...] error_log("{$info['label']}: {$info['previousStatus']} -> {$info['currentStatus']}"); }, );
Response time is always captured on every Snapshot ($snapshot->responseTimeMs), no
separate flag needed.
File monitoring
Watch a local file through the same pipeline (clean/parse/diff) as a web page:
new WatchConfig( localPath: '/etc/nginx/nginx.conf', interval: 60, onChange: fn ($report) => error_log("Config changed: {$report->label}"), );
API monitoring
There's no separate "API mode" - it's the same watch() pipeline with sensible defaults
for a JSON endpoint:
new WatchConfig( url: 'https://api.example.com/health', diffMode: DiffMode::Json, expectedStatus: 200, interval: 60, );
SSL certificate monitoring
new WatchConfig( certHost: 'example.com', certPort: 443, interval: 3600, warnDaysBeforeExpiry: 14, onCertExpiring: function (array $info) { // $info = ['host'=>..., 'daysUntilExpiry'=>..., 'notAfter'=>...] error_log("Certificate for {$info['host']} expires in {$info['daysUntilExpiry']} days"); }, );
The diff itself reports field-level changes (e.g. fingerprint and notAfter both
changing means the certificate was renewed) - pure PHP via stream_socket_client() and
openssl_x509_parse(), no extra dependency.
Database monitoring
Use a separate storage directory from your
WatchDiffinstance (e.g..watchdiff-dbrather than.watchdiff).watchdiff cleandeletes any stored key that isn't a URL/file/cert watcher in the current config file - ifDbWatchershares that storage, its watchers look like orphans and get deleted too.
tableis validated against a plain SQL identifier (letters, digits, underscore) and rejected otherwise - it can never reach the database as raw SQL.query, on the other hand, is an intentional raw-SQL escape hatch forAggregate/Valuemode: never build it by concatenating untrusted input.connectionString's embedded credentials are stripped before being used as the default storage key - setidexplicitly if you'd rather not rely on that.
use WatchDiff\Core\Db\DbChangeKind; use WatchDiff\Core\Db\DbDiffMode; use WatchDiff\Core\Db\DbWatchConfig; use WatchDiff\Core\Db\DbWatcher; $db = new DbWatcher( new JsonStore('.watchdiff-db'), Psr18ClientDiscovery::find(), // optional: only needed for webhooks Psr17FactoryDiscovery::findRequestFactory(), Psr17FactoryDiscovery::findStreamFactory(), ); // Row-level diff - detect inserts, deletes, and column-level updates $db->watch(new DbWatchConfig( connectionString: 'postgresql://user:pass@localhost/mydb', table: 'products', primaryKey: ['id'], ignoreColumns: ['updated_at'], onChange: function ($report) { foreach ($report->changes as $change) { match ($change->kind) { DbChangeKind::Inserted => error_log('New row: '.json_encode($change->row)), DbChangeKind::Deleted => error_log('Deleted row: '.json_encode($change->row)), DbChangeKind::Updated => error_log("Updated {$change->rowKey}: ".json_encode($change->modifications)), default => null, }; } }, webhooks: ['https://ntfy.sh/my-products'], )); // Schema diff - alert when columns are added, removed, or change type $db->watch(new DbWatchConfig( connectionString: 'sqlite://./app.db', table: 'users', diffMode: DbDiffMode::Schema, onSchemaChange: fn (array $info) => error_log('Schema changed: '.json_encode($info['changes'])), )); // Aggregate / threshold diff - alert when a scalar query result changes by >= 10% $db->watch(new DbWatchConfig( connectionString: 'mysql://user:pass@localhost/shop', table: 'orders', diffMode: DbDiffMode::Aggregate, query: "SELECT COUNT(*) FROM orders WHERE status = 'pending'", threshold: 10.0, onThreshold: fn (array $info) => error_log("Pending orders changed by {$info['changePercent']}%"), )); // Value diff - alert whenever a single value changes at all $db->watch(new DbWatchConfig( connectionString: 'postgresql://user:pass@localhost/app', table: 'config', diffMode: DbDiffMode::Value, query: "SELECT value FROM config WHERE key = 'maintenance_mode'", onChange: function ($report) { $change = $report->changes[0]; $mod = $change->modifications[0]; error_log("maintenance_mode: {$mod['before']} -> {$mod['after']}"); }, )); $db->tick(); // Per-watcher counters, mirroring WatchDiff::status() for URL/file/cert watchers foreach ($db->status() as $status) { printf("%s: %d checks, %d changes, %d errors\n", $status->label, $status->checksCount, $status->changesCount, $status->errorsCount); }
Without primaryKey, rows are compared as an unordered set - inserts and deletes are
detected but updates are not.
Row mode watches inserts, deletes, and updates by default - toggle them off individually
with alertOnInsert, alertOnDelete, alertOnUpdate:
$db->watch(new DbWatchConfig( connectionString: 'sqlite://./app.db', table: 'products', primaryKey: ['id'], alertOnUpdate: false, // only report inserted/deleted rows, ignore column changes ));
DbChange::kind is ThresholdExceeded for a change reported in Aggregate mode, and
ValueChanged in Value mode - two distinct kinds rather than both reusing Updated, so a
consumer switching on $change->kind can tell a row-level update apart from a scalar-query
change. In Schema mode, each column in DbSnapshot::$columns also carries primaryKey
(bool) and defaultValue (the driver's raw default-value expression, e.g. SQLite's own
'misc' literal text) alongside name/type/nullable; diffSchema() now also reports a
"primary key changed" change when a column's primary-key status flips. Every DbSnapshot
additionally carries a derived rowCount and a checksum (a fast content fingerprint of its
rows, not a cryptographic hash - it's for change detection, not identity/security).
RSS / Atom monitoring
See Diff modes above.
Sitemap monitoring
new WatchConfig( url: 'https://example.com/sitemap.xml', diffMode: DiffMode::Sitemap, interval: 3600, onChange: function ($report) { foreach ($report->changes as $c) { // kind Added -> new URL in $c->after, kind Removed -> gone URL in $c->before } }, );
AI summaries
use WatchDiff\Core\Config\AiProvider; use WatchDiff\Core\Config\AiProviderType; new WatchConfig( url: 'https://example.com/changelog', aiSummary: true, aiProvider: new AiProvider(AiProviderType::Anthropic, apiKey: $_ENV['ANTHROPIC_API_KEY']), // aiPrompt: 'Summarize this diff in French.' or a Closure(DiffReport): string ); $report = $watchDiff->checkOnce(/* ... */); echo $report->aiSummary; // null if no change, or the model's summary
Auto-detection from the environment
Leaving aiProvider unset while aiSummary: true falls back to AiProvider::fromEnvironment(),
which picks the first of ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY (checked in
that order) that's set - no aiProvider needed on WatchConfig at all:
// $_ENV / putenv() has ANTHROPIC_API_KEY set -> no aiProvider needed new WatchConfig(url: 'https://example.com/changelog', aiSummary: true);
OpenAI-compatible endpoints (Ollama, Mistral, Groq, Together AI, ...)
baseUrl points the OpenAi provider type at any OpenAI-compatible chat completions
endpoint instead of api.openai.com - these providers all speak the same request/response
shape, so no separate provider type is needed per one:
new AiProvider(AiProviderType::OpenAi, apiKey: 'ollama', baseUrl: 'http://localhost:11434/v1'); new AiProvider(AiProviderType::OpenAi, apiKey: $_ENV['GROQ_API_KEY'], baseUrl: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile');
Custom provider
AiProviderType::Custom with a custom closure bypasses the built-in HTTP calls entirely -
useful for a provider this library doesn't speak directly, or for routing through your own
infrastructure:
new AiProvider( type: AiProviderType::Custom, custom: function (string $prompt, DiffReport $report): string { return myInHouseLlmClient()->complete($prompt); }, );
baseUrl and custom are both PHP-only - a JSON config file can set baseUrl (it's a plain
string), but custom needs a Closure and can only be set when building a WatchConfig in
code.
AiProviderType supports Anthropic, OpenAi, and Gemini - each a direct HTTP call to
that provider's REST API through the same PSR-18 client, no vendor SDK required.
Condition-based alerts
new WatchConfig( url: 'https://example.com', diffMode: DiffMode::Json, alertIf: fn ($report) => in_array('price', array_map(fn ($c) => $c->context, $report->changes), true), );
Cron scheduling
new WatchConfig(url: 'https://example.com', schedule: '0 9 * * *'); // every day at 09:00
schedule overrides interval when set, using standard 5-field cron syntax (via
dragonmantank/cron-expression).
Flapping detection
new WatchConfig(url: 'https://example.com', confirmAfter: 30);
A change must be observed again after at least confirmAfter seconds before it's
actually alerted - if the page reverts in the meantime, nothing fires.
Cookie-based authentication
new WatchConfig(url: 'https://example.com/dashboard', cookies: ['session' => 'abc123', 'csrftoken' => 'xyz']);
HTTP Basic Auth
use WatchDiff\Core\Config\BasicAuth; new WatchConfig(url: 'https://example.com/private', auth: new BasicAuth('alice', 'secret'));
POST request support
new WatchConfig(url: 'https://api.example.com/query', requestBody: ['query' => 'SELECT 1']); // objects are JSON-encoded automatically; a string is sent as-is
User-Agent rotation
new WatchConfig(url: 'https://example.com', userAgents: [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0.0.0', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/124.0.0.0', ]);
Watcher tags
new WatchConfig(url: 'https://example.com', tags: ['prod', 'prices']); $watchDiff->pause('prod'); // pauses every watcher tagged "prod"
alertOnFirstCheck
new WatchConfig(url: 'https://example.com', alertOnFirstCheck: true); // fires onChange on the very first snapshot capture, instead of only storing a baseline
Maintenance windows
use WatchDiff\Core\Config\MaintenanceWindow; new WatchConfig(url: 'https://example.com', maintenanceWindows: [ new MaintenanceWindow(new DateTimeImmutable('2026-09-01T02:00:00Z'), new DateTimeImmutable('2026-09-01T04:00:00Z')), ]);
Active hours
use WatchDiff\Core\Config\ActiveHours; new WatchConfig(url: 'https://example.com', activeBetween: new ActiveHours( from: '09:00', to: '18:00', days: ['monday', 'friday'], timezone: 'Europe/Paris', ));
URL comparison
$report = $watchDiff->compareUrls('https://example.com/v1', 'https://example.com/v2', target: '.content', diffMode: DiffMode::Word); echo count($report->changes).' difference(s)';
CSV / JSON export
file_put_contents('reports.csv', $watchDiff->exportReportsCsv($key, limit: 100)); file_put_contents('reports.json', $watchDiff->exportReportsJson($key)); file_put_contents('snapshots.csv', $watchDiff->exportSnapshotsCsv($key));
exportReportsCsv() columns: url, label, comparedAt, kind, before, after, context - one row
per change, so a single report with 3 changes becomes 3 rows sharing the same url/label/
comparedAt. exportSnapshotsCsv() columns: url, capturedAt, statusCode, responseTimeMs, content.
Human-readable formatting
use WatchDiff\Core\Support\Human; echo Human::changeHuman($change); // "[modified] old value -> new value" echo Human::reportSummary($report); // label + "N change(s) detected" + one changeHuman() line each echo Human::dbChangeHuman($dbChange); // "[inserted] {...}", "[updated] key=1 [...]", "[threshold_exceeded] [...]", ... echo Human::dbReportSummary($dbReport);
This is the exact formatting WebhookSender and DbWatcher already use to build webhook
message bodies internally (Discord/Slack/Telegram/ntfy), now exposed as standalone functions -
useful for your own onChange/onThreshold callbacks, logging, or a CLI command, without
reimplementing the same one-line-per-change text.
XLSX export (optional)
composer require phpoffice/phpspreadsheet
$xlsx = new \WatchDiff\Core\Export\XlsxExporter(); file_put_contents('reports.xlsx', $xlsx->exportReports($watchDiff->reports($key)));
Email alerts (optional)
composer require symfony/mailer
$email = new \WatchDiff\Core\Notify\EmailSender('smtp://user:pass@smtp.example.com:587'); $email->send(to: 'ops@example.com', from: 'watchdiff@example.com', report: $report);
JS-rendered pages / screenshots (optional)
composer require chrome-php/chrome
# and have a Chrome/Chromium binary installed on the host
$browser = new \WatchDiff\Core\Fetch\BrowserFetcher(); $result = $browser->fetch('https://spa-example.com', waitForSelector: '.content'); $browser->screenshot('https://spa-example.com', '/tmp/shot.png');
Not wired into WatchDiff::checkOnce() and not covered by CI (no Chrome binary there) -
call it directly rather than through a browser: true option on WatchConfig.
Status server (opt-in)
vendor/bin/watchdiff serve --port 9090 # GET /health -> {"status":"ok"} # GET /status -> JSON array of WatcherStatus # GET /metrics -> Prometheus text format
Built on PHP's own stream_socket_server (no extra dependency). It does not poll
watchers itself - it only reports whatever state watchdiff tick cron runs have left in
the shared store. Run it under a process supervisor (systemd, supervisord, ...), never
from cron.
Binds to 127.0.0.1 by default - /status and /metrics are unauthenticated and
list every watched URL (which may embed API keys or tokens in the query string), so put
it behind your own auth/reverse proxy before passing --host 0.0.0.0 to expose it beyond
localhost.
SQLite storage backend
use WatchDiff\Core\Store\SqliteStore; $watchDiff = new WatchDiff(new SqliteStore('.watchdiff.db'), $httpClient, $requestFactory, $streamFactory);
Config file and CLI
vendor/bin/watchdiff init # create watchdiff.config.json vendor/bin/watchdiff validate # validate the config file vendor/bin/watchdiff tick # check every watcher that is due (run from cron) vendor/bin/watchdiff check <url> --target .price --diff-mode word vendor/bin/watchdiff diff <url> # print the last stored diff vendor/bin/watchdiff reports <url> # list stored change reports (dates + change counts) vendor/bin/watchdiff history <url> # list stored snapshots vendor/bin/watchdiff list # all configured watchers + status vendor/bin/watchdiff pause <id|url|tag> vendor/bin/watchdiff resume <id|url|tag> vendor/bin/watchdiff snapshot <url> # print the latest stored snapshot vendor/bin/watchdiff clear <url> # delete ALL stored data for a single URL vendor/bin/watchdiff clean # delete stored data for watchers no longer in the config vendor/bin/watchdiff compare <urlA> <urlB> vendor/bin/watchdiff export <url> --format csv|json --type reports|snapshots [--output file] vendor/bin/watchdiff db <connectionString> <table> --primary-key id [--webhook ...] [--no-update] vendor/bin/watchdiff serve --port 9090 --host 127.0.0.1
clear removes everything stored for one URL/id (snapshots, reports, state); clean only
removes watchers that no longer appear in the current config file - clean calls the same
underlying WatchDiff::clear() once per orphan it finds. clean also asks for confirmation
before deleting (listing what it's about to remove) unless --yes/-y is passed or stdin
isn't a TTY, in which case it refuses rather than deleting silently - pass --yes for cron/CI.
Every list-producing command (check, compare, diff, snapshot, list, reports,
validate, db) accepts --json to print a single JSON value instead of human-readable
text, for scripting against. tick instead has --log-format json, which prints one JSON
object per line (one per changed watcher) rather than a single value, since it's meant to be
tailed/ingested by a log pipeline, not parsed as a whole.
-q/--quiet and -v/--verbose work on every command - they're Symfony Console's own
global flags, not something this library adds, and every writeln() call here already
respects them for free.
Environment variable fallback
Every option below falls back to a WATCHDIFF_* environment variable when the flag isn't
passed, so a systemd unit, Docker image, or CI job can set them once instead of repeating
flags on every invocation. Precedence is always: **explicit CLI flag > environment variable
the option's own default.**
| Option | Commands | Environment variable |
|---|---|---|
--config |
list, pause, resume, clean, validate, serve, tick |
WATCHDIFF_CONFIG |
--target |
check, compare |
WATCHDIFF_TARGET |
--diff-mode |
check |
WATCHDIFF_DIFF_MODE |
--dry-run |
check |
WATCHDIFF_DRY_RUN |
--json |
check, compare, diff, snapshot, list, reports, validate, db |
WATCHDIFF_JSON |
--format |
export |
WATCHDIFF_FORMAT |
--type |
export |
WATCHDIFF_EXPORT_TYPE |
--limit |
export, history, reports |
WATCHDIFF_LIMIT |
--output |
export |
WATCHDIFF_OUTPUT |
--yes/-y |
clean |
WATCHDIFF_YES |
--port |
serve |
WATCHDIFF_PORT |
--host |
serve |
WATCHDIFF_HOST |
--log-format |
tick |
WATCHDIFF_LOG_FORMAT |
--diff-mode |
db |
WATCHDIFF_DB_DIFF_MODE |
--query |
db |
WATCHDIFF_DB_QUERY |
--threshold |
db |
WATCHDIFF_DB_THRESHOLD |
export WATCHDIFF_CONFIG=/etc/watchdiff/prod.config.json export WATCHDIFF_LOG_FORMAT=json vendor/bin/watchdiff tick # reads both from the environment, no flags needed
validate checks
Beyond "does the config file parse", validate (and --json's {"valid": bool, "errors": [...]} output) checks each watcher for:
- a
url,localPath, orcertHostis present, andurlstarts withhttp:///https://and is otherwise a well-formed URL interval, if set, is a positive integerdiffMode, if set, is one of the validDiffModevaluesjsonPath, if set, is a string starting with$(e.g.$.data.price)schedule, if set, is a parseable cron expression- every
webhooksentry starts withhttp:///https://and is a well-formed URL
All watchers are checked in one pass - every problem across every watcher is reported, not just the first one found.
watchdiff.config.json (as produced by init) accepts almost every WatchConfig option
that doesn't need a closure:
{
"watchers": [
{
"url": "https://example.com",
"target": ".price",
"interval": 300,
"diffMode": "line",
"webhooks": [],
"cooldown": 3600,
"jsonPath": "data.price",
"tags": ["prod"],
"auth": { "user": "alice", "pass": "secret" },
"schedule": "0 9 * * *",
"confirmAfter": 30,
"activeBetween": { "from": "09:00", "to": "18:00", "days": ["monday"] },
"maintenanceWindows": [{ "from": "2026-01-01T00:00:00+00:00", "to": "2026-01-01T02:00:00+00:00" }],
"failurePolicy": { "consecutiveFailures": 3, "consecutiveSuccesses": 2, "respectRetryAfter": true },
"aiProvider": { "type": "anthropic", "apiKey": "...", "model": "claude-haiku-4-5-20251001" }
},
{ "localPath": "/etc/nginx/nginx.conf", "interval": 60 },
{ "certHost": "example.com", "warnDaysBeforeExpiry": 14 }
]
}
Closures (onChange, onError, onBeforeCheck, onRecovery, onSilence, onSpike,
onStatusChange, onCertExpiring, alertIf, and aiProvider.custom) can't be expressed in
JSON - set those up in PHP code instead when you need them. aiProvider.baseUrl (a plain
string) works fine from JSON, for OpenAI-compatible endpoints like Ollama/Groq/Together AI.
Custom storage backend
JsonStore and SqliteStore both implement WatchDiff\Core\Contract\StoreInterface -
write your own class against that interface (e.g. to store in Redis, DynamoDB, a
different SQL schema, ...) and pass it to new WatchDiff($yourStore, ...) instead:
use WatchDiff\Core\Contract\StoreInterface; use WatchDiff\Core\Model\DiffReport; use WatchDiff\Core\Model\Snapshot; final class RedisStore implements StoreInterface { public function saveSnapshot(string $key, Snapshot $snapshot, ?int $maxSnapshots = null): void { /* ... */ } public function latestSnapshot(string $key): ?Snapshot { /* ... */ } public function snapshots(string $key, ?int $limit = null): array { /* ... */ } public function saveReport(string $key, DiffReport $report): void { /* ... */ } public function reports(string $key, ?int $limit = null): array { /* ... */ } public function lastAlertAt(string $key): ?\DateTimeImmutable { /* ... */ } public function setLastAlertAt(string $key, \DateTimeImmutable $at): void { /* ... */ } public function clear(string $key): void { /* ... */ } public function pruneSnapshotsOlderThan(string $key, \DateTimeImmutable $cutoff): void { /* ... */ } public function getState(string $key): array { /* ... */ } // pause flag, counters, failure streaks, ... public function setState(string $key, array $state): void { /* ... */ } public function keys(): array { /* ... */ } // every watcher key with stored data }
Types
The exact shape of every object you get back from a callback, checkOnce(),
tick(), history(), reports(), or status().
enum ChangeKind: string { case Added = 'added'; case Removed = 'removed'; case Modified = 'modified'; } final readonly class Change { public ChangeKind $kind; public ?string $before; public ?string $after; public ?string $context; // e.g. the JSON key path, or an RSS item's link } final readonly class Snapshot { public string $url; // or the local path / "host:port" key, for file/cert watchers public string $content; public \DateTimeImmutable $capturedAt; public int $statusCode; // 200 for file/cert watchers public ?float $responseTimeMs; } final readonly class DiffReport { public string $url; public string $label; public Snapshot $before; public Snapshot $after; public Change[] $changes; public DiffMode $diffMode; public \DateTimeImmutable $comparedAt; public ?string $aiSummary; // set only when aiSummary: true and there are changes public function hasChanges(): bool; } final readonly class WatcherStatus { public string $url; public ?string $id; public string $label; public ?string $target; public int $interval; public bool $paused; public bool $inMaintenance; public ?\DateTimeImmutable $lastCheckAt; public ?\DateTimeImmutable $nextCheckAt; public ?\DateTimeImmutable $lastChangeAt; public int $checksCount; public int $changesCount; public int $errorsCount; } // Database monitoring (WatchDiff\Core\Db\*) enum DbChangeKind: string { case Inserted = 'inserted'; case Deleted = 'deleted'; case Updated = 'updated'; case SchemaChanged = 'schema_changed'; case ThresholdExceeded = 'threshold_exceeded'; // Aggregate mode case ValueChanged = 'value_changed'; // Value mode } final readonly class DbChange { public DbChangeKind $kind; public ?array $row; // the full row, for Inserted/Deleted public ?string $rowKey; // the primaryKey value(s), for Inserted/Deleted/Updated public ?array $modifications; // [{column, before, after}, ...], for Updated/ThresholdExceeded/ValueChanged public ?string $column; // for SchemaChanged public ?string $context; // e.g. "column added" / "type changed" / "primary key changed" / "column removed", for SchemaChanged } final readonly class DbSnapshot { public array $rows; public array $columns; // [{name, type, nullable, primaryKey, defaultValue}, ...], only populated in Schema mode public \DateTimeImmutable $capturedAt; public int $rowCount; // derived: count($rows) public string $checksum; // derived: a fast content fingerprint of $rows, not a cryptographic hash } final readonly class DbDiffReport { public string $connectionString; public string $table; public string $label; public DbSnapshot $before; public DbSnapshot $after; public DbChange[] $changes; public DbDiffMode $diffMode; public \DateTimeImmutable $comparedAt; public function hasChanges(): bool; }
Security considerations
Hardening already built in:
urlmust start withhttp://orhttps://- rejected otherwise (defense in depth against the URL being used as a generic SSRF primitive, e.g.file://).- The host is resolved and checked against loopback/link-local/private IP ranges before every request, and the fetch is refused unless
allowPrivateUrls: trueis set. This is a pre-check against the hostname's current resolution, not a guarantee against DNS rebinding (where the name resolves differently between this check and your HTTP client's own connection). - Response bodies (HTTP fetches and AI provider calls) are capped at 10 MiB and the fetch fails past that, instead of buffering an unbounded response into memory.
- RSS/sitemap XML is parsed with
LIBXML_NONET(defense in depth against XXE-driven network/entity resolution). - The
Cookieheader strips\r/\nbefore being sent, to prevent header injection via cookie names/values. DbWatchConfig'stableis validated as a plain SQL identifier before being used in a query - it can never reach the database as raw SQL. A customquery(theAggregate/Value-mode escape hatch) is validated as a singleSELECTstatement - no semicolons, no other statement type - before it's ever executed.- A DB
connectionString's embedded credentials are stripped before being used as the default storage key, so a password never lands in.watchdiff/.watchdiff-dbin plaintext. watchdiff servebinds to127.0.0.1by default; its/status//metricsendpoints are unauthenticated.maxWatchersonWatchDiff(0 = unlimited) caps how many watchers a single process will register, bounding memory use in a long-running process that registers watchers dynamically.- Webhook destination URLs (
WatchConfig::$webhooks,DbWatchConfig::$webhooks) go through the same loopback/link-local/private IP check as the mainurl/connectionStringbefore a request is sent, gated by the sameallowPrivateUrlsflag - a webhook URL is just as capable of being used as an SSRF primitive as the watched URL itself. - A webhook send failure (network error, non-2xx the client throws on, etc.) is caught and routed to
onErrorinstead of propagating out ofcheckOnce()/tick()- this also avoids an uncaught exception leaking a secret embedded in the webhook URL itself (e.g. a Telegram bot token) through an upstream logger/error tracker that captures exception messages. - Certificate monitoring (
certHost/certPort) connects withverify_peer: false- inspecting an invalid/expired/self-signed certificate is the point of the feature - but keepsverify_peer_name: true, so a MITM presenting a validly-issued certificate for an unrelated domain is still rejected rather than silently inspected as if it belonged tocertHost. - Storage keys (
.watchdiff/<hash>.*.jsonfilenames, and thekeys.jsonregistry) are hashed with SHA-256, not a fast non-cryptographic hash, since the key space can include attacker-influenced input (e.g. a user-submitted URL).
Trust boundaries that are inherent to what these features do, not bugs:
localPathreads whatever file your process can access. Never let untrusted input choose the path without validating it against an allowed directory yourself.url, even restricted to http/https and public IP ranges, can still be redirected (by the target server) to an internal address - this library has no control over how your PSR-18 client follows redirects, and doesn't re-validate each hop. If you let external users submit URLs to watch, validate/allowlist them before constructing aWatchConfig, and prefer an HTTP client configured to not follow redirects, or to validate them itself.queryonDbWatchConfigis restricted to a singleSELECT, but a read can still leak data your database user can see (e.g.SELECT * FROM users) - scope the DB credentials you hand toDbWatcherto only what it needs to read.certHost/certPortintentionally connect withverify_peer: false- the point of the feature is inspecting a certificate even if it's invalid/self-signed, not making a trusted connection. Only chain-of-trust validation is skipped; hostname verification stays on (see above).
Not implemented (compared to the npm/PyPI ports)
Two different situations get lumped into "not implemented" below, and they're not the same claim - the table says which is which:
- Architectural trade-off - genuinely blocked by this library's own design choice to
accept any PSR-18 HTTP client instead of forcing Guzzle. It's not that PHP can't do it
(Guzzle's
Pool,curl_multi, ReactPHP/AMPHP all can) - it's that doing it generically, for whatever PSR-18 client you hand in, isn't possible. Dropping the "any PSR-18 client" promise would unblock these, but that's a deliberate trade, not a limitation to work around. - Not implemented yet - fully possible in PHP, not blocked by anything structural, simply not built. These are open to a PR/issue, unlike the ones above.
| Feature | Category | Why |
|---|---|---|
| Proxy rotation | Architectural trade-off | Proxy configuration lives on the concrete HTTP client (Guzzle's proxy option, Symfony HttpClient's proxy option), not in the PSR-18 ClientInterface::sendRequest() contract. Workaround: build several WatchDiff instances, each with its own proxy-configured client, or add proxy-choosing middleware to your own Guzzle stack before handing the client to WatchDiff. |
| Concurrency limiting | Architectural trade-off | ClientInterface::sendRequest() is synchronous by contract - true parallel fetches need either curl_multi/Guzzle's Pool (which would force Guzzle) or an async runtime (ReactPHP/AMPHP), both of which break the "any PSR-18 client" promise the core is built on. Workaround: run several watchdiff tick cron invocations with disjoint config files, or bypass WatchDiff\Core\Fetch\Fetcher and use Guzzle's Pool directly in your own code. |
| Per-request timeout | Architectural trade-off | Same root cause as proxy rotation - a request timeout is configured on the concrete PSR-18 client (Guzzle's timeout option, Symfony HttpClient's max_duration), not through ClientInterface::sendRequest(). There is no timeout option on WatchConfig. Workaround: set the timeout when you construct the HTTP client you pass to WatchDiff. |
| Per-hop redirect validation | Architectural trade-off | The SSRF guard checks the URL you configured before the first request, but this library doesn't control how your PSR-18 client follows redirects (or whether it does at all) - a redirect to an internal address on hop 2 isn't re-validated. Workaround: configure your HTTP client to disable redirect-following (e.g. Guzzle's allow_redirects => false) if you watch URLs you don't fully trust, or to validate each hop itself. |
semantic diff mode (paragraph/heading-level, like the JS/Python ports) |
Not implemented yet | Only line, word, json, rss, and sitemap exist - nothing stops a paragraph/heading-aware diff mode from being added, it just hasn't been. Workaround: target a more specific selector to approximate paragraph-level diffing, or use line mode, which is close for most prose content. |
| Automatic email alerts | Not implemented yet | WatchDiff\Core\Notify\EmailSender exists and works, but it isn't wired into the onChange/webhook dispatch pipeline the way webhooks are - there's no email option on WatchConfig that fires automatically. Wiring it up is a small, deliberate addition away. Workaround: call EmailSender::send() yourself from an onChange callback. |
| Configurable response size cap | Not implemented yet | The 10 MiB limit in BoundedBodyReader is a fixed constant, not a WatchConfig option - trivial to expose, just hasn't been asked for yet. Workaround: none currently - open an issue if 10 MiB is too small for a legitimate use case. |
Development
composer install vendor/bin/phpunit vendor/bin/phpstan analyse vendor/bin/php-cs-fixer fix --allow-risky=yes
Code style aligns consecutive = and => signs within a block (e.g. constructor
promoted property defaults). This is done by hand, not by php-cs-fixer - PHP-CS-Fixer's
PSR12 ruleset has no rule for aligning parameter defaults and actively collapses this
alignment back to a single space, so running php-cs-fixer fix will undo it on those
lines. CI only runs PHPStan and PHPUnit, not a style-lint gate, for this reason.
Some tests need ext-pdo_sqlite (SQLite-backed store/DB tests) and outbound network
access (the SSL certificate test); they're skipped automatically when unavailable
locally, but run in CI.
License
This project is licensed under the BSD 3-Clause License.
You are free to use, modify, and distribute this software in source and binary forms, with or without modification, provided the copyright notice and disclaimer are retained. No copyleft restrictions - compatible with MIT and proprietary projects.