Zero external dependencies. Parent/child observability built entirely on native Laravel events, stored in the relational database you already run (MySQL / MariaDB / PostgreSQL).
Observer 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.
The parent's self-hosted dashboard (Blade + Tailwind, no build step):
| Fleet overview | Project dashboard |
|---|---|
![]() |
![]() |
| Trace timeline (N+1 flagged) | Issues |
![]() |
![]() |
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)──> obs_outbox ──(observer: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).
You need one parent app (collects + shows the data) and one or more children (the apps you want to observe). The same package powers both — only the mode differs.
composer require v4codes/observer
php artisan observer:install --parent # writes .env, publishes, migratesThat's it — the parent is in parent mode, the dashboard is live at
https://apm.example.com/observer, and the maintenance schedule
(aggregate / evaluate / partition / prune) is auto-registered. Just make
sure the Laravel scheduler cron is running (Forge configures it by default):
* * * * * cd /path && php artisan schedule:run >> /dev/null 2>&1
Open the dashboard, go to Manage projects, and create one project per child. On create, the panel shows a ready-to-run install command (the secret is shown only once) — copy it. You can also do it from the CLI:
php artisan observer:project "My App" # scheduler delivery (default)
php artisan observer:project "My App" --delivery=daemonPaste the command from Step 2 into the child app (or into your Forge deploy script — it is fully non-interactive):
php artisan observer:install --child \
--parent-url=https://apm.example.com \
--project=my-app \
--token=Yz3... \
--secret=9aF...This writes the child's .env, publishes, migrates, and — with delivery=scheduler
(the default) — auto-registers observer:ship --once every minute. With the
scheduler cron already running, nothing else is needed.
High volume? Create the project with
--delivery=daemon(or setOBSERVER_DELIVERY=daemon) and supervisephp artisan observer:shipunder Supervisor / a Forge Daemon for near-real-time delivery.
Generate some traffic on the child (load a page, run a job). Within a minute you'll see the
project light up on the parent's overview, with traces, slow queries, issues and host metrics.
Tune what's captured and how long it's kept in config/observer.php.
OBSERVER_SAMPLE_REQUEST=1.0 # keep 100% of request traces (lower for high volume)
OBSERVER_ALWAYS_KEEP_MS=1000 # always keep traces slower than this, regardless of sampling
OBSERVER_RAW_RETENTION_DAYS=7 # how long raw events live
OBSERVER_AGG_RETENTION_DAYS=90 # how long aggregates live
OBSERVER_DELIVERY=scheduler # scheduler (cron) or daemon (supervised observer:ship)Disable a noisy recorder entirely, or sample a category, in config/observer.php
(child.recorders and child.sample.type_gate).
| Command | Mode | What it does |
|---|---|---|
observer:install --parent|--child |
both | Write .env, publish config + migrations, migrate |
observer:project {name} |
parent | Create a project (mints token + secret); --list to list |
observer:ship |
child | Drain the outbox and ship batches (daemon; --once for the scheduler) |
observer:aggregate |
parent | Roll raw events into aggregates + group exceptions into issues |
observer:evaluate |
parent | Evaluate heartbeats/issues, open/resolve incidents, fire alerts |
observer:partition |
parent | Ensure/pre-create obs_events partitions (MySQL) |
observer:prune |
parent | Apply retention (drop old raw events + aggregates) |
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. SetOBSERVER_PARENT_SCHEDULE=false/OBSERVER_CHILD_SCHEDULE=falseto opt out and wire them by hand.
The parent serves a self-contained dashboard (Blade + Tailwind via CDN — no build step, no NPM, no Composer package outside Laravel core) at the route prefix:
https://apm.example.com/observer
It reads exclusively through the read layer (ObserverRepository / DashboardRepository) and
covers an overview of all projects (health, throughput, error rate, p95), 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 viewObserver ability — define it in a service provider
to open it beyond the local environment:
use Illuminate\Support\Facades\Gate;
Gate::define('viewObserver', fn ($user) => $user->isAdmin());Write actions (creating/rotating projects, triggering maintenance commands) are
guarded by a separate manageObserver ability. Define it the same way:
use Illuminate\Support\Facades\Gate;
Gate::define('manageObserver', fn ($user) => $user->isAdmin());Incidents (a dead scheduler, an error spike) fire through internal channels
listed in observer.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:
OBSERVER_ALERT_EMAILS=ops@example.com,oncall@example.com// config/observer.php — observer.alerts.channels
\Stochero\Observer\Alerting\Channels\MailAlertChannel::class,composer test # PHPUnit — acceptance criteria from the spec (§15) + dashboard render
composer phpstan # PHPStan at level max (Larastan), greenStatic 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/observer.php for the full configuration surface and
docs/ARCHITECTURE.md for the design.
- MySQL / MariaDB:
obs_eventsis RANGE-partitioned onoccurred_date;observer: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 lowerobserver:ship --batchif individual traces are large — the parent rejects a POST overOBSERVER_MAX_BODY_BYTESorOBSERVER_MAX_EVENTSwith HTTP 413.
- Children authenticate with a per-project token and sign each batch body with an HMAC-SHA256 secret (timing-safe comparison, anti-replay window). Secrets are stored encrypted and shown only once.
- Sensitive keys (
observer.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 read access is gated by the
viewObserverability; write actions (managing projects, triggering maintenance) by a separatemanageObserver. Define both in a service provider to open access beyond local.
MIT.



