yosymfony/resource-watcher
A PHP library for watching files and directories for changes. It detects file creations, modifications, and deletions using inotify, polling, or hashing strategies, with a simple event-based API for building file monitors, hot-reload tools, and build watchers.
Install the package and the two Symfony components it builds on:
composer require yosymfony/resource-watcher symfony/finder symfony/event-dispatcher
The mental model: a Finder decides what to watch, a ResourceCache remembers the last-seen checksums, and a ResourceWatcher diffs the current filesystem against that cache on every call to findChanges().
use Symfony\Component\Finder\Finder;
use Yosymfony\ResourceWatcher\ResourceFinder;
use Yosymfony\ResourceWatcher\ResourceWatcher;
use Yosymfony\ResourceWatcher\ResourceCache\ResourceCacheArray;
$finder = new Finder();
$finder->files()->in(__DIR__.'/src')->name('*.php');
$watcher = new ResourceWatcher(new ResourceFinder($finder), new ResourceCacheArray());
$watcher->initialize(); // seed baseline hashes — no events yet
while (true) {
$changes = $watcher->findChanges();
foreach ($changes->getCreated() as $path) { echo "created: $path\n"; }
foreach ($changes->getUpdated() as $path) { echo "updated: $path\n"; }
foreach ($changes->getDeleted() as $path) { echo "deleted: $path\n"; }
sleep(1);
}
First use case to try: a bin/watch script that just prints the three lists. It is the fastest way to confirm your Finder scope and your cache choice are right before wiring any real action onto the events. Read ResourceWatcher first, then ResourceFinder and the ResourceCache\* classes — those three files are the whole library.
Pick the cache based on process lifetime. ResourceCacheArray is per-process only — good for tests and one-shot scripts. For a CLI daemon you want persistence across restarts so a reboot doesn't re-announce the whole tree: the JSON-backed cache for a single watcher, or the Symfony-cache-backed implementation when you already have a CacheInterface pool in the app.
Wrap the loop in a console command. Don't put the loop in a controller. A command gives you --interval, --once, verbosity, and a clean place to trap signals:
protected function execute(InputInterface $in, OutputInterface $out): int
{
$this->watcher->initialize();
$running = true;
pcntl_signal(SIGINT, function () use (&$running) { $running = false; });
while ($running) {
pcntl_signal_dispatch();
$changes = $this->watcher->findChanges();
$this->dispatchChanges($changes);
usleep($this->intervalMs * 1000);
}
return Command::SUCCESS;
}
Turn results into events yourself. ResourceWatcherResult is just three path lists. Map them to your own domain events (SourceFileChanged, TemplateDeleted, …) and push those through Symfony's EventDispatcher. This decouples detection from reaction, makes listeners unit-testable, and keeps you independent of whatever the package's own dispatcher wiring looks like (see Gotchas). Keep listeners idempotent and cheap — they run on every triggering poll.
Scope the Finder aggressively. Finder walks the tree on every findChanges(), so the scan is the dominant cost. ->files()->name('*.php')->exclude('vendor')->exclude('var')->notPath('node_modules') is not optional polish; it is the difference between a 20 ms poll and a 2 s poll. Because Finder is re-evaluated each time, files that start matching the pattern later are picked up as "created" automatically.
Use it as a trigger, not a build system. The common integration is "on change, shell out": re-run the test suite, kick npm run build, invalidate an opcode/DI cache, or push a reload signal to a browser over SSE/WebSocket. Debounce with a short sleep after firing so a burst of saves produces one build, not five.
Testing pattern. Use ResourceCacheArray, write files into a sys_get_temp_dir() fixture, call initialize(), mutate the fixture, then assert on findChanges(). Call findChanges() a second time with no mutation and assert all three lists are empty — that single assertion catches most cache misuse.
sleep plus a full Finder scan. On large trees, prefer a 1–2 s interval and a tight Finder scope over a 100 ms interval.touch alone does nothing. If you need mtime semantics, that's the hook for a custom hasher (see below).initialize() is a policy decision. Call it on first start and you get "no events until something actually changes." Skip it against an empty cache and every existing file arrives as created — occasionally what you want (initial build), usually a surprise.initialize() on a quiet moment, or a cache implementation that prunes, keeps memory honest.getRealPath() consistently — mixing SplFileInfo objects, relative paths, and absolute strings is the usual cause of "the listener never matches."symfony/finder and symfony/event-dispatcher constraints resolve on your PHP version before committing; if they don't, pin the compatible Symfony major or fork. Lock the versions in composer.json.ResourceCacheInterface for Redis/DB-backed persistence across machines, and supply your own hasher (mtime + size) to trade correctness for speed on huge trees. Verify the exact interface signatures against the installed source before implementing — the library is small enough that reading src/ is faster than trusting any tutorial, including this one.findChanges() results is two extra lines and immune to that churn.How can I help you explore Laravel packages today?