watcher-php / laravel
Security intelligence and protection layer for Laravel applications: static analysis, runtime threat detection, incident management and notifications.
Requires
- php: ^8.1
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- nikic/php-parser: ^5.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpstan/phpstan: ^2.3
- phpunit/phpunit: ^10.0|^11.0
- vimeo/psalm: ^7.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
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. - Understand — deterministic (no AI) business-logic discovery: reads your routes, controllers and models to classify sensitive operations and check for missing ownership/access-control checks (IDOR). See Business Logic Intelligence.
- 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, business-logic 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:analyse
Findings 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. |
/demo/invoices/{invoice} |
Secure: checks #[WatcherOwnership('user_id')] via a real $this->authorize() call. watcher:discover catalogues it, raises no finding. |
/demo/invoices/{invoice}/vulnerable |
Deliberately vulnerable: same model, no ownership check. watcher:discover flags it — see Business Logic Intelligence. |
/_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. |
business_logic.* |
Column-name heuristics (ownership/tenant/financial) and sensitive route keywords watcher:discover uses, plus controller namespaces it never analyses. See Business Logic Intelligence. |
Architecture
src/Core/— framework-independent: events, detection rules, incident correlation, redaction, the business-rule shape (Core/BusinessLogic/). NoIlluminate\*imports.src/Laravel/— adapters: service provider, listeners/observers, the dashboard controller, notification channels, Eloquent repositories, business-logic discovery services (Laravel/BusinessLogic/).src/Attributes/—RequirePermission,Guarded,Audit, plus the business-logic declarationsWatcherSensitive,WatcherOwnership,WatcherTenantIsolation,WatcherStateTransition. Plain PHP 8 attributes with their own autoload root; zero framework dependency, since they're pure metadata.src/StaticAnalysis/— the bundled PHPStan rules, kept separate from both Core and Laravel since they're 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. Findings are tracked across runs by a stable fingerprint: one missing from a run is marked resolved, and one that reappears after being resolved is flagged as regressed (and audited as a vulnerability_regressed event) rather than silently treated as new. |
watcher:verify {finding} {url} |
Fires a real OWASP ZAP active scan — live attack payloads — at a URL you give it, to confirm whether a specific flagged finding is actually exploitable there. Disabled by default (watcher.verification.enabled); requires a ZAP instance you already have running (Watcher doesn't start or bundle one); the URL is never inferred, only ever the one you pass; prompts for confirmation in production unless --force. |
watcher:discover |
Runs deterministic business-logic discovery and analysis — see Business Logic Intelligence. |
Watcher's own PHPStan rules also include SensitiveAttributeMassAssignmentRule, which flags an Eloquent model whose $fillable includes a privilege-shaped column (is_admin, role, permissions, ...) — the concrete, buildable slice of "identify privilege escalation possibilities": a column like that being mass-assignable means a normal update()/create() call can grant it, not just an obviously-dangerous one.
Business Logic Intelligence
php artisan watcher:discover reads your application's routes, controllers and models — via Laravel's own Router, PHP reflection, real database schema introspection (Schema::getColumnListing()), and a static AST read of your source (via nikic/php-parser) — and never executes application business methods to figure out what they do.
What it builds:
- A Business Rule Registry (
watcher_business_rulestable / Business Logic dashboard page): every route Watcher classifies as a sensitive business operation, with the evidence for that classification, its discovery method, and a confidence/verification status. Classification combines multiple signals (route wording, HTTP method, related model's columns,DB::transaction()wrapping) — never a single signal like a method name. Inferred classification is capped atConfidence::Medium; only an explicit developer attribute reachesConfirmed. - Developer-declared attributes —
#[WatcherSensitive],#[WatcherOwnership('user_id')],#[WatcherTenantIsolation('tenant_id')],#[WatcherStateTransition(...)]— always take precedence over inference. If a declared attribute disagrees with discovered evidence (e.g.#[WatcherOwnership('account_id')]but the model has no such column), that's recorded as a conflict and surfaced as its own finding, never silently resolved. - One security analyzer, fully implemented: ownership/IDOR. For a sensitive operation whose route binds a model with an ownership/tenant column, Watcher checks whether an
authorize()/Gate/can()call (or acan:route middleware) was detected. Finding its absence is reported as "no ownership check was found" at Low/Medium confidence — never as a confirmed IDOR vulnerability, since confirming that would require testing it against real data. Findings land in the existingwatcher_findingstable (tool = business_logic), through the same fingerprint-based open/resolved/regressed lifecyclewatcher:analyseuses.
Deliberately not yet implemented (so nothing here is overclaimed):
- Workflow/state-machine mapping beyond detecting that a status-like column exists.
#[WatcherStateTransition]is catalogued but not yet security-analyzed. - The other five analysis categories from the original design: payment/financial integrity, approval-workflow security, state-transition integrity, data integrity, and race conditions. Race conditions in particular need reproducible concurrent testing before any claim — a static scan alone must never assert one is exploitable.
- Automatic security test generation from discovered rules.
- Five of the six demo domains originally envisioned (wallet transfer, payments, student results, tenant records, approvals, document uploads) — only the ownership/invoice pair is built, in the existing
workbench/example app.
Known limitations specific to discovery:
- Middleware applied via an old-style
$this->middleware(...)call inside a controller constructor is invisible — discovery readsRoute::middleware()(route/group-declared only) rather thanRoute::gatherMiddleware(), deliberately, since the latter instantiates the controller. - A relationship method is only recognised if its body contains a direct
$this->hasMany(...)/belongsTo(...)/etc. call Watcher's parser can see — a relation built through a trait method or a dynamically-constructed call isn't detected. - Discovery instantiates each related Eloquent model with
new $class()(no arguments) to read$fillable/getTable()— the one narrow exception to "never execute business methods," since that constructor runs no I/O or business logic (the same thingphp artisan model:showdoes).
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.
- There is no AI-assisted analysis module, and business-logic discovery is not a substitute for one.
watcher:discover(see Business Logic Intelligence) is deterministic pattern-matching over routes/controllers/models/schema — it has no semantic understanding of what your application is supposed to do beyond what a developer attribute or a column name tells it, and it implements exactly one security analyzer (ownership/IDOR). It deliberately doesn't depend on an external LLM for anything. If you want a business invariant checked that isn't covered,Watcher\Core\Contracts\DetectionRuleInterface(runtime) or a#[WatcherSensitive]/#[WatcherOwnership]attribute (discovery-time) are the extension points — encode it yourself rather than expecting Watcher to infer it. watcher:verify's ZAP integration could not be tested against a real ZAP instance in this environment (no running instance was available). The client (Watcher\Laravel\Verification\ZapClient) is written against ZAP's documented REST API shape and tested with mocked HTTP responses matching that shape — the same level of verification as the rest of the external-tool integrations where the tool itself can't run here, stated plainly rather than implied to be proven.
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), BusinessLogic, 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.