innmind/file-watch
Watch files and directories for changes from PHP. innmind/file-watch surfaces filesystem events when files are created, modified, or deleted, making it useful for hot reloading, build watchers, and automation that must react to file changes.
Install
composer require innmind/file-watch
This is a small, single-purpose package: it answers one question — "has this file/directory changed?" — and hands the change back to you as a value so you can decide what to run. It does not run your tests, compile assets, or watch a whole project tree for you. That wiring is your job, and it's usually 20–40 lines.
Where to look first
src/ — the package has a tiny surface. There is one interface (the "watch" contract, exposing something like ->watch(Path $path) returning a lazy stream/Sequence of file-change events), plus one or more concrete implementations (an inotify-backed one for Linux and a polling one as a portable fallback).README.md for the canonical construction snippet. Because this is a 1-star, zero-dependent package, the source is the documentation; don't expect blog posts.First use case: rerun PHPUnit on save
<?php
// watch.php — run with `php watch.php`, leave it in a terminal
require 'vendor/autoload.php';
use Innmind\Url\Path;
use Innmind\TimeContinuum\Earth\Clock;
use Innmind\FileWatch\Watch\Polling; // or the inotify implementation
use Symfony\Component\Process\Process;
$watch = Polling::of(Clock::live()); // confirm exact factory in src/
$watch
->watch(Path::of('src/'))
->foreach(function ($file) {
fwrite(STDERR, "changed: {$file->toString()}\n");
$process = new Process(['vendor/bin/phpunit', '--colors=always']);
$process->setTimeout(null);
$process->run(fn ($type, $buffer) => print($buffer));
});
That foreach is the whole idea: an infinite loop that blocks on the next change, then runs a callback. Everything else is ergonomics.
Pattern 1 — watch the directory, run the tool. Watch src/ and tests/, not individual files. Directory watching survives editor "atomic save" behaviour (write temp file → rename over original), which looks like a delete + create to a file-level watcher.
Pattern 2 — filter inside the callback, not in the watcher. The watcher reports something changed; you decide if it matters. Keep a cheap guard so a change to README.md doesn't trigger a 3-minute test suite:
->foreach(function ($file) use ($runTests) {
$path = $file->toString();
if (!str_ends_with($path, '.php')) {
return;
}
if (str_contains($path, '/vendor/') || str_contains($path, '/.git/')) {
return;
}
$runTests();
})
Doing the filtering here (rather than fighting the watcher's path matching) keeps the code portable across the inotify and polling implementations, which rarely have identical matching semantics.
Pattern 3 — debounce by coalescing, not by sleeping in the callback. Saves arrive in bursts (an IDE reformat touches 30 files). If the watcher gives you a lazy Sequence, prefer pulling a batch and draining it before running the command:
use Innmind\Immutable\Sequence;
$watch
->watch(Path::of('src/'))
->foreach(function ($file) use ($runTests) {
// drain any events already queued before doing expensive work
$runTests();
});
If the API doesn't expose a drain, the pragmatic version is a short usleep(150_000) at the top of the callback and a "skip if a run is already in flight" flag. It's inelegant but has saved more CI minutes than any clever queue.
Pattern 4 — run the watcher as a supervisor-managed process. Don't nohup it. Give it a systemd unit (Restart=on-failure) or a docker compose service, because inotify processes die when you edit the code they're watching on some kernel/config combinations, and a crash-loop with logs beats a silent dead watcher.
Pattern 5 — wrap it behind your own thin interface for testing. The package is the only thing in your codebase that should know about inotify. Define your own FileWatcher contract with a single watch(iterable $paths, callable $onChange) method, adapt innmind/file-watch inside an implementation, and use a fake in tests. This also means a future BC break in this package costs you one file, not a refactor.
Pattern 6 — chain the callback, don't nest it. The natural growth path is watch → test → (on failure) re-run just the failing test. Model the callback as a small invokable class rather than an inline closure; the loop runs for hours and closures capturing $this of a large object graph is how long-running PHP processes bloat.
Pattern 7 — integration with a dev server. If you're serving via php -S or FrankenPHP, the watcher's job is usually just to touch a reload-trigger file or send a signal, not to restart the server itself. Keep the watcher and the server as sibling processes; the watcher should never be the parent.
inotify has hard limits and they are per-user, not per-process. Hitting No space left on device from inotify_add_watch is the single most common failure. Check before you debug your code:
cat /proc/sys/fs/inotify/max_user_watches
cat /proc/sys/fs/inotify/max_user_instances
Raise them via sysctl (fs.inotify.max_user_watches=524288). In containers, the limit is the host's — you'll see this in Docker and WSL2 constantly, and it looks like a library bug when it isn't.
Polling is the fallback and it behaves differently. It burns CPU proportional to tree size × poll frequency and has an inherent latency floor. On network filesystems (NFS, SMB, some Docker bind mounts on macOS) inotify silently doesn't fire, and you're on polling whether you know it or not. If a watcher "doesn't work in Docker but works on the host," that's the answer.
Atomic saves break file-level watching. Vim (backupcopy=no), VS Code, JetBrains, and sed -i all replace the inode. If you watch src/Foo.php, you may get exactly one event — the delete — and then nothing. Always watch the parent directory.
Blocking is the point, so never call it from a web request. The foreach/stream is an intentional infinite loop. Put it in bin/, not src/, and make sure your framework's autoloader isn't scanning it into a controller.
Exceptions inside the callback can kill the loop. Wrap the body in try { … } catch (\Throwable $e) { error_log(…); } unless the API explicitly documents recovery. A failed phpunit run should not stop you from seeing the next change — otherwise you fix one bug and discover the watcher died 40 minutes ago.
Version pinning is not optional here. One star, zero dependents, and a release date that may run ahead of the surrounding ecosystem (innmind/immutable, innmind/time-continuum) means transitive constraint conflicts are likely. Pin the exact version ("innmind/file-watch": "1.2.3") and upgrade deliberately, checking the changelog for interface changes rather than relying on semver discipline.
Watch out for the innmind value-object tax. Like its siblings, this library speaks in Path, Sequence, Maybe/Either rather than native strings and arrays. That's fine — but don't let those types leak through your own service boundary. Convert at the edge: $file->toString() in, native types out.
Debugging checklist, in order: (1) is the event firing at all — add an unconditional fwrite(STDERR, …); (2) is your filter dropping it — log before the guard; (3) is the command failing silently — run it manually; (4) is the watch backend inotify or polling — check which class you constructed.
Extension point: implement the watch contract yourself. The interface is small, and a custom implementation over inotifywait, fswatch, or a Go helper binary is a legitimate escape hatch when the bundled backends don't fit (e.g. recursive watching with excludes, or watching files that live outside the project root). You get the rest of your pipeline untouched.
On the release date: a 2026-08-16 last release is either a metadata artifact or a forward-dated tag. Treat the published version as authoritative only after it actually resolves — run composer update --dry-run innmind/file-watch and read the version it picks before building anything on top of it.
How can I help you explore Laravel packages today?