victorstochero / warden
Self-hosted APM for Laravel with zero external dependencies. Parent/child observability over native Laravel events, stored in your existing RDBMS.
Requires
- php: ^8.2
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^2.0|^3.0
- laravel/pint: ^1.29
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpstan/phpstan: ^1.11|^2.0
- phpunit/phpunit: ^10.0|^11.0|^12.0
README
Zero external dependencies. Parent/child observability built entirely on native Laravel events, stored in the relational database you already run (MySQL / MariaDB / PostgreSQL).
Warden is a single installable package that gives you full application-performance coverage — requests, queries, jobs, exceptions, logs, mail, notifications, cache, commands, scheduled tasks, outbound HTTP, users and host metrics — with correlated traces, exception grouping into issues, aggregated dashboards and internal alerting. No SaaS, no third-party agent, no external service.
Why Warden
You already have great tools for one app: Telescope (local debugging), Pulse (in-app production metrics) and SaaS suites like Sentry / Flare (powerful, but paid and off-premise). The gap nobody fills well is a single self-hosted panel for your whole fleet of Laravel apps — no SaaS account, no agent, no external service, and zero runtime dependencies (no build step, nothing outside Laravel core).
That's Warden: run one parent, point every app at it, and watch the entire fleet from one place — stored in the database you already operate.
Screenshots
The parent's self-hosted dashboard (Blade + Tailwind, no build step):
| Fleet overview | Project dashboard |
|---|---|
![]() |
![]() |
| Trace timeline (N+1 flagged) | Issues |
![]() |
![]() |
How it works
One app runs as the parent (ingests, stores, aggregates, exposes read contracts). Every other app runs as a child (observes its own lifecycle via native Laravel events and ships batches to the parent). Capture is fully decoupled from delivery:
request lifecycle ──> in-memory buffer ──(terminate)──> wdn_outbox ──(warden:ship daemon)──> parent /ingest
The request path never does network I/O or heavy serialization. If the parent is offline the outbox accumulates and drains later — the host app never breaks (RNF-2).
Getting started
The mental model first: you run one parent app (it collects the data and shows the
dashboard) and one or more children (the apps you want to observe). The same package
powers both — warden:install just writes WARDEN_MODE=parent or =child to that app's
.env, and the rest of the package reads that flag to decide how to behave.
Setup is two parts: A — stand up the parent once; B — connect each child. Every
warden:install run publishes the config + migrations and runs migrate for you, and the
child form even writes the credentials into the child's .env — so there are no .env files
to hand-edit except the parent's dashboard login (Part A, step 2).
Part A — Set up the parent (once)
A1. Install the package in parent mode
composer require victorstochero/warden
php artisan warden:install --parent # publishes config + migrations, migrates, writes WARDEN_MODE=parent
This boots the app in parent mode and auto-registers the maintenance schedule
(aggregate / evaluate / partition / prune). It does not set up dashboard
access — that's the next step.
A2. Open the dashboard login (required outside local)
The dashboard lives at https://apm.example.com/warden. Important: out of the box,
when no login is configured Warden locks the dashboard to the local environment only — so
on a real server you'll get denied until you pick an auth mode. The simplest is a built-in
password (no host users, no code), set in the parent's .env:
WARDEN_DASHBOARD_AUTH=password WARDEN_DASHBOARD_PASSWORD=choose-a-strong-view-password WARDEN_DASHBOARD_ADMIN_PASSWORD=choose-a-strong-admin-password # optional: grants "manage" rights
WARDEN_DASHBOARD_PASSWORD grants read access; WARDEN_DASHBOARD_ADMIN_PASSWORD grants
management (creating projects, rotating secrets, running maintenance). If you set only the
first, any successful login is treated as admin. Prefer logging in with your app's own users,
or wiring custom gates? See Dashboard access for the email and gate
modes — but password is the fastest way to get in.
A3. Make sure the scheduler cron is running
The maintenance schedule from A1 only fires if Laravel's scheduler is running. One cron line on the parent (Forge adds this for you by default):
* * * * * cd /path && php artisan schedule:run >> /dev/null 2>&1
The parent is now live. Log in, and continue to Part B to start sending it data.
Part B — Connect each child
Repeat this for every app you want to observe.
B1. Create a project on the parent (mints the credentials)
Each child authenticates with its own token + signing secret. Create a project to mint them — either in the dashboard (Manage projects → New, which shows a ready-to-run install command; the secret is shown only once) or from the parent's CLI:
php artisan warden:project "My App" # scheduler delivery (default) php artisan warden:project "My App" --delivery=daemon # for high-volume children
B2. Run install on the child
Paste the command from B1 into the child app (or into your Forge deploy script — it's fully non-interactive). Note this runs in the child's project, not the parent's:
php artisan warden:install --child \ --parent-url=https://apm.example.com \ --project=my-app \ --token=Yz3... \ --secret=9aF...
That single command publishes + migrates and writes the four credentials into the child's
.env for you (WARDEN_PARENT_URL, WARDEN_PROJECT, WARDEN_TOKEN, WARDEN_SECRET). With
the default scheduler delivery it also auto-registers warden:ship --once to run every
minute — so as long as that child's scheduler cron is running, nothing else is needed.
High volume? Create the project with
--delivery=daemon(or setWARDEN_DELIVERY=daemonin the child's.env) and supervisephp artisan warden:shipunder Supervisor / a Forge Daemon for near-real-time delivery instead of once-a-minute.
B3. Verify it's working
Generate some traffic on the child (load a page, run a job). Within a minute the project lights
up on the parent's overview, with traces, slow queries, issues and host metrics. If nothing
appears, check that the child's scheduler cron is running and that WARDEN_PARENT_URL points
at the parent over HTTPS.
Tuning knobs (most common)
WARDEN_SAMPLE_REQUEST=1.0 # keep 100% of request traces (lower for high volume) WARDEN_ALWAYS_KEEP_MS=1000 # always keep traces slower than this, regardless of sampling WARDEN_RAW_RETENTION_DAYS=7 # how long raw events live WARDEN_AGG_RETENTION_DAYS=90 # how long aggregates live WARDEN_DELIVERY=scheduler # scheduler (cron) or daemon (supervised warden:ship)
Disable a noisy recorder entirely, or sample a category, in config/warden.php
(child.recorders and child.sample.type_gate).
Environment variables
warden:install / warden:switch write the required keys for you; the rest are optional
overrides with sane defaults. This is the practical surface per role — see
config/warden.php for the exhaustive list and inline docs.
Shared (both roles)
| Variable | Required | Default | What it does |
|---|---|---|---|
WARDEN_MODE |
yes | child |
parent or child — the one flag that decides the role |
WARDEN_CONNECTION |
no | (default) | Dedicated DB connection name for the wdn_ tables (must point at the same database) |
Parent (collector + dashboard)
A parent needs only WARDEN_MODE=parent to ingest and self-monitor. To reach the dashboard
outside local, you must also pick an auth mode (it locks to local until you do):
| Variable | Required | Default | What it does |
|---|---|---|---|
WARDEN_MODE=parent |
yes | — | Run as the parent |
WARDEN_DASHBOARD_AUTH |
for remote access | (unset → local-only) |
password, email or gate |
WARDEN_DASHBOARD_PASSWORD |
password mode |
— | Grants view access (built-in login) |
WARDEN_DASHBOARD_ADMIN_PASSWORD |
no | — | Grants manage rights; if unset any login is admin |
WARDEN_DASHBOARD_EMAILS / WARDEN_DASHBOARD_ADMIN_EMAILS |
email mode |
— | Comma-separated allowlists of host-user e-mails |
Common parent overrides (all optional): WARDEN_ROUTE_PREFIX (warden), WARDEN_SELF_MONITOR
(true), WARDEN_PARENT_SCHEDULE (true), WARDEN_REQUIRE_HTTPS (false),
WARDEN_RAW_RETENTION_DAYS (7), WARDEN_AGG_RETENTION_DAYS (90), WARDEN_PARTITIONING
(true), WARDEN_SLOW_REQUEST_MS (1000), WARDEN_SLOW_QUERY_MS (100),
WARDEN_INGEST_RATE_LIMIT (300,1), WARDEN_MAX_BODY_BYTES (1048576), WARDEN_MAX_EVENTS
(5000), WARDEN_ALERT_EMAILS, WARDEN_ALERT_COOLDOWN (300).
Child (observed app)
The four credentials are required for the child to ship anything (an unconfigured child stays fully inert — it never errors):
| Variable | Required | Default | What it does |
|---|---|---|---|
WARDEN_MODE=child |
yes | child |
Run as a child |
WARDEN_PARENT_URL |
yes | — | Base URL of the parent (HTTPS) |
WARDEN_PROJECT |
yes | — | Project slug minted on the parent |
WARDEN_TOKEN |
yes | — | Per-project ingest token |
WARDEN_SECRET |
yes | — | Per-project HMAC signing secret |
WARDEN_DELIVERY |
no | scheduler |
scheduler (cron) or daemon (supervised warden:ship) |
Common child overrides (all optional): WARDEN_CHILD_SCHEDULE (true), WARDEN_OUTBOX
(database/redis), WARDEN_OUTBOX_HIGH_WATER (10000), WARDEN_OUTBOX_LOW_WATER (8000),
WARDEN_SAMPLE_REQUEST (1.0), WARDEN_SAMPLE_JOB (1.0), WARDEN_ALWAYS_KEEP_MS (1000),
WARDEN_HOST_INTERVAL (15), WARDEN_AUDIT_SCHEDULE (false), WARDEN_AUDIT_CRON
(0 3 * * *).
Switching modes & uninstalling
Installed the wrong role, or want to tear Warden down? Two commands handle it without
hand-editing files. Both are destructive to the wdn_ tables and prompt for confirmation
unless you pass --force (use --force in deploy scripts / non-interactive shells).
Switch an installed app between parent and child — rewrites WARDEN_MODE (and, for a child,
the credentials), drops the wdn_ tables, rebuilds the schema from scratch and clears the
config + route cache so the new mode takes effect immediately:
php artisan warden:switch parent # become the collector + dashboard
php artisan warden:switch child --parent-url=https://apm.example.com --token=… --secret=…
A blank
/warden(404) right after editingWARDEN_MODE=parentby hand almost always means the config cache is stale —warden:switchclears it for you, or runphp artisan config:clearyourself.
Uninstall completely — drops every wdn_ table, strips all WARDEN_* keys from the .env
and deletes the published config/warden.php (published migration files are left in place):
php artisan warden:uninstall
composer remove victorstochero/warden # then drop the package itself
Commands
| Command | Mode | What it does |
|---|---|---|
warden:install --parent|--child |
both | Write .env, publish config + migrations, migrate |
warden:switch parent|child |
both | Switch an installed app between modes, rebuilding the wdn_ schema from scratch (--force to skip the prompt) |
warden:uninstall |
both | Drop all wdn_ tables, strip WARDEN_* from .env and delete the published config (--force to skip the prompt) |
warden:project {name} |
parent | Create a project (mints token + secret); --list to list |
warden:ship |
child | Drain the outbox and ship batches (daemon; --once for the scheduler) |
warden:aggregate |
parent | Roll raw events into aggregates + group exceptions into issues |
warden:evaluate |
parent | Evaluate heartbeats/issues, open/resolve incidents, fire alerts |
warden:partition |
parent | Ensure/pre-create wdn_events partitions (MySQL) |
warden:prune |
parent | Apply retention (drop old raw events + aggregates) |
warden:audit |
child | Run composer audit + npm audit and ship vulnerabilities to the parent |
warden:demo |
child | Generate one of each event type to exercise the pipeline (dev/testing) |
The parent's maintenance schedule and the child's shipping (
schedulerdelivery) are auto-registered by the package — you only need the Laravel scheduler cron running. SetWARDEN_PARENT_SCHEDULE=false/WARDEN_CHILD_SCHEDULE=falseto opt out and wire them by hand.
Dashboard
The parent serves a self-contained dashboard (Blade + a bundled Tailwind stylesheet served locally — no build step, no NPM, no Composer package outside Laravel core) at the route prefix:
https://apm.example.com/warden
It reads exclusively through the read layer (WardenRepository / DashboardRepository) and
covers an overview of all projects (health, throughput, error rate, p95, 30-day uptime),
per-project drill-down (requests, slow queries + N+1, jobs/queues, cache hit rate, schedule +
heartbeats, outgoing HTTP, logs, mail/notifications, host metrics), grouped issues with stack
traces, and a span-waterfall trace viewer. Access is guarded by the viewWarden ability —
define it in a service provider to open it beyond the local environment:
use Illuminate\Support\Facades\Gate; Gate::define('viewWarden', fn ($user) => $user->isAdmin());
Write actions (creating/rotating projects, triggering maintenance commands) are
guarded by a separate manageWarden ability. Define it the same way:
use Illuminate\Support\Facades\Gate; Gate::define('manageWarden', fn ($user) => $user->isAdmin());
Beyond the aggregate views, each section has a drill-down of recent raw events (the actual log message, mail recipient, job error, outgoing URL + status, per-request status…), incidents are clickable with a detail page, KPI cards link to their section, the Logs breakdown filters the list by level, and a per-project timezone controls how absolute timestamps render. A Delivery section shows when batches arrive (so you can see daemon vs. minute-by-minute cron at a glance), and Manage projects lets you reset a project's metrics, set its display timezone, and schedule its security audit. See the wiki for the full tour.
Alerting
Incidents (a dead scheduler, an error spike) fire through internal channels
listed in warden.alerts.channels. By default that's the Database channel
(the incident surfaces in the dashboard) and the Log channel. To also send
e-mail, enable the mail channel and set recipients — it uses the parent app's
own mailer (config/mail.php / your .env SMTP), no separate transport:
WARDEN_ALERT_EMAILS=ops@example.com,oncall@example.com
// config/warden.php — warden.alerts.channels \VictorStochero\Warden\Alerting\Channels\MailAlertChannel::class,
Security audits
A child can audit its own dependencies and surface vulnerabilities on the parent:
php artisan warden:audit # runs composer audit + npm audit, ships a snapshot
The result appears in the project's Security section (counts by severity + the
advisory list). To run it automatically, set a frequency per project under Manage
projects → Audit (hourly / 6h / daily / weekly): the parent advertises "audit due" on
the ingest response and the child's shipper runs warden:audit when it elapses — no
extra cron. A child-side cron (WARDEN_AUDIT_SCHEDULE=true, WARDEN_AUDIT_CRON) is
also available as an alternative.
Scaling & databases
- MySQL / MariaDB:
wdn_eventsis RANGE-partitioned onoccurred_date;warden:prunedrops whole partitions (cheap at any volume). - PostgreSQL / SQLite: a single table pruned with chunked DELETEs — fine for moderate volume; for very high volume prefer MySQL partitioning.
- The parent ingest is a single write path. Past roughly 5–10M events/day on one node, scale the parent's database (faster disk, more IOPS) first.
- High shipping volume? Create the project with
--delivery=daemonand lowerwarden:ship --batchif individual traces are large — the parent rejects a POST overWARDEN_MAX_BODY_BYTESorWARDEN_MAX_EVENTSwith HTTP 413.
Security
Child → parent communication
The ingest channel is authenticated and tamper-evident end to end:
- Per-project token identifies the sender; an inactive project or a wrong token is rejected with 401.
- HMAC-SHA256 signature over the exact request body, compared timing-safe
(
hash_equals). The signing secret is stored encrypted and shown only once. - Anti-replay: the signed body carries a
sent_at; bodies outsideWARDEN_MAX_SKEWseconds are rejected as stale. - Idempotent dedup: each batch carries a
batch_id, so a retried POST is recorded once. - Rate limiting on the ingest route (
WARDEN_INGEST_RATE_LIMIT) plus payload guards (WARDEN_MAX_BODY_BYTES/WARDEN_MAX_EVENTS, HTTP 413). - HTTPS enforcement (optional): set
WARDEN_REQUIRE_HTTPS=trueon the parent to reject any non-TLS ingest (HTTP 403); the child logs a one-time warning ifWARDEN_PARENT_URLis nothttps://. The check honours trusted-proxy headers, so a TLS-terminating proxy still works. Off by default. - Rotate a project's secret any time from Manage projects → Rotate secret.
Reminder: a child needs only warden:install --child plus its .env
(WARDEN_PARENT_URL, WARDEN_PROJECT, WARDEN_TOKEN, WARDEN_SECRET) — no code.
Data redaction
Sensitive keys (warden.child.scrub) are redacted from query bindings, request
input, headers, log context and exception messages; stack-trace file paths
are relativized to the app base path.
Dashboard access
Read access is gated by the viewWarden ability; write actions (managing
projects, triggering maintenance) by a separate manageWarden. Pick the model
from the .env with WARDEN_DASHBOARD_AUTH — no code required:
password— a built-in login form, independent of the host app's users (ideal for a dedicated parent).WARDEN_DASHBOARD_PASSWORDgrants view access; the optionalWARDEN_DASHBOARD_ADMIN_PASSWORDgrants management. With no admin password set, any successful login is treated as admin. Passwords are compared timing-safe.email— uses the host app's authenticated user. An e-mail inWARDEN_DASHBOARD_EMAILSgets view access; one inWARDEN_DASHBOARD_ADMIN_EMAILSgets management (when no admin list is set, the viewer list grants both).gate— advanced: defineviewWarden/manageWardenyourself in a service provider. A host-defined gate always wins over the package defaults.
When WARDEN_DASHBOARD_AUTH is unset it resolves to password if a dashboard
password is configured, otherwise gate (local-only) — the historical default.
Quality
composer test # PHPUnit — acceptance criteria from the spec (§15) + dashboard render composer phpstan # PHPStan at level max (Larastan), green
Static analysis runs at level max with no baseline — zero errors. mixed from
config(), json_decode() and query-builder rows is narrowed at the edges with typed
helpers (Support\Cast, Support\Json) and precise array-shape / generic annotations, so
the type information flows all the way through. PHPStan and Larastan are dev-only — they
don't affect the zero runtime-dependency guarantee.
See config/warden.php for the full configuration surface and
docs/ARCHITECTURE.md for the design.
Roadmap
Warden ships incrementally — each release adds one focused capability. Planned next
(see docs/ROADMAP.md for the full picture and positioning):
- Multilingual dashboard — English, Português, Español.
- Alerts center — e-mail plus Slack / Discord / generic webhook channels, managed from the UI.
- Fleet-wide distributed tracing — one request crossing apps becomes a single trace.
- Release / deploy tracking — "errors since this deploy" and regression detection.
- Real-time dashboard, configurable uptime windows, and a configurable alert-rule engine.
License
MIT.



