{"id":51040146,"url":"https://github.com/mustafakhaleddev/filament-record-watcher","last_synced_at":"2026-06-22T10:01:45.867Z","repository":{"id":358044695,"uuid":"1207984165","full_name":"mustafakhaleddev/filament-record-watcher","owner":"mustafakhaleddev","description":"Subscribe to individual Eloquent records and receive in-panel Filament notifications whenever they change — with the actor (who) and a field-level diff (what).","archived":false,"fork":false,"pushed_at":"2026-04-11T17:01:21.000Z","size":19,"stargazers_count":1,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-05-15T13:14:25.139Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mustafakhaleddev.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-11T17:00:27.000Z","updated_at":"2026-04-27T21:50:41.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/mustafakhaleddev/filament-record-watcher","commit_stats":null,"previous_names":["mustafakhaleddev/filament-record-watcher"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/mustafakhaleddev/filament-record-watcher","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mustafakhaleddev%2Ffilament-record-watcher","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mustafakhaleddev%2Ffilament-record-watcher/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mustafakhaleddev%2Ffilament-record-watcher/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mustafakhaleddev%2Ffilament-record-watcher/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mustafakhaleddev","download_url":"https://codeload.github.com/mustafakhaleddev/filament-record-watcher/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mustafakhaleddev%2Ffilament-record-watcher/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34643624,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-22T02:00:06.391Z","response_time":106,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-22T10:01:42.035Z","updated_at":"2026-06-22T10:01:45.861Z","avatar_url":"https://github.com/mustafakhaleddev.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Filament Record Watcher\n\nSubscribe to individual Eloquent records and receive in-panel Filament notifications whenever they change — with the actor (who) and a field-level diff (what). Watches can carry conditions (\"only if status changes\", \"only if amount \u003e 10K\"), can be paused, and live on a personal **My Watches** page scoped to the authenticated user. Every fan-out is also persisted to a permanent event log, so users can review the full change history even after dismissing notifications.\n\n## Highlights\n\n- **One-click Watch / Unwatch actions** for any Filament Resource record.\n- **Conditions DSL** — JSON rules with `changed`, `=`, `!=`, `\u003e`, `\u003c`, `\u003e=`, `\u003c=`, `contains`. ANDed. Empty = notify on any change.\n- **In-panel notifications** delivered through Filament's built-in `databaseNotifications()` channel — they appear in the bell dropdown.\n- **Persistent event history** — every fan-out writes a `WatchEvent` row keeping the actor + diff, so users can see what they missed even after clearing notifications.\n- **My Watches page** — auth-scoped, shows event count, supports pause / resume / unwatch / history.\n- **Per-action permission gates** via `-\u003ecan()` (boolean or closure).\n- **Curated field selector** — the condition modal renders a `Select` populated from a `getWatchableFields()` hook (auto-introspected by default).\n- **Actor capture for queues / commands** through `WatchEngine::actingAs($user, fn () =\u003e …)`.\n- **Extensible** via the `RecordWatchedChange` Laravel event for email / Slack / audit pipelines.\n\n## Requirements\n\n- PHP 8.2+\n- Laravel 11+ / Laravel 13\n- Filament v4+\n- A panel with `-\u003edatabaseNotifications()` enabled (the package writes to Filament's database notifications channel)\n\n## Installation\n\nThe package is path-linked inside this monorepo. From the project root:\n\n```bash\ncomposer require wezlo/filament-record-watcher\nphp artisan migrate\n```\n\nThe migrate step creates two tables: `watches` and `watch_events`.\n\n### Tailwind v4 — register the package's views\n\nThe package ships a Blade view (the **History** modal) that uses Tailwind utility classes. Tailwind v4 only scans paths it has been told about, so you must add an `@source` directive to your panel's theme CSS file (typically `resources/css/filament/admin/theme.css`):\n\n```css\n@source '../../../../vendor/wezlo/filament-record-watcher/resources/views/**/*';\n```\n\nAfter editing the theme, rebuild your assets (`npm run build` or `npm run dev`). Without this line the History modal will render unstyled.\n\nRegister the plugin on each panel where you want the **My Watches** page:\n\n```php\nuse Wezlo\\FilamentRecordWatcher\\FilamentRecordWatcherPlugin;\n\npublic function panel(Panel $panel): Panel\n{\n    return $panel\n        -\u003edatabaseNotifications() // required — watchers receive db notifications\n        -\u003eplugins([\n            FilamentRecordWatcherPlugin::make(),\n        ]);\n}\n```\n\n## Make a model watchable\n\nAdd the `HasWatchers` trait to any Eloquent model:\n\n```php\nuse Wezlo\\FilamentRecordWatcher\\Concerns\\HasWatchers;\n\nclass Order extends Model\n{\n    use HasWatchers;\n}\n```\n\nYou can now:\n\n```php\n$order-\u003ewatchFor($user);                                  // subscribe with no conditions\n$order-\u003ewatchFor($user, [                                 // subscribe with rules\n    ['field' =\u003e 'status', 'operator' =\u003e 'changed', 'value' =\u003e null],\n    ['field' =\u003e 'amount', 'operator' =\u003e '\u003e',       'value' =\u003e 10_000],\n]);\n\n$order-\u003eisWatchedBy($user);    // bool\n$order-\u003ewatches;               // MorphMany of Watch rows\n$order-\u003ewatchers();            // collection of distinct subscribed users\n\n$order-\u003eunwatchFor($user);     // delete the user's subscription (cascades to events)\n\n$order-\u003eupdate(['status' =\u003e 'paid']);   // → fans out notifications + events to all matching watchers\n```\n\n`watchFor()` is idempotent — calling it twice for the same `(record, user)` pair updates the existing row instead of creating a duplicate (enforced by a unique index).\n\n### Curating which fields users can build conditions on\n\nThe **Field** input in the condition modal is rendered as a searchable `Select`, populated from `getWatchableFields()`. The trait's default introspects the model's table and returns every column minus the primary key, `deleted_at`, and any plugin-level ignored columns (default `updated_at`, `created_at`).\n\nOverride the method on your model to expose a curated list with friendlier semantics:\n\n```php\nclass Order extends Model\n{\n    use HasWatchers;\n\n    public function getWatchableFields(): array\n    {\n        return ['status', 'total', 'client_name', 'address'];\n    }\n}\n```\n\n## Filament integration — `WatchAction` / `UnwatchAction`\n\nDrop the actions into any Resource that uses a watchable model. They are two separate actions:\n\n- **`WatchAction`** — always visible. Label flips between \"Watch\" and \"Edit watch\" depending on whether the current user is already subscribed. Opens a modal with a `Repeater` of condition rows.\n- **`UnwatchAction`** — visible only when the current user is already subscribed. One-click confirmation modal.\n\n```php\nuse Filament\\Actions\\EditAction;\nuse Filament\\Actions\\ViewAction;\nuse Wezlo\\FilamentRecordWatcher\\Actions\\UnwatchAction;\nuse Wezlo\\FilamentRecordWatcher\\Actions\\WatchAction;\n\npublic static function table(Table $table): Table\n{\n    return $table\n        -\u003ecolumns([\n            TextColumn::make('reference'),\n            TextColumn::make('status')-\u003ebadge(),\n        ])\n        -\u003erecordActions([\n            ViewAction::make(),\n            EditAction::make(),\n            WatchAction::make(),\n            UnwatchAction::make(),\n        ]);\n}\n```\n\nBoth actions also work in page header arrays (`headerActions()`) on view / edit pages.\n\n### Permissions\n\nBoth actions expose a `-\u003ecan()` method that accepts a boolean or a closure receiving `(Model $record, ?Authenticatable $user)` and returning `bool`. Returning `false` hides the action for that user / row. The check is ANDed with the action's built-in visibility rules.\n\n```php\nWatchAction::make()\n    -\u003ecan(fn (Order $record, ?Authenticatable $user) =\u003e $user?-\u003ecan('view', $record) ?? false),\n\nUnwatchAction::make()\n    -\u003ecan(fn (Order $record) =\u003e auth()-\u003euser()-\u003ehasPermissionTo('manage-watches')),\n```\n\n## Conditions DSL\n\nConditions are stored as a JSON array of rules. Empty / null conditions are **fail-open** — every non-ignored change notifies. Multiple rules are **ANDed** together.\n\n```json\n[\n  {\"field\": \"status\", \"operator\": \"changed\", \"value\": null},\n  {\"field\": \"amount\", \"operator\": \"\u003e\",       \"value\": 10000}\n]\n```\n\n| Operator     | Semantics                                                                       |\n| ------------ | ------------------------------------------------------------------------------- |\n| `changed`    | Passes if the field appears in the diff for this update.                        |\n| `=` / `!=`   | Loose comparison against the **new** attribute value.                           |\n| `\u003e` / `\u003c`    | Numeric / lexical comparison against the new attribute value.                   |\n| `\u003e=` / `\u003c=`  | Numeric / lexical comparison against the new attribute value.                  |\n| `contains`   | True if the new attribute value is a string and contains the rule's value.      |\n\nThe `Repeater` in `WatchAction`'s modal builds this JSON for end users — they pick a field, an operator, and (optionally) a value, then save.\n\n## \"My Watches\" page\n\nThe plugin registers a custom Filament `Page` at `/{panel}/my-watches`. It's auth-scoped at the query level — users only ever see their own watches.\n\nColumns: model type, record id, conditions (\"Any change\" or \"N rule(s)\"), event count badge, paused indicator, watching since.\n\nRow actions:\n\n- **History** — opens a modal listing every recorded change event for this watch (timestamp, actor name, per-field `old → new` diff). Survives notification cleanup. Limited to the latest 100 events per click.\n- **Pause** — sets `paused_at`. Paused watches stop receiving notifications without losing their conditions.\n- **Resume** — clears `paused_at`.\n- **Unwatch** — deletes the row (and its event history via cascade).\n\nDisable the page from a specific panel:\n\n```php\nFilamentRecordWatcherPlugin::make()-\u003eregisterMyWatchesPage(false)\n```\n\nOr change its navigation:\n\n```php\nFilamentRecordWatcherPlugin::make()\n    -\u003enavigationGroup('Notifications')\n    -\u003enavigationIcon('heroicon-o-bell-alert')\n```\n\n## Persistent event history\n\nNotifications can be dismissed, cleared, or simply missed. To make sure users never lose track of changes, every successful fan-out also persists a row in the `watch_events` table. The history retains the actor and the full field-level diff and is exposed on the My Watches page through the **History** action.\n\n```php\n$watch-\u003eevents;                                     // HasMany WatchEvent, latest first\n$watch-\u003eevents()-\u003elatest()-\u003elimit(50)-\u003eget();\n$watch-\u003eevents-\u003efirst()-\u003eactor;                     // morphTo — the user who made the change\n$watch-\u003eevents-\u003efirst()-\u003ediff;                      // ['status' =\u003e ['old' =\u003e 'pending', 'new' =\u003e 'paid'], ...]\n$watch-\u003eevents-\u003efirst()-\u003ecreated_at;\n```\n\nSchema:\n\n| Column                            | Purpose                                              |\n| --------------------------------- | ---------------------------------------------------- |\n| `id`                              | Primary key                                          |\n| `watch_id`                        | FK to `watches` (cascade on delete)                  |\n| `actor_type`, `actor_id`          | Polymorphic actor (nullable for unattributed writes) |\n| `diff`                            | JSON `{field: {old, new}}` for the changed columns   |\n| `created_at`                      | When the event was recorded                          |\n\nIndexed `(watch_id, created_at)` for fast per-watch history queries. Unwatching cascades — deleting a watch removes its event log automatically.\n\n## Actor capture (queues, jobs, commands)\n\nBy default, `WatchEngine` reads `auth()-\u003euser()` to attribute the change. In a queued job, scheduled command, or any context where there is no authenticated user, wrap the write with `actingAs()`:\n\n```php\nuse Wezlo\\FilamentRecordWatcher\\Services\\WatchEngine;\n\nWatchEngine::actingAs($user, function () use ($order): void {\n    $order-\u003eupdate(['status' =\u003e 'paid']);\n});\n```\n\nThe actor is included in the notification body (`By {name}`), persisted on the `WatchEvent` row, and dispatched on the event payload.\n\n### Self-notify is suppressed\n\nBy design, the engine **does not notify the actor on their own changes**. If user A is watching an order and user A edits it, no notification is delivered (otherwise watching anything you ever touched would spam your bell). The event is also not recorded for the actor's own watches. To verify the system end-to-end, log in as a second user and edit a record the first user is watching.\n\n## Events\n\nEvery notification fan-out also dispatches a plain Laravel event:\n\n```php\nWezlo\\FilamentRecordWatcher\\Events\\RecordWatchedChange\n```\n\nThe event carries the `Watch` model, the changed `Model`, the diff array, and the `?Authenticatable $actor`. Subscribe to it for email / Slack delivery, audit logging, analytics, or anything else you need beyond the in-panel notification.\n\n```php\nuse Illuminate\\Support\\Facades\\Event;\nuse Wezlo\\FilamentRecordWatcher\\Events\\RecordWatchedChange;\n\nEvent::listen(function (RecordWatchedChange $event): void {\n    // $event-\u003ewatch, $event-\u003erecord, $event-\u003ediff, $event-\u003eactor\n});\n```\n\n## How change detection works\n\n`HasWatchers::bootHasWatchers()` registers a static `updated` model hook that:\n\n1. Builds a field-level diff with `DiffBuilder::build($model, $ignored)` using the model's `getOriginal()` + `getChanges()`.\n2. Skips ignored columns (`updated_at`, `created_at` by default — configurable).\n3. If the filtered diff is non-empty, calls `WatchEngine::fanOut($model, $diff)`.\n\n`WatchEngine::fanOut()`:\n\n1. Eager-loads `$model-\u003ewatches()-\u003eactive()-\u003ewith('user')` (active = `paused_at IS NULL`).\n2. Skips the actor's own watch.\n3. For each remaining watch, evaluates conditions via `ConditionEvaluator::passes()`.\n4. For each passing watch: persists a `WatchEvent`, sends a Filament database notification, dispatches `RecordWatchedChange`.\n\n## Database schema\n\n### `watches`\n\n| Column                                | Purpose                                              |\n| ------------------------------------- | ---------------------------------------------------- |\n| `id`                                  | Primary key                                          |\n| `watchable_type`, `watchable_id`      | Polymorphic target                                   |\n| `user_id`                             | Subscriber (FK to `users`, cascade on delete)        |\n| `conditions`                          | JSON rules array (nullable = notify on any change)   |\n| `paused_at`                           | When the watch was paused (nullable)                 |\n| `created_at`, `updated_at`            | Timestamps                                           |\n\nIndexes:\n\n- Unique `(watchable_type, watchable_id, user_id)` — one watch per user per record.\n- `(user_id, paused_at)` — fast lookup for the My Watches page.\n\n### `watch_events`\n\nSee the [Persistent event history](#persistent-event-history) section above.\n\n## Configuration reference\n\nPublish with `php artisan vendor:publish --tag=\"filament-record-watcher-config\"`.\n\n| Key                                  | Default                            | Description                                                           |\n| ------------------------------------ | ---------------------------------- | --------------------------------------------------------------------- |\n| `user_model`                         | `App\\Models\\User`                  | Model used for the `user_id` relationship.                            |\n| `table_name`                         | `watches`                          | Name of the polymorphic subscriptions table.                          |\n| `events_table_name`                  | `watch_events`                     | Name of the persistent event history table.                           |\n| `ignored_columns`                    | `['updated_at', 'created_at']`     | Column changes that should NOT trigger watcher notifications.         |\n| `max_diff_lines`                     | `8`                                | Max diff lines included in a notification body.                       |\n| `my_watches.enabled`                 | `true`                             | Whether the **My Watches** page registers itself.                     |\n| `my_watches.navigation_group`        | `null`                             | Navigation group for the page.                                        |\n| `my_watches.navigation_icon`         | `heroicon-o-bell`                  | Navigation icon.                                                      |\n| `my_watches.navigation_sort`         | `95`                               | Navigation sort order.                                                |\n\nYou can also configure most of these fluently on the plugin (`-\u003eignoredColumns([...])`, `-\u003enavigationGroup(...)`, `-\u003enavigationIcon(...)`, `-\u003euserModel(...)`, `-\u003eregisterMyWatchesPage(false)`) — the plugin values win over config values when both are set.\n\n## Low-level service API\n\n```php\nuse Wezlo\\FilamentRecordWatcher\\Services\\WatchEngine;\nuse Wezlo\\FilamentRecordWatcher\\Services\\ConditionEvaluator;\n\n// Manually fan out (rarely needed — the observer handles this for you)\napp(WatchEngine::class)-\u003efanOut($order, [\n    'status' =\u003e ['old' =\u003e 'pending', 'new' =\u003e 'paid'],\n]);\n\n// Evaluate a rule set against a model + diff\n$passes = app(ConditionEvaluator::class)-\u003epasses($order, $diff, $watch-\u003econditions);\n\n// Run a write under an explicit actor (queues, console, system jobs)\nWatchEngine::actingAs($systemUser, fn () =\u003e $order-\u003eupdate(['status' =\u003e 'paid']));\n```\n\nThe engine performs no authorization of its own — it's a low-level primitive. Enforce permissions at the caller (action, job, command) before invoking it.\n\n## Translations\n\nThe package ships English translations under `filament-record-watcher::watcher.*`. Every user-visible string — action labels, modal headings, notifications, table columns, page title, history modal — is routed through `__()`. Add additional locales by publishing:\n\n```bash\nphp artisan vendor:publish --tag=\"filament-record-watcher-translations\"\n```\n\n## Testing\n\nThe package is exercised by 20 Pest tests covering the condition evaluator, the trait surface (`watchFor` / `unwatchFor` / `getWatchableFields`), the observer + engine notification path, the persistent event log (survives notification deletion), the diff builder's ignored-columns behaviour, and the auth-scoped My Watches page. Run them from the host app:\n\n```bash\nphp artisan test --compact tests/Feature/FilamentRecordWatcher\n```\n\n## License\n\nMIT.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmustafakhaleddev%2Ffilament-record-watcher","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmustafakhaleddev%2Ffilament-record-watcher","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmustafakhaleddev%2Ffilament-record-watcher/lists"}