ultraviolettes / constellation-for-laravel
Send a periodic health snapshot of your Laravel application to Constellation.
Package info
github.com/ultraviolettes/constellation-for-laravel
pkg:composer/ultraviolettes/constellation-for-laravel
Requires
- php: ^8.2|^8.3|^8.4|^8.5
- ext-json: *
- guzzlehttp/guzzle: ^7.8|^8.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- ramsey/uuid: ^4.7
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-03 14:37:36 UTC
README
Send a periodic health snapshot of your Laravel application to Constellation.
No migration, no model, no route, no view. composer require, two keys in
.env (token and endpoint), and the Laravel scheduler running.
Installation
1. Require the package
composer require ultraviolettes/constellation-for-laravel
Requires Laravel 12 (PHP 8.2+) or 13 (PHP 8.3+). Composer pins ^0.1: you get
the 0.1.x fixes, never a 0.2 without asking for it.
2. Set the environment
# Required: the token of this environment, issued by Constellation. CONSTELLATION_TOKEN=cnst_ing_... # Optional, with their defaults. CONSTELLATION_ENABLED=true CONSTELLATION_ENDPOINT=https://<your Constellation host>/api/v1/snapshots # shown by "Connect your app" CONSTELLATION_SCHEDULE=true # register the send in your scheduler CONSTELLATION_CRON="*/15 * * * *" CONSTELLATION_QUEUE_CONNECTION= # empty: your default connection CONSTELLATION_QUEUE= # empty: that connection's default queue CONSTELLATION_CACHE_STORE= # empty: your default cache store
One token per environment: production and staging each get their own. A
production token copied into a staging .env is refused by the server
(environment_mismatch) rather than mixing the two series.
The SDK keeps a little state between two snapshots — the window cursor, the
schedule history, the queue activity — in your cache, never in a table.
Use a store every process shares (Redis, database, Memcached, DynamoDB; file
on a single server). On array, nothing survives between processes: the
jobs and schedule sections say so with quality: degraded, and the
dead-worker signal stays null.
3. Run the scheduler and a worker
The package registers its own scheduled task (every 15 minutes by default):
there is nothing to add to routes/console.php. Your server still has to run
the Laravel scheduler, as for any scheduled task:
* * * * * cd /path/to/your/app && php artisan schedule:run >> /dev/null 2>&1
The task builds the snapshot and queues its sending: a worker must be
running on that connection (CONSTELLATION_QUEUE_CONNECTION /
CONSTELLATION_QUEUE to give it its own). When the snapshot it just built
shows that very queue blocked — stalled, or a job older than
critical_age_seconds — the task sends synchronously instead: the snapshot
reporting a dead worker must not wait behind it. A snapshot that still sat in
the queue for more than 30 minutes is dropped, not sent late: a snapshot is a
state, and the next one carries the same truth.
If the scheduler itself stops, no snapshot leaves at all — Constellation notices the silence after 45 minutes and opens an incident; no section of the snapshot can report its own absence.
4. Restart your workers — after install and after every upgrade
php artisan queue:restart # or: php artisan horizon:terminate
A queue worker is a long-running process: it keeps the code it booted with.
Until it restarts, it runs the previous version of the package — or none at
all on a fresh install, so the dead-worker detection below never sees it start
a job. php artisan about points at exactly this when it happens. Many deploy
scripts already run queue:restart on every deploy: check that yours does.
Checking your installation
php artisan about --only=constellation # token, endpoint, cache, last send, workers php artisan monitor:send --dry-run --pretty # print the payload, send and write nothing php artisan monitor:send --sync # send immediately and print the outcome php artisan monitor:send # queue the send (what the scheduler does)
The about section never prints the token, only whether there is one:
Constellation ............................................................
Cache store .................................................. redis (redis)
Enabled ...................................................... ENABLED
Endpoint ...................... https://<your Constellation host>/api/v1/snapshots
Last successful send ............ 2026-11-02 14:00:02 UTC (3 minutes ago)
Queue workers ............................................ job starts observed
Token ........................................................ configured
Unavailable collectors ..................... backups (collector_not_installed)
Version ........................................................ 0.1.0
Publish the config only if you need to change something:
php artisan vendor:publish --tag=constellation-config
What leaves your server
A few kilobytes of JSON every fifteen minutes: framework and PHP versions, debug mode, pending migrations, and aggregated queue failures. Exception messages are normalised — quoted strings, UUIDs, hashes and numbers are replaced by placeholders — so no data of your own users ever leaves your server. No job payloads, no stack traces, no query strings.
Sections
| Section | Content |
|---|---|
app |
PHP and Laravel versions, environment, debug mode, pending migrations, config and route caches |
jobs |
Processed and failed totals over the window, plus the top failure groups |
queues |
Per queue: depth, age of the oldest pending job, throughput and failures over the window, dead-worker signal |
backups |
Per spatie/laravel-backup destination: health, last successful backup, count, size |
schedule |
Per scheduled task: last run, duration, exit code, missed runs |
The jobs section reads the tables of
croustibat/filament-jobs-monitor
when they are present, and falls back to failed_jobs otherwise. Without the
plugin there is no source for the number of processed jobs, so the section is
honestly reported as quality: degraded.
Dead-worker detection
The surest sign of a dead worker is a pending job that keeps ageing. On the
database driver, and on Redis from Laravel 12.20, the SDK reads that age
directly (oldest_pending_age_seconds). Redis and SQS on Laravel 11 and 12
before 12.20 cannot give it — the field stays null and the section says
queue_driver_unsupported:<driver>.
For every driver, the SDK therefore also listens to JobQueued and
JobProcessing and keeps two timestamps per queue in your cache. Each row of
the queues section carries:
| Field | Meaning |
|---|---|
last_queued_at |
last job put on this queue (delayed jobs are ignored) |
last_started_at |
last job a worker started on it |
stalled |
true: jobs were queued after the last start, the queue still holds work, and no job started for queues.stalled_after_seconds (1 hour); false: no stall; null: cannot tell |
A stall turns the section critical, like a pending job older than
critical_age_seconds. One snapshot is enough on the SDK side: through two
timestamps, a dead worker and a queue idle for hours that received a job a
second before the snapshot look the same, and it is the server that tells
them apart — it opens an incident only on two consecutive critical
snapshots. A queue whose depth reads zero is never stalled, whatever the
timestamps say (queue:clear, a drained queue). Stalled queues are listed
first and never cut by queues.max.
stalled is null on the array cache, when the cache cannot be read, and
when jobs are queued but no worker was ever seen starting one — a worker that
predates the installation looks exactly like that, and calling it dead would
be a false alarm on every fresh install (see step 4 above).
Why one hour: a single job holds the only worker of its queue for as long as
it runs, and exports, imports or backups commonly take tens of minutes; a
shorter threshold would call a busy worker dead. With the server's
confirmation, an incident opens at most about 1 h 30 after the last start, well within
what a maintenance report needs. Tune it in config/constellation.php.
Cost, and your transactions: the listeners never write while you hold a
database transaction open — with the database cache store, a write there
would take a lock your other dispatches wait on, and on PostgreSQL a failing
write would abort your transaction. They keep the timestamp in memory and
write it after the response is sent (HTTP), or at once outside any
transaction and otherwise after your commit (console and workers). At most one
write per queue and per minute, across processes: the stored value is read
first and only replaced when it is a minute old. Nothing is registered at all
when the package is disabled, has no token, or the queues collector is
removed.
Writing your own collector
use Ultraviolettes\Constellation\Contracts\Collector; use Ultraviolettes\Constellation\Snapshot\{Section, Status, Window}; final class SeoCollector implements Collector { public function key(): string { return 'seo'; } public function collect(Window $window): Section { return Section::make($this->key(), Status::Ok, ['broken_links' => 0], now()->toImmutable()); } }
Add the class to config('constellation.collectors'). The ingest endpoint
stores unknown sections as they are — no backend deployment is needed.
Events
SnapshotBuilt, SnapshotSent, SnapshotRejected and SnapshotSendFailed
let your application react without the SDK deciding for you. A snapshot that
cannot be delivered is abandoned after two attempts with a warning in the log:
a snapshot is a state, not a log, and the next one carries the same truth.
Development
composer install vendor/bin/pint --test vendor/bin/phpstan analyse --memory-limit=2G vendor/bin/pest
The transaction tests of the dead-worker signal also run on PostgreSQL when given a throwaway database (CI does):
CONSTELLATION_TEST_PGSQL_URL=pgsql://user:secret@127.0.0.1:5432/sdk_pg_test vendor/bin/pest
End-to-end test against a real Constellation server
vendor/bin/pest skips it. bin/e2e runs it against a local checkout of
ultraviolettes/constellation, without modifying its code, and refuses any
database that is not on this machine:
CONSTELLATION_SAAS_PATH=../constellation bin/e2e
It migrates the SaaS database (CONSTELLATION_SAAS_DB, default
constellation_d), creates a throwaway team, project and environment with the
SaaS factories, issues its token with php artisan ingest:token:issue, serves
the SaaS on 127.0.0.1:8004 (CONSTELLATION_SAAS_PORT), sends a real snapshot
from a Testbench application — a confirmed dead worker on a Redis queue that
cannot report its head age included — then checks the rows in snapshots and
snapshot_sections, deletes the throwaway team (everything else cascades)
and its owner, and stops the server. Plain HTTP is accepted locally
because the SaaS ships with INGEST_REQUIRE_TLS=false and runs with
APP_ENV=local; nothing is bypassed.
Against a server you already run:
CONSTELLATION_E2E_ENDPOINT=http://127.0.0.1:8004/api/v1/snapshots \ CONSTELLATION_E2E_TOKEN=cnst_ing_... \ vendor/bin/pest --group=e2e
License
MIT.