The checks
Every check has a key. The key names it in laravel-health, under production-preflight.acknowledged, and in every report. A failed check stops preflight --deploy; a warning is printed and lets it through.
The .env file
Read as text, without booting anything, so env:lint can run these in a pipeline long before a deploy. The reader takes the special cases one by one: a comment line sets nothing, the export prefix is not part of the key, a value may contain =, an empty value counts, and a quoted value that runs over several lines is skipped to its closing quote, so a line of an embedded certificate never reads as a key of its own.
| Key | Finds | Verdict |
|---|---|---|
duplicate-env-keys | a key set on more than one line | fail for different values, warning for equal ones |
env-example-keys | a key of .env.example missing from .env, and the other way round | fail for a key missing from .env, warning for one missing from the example |
placeholder-values | a value that is still a placeholder, a secret copied from the committed example, a required key left empty | fail, in production and on staging |
unquoted-hash | an unquoted value with a # the application stops reading at | fail when the # touches the value, warning after a space |
env-bytes | a byte order mark, carriage returns, whitespace after an unquoted value | warning |
When a key is set twice, the value of the last line applies. phpdotenv lets a later line overwrite an earlier one and protects only what the real environment held before loading, so the first line has no effect and nothing says so.
The example file comparison runs in both directions at different volumes. A key the example names and .env lacks is the most common go-live mistake: the feature ships, env() answers null, and null is usually a valid value. A key .env sets and the example lacks is drift, and the next installation, set up from the example, then misses it.
Placeholders are listed in production-preflight.placeholders.values, compared without case, and an entry ending in * matches every value it begins. null is not on the shipped list: Laravel's own example file sets REDIS_PASSWORD=null. A key whose name matches production-preflight.placeholders.secret_keys also fails when its value is the one the committed example carries, since everyone who reads the repository knows it. production-preflight.placeholders.required names the keys that must be set and not empty. The stage is read from the file's own APP_ENV where it sets one, so a production file linted in a pipeline is held to the production rule.
phpdotenv starts a comment at the first unquoted #, with or without a space in front of it: APP_NAME=My #1 App loads as My, and THEME=#ff0000 as nothing. Quoting the value keeps the #.
Laravel reads a byte order mark, carriage returns and trailing whitespace correctly; measured against phpdotenv, it drops the mark, ends a line at a carriage return as well, and trims an unquoted value. Everything else that reads the file as text does not: a shell that sources it keeps the carriage return in each value, and a tool that takes a line literally, such as Docker's --env-file, keeps the whitespace. That is why these warn.
The configuration
These need the loaded configuration and nothing of the host, so they run in the deploy, before any harm is done. They apply in production and on staging.
| Key | Finds | Verdict |
|---|---|---|
app-debug | debug mode on | fail |
app-key | no key, a placeholder an old Laravel release shipped, or the key of the committed example file | fail |
mail-transport | a default mailer that delivers nothing: log, array, a mail catcher | fail; SMTP to the machine itself warns |
health-notifications | alerts in operation that only mail delivers | warning |
test-mode-keys | Stripe or Mollie keys in test mode, a sandbox switch that is on | fail |
queue-connection | a default queue that discards jobs, or runs them inside the request | fail for null, warning for sync |
session-driver | sessions that last one request | fail |
cache-store | a default cache that keeps nothing past the request | fail for array and null |
secure-cookie | a session cookie without the Secure flag on a site that runs over HTTPS | fail |
debug-tools | Telescope recording, an enabled Debugbar, Ignition loaded | fail; Ignition warns |
log-level | the default log channel at debug level | warning |
The cache fails for array as well as null because more than speed hangs on it: the rate limiters count in it, atomic locks live in it, and queue:restart leaves its signal there.
queue-connection warns for sync rather than failing, because on a small installation it is a choice. Acknowledge it there.
mail-transport warns for SMTP to the machine itself: a relay on the same server is a valid setup, and only whoever configured it knows whether that is what it is. A failover mailer is read by its first mailer, since log never fails over; a roundrobin mailer by every mailer.
health-notifications is the second half of the mail check. In operation, laravel-health delivers what the checks find, by mail unless told otherwise, and one of the findings is a mailer that delivers nothing. It warns until a second channel is set up. Deploying says which.
test-mode-keys walks the whole configuration, because a provider's keys stand wherever its package put them, and reports a setting by its path, never its value.
debug-tools asks the application whether a tool's service provider is registered, which is what a --no-dev install leaves out.
laravel-health ships a DebugModeCheck of its own. app-debug reads the stage, so one registration holds on every server, and it runs in the deploy command.
The installation
These need the server. They do not run in a deploy, and that is the reason they exist: half of these states arise after the go-live.
| Key | Finds | Verdict |
|---|---|---|
config-cache-fresh | a cached configuration older than .env | fail |
queue-restart | workers that run the code from before the last deploy | fail |
pending-migrations | migrations that have not run | fail |
writable-directories | a directory Laravel writes to that is missing or not writable | fail |
storage-link | a storage link that is missing or points elsewhere | fail over stored files, warning before the first |
env-exposure | a .env every user of the server can read or write, a .env or .git in the web root | fail |
env-over-http | the site serving .env or .git/config to anyone who asks | fail; off unless switched on |
dev-dependencies | development packages installed | fail |
optimized-autoloader | an autoloader that is not optimized | warning |
php-version-parity | the command line on another PHP version than the web server | fail; needs the binary named |
opcache | OPcache off, or checking every file for changes | fail for off, warning for timestamps |
The first two share a shape: the code is deployed, and something still runs on the state from before. Somebody changed a value on the server and forgot config:cache, so Laravel, which reads no .env while the configuration is cached, runs on the old value. Or the deploy forgot queue:restart, and the workers keep yesterday's classes. Both are invisible and cheap to measure: two modification times, and two timestamps.
queue-restart holds the signal queue:restart writes against the time preflight --deploy recorded. Without a recorded deploy it says it cannot tell.
Some of these cannot answer on their own, and none of them guesses. Whether the site serves .env depends on the web server's configuration, which no file of the application describes: env-over-http asks the site with a request when switched on, and says it did not look otherwise. The PHP of the web server is a binary php-version-parity runs; without one named, or on a host that switched off proc_open, it is not checked. OPcache settings of the web server are invisible from the command line; opcache answers in a web request. In operation shows how to switch each on.
Two entries a reader might expect come from laravel-health itself: ScheduleCheck watches the scheduler with a heartbeat, and OptimizedAppCheck reports a configuration, routes or events that are not cached. Register them beside these checks. Views are not on the list: Blade compiles a template once and keeps it, so view:cache only moves that first compile into the deploy.