Self-hosted APM for Laravel with zero external dependencies. Parent/child observability over native Laravel events, stored in your existing RDBMS.

Maintainers

Package info

github.com/VictorStochero/Warden

pkg:composer/victorstochero/warden

Statistics

Installs: 14

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v0.1.1 2026-06-10 13:43 UTC

This package is auto-updated.

Last update: 2026-06-10 13:44:45 UTC


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
Fleet overview Project dashboard
Trace timeline (N+1 flagged) Issues
Trace timeline 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 set WARDEN_DELIVERY=daemon in the child's .env) and supervise php artisan warden:ship under 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 editing WARDEN_MODE=parent by hand almost always means the config cache is stale — warden:switch clears it for you, or run php artisan config:clear yourself.

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 (scheduler delivery) are auto-registered by the package — you only need the Laravel scheduler cron running. Set WARDEN_PARENT_SCHEDULE=false / WARDEN_CHILD_SCHEDULE=false to 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_events is RANGE-partitioned on occurred_date; warden:prune drops 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=daemon and lower warden:ship --batch if individual traces are large — the parent rejects a POST over WARDEN_MAX_BODY_BYTES or WARDEN_MAX_EVENTS with 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 outside WARDEN_MAX_SKEW seconds 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=true on the parent to reject any non-TLS ingest (HTTP 403); the child logs a one-time warning if WARDEN_PARENT_URL is not https://. 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_PASSWORD grants view access; the optional WARDEN_DASHBOARD_ADMIN_PASSWORD grants 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 in WARDEN_DASHBOARD_EMAILS gets view access; one in WARDEN_DASHBOARD_ADMIN_EMAILS gets management (when no admin list is set, the viewer list grants both).
  • gate — advanced: define viewWarden / manageWarden yourself 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.