stackmonitor / agent-laravel
Read-only StackMonitor agent for Laravel applications
Package info
github.com/tommy-hasert-dev/stackmonitor-agent-laravel
pkg:composer/stackmonitor/agent-laravel
Requires
- php: ^8.1
- laravel/framework: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^2.11|^3.13
- laravel/pint: ^1.32.1
- opis/json-schema: ^2.6
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- pestphp/pest: ^2.36|^3.8|^4.1|^5.2.1
- shipmonk/composer-dependency-analyser: ^1.8
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
StackMonitor Agent for Laravel
Read-only agent. It gives the StackMonitor dashboard the Laravel, PHP and package versions, the errors of the last 24 hours from the log and operational data that only the server can see. Supports Laravel 10–13 and PHP ≥ 8.1.
Installation
composer require stackmonitor/agent-laravel- In the StackMonitor dashboard, open the site, then Bearbeiten → Agent → Secret erzeugen (Edit → Agent → Generate secret).
- Add the line shown there to the site's
.env:STACKMONITOR_AGENT_SECRET=… - Run
php artisan config:cacheif the config is cached. If the.envchanges again later (e.g. a new secret, a different path), runphp artisan config:cacheagain and, if routes are cached,php artisan route:cache— otherwise the old, cached configuration stays in effect.
Since 1.15.0 further secrets go comma-separated into STACKMONITOR_AGENT_SECRETS, one per StackMonitor
instance or account that monitors the app (an agency and its client, production and a local stack), five in all
with STACKMONITOR_AGENT_SECRET. To change a secret without a gap, add the new one there, switch the dashboard
to it and remove the old one afterwards.
The endpoint is GET /stackmonitor/status. The path can be changed with STACKMONITOR_AGENT_PATH; the agent URL in the dashboard then has to be changed to match.
Errors in the log
The agent counts the entries of level error and above from the last 24 hours in the app's file log: in the
default channel if it is single or daily, otherwise in the first such channel of a stack. It reads at most
the last 5 MB. Other channels (stderr, Sentry, Papertrail …) it reports as "not readable".
It sends along the three most frequent messages, only the first line without context and stack trace, cut to
200 characters. Email addresses, URLs, quoted values, IDs, tokens, IP addresses, numbers, SQL and directories
are replaced first. Since 1.13.0 it names for each message where the exception was thrown: the Composer
package under vendor/, the framework or the app's own code. With STACKMONITOR_AGENT_LOG_MESSAGES=false in
the .env only the counts are sent.
Operational data
Since 1.3.0 the agent also reports: failed jobs, pending jobs of the default queue (not with the sync
driver; only on Laravel versions with pendingSize(), otherwise "unknown"), when the scheduler last ran, free
disk space, migrations not yet run and whether config and routes are cached. For the scheduler it records each
start of schedule:run in the app's default cache; until the first run after installation it reports "no run
yet". Whatever it cannot read it reports as unknown.
Since 1.20.0 it reports every queue instead of the default one only: the default queue and the queues Horizon's supervisors are set up for or work, each with the number of due jobs and since when the oldest waits (delayed jobs count once due, jobs a worker holds only once their reservation expired; SQS and Beanstalkd only the number), and with laravel/horizon whether it runs, is paused or inactive, with its supervisors.
Since 1.16.0 it also reports the default cache store and its driver (redis, file, database …) and whether
spatie/laravel-responsecache is installed and on (responsecache.enabled). The dashboard shows both in the
operational data and next to the page cache it sees from the uptime check's response.
Since 1.4.0 it also reports the last backup of spatie/laravel-backup if the app uses it: the newest backup
file on the configured disks with the local driver. It does not query remote disks (S3, FTP …), as that would
cost a network request on every report; if the app only backs up there, it reports the time as unknown.
Since 1.5.0 it reports the web server's PHP settings: SAPI, OPcache with fill level, memory_limit,
max_execution_time, upload_max_filesize, post_max_size, display_errors, error_reporting,
date.timezone and the names of the loaded extensions. The dashboard asks over HTTP; the command-line values
(cron, queue workers) may differ. Because Laravel turns display_errors off at boot, it reports the value from
the PHP configuration for it instead: that is what applies when an error happens before boot.
If opcache_get_status() is blocked by opcache.restrict_api, the fill level stays unknown.
Since 1.14.0 it reports the server's time, as the last value before sending, and the app's time zone
(app.timezone). From this the dashboard detects a server clock that is off.
Scheduled tasks
Since 1.7.0 it reports every scheduled task of the scheduler individually: command or description, cron
expression, time zone and the last run with start, end, result, exit code, a short error message and the
durations of the last 10 successful runs. To do this it listens to the scheduler's events and records the runs
in the app's default cache; for tasks with runInBackground() the end is taken from schedule:finish. It
identifies a task by its command with arguments (a closure by its description), not by its schedule. If the
app runs on several servers, the cache has to be shared (Redis, database), otherwise each agent only sees the
runs on its own server – onOneServer() requires that anyway.
Since 1.7.1 the list of tasks comes from the last schedule:run, not from the request: if the schedule is
defined in routes/console.php (the default since Laravel 11), only the console loads it. Until the
scheduler's first run after installation the list is therefore missing.
Failed logins
Since 1.8.0 it counts failed logins per hour, so the dashboard can show the trend and flag outliers: how many
failed, how many of those were for an existing user (wrong password) and from how many different addresses. To
do this it listens to the Attempting and Failed events of Laravel's auth and keeps the numbers of the last
14 days in the app's default cache. Only numbers leave the app, no addresses and no user names; within the hour
it tells addresses apart by a short hash built with APP_KEY (at most 1000 per hour), for past hours only
their count remains. As long as the app doesn't log anyone in through Laravel's auth (Breeze,
Jetstream/Fortify, Auth::attempt()), this part is missing; token guards such as Sanctum's don't fire these
events.
Suspicious files
Since 1.9.0 it searches the folder of the public disk (storage/app/public) with all subfolders, and
public/storage if that is a folder of its own instead of the usual link, for files that can execute PHP: PHP
extensions (.php, .phtml, .phar, .pht, .php3 to .php8, .phps), a PHP extension before the last
one (image.php.jpg) and .htaccess files that hand files to PHP or set php_flag engine on. Only path, size
and modification date leave the app, never the contents. It skips an index.php that is empty or consists only
of comments; it doesn't follow linked folders. The scan runs during the report, but per report for at most
50,000 entries and 2 seconds; large folders are thus searched over several reports, with the progress kept in
the app's default cache. A new scan starts once the last result is 30 minutes old.
Since 1.12.0 the dashboard may read the beginning (at most 64 KB) of a suspicious file the agent found itself,
and checks it for signs of malicious code. This is off until the app allows it with
STACKMONITOR_AGENT_FILE_CONTENTS=true in the .env; the dashboard cannot turn it on. Only files from the
agent's last complete scan can be read, requested by the SHA-256 of their path: no other file, none that has
changed since the scan, and none that is a link. Whether it is allowed is stated in the report under
suspicious_files.contents. The request is signed like any other, including the path hash.
Deploys
Since 1.10.0 it reports the last deploy, so the dashboard can mark it in its charts. It checks, in this order:
- Deploy file:
.stackmonitor-deployin the project folder (a different path viaSTACKMONITOR_AGENT_DEPLOY_FILE, relative to the project or absolute). Its modification time is the deploy, its first line, if present, the revision (at most 40 characters). This also works for deploys without Git on the server, e.g. via rsync. In the deploy script, after copying the files, e.g.git rev-parse HEAD > .stackmonitor-deploy(run locally and copied along) ortouch .stackmonitor-deployon the server is enough. - Git: the commit
HEADpoints to, with the time the branch moved there. - Config cache: when
php artisan config:cachelast ran (bootstrap/cache/config.php), without a revision.
If none of these is present, it reports no deploy.
Database size
Since 1.11.0 it reports the size of the default connection's database, so the dashboard shows the growth before
the host's limit is reached: the total size (data and indexes of all tables), the number of tables and the 10
largest with size and approximate row count, never their contents. MySQL and MariaDB it reads from
information_schema, or with SHOW TABLE STATUS if the host blocks that; views don't count. With SQLite the
size of the file counts, and it reads the tables from dbstat if SQLite was built with it; SQLite doesn't
estimate a row count. It measures about once a day (every 23 hours, so no day is skipped with hourly reports),
with the result kept in the app's default cache; if the measurement fails, it tries again after an hour. For
other drivers (PostgreSQL, SQL Server) and an in-memory database the value is missing.
Services
Since 1.17.0 the agent checks with every report whether the services the app depends on answer, in
extra.services, without any setup. What it checks comes from the app's config, each with a timeout of 2 seconds:
- Database: the default connection and the ones the queue, failed jobs, cache and sessions use, with
select 1. - Redis: the connections the default cache store, the default queue and the sessions use, with
PING. The connections Laravel's config has by default but the app doesn't use stay out. - Cache: the default store, by writing, reading and removing a key starting with
stackmonitor-agent-probe-. - Queue: the default connection by its size;
synchas none. - Storage: the default and cloud disk and the ones set up (local with an existing folder, S3 with a bucket,
FTP/SFTP with a host). Local disks get a file starting with
stackmonitor-agent-probe-written, read and removed; remote disks are only read (S3: one key listed), which costs next to nothing and leaves nothing behind. - Mail: the default mailer (each of a failover or round robin): SMTP up to the server's answer to
EHLO, without logging in or sending a mail; other transports (Mailgun, Postmark, SES, sendmail, log …) are only named.
A cache or queue on a connection that already failed is skipped. The result per service is ok or failed, with
the time it took and a short error message; values of password, secret, key, token, username and url
from the service's config are replaced by ***. If the app has spatie/laravel-health, the results of its last
run come along.
Test mail
Since 1.19.0 the dashboard can ask the agent to send a test mail through the app's default mailer, the way a
contact form or an order confirmation would, to check that mails really go out and arrive. The request names
only a token and the recipient, StackMonitor's own mailbox, both signed along; subject ([StackMonitor] Testmail – bitte ignorieren) and text are fixed, the token goes into the header X-StackMonitor-Test and the text. The
answer says whether the mailer took the mail, its error message if not, and the mailer's transport (smtp,
ses, log …). The agent sends one test mail a minute at most. The mail goes through the app's mail events
like any other, so a listener that logs or copies mails sees it too.
PHP code
Since 1.18.0 it reports the PHP code the dashboard cannot download itself, so it can tell whether the next PHP
version breaks the site: the app's own code (app/) and every package from composer.lock that is not on
packagist.org (from a VCS, path or Private Packagist repository); development packages stay out. For each it
sends the number of PHP files, their total size and a fingerprint, a SHA-256 over path, size and modification
time of every *.php file, never their contents; above 5000 files it sends no fingerprint. Linked folders and
files inside a package are skipped, only a package folder that is itself a link (a path repository) is followed.
It reads the file lists within 2 seconds per report and keeps each result in the app's default cache for 10
minutes; the packages read longest ago come first, one there is no time left for keeps its last result or is sent
once it has had its turn, and one that doesn't get through in time waits behind the others and gets 20 seconds of a
later report to itself. The public packages the dashboard fetches from Packagist itself.
With STACKMONITOR_AGENT_FILE_CONTENTS=true (see suspicious files) the dashboard may also fetch the PHP files of
one of these packages as a ZIP, requested by the package's ID from the report: every *.php file of the package
at its path inside it, nothing else and nothing outside the package's folder, at most 5000 files and 8 MB of
ZIP. Without the PHP extension zip it gets none. Whether it is allowed is stated in the report under
php_code.contents. The request is signed like any other, including the package's ID.
npm packages
Since 1.6.0 it reports the app's JavaScript packages, so the dashboard can check them for vulnerabilities.
For this it only reads the lockfile next to package.json: package-lock.json (versions 1 to 3),
pnpm-lock.yaml or yarn.lock, in that order. It reports all installed packages, transitive ones included,
with version and whether they are only needed for development (at most 5000). yarn.lock and pnpm from
lockfile version 9 don't record that; there only the direct devDependencies from package.json count as
development dependencies. If there is no lockfile on the server, e.g. because the assets are built in CI, it
reports that; without a package.json this part is left out.
Security
- Every request has to be signed with HMAC-SHA256. Timestamp (±300 s) and nonce are checked; a nonce is only valid once.
- The endpoint answers invalid requests with
404— the same generic 404 that Laravel returns for unknown routes, for every HTTP method, so the endpoint doesn't stand out from the outside. The response to a valid request is signed as well. - Known limitation: with
APP_DEBUG=true, the error page of a rejected request shows the stack trace and the agent in it. In productionAPP_DEBUGbelongs onfalseanyway, otherwise the whole application is exposed. - The agent is read-only: no write actions, no session, no cookies. The one thing it does on request is send the test mail above, with a fixed text, at most one a minute.
- For replay protection it uses the application's default cache (
cache.default). This cache has to be persistent and, with several application nodes, shared (e.g. Redis or the database driver) — thearrayandnulldrivers don't protect against replays:arrayforgets used nonces with the next request process,nullstores nothing at all, and with several nodes without a shared cache each node only sees the nonces already used on itself.
Development
Like everything local, it runs in the tools container (see the README in the repository root), from the
repository root:
docker compose run --rm -w /app/agents/laravel tools composer install docker compose run --rm -w /app/agents/laravel tools composer test # all of the below docker compose run --rm -w /app/agents/laravel tools composer lint # Pint, fixes the style docker compose run --rm -w /app/agents/laravel tools composer types:check # PHPStan with Larastan, level 10 docker compose run --rm -w /app/agents/laravel tools composer deps # composer.json requires what the code uses
Locally that is the newest PHP and Laravel. The workflow .github/workflows/laravel-agent.yml runs the Pest
tests on every PHP and Laravel version the agent supports (PHP 8.1–8.5, Laravel 10–13), each once with the
lowest and once with the newest versions Composer finds; bin/release.sh only publishes after a green run.
The tests therefore use what Pest 2 to 5 and Testbench 8 to 11 all have (uses(), not pest()->extend()),
and a test for something a Laravel 10 release only got later is skipped where it is missing.
Changes
What changed between versions is in CHANGELOG.md and in the GitHub releases.
License
MIT, see LICENSE.