laravel maintained by watcher-php
Watcher
Security should never be an afterthought.
Watcher is a security intelligence and protection layer for Laravel applications. It identifies potentially insecure code before deployment, monitors runtime behavior for suspicious activity, correlates related events into investigable incidents, and notifies your team — without claiming to prevent all attacks or guarantee application security.
What it does
- Prevent — static analysis integration (Psalm taint analysis for SQLi/XSS/SSRF/etc., a custom PHPStan rule for Eloquent mass-assignment,
composer auditfor known dependency vulnerabilities), plus#[RequirePermission]/#[Guarded]/#[Audit]attributes enforced on controller actions. - Detect — listens to real Laravel events (authentication, authorization denials, queue failures, database errors, sensitive model access) and correlates repeated failures per subject (user or IP) into incidents, instead of raising one alert per event.
- Investigate — a protected dashboard showing events, incidents (with evidence, timeline and recommended steps), static-analysis findings, and the notification log.
- Notify — mail, Slack and generic webhook channels, with a severity threshold and a per-incident cooldown so a sustained attack doesn't flood your team.
Watcher does not implement its own static analyser, taint tracker, or SQL parser — it integrates Psalm, PHPStan and Composer's own advisory database, and extends them with a small number of Laravel-specific rules where generic tools don't reach (e.g. protected $guarded = [];).
Requirements
- PHP
^8.1(the floor is set by enums and readonly properties, used throughoutsrc/Core/; Laravel 10 itself requires ^8.1, so this matches the lowest supported framework version rather than being arbitrary) - Laravel (
illuminate/support,illuminate/console,illuminate/database)^10.0 | ^11.0 | ^12.0 - Optional, for
watcher:analyse:vimeo/psalmand/orphpstan/phpstanas dev dependencies of your application, andcomposeritself onPATH.
Installation
composer require watcher-php/laravel
php artisan watcher:install
watcher:install publishes config/watcher.php and the migrations, and offers to run them. It will warn you that the dashboard is inaccessible until you define its gate:
// e.g. in a service provider's boot()
Gate::define('viewWatcherDashboard', fn ($user) => $user->isAdmin());
There is no default-open dashboard route — if you never define this gate, the dashboard denies everyone.
Check everything is wired up:
php artisan watcher:status
Quick start
-
Define the dashboard gate (above).
-
Visit
/watcher(configurable viawatcher.dashboard.path) for the overview, events, incidents, findings and notifications. -
Protect a controller action:
use Watcher\Attributes\{Guarded, RequirePermission, Audit}; #[RequirePermission('manage-users')] #[Audit('user-deletion')] public function destroy(User $user) { $user->delete(); }RequirePermissionis checked against your ownGate/policies;Guardedjust requires an authenticated user;Auditrecords every invocation regardless of outcome. Read Limitations before relying on these — they only protect HTTP requests routed through a controller action with the enforcement middleware in the pipeline (on by default, via thewebgroup). -
Run static analysis against your own app:
composer require --dev vimeo/psalm phpstan/phpstan vendor/bin/psalm --init --taint-analysis php artisan watcher:analyseFindings are persisted and shown on the dashboard's Findings page; each run replaces that tool's prior findings.
-
Trigger a real incident to see the pipeline end to end: see docs/incident-scenarios.md, or use the bundled example application below.
Example application
workbench/ is a real, runnable Laravel app (via Orchestra Workbench) with Watcher installed, a demo user, and routes exercising every attribute. From a clone of this repository:
composer install
vendor/bin/testbench workbench:build
vendor/bin/testbench db:seed --class="Workbench\Database\Seeders\WorkbenchDatabaseSeeder"
composer run serve
Then, in a browser or with curl:
| URL | What it shows |
|---|---|
/demo/open |
A route with no Watcher attributes — unaffected. |
/demo/guarded |
#[Guarded] — 403 until you log in. |
/demo/manage-users |
#[RequirePermission('manage-users')] + #[Audit] — 403 unless you're the demo admin (user 1); the call is audited either way. |
/_workbench/login/1 |
Quick-login as the seeded demo admin (Workbench's own helper — not a Watcher feature). |
/watcher |
The dashboard, once logged in (the demo's WorkbenchServiceProvider grants viewWatcherDashboard to any authenticated user — a real app must authorize this properly, not like the demo does). |
See docs/incident-scenarios.md for a walkthrough that also triggers a real detected incident.
Configuration
Published to config/watcher.php. Highlights:
| Key | Purpose |
|---|---|
enabled |
Kill switch — disables every listener/observer/middleware without uninstalling the package. |
dashboard.gate, dashboard.path |
The authorization gate and URL prefix for the dashboard. |
exclude.routes |
Route patterns (Str::is() wildcards) never observed. |
sensitive_models |
Allow-list of Eloquent models to observe for access — nothing is watched unless listed here. |
redaction.allowed_context_keys |
Empty by default (deny-list mode: secret-shaped keys like password/token are always redacted, everything else passes through). Populate to switch to strict allow-list mode. |
sampling.rate |
Drops a fraction of low-severity events under load; critical/high severity is never sampled away. |
collection.queue |
Persist events via a queued job instead of inline on the request. |
detection.repeated_failure.* |
Threshold/window for correlating repeated auth/authz failures into one incident. |
notifications.channels.*, minimum_severity, cooldown_minutes |
Mail/Slack/webhook channels, the severity floor for alerting, and the per-incident re-alert cooldown. |
attributes.enforce |
Whether #[RequirePermission]/#[Guarded]/#[Audit] are checked automatically on the web group. |
Architecture
src/Core/— framework-independent: events, detection rules, incident correlation, redaction. NoIlluminate\*imports.src/Laravel/— adapters: service provider, listeners/observers, the dashboard controller, notification channels, Eloquent repositories.src/Attributes/—RequirePermission,Guarded,Audit. Plain PHP 8 attributes with their own autoload root; zero framework dependency, since they're pure metadata.src/StaticAnalysis/— the bundled PHPStan rule, kept separate from both Core and Laravel since it's neither runtime logic nor a Laravel integration.
Runtime flow: a Laravel event/observer/middleware builds a SecurityEvent via SecurityRecorder (applies sampling + redaction) → an EventCollectorInterface persists it → if detection is enabled, DetectionEngine evaluates rules against a window of recent events for the same subject → a matching Finding is correlated into an Incident by IncidentManager (new incident, or folded into an existing open one within the correlation window) → NotificationDispatcher applies the severity threshold and cooldown and sends through configured channels.
Commands
| Command | Does |
|---|---|
watcher:install |
Publishes config + migrations, offers to run them. |
watcher:status |
Reports whether Watcher is enabled, tables exist, and the dashboard gate is defined. |
watcher:analyse |
Runs Psalm, PHPStan and composer audit (whichever are available) and persists findings. |
Limitations
Documented honestly rather than glossed over — read these before relying on the corresponding feature:
- Attribute enforcement only covers HTTP requests through a controller action, dispatched through a pipeline that includes
EnforceSecurityAttributes(thewebgroup, by default). It does not cover closure-based routes, and it cannot do anything for a method called directly from other PHP code — console commands, queued jobs, Tinker, or another controller calling it in-process. PHP attributes carry no runtime behavior of their own; there is no way around this without bytecode-level proxying, which Watcher does not do. A test (EnforceSecurityAttributesTest::test_calling_a_guarded_method_directly_bypasses_enforcement) proves this gap exists rather than papering over it. - CSRF protection on the dashboard's status-update endpoint cannot be demonstrated in this package's own test suite — Laravel's
VerifyCsrfTokendeliberately disables itself wheneverapp()->runningUnitTests()is true. The middleware is present (confirmed by inspecting the resolved route's middleware at test time); it just isn't something a feature test can prove. - Psalm's SSRF/file taint sinks are only cleared by an explicit
@psalm-taint-escapeannotation, not by ad-hoc validation logic — even genuinely-safe allow-list checks will still be flagged unless the validating function carries that annotation. Seetests/StaticAnalysis/vulnerable-samples/SafeExamples.php. - Incident correlation uses a per-subject cache lock (
Cache::lock()) to close the read-then-write race where two concurrent requests for the same subject could each open a duplicate incident. This only provides real cross-process protection on a cache driver that supports it (redis, memcached, database — not thearraydriver). True multi-process concurrency isn't reproducible in this package's single-process test suite; the lock's behavior (and its fail-safe skip when contended) is tested, the race itself is not. - Role/permission-change and API-token-lifecycle events have no automatic hook — no universal Laravel event exists for these without assuming a specific package (Sanctum, Passport, a permissions package). Call
Watcher::record(...)manually from your own code for these. - "Unusual request pattern" is not implemented. A credible version needs a statistical baseline this package doesn't build yet; shipping a heuristic that looks like detection without being grounded in one would be worse than not having it.
Security policy
See SECURITY.md.
Testing
composer test
Not vendor/bin/phpunit directly, if you've used any vendor/bin/testbench workbench command (building the example app, serving it, seeding it) in this checkout — those write compiled caches into the shared Testbench skeleton that composer test clears first (package:purge-skeleton). Running phpunit directly against a skeleton contaminated this way can produce confusing, resource-exhausting failures unrelated to your change. See CONTRIBUTING.md for the full story.
Test suites: Unit, Feature, StaticAnalysis (shells out to the real Psalm/PHPStan binaries — slower), Performance (prints measured overhead to stderr; not a pass/fail gate). Run one with vendor/bin/phpunit --testsuite <name> after a composer test run has cleared the skeleton.
Contributing
See CONTRIBUTING.md.
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.