Preparing for Production

The state a production release needs: two environment flags, a dev-free install, four warmed caches and writable storage.

Syntaxcomposer install --no-dev --optimize-autoloader && php artisan optimize && php artisan migrate --force

A 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

Example · php
<?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.

Example · shell
# 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 reload

Where 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.

Example · php
<?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.

Example · shell
# 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

  • Be the first to comment on this lesson.