Preparing for Production
The state a production release needs: two environment flags, a dev-free install, four warmed caches and writable storage.
composer install --no-dev --optimize-autoloader && php artisan optimize && php artisan migrate --forceA Laravel application is not switched into production mode. It is put there by a handful of environment values and a handful of cache files. Knowing that list is most of what deployment means. Laravel 13 requires PHP 8.3 or newer; this course uses PHP 8.5.
The two flags
APP_ENV=production and APP_DEBUG=false. Those are read in config/app.php as 'env' => env('APP_ENV', 'production') and 'debug' => (bool) env('APP_DEBUG', false). With debug left on, any uncaught exception renders a page listing your configuration values, including credentials, to whoever triggered it.
Install, then warm
Install without development packages: composer install --no-dev --optimize-autoloader --no-interaction. Then run php artisan optimize. That one command runs four others in order: config:cache, event:cache, route:cache and view:cache. The longhand is to type all four; optimize is the shorthand for exactly that list, and --except=views drops one of them. php artisan optimize:clear reverses all four and also flushes the default cache store.
Storage and the database
The process that serves the site must be able to write to storage and bootstrap/cache. Run php artisan storage:link if you serve uploads from the public disk. Migrations ask for confirmation when APP_ENV is production, so a script needs php artisan migrate --force.
What trips people up
After config:cache, the .env file is no longer read during requests. env() then sees only real system environment variables.
Example
<?php
// app/Console/Commands/DeployCheck.php
// Generated with: php artisan make:command DeployCheck
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;
class DeployCheck extends Command
{
protected $signature = 'app:deploy-check';
protected $description = 'Refuse the release if this box is not configured for production';
public function handle(): int
{
$failures = [];
if (config('app.debug') === true) {
$failures[] = 'APP_DEBUG is true';
}
if (config('app.env') !== 'production') {
$failures[] = 'APP_ENV is '.config('app.env');
}
// $this->laravel is the application instance the command runs in.
if (! $this->laravel->configurationIsCached()) {
$failures[] = 'config:cache has not run';
}
if (! is_writable(storage_path('framework'))) {
$failures[] = 'storage/framework is not writable';
}
try {
DB::connection()->getPdo();
} catch (Throwable $e) {
$failures[] = 'database unreachable: '.$e->getMessage();
}
foreach ($failures as $failure) {
$this->components->error($failure);
}
return $failures === [] ? self::SUCCESS : self::FAILURE;
}
}When to use it
- Writing the deploy script for a new Laravel 13 application on a plain Linux server.
- Tracking down why a credential that works locally is null in production.
- Adding a check that refuses to finish a release when APP_DEBUG is still true.
- Fixing 'failed to open stream: Permission denied' from storage/framework/views.
More examples
The order the commands have to run in
Install before optimize, because route:cache and event:cache load your application code and will cache whatever is on disk at that moment. Migrate before optimize too, so a failed migration stops the release before the caches are rewritten. php artisan reload, added in Laravel 12.41, terminates queue workers and interrupts the scheduler; it is covered in the zero-downtime lesson.
# 1. Code and dependencies, with nothing from require-dev.
composer install --no-dev --optimize-autoloader --no-interaction --no-progress
# 2. Schema. --force skips the production confirmation prompt.
php artisan migrate --force
# 3. config:cache + event:cache + route:cache + view:cache.
php artisan optimize
# 4. Confirm, then let long-running processes pick up the new code.
php artisan app:deploy-check
php artisan reloadWhere env() is allowed, and where config() replaces it
The rule is one sentence long: call env() only inside config/. Everything else calls config(). Reach for this pattern whenever you add a new third-party credential, because an env() call hidden in a service class is the failure that only appears on the server.
<?php
// config/services.php
return [
'shipping' => [
// env() is read once, when the config cache is built.
'key' => env('SHIPPING_API_KEY'),
'base_url' => env('SHIPPING_BASE_URL', 'https://api.example.com'),
],
];
// app/Services/ShippingClient.php -- anywhere outside config/, read the
// config value instead. These two lines are the same call:
$key = config('services.shipping.key');
$key = \Illuminate\Support\Facades\Config::get('services.shipping.key');
// This returns null in production once config:cache has run, because the
// .env file is no longer loaded:
$key = env('SHIPPING_API_KEY');Inspecting a release you did not deploy yourself
Use these when a server behaves differently from the last one. about reports the cached/not cached state of config, events, routes and views, so it answers the 'did optimize actually run' question without reading files. config:show prints resolved values, so it shows what the application believes rather than what the .env file says.
# Drivers, versions, and whether each cache is warm.
php artisan about
# Just the environment block.
php artisan about --only=environment
# The effective values of one config file, after caching.
php artisan config:show app
Discussion