Configuration files are the only place env() is called:
// config/services.php
'statamic' => [
'url' => env('STATAMIC_API_URL'),
],
This is not a style preference. php artisan optimize reads every config file once, resolves every env() call at that moment, and writes the result to one cached file. From then on Laravel doesn’t read .env at all. An env() call sitting in a controller, a job, or a command returns null in production, and only in production, because that is the only place the config is cached.
So env() stays in config files, and everything else calls config('services.statamic.url'). There is no provider token in that file: Chapter 4 moved it onto each consumer. It also means that changing a value in .env on a server does nothing until the config cache is rebuilt, which a deploy does and editing a file doesn’t. (Laravel documentation: Configuration › Configuration Caching.)
The values themselves never live in the repository. .env is ignored by git. What is committed is the name of the key, inside env(). The value lives on the server, set through your host’s environment management, entirely outside git pull. A deploy that pulls new code never touches secrets, because secrets were never something a deploy pulls.
A secret that has been in the repository is no longer a secret. Removing it in a later commit doesn’t help: it is in the history. Rotate it.
Know how you would rotate each one. The provider token, the application key, the database password. If the answer for any of them is “I’m not sure what would break,” find out on a quiet afternoon and not during an incident.
The application key needs the most care, because it does more here than sign cookies. It encrypts every consumer’s settings. Replace it carelessly and every partner’s provider token becomes unreadable at once. Laravel lets you rotate it gracefully: set the new key as APP_KEY and list the old one in APP_PREVIOUS_KEYS. New values are encrypted with the new key, and old ones can still be read with the old. (Laravel documentation: Encryption › Gracefully Rotating Encryption Keys.)
That is half a rotation. The old key still opens every value written before the change. To finish, write each encrypted value again, and do it while the old key is still listed:
// app/Console/Commands/ReencryptConsumers.php, in handle()
Consumer::withTrashed()->chunkById(100, function ($rows) {
foreach ($rows as $consumer) {
$consumer->forceFill([
'settings' => $consumer->settings,
'webhook_secret' => $consumer->webhook_secret,
])->saveQuietly();
}
});
The assignment is the point of it. Loading a model and saving it writes nothing, because nothing changed. Assigning an encrypted attribute its own value makes Eloquent encrypt it again, with the new key. Only when that has run everywhere does the old key leave APP_PREVIOUS_KEYS. Remove it sooner and every partner’s credentials become unreadable.
Remember where the secrets end up. php artisan optimize writes every resolved config value, secrets included, into a file under bootstrap/cache. Keep that directory and .env out of backups you don’t encrypt and out of anything the web server can serve. A database backup stored next to the key that decrypts it protects nothing.
Two Values Production Must Set
The .env.example that every Laravel project starts from is written for a laptop:
APP_ENV=local
APP_DEBUG=true
Copy it to a server unchanged and every unhandled exception answers with file paths, queries, and environment values. Production sets both:
APP_ENV=production
APP_DEBUG=false
Check them after every change to how the server is provisioned, with php artisan about. The same command has a --json flag, so a deploy script can refuse to continue unless the environment reads production and debug mode reads false.
Better than checking is making the mistake impossible to ship:
// app/Providers/AppServiceProvider.php, in boot()
$local = $this->app->environment('local', 'testing');
if (config('app.debug') && ! $local) {
config(['app.debug' => false]);
throw new RuntimeException('Debug is on outside local.');
}
The test is “not local”, and not “production”, because the usual accident is a server that was never told it is production at all. Debug is switched off before the exception is thrown, or the refusal itself would be rendered as a debug page. With that in place, a release with debug on answers every request, the health route included, with a bare 500, and a deploy that checks /up before it moves traffic never switches to it.
Infrastructure You Can Repeat
Setting up a server by hand, once, carefully, is easy. Doing it the same way the fifth time, late, under pressure, without skipping the step you always skip, is where hand-built infrastructure fails.
The answer has the same shape as everything else in this book: decide once, write it down as code, and run the same thing every time. What that code is depends on where you host. A managed platform reduces it to a configuration file. A provisioning service reduces it to a recipe. Your own servers need a script. In every case the test is the same: could someone who has never seen this system bring up a second, identical environment from what is in the repository?
Chapter 20 Summary
Before you deploy:
- The deploy is a script in the repository, and it runs only on code that passed CI.
- Releases are built in their own directory and switched in atomically.
- The health route is checked before traffic moves.
- Migrations are additive, and run with
--force --isolated. env()appears only in config files. Secrets live on the server.APP_ENV=productionandAPP_DEBUG=false, verified.
After the switch:
- Reload workers, every time.
When something goes wrong:
- Roll the code back first. Leave the schema alone.
- Treat
migrate:rollbackas a last resort. Adown()method can delete data.