qiick / excubia-core
Health checks for Laravel apps.
Requires
- php: ^8.2
- composer/semver: ^3.4
- symfony/console: ^7.2 || ^8.0
- symfony/http-client: ^7.2 || ^8.0
Requires (Dev)
- justinrainbow/json-schema: ^6.14
- larastan/larastan: ^3.3.1
- laravel/ai: ^1.3
- laravel/pint: ^1.30.4 <1.31.0
- nunomaduro/collision: ^8.5
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- phpstan/phpstan: ^2.1.11
Suggests
- illuminate/console: Required for the php artisan excubia:check alias on Laravel 12 and 13.
- illuminate/support: Required for the php artisan excubia:check alias on Laravel 12 and 13.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-12 08:27:45 UTC
README
Excubia
Status: 1.0.0-beta. The public contracts (CLI options, report JSON, check ids, exit codes,
excubia.json, push payloads, health endpoint, SARIF ids, Action inputs) are frozen for 1.0.0-beta:docs/CONTRACTS.md. Seedocs/ROADMAP.mdanddocs/VERSIONING.md.
Excubia is an open-source (MIT) health check for Laravel apps, part of the qiick suite. The Composer package qiick/excubia-core reads your lockfiles and manifests and reports, in one place: PHP / Laravel / Node end of life, known vulnerabilities, abandoned and stale packages, blockers for the next Laravel major, and a suggested upgrade order. Run through Artisan, it also checks the posture of the booted app (debug mode, caches, migrations, failed jobs, scheduler, maintenance mode).
Optional, and off by default: push the report to your own self-hosted excubia-app, send runtime aggregates and masked exceptions to it, or serve an Oh Dear-compatible health endpoint. Nothing is sent to qiick or any third party.
| Guides | Configuration · CI · excubia-app push · Runtime monitoring · Exception capture · Health endpoint · Privacy · Upgrading to 1.0 |
| Spec | docs/SPEC.md · docs/ARCHITECTURE.md |
| Changes | CHANGELOG.md · GitHub Releases |
| Security | SECURITY.md |
Requirements
| Requirement | Notes |
|---|---|
PHP ^8.2 |
To run Excubia. The scanned project may use any PHP version. |
| Composer 2 | composer.lock is required in the scanned project. |
| Laravel 12 or 13 (optional) | Only for the Artisan commands and the opt-in features. Laravel 11 is not supported for the Artisan integration (D19); the standalone CLI still scans Laravel 11 projects and reports them as end of life. |
Any Composer project can be scanned. Laravel-specific checks are skipped when laravel/framework is not locked.
Quick start
composer require --dev qiick/excubia-core vendor/bin/excubia check
vendor/bin/excubia does not boot your app and never runs project code. In a Laravel app the package is auto-discovered, so the same checks plus the six posture checks run with:
php artisan excubia:check
Install without --dev (composer require qiick/excubia-core) when the deployed app runs Excubia itself: the scheduled push, runtime monitoring, exception capture, the health endpoint or posture checks in production. While those opt-ins are off, the package registers four Artisan commands and one every-minute scheduled heartbeat, and nothing else.
More examples:
vendor/bin/excubia check --format=json --output=report.json vendor/bin/excubia check --format=markdown --output=report.md vendor/bin/excubia check --fail-on=critical vendor/bin/excubia check --only=php-eol,composer-advisories vendor/bin/excubia check --skip=node-eol --no-dev vendor/bin/excubia check ../other-project --offline vendor/bin/excubia cache:clear vendor/bin/excubia --version
Checks
| ID | Name | Data source | Fails or warns when (severity) |
|---|---|---|---|
php-eol |
PHP end of life | endoflife.date | Security support ended (critical); active support ended or security ends within 180 days (medium) |
laravel-eol |
Laravel end of life | endoflife.date | Security fixes ended (critical); bug fixes ended or security ends within 180 days (medium) |
node-eol |
Node end of life | endoflife.date | EOL (high); maintenance ends within 180 days (low). Skipped when no Node version is declared |
composer-advisories |
Known vulnerabilities (Composer) | Packagist security advisories | An advisory affects the locked version (severity from the advisory) |
npm-advisories |
Known vulnerabilities (npm) | OSV.dev | A vulnerability affects a version in the JavaScript lockfile (severity from OSV, default medium) |
composer-abandoned |
Abandoned packages | Packagist | Abandoned direct dependency (high); abandoned transitive dependency (medium) |
composer-stale |
Stale packages | Packagist, GitHub | Repository archived (high); no release for more than 24 months (low) |
laravel-next-blocker |
Next-major blockers | Packagist | The latest release of a direct dependency does not allow the next Laravel major (medium), or only in a pre-release (low) |
laravel-debug-mode |
Debug mode | Booted app | app.debug on outside local / testing (high) |
laravel-production-caches |
Production caches | Booted app | Config, route or event cache missing in production (low, one row per cache) |
laravel-pending-migrations |
Pending migrations | Migrator | Migrations have not run (medium) |
laravel-failed-jobs |
Failed jobs | Failed job provider | Failed jobs exist (medium; high once the oldest is 24 hours old) |
laravel-scheduler-heartbeat |
Scheduler heartbeat | Default cache store | The scheduler has not run for more than 10 minutes, or never (medium) |
laravel-maintenance-mode |
Maintenance mode | Booted app | The app is in maintenance mode (medium) |
laravel-app-key |
Application key | Booted app | app.key is empty, in any environment (critical) |
laravel-session-security |
Session cookie security | Booted app | In production: session.secure false while app.url is https, or session.http_only false (medium, one row per key) |
laravel-cors-wildcard |
CORS wildcard origin | Booted app | In production: cors.allowed_origins contains * with cors.supports_credentials (high). * alone passes |
laravel-production-drivers |
Production drivers | Booted app | In production: queue sync, cache array / null, session array, mail log / array (medium, one row per driver) |
laravel-log-level |
Log level | Booted app | In production: the default log channel logs at debug (low) |
laravel-dev-packages |
Development packages | composer.json, booted app |
In production: laravel/telescope, barryvdh/laravel-debugbar, spatie/laravel-ray, laravel/pail or itsgoingd/clockwork is in require (not require-dev) and enabled (medium, one row per package) |
dependency-licenses |
Dependency licenses | composer.lock, package-lock.json (no request; yarn.lock and pnpm-lock.yaml record no licenses) |
A license in checks.dependency-licenses.denied (fail, high); a strong copyleft license such as GPL, AGPL, SSPL or EUPL (medium); a missing or unknown license (low). Development dependencies only with includeDev |
laravel-database |
Database | Database connections | The default connection, or one listed in checks.laravel-database.connections, does not answer select 1 (critical, one row per connection) |
laravel-cache |
Cache | Default cache store | The default store cannot write, read and delete a probe key (high). Skipped with the null driver |
laravel-storage |
Writable storage | Booted app | storage or bootstrap/cache is not writable (high, one row per directory) |
laravel-disk-space |
Disk space | Disk holding storage/ |
At least 80 % used (medium), at least 90 % (high, fail) |
laravel-queue-backlog |
Queue backlog | Default queue connection | A queue's oldest pending job has waited more than 15 minutes, or it holds more than 1000 pending jobs (medium, one row per queue). database and redis drivers only, others skipped |
php-platform-requirements |
Platform requirements | composer.lock, running PHP |
The running PHP misses an extension, or its version does not satisfy a php constraint, that a locked package or the root platform requires (high, one row per requirement), like composer check-platform-reqs |
env-example-sync |
.env.example sync |
.env.example, config/*.php (key names only, no request) |
A key that config/*.php reads with env() is missing from .env.example (low, one row per key); keys in .env.example that no config file reads are one info row. Skipped without .env.example or config/ |
composer-outdated |
Outdated packages | Packagist (the metadata composer-abandoned and composer-stale already read) |
A direct dependency has a newer stable release: behind on a patch (info), on a minor (low) or on a major (low), one row per package, like composer outdated --direct |
laravel-queue-workers |
Queue workers | Laravel Horizon status, or the worker heartbeat in the default cache store | With Horizon: inactive (high, fail), paused (medium). Without Horizon: a queue of the default connection has pending jobs and no worker has reported on it for more than 5 minutes (medium, one row per queue). Skipped with the sync driver, drivers other than database / redis, and while no worker has ever reported |
npm-outdated |
Outdated packages (npm) | npm registry (registry.npmjs.org, package names only) |
A direct dependency in package.json has a newer stable release: behind on a patch (info), on a minor (low) or on a major (low), one row per package, like npm outdated. Skipped without a JavaScript lockfile |
laravel-backups |
Backups | spatie/laravel-backup destinations (when installed) | A monitored destination has no backup, cannot be reached, or its newest backup is older than 26 hours (high, fail); the newest backup is less than half the size of the previous one (medium). One row per destination. Skipped without spatie/laravel-backup |
laravel-database-connections |
Database connections | Default database connection (Threads_connected on MySQL / MariaDB, pg_stat_activity on PostgreSQL) |
More than 50 open connections (medium), more than 100 (high, fail); other drivers skipped |
laravel-database-size |
Database size | Default database (information_schema on MySQL / MariaDB, pg_database_size on PostgreSQL, the file size on SQLite) |
Reports the size and the five largest tables (pass, info); above checks.laravel-database-size.warnAtMb (unset by default) it warns (medium) |
laravel-redis-memory |
Redis memory | INFO memory of the Redis connections the default cache store, queue connection and session use |
80 % of maxmemory used or more (medium), 90 % or more (high, fail); no maxmemory limit, or the noeviction policy on the cache connection (low). One row per connection. Skipped when Redis is not used or does not answer |
php-opcache |
OPcache | The web-SAPI snapshot the health endpoint stores (never the CLI) | In production: OPcache disabled (medium), opcache.validate_timestamps on (low). In any environment: hit rate under 90 % or more than 90 % of the OPcache memory used (low). Skipped without a web snapshot |
php-production-ini |
Production php.ini | Same as php-opcache |
In production only: display_errors on (high, fail), expose_php on (low), log_errors off (low). One row per directive, booleans only. Skipped outside production and without a web snapshot |
laravel-octane-config |
Octane configuration | octane.server and octane.max_requests, when Laravel Octane is installed |
Always one info row with the server type. In production only: max_requests unset (low) or 0 (medium, workers never restart, so leaks build up). Skipped without laravel/octane |
laravel-reverb |
Reverb server | A TCP connect (2 s timeout) to the host and port of the Reverb broadcast connection, when Laravel Reverb is installed and is the default broadcast driver | The server does not answer (high, fail: broadcasts fail). One info row with the connect time otherwise; never the host or port. Skipped without laravel/reverb, when Reverb is not the broadcast driver or names no host |
laravel-cashier |
Cashier billing | Per installed Cashier package (Stripe or Paddle): whether the webhook secret is set, whether the keys are test keys, whether a route reaches Cashier's webhook controller. Booleans only | No webhook secret (high in production, low elsewhere: forged webhooks are accepted), test keys in production (high, fail), no webhook route (medium). One info row otherwise. Skipped without laravel/cashier and laravel/cashier-paddle |
laravel-api-tokens |
API tokens | Per installed package, Laravel Sanctum: sanctum.expiration, the stateful domains (wildcard or none) and counts of its valid tokens, stale (unused 90 days) and never used; Laravel Passport: keys present, private key file mode, token lifetimes and the number of personal access clients. Counts and booleans only |
Sanctum tokens never expire (low, medium in production), stale tokens (low), in production a stateful wildcard (medium) or no domain (low); Passport without keys (high in production, fail), a private key readable by others (high in production, fail), very long token lifetimes (low). One info row otherwise. Skipped without laravel/sanctum and laravel/passport |
- Supported lockfiles:
composer.lock(Composer 2, required) and one JavaScript lockfile:package-lock.json(lockfileVersion 2 and 3),yarn.lock(Yarn classic v1 and Yarn Berry v2+) orpnpm-lock.yaml(lockfileVersion 6 and 9). When several exist, Excubia reads the one of thepackageManagerinpackage.json, else the first ofpnpm-lock.yaml,yarn.lock,package-lock.json; the JSON report names it injavascriptLockfile, npm findings in their message and SARIF location. dependency-licensesreads the licenses the lockfiles already record and sends nothing.yarn.lockandpnpm-lock.yamlrecord none: their packages get oneskippedrow ("N packages from yarn.lock have no license data") instead of a low warning each. An SPDX expression such asMIT OR GPL-3.0-onlypasses when any option is fine;ANDneeds every part to be. Allow or deny licenses inexcubia.json.env-example-synccompares key names only: it tokenizesconfig/*.php(never runs it), reads the names in.env.exampleand drops the values, and never opens.env. Optional Laravel keys (AWS_*,PUSHER_*,VITE_*,MEMCACHED_*,REDIS_CLIENT, …) are ignored by default; extend or replace the list withchecks.env-example-sync.ignoreKeys.composer-outdatedcompares each direct dependency incomposer.json(require, plusrequire-devunless--no-dev) with the newest stable release on Packagist, using Composer's own version rules. Pre-releases count only when the locked version is itself a pre-release;minimum-stabilityis not read. Locked dev branches are left out. It reuses the Packagist metadata the other Composer checks fetch, so it adds no request.npm-outdateddoes the same forpackage.json(dependenciesandoptionalDependencies, plusdevDependenciesunless--no-dev), with the versions locked in the JavaScript lockfile Excubia reads. It sends one request per public direct dependency tohttps://registry.npmjs.org/<name>, carrying the name only (abbreviated metadata); private packages are never sent. Pre-releases count only when the locked version is itself one, and a release newer than thelatestdist-tag is ignored. Versions are compared with Composer's version rules, so an unusual pre-release tag (1.0.0-canary.abc) is left out.--offlinereads cached answers only.- The seventeen
laravel-*checks afterlaravel-next-blocker,laravel-queue-workers,laravel-backups,laravel-database-connections,laravel-database-size,laravel-redis-memory,php-opcache,php-production-ini,laravel-octane-config,laravel-reverb,laravel-cashierandlaravel-api-tokensneed the booted app, so onlyphp artisan excubia:checkandexcubia:pushrun them.vendor/bin/excubiareports them asskipped("requires Artisan"). php-platform-requirementsalso runs invendor/bin/excubia, but that is the PHP of the CLI, which can differ from the app's: its findings say so and carryisStandaloneCli: true. Under Artisan it checks the app's PHP. Extensions that a locked package provides (polyfills) count as present;config.platformoverrides do not.- The infrastructure checks (
laravel-database,laravel-cache,laravel-storage,laravel-disk-space,laravel-queue-backlog) only read, except the cache probe keyexcubia:cache-probe:<random>, which is deleted right away. A service that does not answer is a finding, never an exception in the app. Findings name the connection, store, directory or queue, and carry percentages, counts and ages: never hostnames, DSNs, credentials or absolute paths. laravel-database-connectionsandlaravel-database-sizerun read-only catalog queries on the default connection only (show status,pg_stat_activity,information_schema.tables,pg_database_size, SQLitedbstatwhen the build has it), opened with the same 3 s connect timeout aslaravel-database; a query that fails skips the check. They report the connection name, counts, megabytes and up to five table names (cut to 64 characters), never the database name, host or SQLite file path.php-opcacheandphp-production-inineed the health endpoint. Artisan runs on the CLI SAPI, whose OPcache followsopcache.enable_cli(off by default) and which often has its ownphp.ini, so its values say nothing about PHP-FPM and are never checked. Instead, each authenticated health endpoint request (at most once an hour) stores a web-SAPI snapshot of the same values, booleans, percentages and a count only, under the cache keyexcubia:php-runtime:webin the default cache store for a day, and both checks read it (sapi: "web",ageMinutesits age). Without a web snapshot both areskipped(not-applicable): "PHP web settings unknown: enable the health endpoint so Excubia can read the web SAPI (the CLI uses different ini settings)." Neither check ever reports an ini file path or value.- For
laravel-scheduler-heartbeat, the service provider schedulesphp artisan excubia:heartbeatevery minute. It stores the Unix time under the cache keyexcubia:scheduler-heartbeatin the default cache store. Keep the usual* * * * * php artisan schedule:runcron (orschedule:work), and use a shared cache store (database, Redis) when the scheduler andexcubia:checkrun on different machines. - For
laravel-queue-workerswithout Horizon, the service provider listens to the queue worker'sLoopingevent and, at most once a minute per worker queue, stores the Unix time under the cache keyexcubia:worker-heartbeat:<connection>:<queue>in the default cache store. Restart your workers (php artisan queue:restart) after installing Excubia, and use a shared cache store when workers andexcubia:checkrun on different machines. Horizon is not a dependency: when it is installed the check reads its status through Horizon's own repositories. Findings carry connection, queue and supervisor names (without the hostname prefix), never hostnames. - spatie/laravel-backup is not a dependency: when it is installed,
laravel-backupslists the backups of each destination inbackup.monitor_backups(orbackup.backup.destination.diskswhen nothing is monitored) through itsBackupDestinationAPI, and only sizes the two newest. Findings carry the disk name, the age in hours, the size in MB and the count: never a disk root, bucket, path or credential. laravel-redis-memorysendsINFO memoryon each Redis connection the default cache store, the default queue connection or the session use (or onchecks.laravel-redis-memory.connectionsinstead) and keeps onlyused_memory,maxmemoryandmaxmemory_policy. A connection that does not answer is left out;laravel-cachereports reachability. Findings carry the connection name, megabytes and the policy: never a host, port or password.- Every report ends with an ordered upgrade plan (
upgrade-plan): security fixes, runtime EOL, blockers, Laravel major, replacements, then one step with the latest patch of every outdated Composer package that has no advisory, the same for npm packages, and a license review step whendependency-licensesfinds a denied license (naming the denied and strong copyleft packages). It isinfoonly, never changes the exit code and cannot be selected with--only/--skip. - Thresholds (180 days, 24 months, 10 minutes, 24 hours, 80 / 90 %, 15 minutes, 1000 jobs, 5 minutes, 26 hours, 80 / 90 % of
maxmemory) are configurable inexcubia.json. Full severity rules:docs/SPEC.mdsection 3.
Formats
--format |
Output |
|---|---|
table (default) |
Human-readable, grouped by severity, coloured on a TTY (not with NO_COLOR or when writing to a file), summary line and upgrade plan |
json |
Versioned report, schemaVersion 1, schema in docs/report-schema.json. Parse this, not the text formats |
markdown |
For pull requests, job summaries and AI agents |
sarif |
SARIF 2.1.0 for GitHub code scanning, see CI guide |
In the JSON report every skipped finding has a skipReason: data-source-failure, offline (--offline found no cached response) or not-applicable (the check does not apply or is disabled).
The JSON report also carries, as optional fields of schema version 1:
- top level:
generatedAt(UTC,Y-m-d\TH:i:s\Z),excubiaVersion(the value of--version) andprojectwithphp,laravelandnode, each{version, cycle, securitySupportEndsOn, activeSupportEndsOn}ornullwhen not declared (no name, path or hostname). The cycle and theY-m-ddates come from the endoflife.date data the EOL checks already read, also when they pass; they arenullwhen that check was skipped or did not run; - top level, when the project has a JavaScript lockfile:
javascriptLockfile(package-lock.json,yarn.lockorpnpm-lock.yaml), the one the npm checks read; - top level, from
php artisan excubia:checkandexcubia:pushonly (#146):schedule, the scheduled task inventory of the booted app, one{taskId, task, expression, timezone, withoutOverlapping, onOneServer, runsInMaintenanceMode, environments}per event pluslateAfterMinutes(the configured grace), named like the runtimescheduledTaskaggregates (description,artisan <command name>, executable file name orClosure, never arguments);taskIdis sha256 oftask, a NUL byte andexpression, the same value the runtime aggregates carry. The standalone CLI cannot boot the app and leaves it out; - top level, when
dependency-licensesran:licenses, the license inventory without package names:packageCount,byLicense(list of{license, packageCount},NOASSERTIONfor packages that declare none) andbyEcosystemwith the same counts forcomposerandnpm; - per finding:
fingerprint(sha256 ofcheckId,subjectandadvisoryIdjoined by a NUL byte, the same value as the SARIFpartialFingerprints, stable across runs),advisoryIdon advisory findings, anddetailswith check-specific values so you do not parsemessage:
checkId |
details keys |
|---|---|
composer-advisories |
package, lockedVersion, affectedVersions (Composer constraint), fixedVersions (list: the exclusive upper bound of each affected range) |
npm-advisories |
package, lockedVersion, fixedVersions (always empty for now: the OSV batch answer has ids only), lockfile (the JavaScript lockfile) |
php-eol, laravel-eol, node-eol |
cycle, securitySupportEndsOn, activeSupportEndsOn (Y-m-d or null) |
composer-abandoned |
package, lockedVersion, replacement (or null) |
composer-stale |
package, lockedVersion |
laravel-next-blocker |
package, lockedVersion, targetMajor (integer) |
laravel-pending-migrations |
count |
laravel-failed-jobs |
count, oldestAgeHours (or null) |
laravel-scheduler-heartbeat |
minutesSinceLastRun (null: never ran) |
laravel-cors-wildcard |
hasCredentials |
laravel-production-drivers |
component (queue, cache, session, mail), driver |
laravel-dev-packages |
package |
dependency-licenses |
package, lockedVersion, licenses (list of SPDX ids or expressions as declared, empty when none), ecosystem (composer or npm), kind (denied, copyleft or unknown, #204) |
laravel-database |
connection |
laravel-cache |
store |
laravel-storage |
directory (storage or bootstrap/cache) |
laravel-disk-space |
usedPercent |
laravel-queue-backlog |
queue, pendingCount, oldestAgeMinutes (or null) |
php-platform-requirements |
requirement (php or ext-*), requiredBy (list of packages, composer.json for the root), checkedPhpVersion, isStandaloneCli |
env-example-sync |
missing key rows: key, configFiles (project-relative, such as config/services.php); the unused keys row: keys (names only, never values) |
composer-outdated |
package, lockedVersion, latestPatch (newest release in the locked minor), latestMinor (newest in the locked major), latestMajor (newest in a later major), each null when nothing newer exists there, and isMajorBehind |
laravel-queue-workers |
without Horizon: connection, queue, minutesSinceLastSeen (null: no worker ever reported on the queue); with Horizon: status (inactive or paused), processes (worker process count), supervisors (names, without the hostname prefix) |
npm-outdated |
the same keys as composer-outdated, versions as the npm registry prints them |
laravel-backups |
disk (filesystem disk name), ageHours and sizeMb of the newest backup (null when there is none or the destination cannot be reached), count (null when unreachable) |
laravel-database-connections |
connection, connectionCount |
laravel-database-size |
connection, sizeMb (two decimals), largestTables (up to five {table, sizeMb}, largest first; empty when the catalog cannot be read) |
laravel-redis-memory |
connection (Redis connection name), usedMb, maxMb (null when maxmemory is 0), policy (maxmemory_policy, null when not reported) |
php-opcache |
sapi (always web), ageMinutes (age of the web snapshot), isOpcacheEnabled, hitRate and memoryUsedPercent (percent, null when OPcache is off), cachedScripts, isValidateTimestampsEnabled |
php-production-ini |
directive (display_errors, expose_php or log_errors), isEnabled, sapi, ageMinutes |
laravel-octane-config |
server (swoole, roadrunner, frankenphp or unknown), maxRequests (null when unset) |
laravel-reverb |
isReachable, latencyMs (null when the connect failed) |
laravel-cashier |
provider (stripe or paddle), isWebhookSecretConfigured, isWebhookRouteRegistered, hasTestKeys |
laravel-api-tokens |
Sanctum: provider (sanctum), expirationMinutes, hasStatefulWildcard, isStatefulEmpty, tokenCount, nonExpiringTokenCount, staleTokenCount, neverUsedTokenCount. Passport: provider (passport), hasKeys, isPrivateKeyTooOpen, accessTokenLifetimeMinutes, refreshTokenLifetimeMinutes, personalAccessTokenLifetimeMinutes, personalAccessClientCount |
| private packages row of the seven checks that send names | count |
laravel-debug-mode, laravel-production-caches, laravel-maintenance-mode, laravel-app-key, laravel-session-security, laravel-log-level and other skipped rows have no details. The exact rules are in docs/report-schema.json.
CLI
vendor/bin/excubia check [path] [options]
vendor/bin/excubia push [path] [--config=FILE] [--strict] [--deploy]
vendor/bin/excubia cache:clear [path] [--config=FILE]
path is the project directory to scan (default: the current directory).
| Option | Commands | Meaning |
|---|---|---|
--format=FORMAT |
check |
table (default), json, markdown or sarif |
--output=FILE |
check |
Write the report to FILE instead of stdout. The only file Excubia writes in your project |
--fail-on=SEVERITY |
check |
critical, high (default), medium, low or none. A finding at or above it makes the exit code 1. Wins over failOn in excubia.json |
--only=IDS |
check |
Comma-separated check ids to run. Unknown ids are an error |
--skip=IDS |
check |
Comma-separated check ids to skip. With --only, applied to that selection |
--no-dev |
check |
Ignore require-dev / packages-dev. Same as "includeDev": false |
--offline |
check |
Use cached data-source responses only; checks without a cached response are skipped |
--no-cache |
check |
Do not read or write the response cache |
--config=FILE |
all | Config file (default: excubia.json in the project, if present). A missing explicit file is an error |
--no-banner |
check |
Hide the ASCII banner. json and sarif never print it |
--strict |
check, push |
Exit 3 when a data-source failure skipped a check; push then sends nothing. Same as "strict": true |
--deploy |
push |
Mark the report as a deploy: it carries deployedAt next to its release, so excubia-app draws a deploy marker. Run it as the post-deploy step (CI guide) |
-q, -v, -vv |
all | Quiet, verbose, debug. From -v every HTTP request is listed on stderr |
--version |
Print the Excubia version |
The Artisan commands take the same options, without path (they always scan the app's base path):
| Artisan command | Options | What it does |
|---|---|---|
php artisan excubia:check |
as check |
Runs every check, posture checks included |
php artisan excubia:push |
--config, --strict, --deploy |
Runs the checks and sends the report to your excubia-app (guide) |
php artisan excubia:push-runtime |
--config |
Sends the buffered runtime aggregates and exceptions (guide). Scheduled every minute when an opt-in is on |
php artisan excubia:heartbeat |
Stores the scheduler heartbeat. Scheduled every minute by the provider |
Exit codes
| Code | check |
push, push-runtime |
|---|---|---|
0 |
No finding at or above --fail-on |
Sent, or nothing to send. Findings do not change it |
1 |
At least one finding at or above --fail-on |
— |
2 |
Usage or configuration error: missing or broken composer.lock, invalid excubia.json, unknown check id, bad option value |
Configuration error: missing URL or token, plain HTTP to a host other than localhost, invalid config |
3 |
Only in strict mode: a check was skipped because a data source failed | The app rejected the token, answered an error or could not be reached; in strict mode also a data-source failure (nothing is sent) |
When several apply, 2 wins over 1, and 1 wins over 3, so CI sees real findings first.
Strict mode is --strict or "strict": true. A failed data source means the network was down with no cached response, or an API answered an error; the message on stderr names each check and data source. Without strict mode such checks are only marked skipped and the exit code ignores them. Deliberate skips never exit 3: --offline without a cached response, --only / --skip, Laravel checks on a non-Laravel project, a disabled check, or a row removed by an ignore rule. --strict can only turn strict mode on: it wins over "strict": false, and nothing on the command line turns "strict": true off.
Configuration
Excubia works without configuration. To change defaults, add excubia.json at the project root:
{
"$schema": "https://raw.githubusercontent.com/qiick-io/excubia-core/main/excubia.schema.json",
"failOn": "high",
"checks": {
"composer-stale": { "staleAfterMonths": 24 }
},
"ignore": [
{
"checkId": "composer-advisories",
"subject": "vendor/package",
"advisoryId": "GHSA-xxxx-xxxx-xxxx",
"reason": "Not reachable from our code",
"until": "2027-01-31"
}
]
}
Unknown keys are a configuration error (exit 2). Every key, type and default, and the environment variables (EXCUBIA_APP_URL, EXCUBIA_APP_TOKEN, EXCUBIA_RELEASE, EXCUBIA_HEALTH_SECRET, EXCUBIA_SERVER_NAME, GITHUB_TOKEN), are in the configuration reference. Put the push token in EXCUBIA_APP_TOKEN, not in excubia.json, which is normally committed; Excubia warns when app.token is set in the file. JSON Schema: excubia.schema.json.
Guides
- Configuration reference: every
excubia.jsonkey and environment variable. - CI: the GitHub Action with code scanning, and any other CI.
- Push to excubia-app: from the scheduler and after a deploy.
- Runtime monitoring: request, query, job, task and outgoing HTTP aggregates, plus opt-in memory, queue wait, cache, mail, notification, log level and PHP deprecation counts, N+1 duplicate query detection, Artisan command runs, server CPU, memory and disk metrics, minute-level health samples for failure alerts, Laravel AI SDK usage (tokens, latency, failures and tool calls) Laravel Octane workers (restarts, requests, errors and memory growth per worker) Laravel Reverb servers (connections, messages, errors and channels per app), Laravel Cashier webhooks (received, handled, failed and payment signals per provider and event type), authentication (logins, failed logins, lockouts and Sanctum token authentications per guard), Laravel Scout (queued index jobs and searches per model and engine) and Laravel Pennant feature checks (active, inactive, resolved and undefined per feature).
- Exception capture: masked exceptions grouped by fingerprint.
- Health endpoint: Oh Dear application health JSON.
- Privacy: exactly what leaves the machine, per feature.
- Upgrading from 0.x to 1.0.
Privacy
By default only package names and versions (and runtime versions) leave the machine, and only to the public data sources in the check table. No file contents, .env values, source code, paths or hostnames. Private packages (path, VCS, Satis, Private Packagist, file: or other-registry origins in the lockfile, plus privacy.skipPackages) are never sent and show as skipped. No telemetry. Each opt-in that sends data to your own excubia-app is listed field by field in the privacy guide.
Versioning and support
Excubia follows SemVer. Public contracts change only in a major; deprecated options keep working with a warning for at least one minor. In 0.x, minors may still break and only the latest minor gets fixes. Policy and supported versions: docs/VERSIONING.md. The list of public contracts, with their files, version fields and contract tests: docs/CONTRACTS.md; everything else (PHP classes, message wording, table and Markdown layout) is internal.
Contributing
See CONTRIBUTING.md. Scope is defined by docs/SPEC.md; new ideas need a needs-decision issue first.