Skip to main content
Laravel, shipping fast.

Here's the rule: rolling back code and rolling back a schema are two different operations, and treating them as one is how a five-minute incident becomes a five-hour one.

Rolling back code, if you deployed by releases, is close to instant. The previous release directory is still sitting on disk with all its dependencies already installed. Rolling back means pointing the symlink at it again, the same atomic swap that got you into the new release, just run backwards. No composer install, no waiting, no half-built directory this time either.

Rolling back a schema is not instant, and it's not safe by default, because down() methods are written to reverse a migration cleanly, not to reverse it while a still-half-deployed old release might depend on what the migration just removed.

// Bad: treating schema rollback as part of the same motion as a code rollback
php artisan migrate:rollback --step=1
# then swap the symlink back to the previous release

The problem isn't the commands, they're both real and both work. The problem is the order and the assumption behind it. If anything wrote a row using the new columns between the deploy and the rollback, migrate:rollback on that add_activation_metrics migration runs dropColumn(['license_at', 'license_ms', ...]) and that data is gone, not rolled back, gone. You've traded a bug for data loss.

// Good: roll the code back, leave the schema alone
# swap the symlink back to the previous release
# the additive columns stay -- the old release never read them, so their
# presence changes nothing it does

Because the earlier migrations were additive, the old release doesn't care that license_at exists. It never looked for it. Rolling back the code is enough to stop the bleeding. The schema stays where it is, and if it genuinely needs to change, that's a new forward migration, written calmly, not a down() method executed under incident pressure against a database nobody's fully sure of the current state of.

Six months from now, when someone on your team is debugging a 2am page, they will not remember which of your migrations are safe to reverse and which ones quietly delete something. Don't make them guess. Forward-only, additive migrations mean the answer is always the same: roll the code back first, and you're usually already done.

Secrets that never reach the repository

Every config file in this fleet opens with declare(strict_types=1);, and every one of them is where env() calls live and nowhere else:

// config/database.php
declare(strict_types=1);

return [
    'connections' => [
        'mysql' => [
            'driver' => 'mysql',
            'database' => env('DB_DATABASE', 'laravel'),
            'username' => env('DB_USERNAME', 'root'),
            'password' => env('DB_PASSWORD', ''),
        ],
        // ...other connections
    ],
];

Why this matters isn't just a style preference. php artisan config:cache, which every production deploy should run, reads every config file once, resolves every env() call at that moment, and writes the result to a single compiled file. After that cache exists, Laravel stops reading .env at all for config lookups. An env() call sitting inside a controller or a job, instead of a config file, gets baked in wrong or returns null after caching, depending on when it runs relative to the cache being built. That's not a style rule, that's a runtime bug waiting for the first production deploy that runs config:cache. Confine env() to config files, call config('database.connections.mysql.password') everywhere else, and the cache can never disagree with your code about what a secret's value is.

The actual values, DB_PASSWORD, whatever your secret is, never live in the repository at all. .env, .env.backup, and .env.production are all in .gitignore. What's committed is the key name, wrapped in env(), with a safe default for local development. The value lives on the server, set through whatever your host's environment management gives you, entirely outside of git pull. A deploy that pulls new code never touches secrets, because secrets were never something a deploy pulls.