Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Laravel Deployment Mistakes That Break Production, and How to Avoid Them

About Post

The pull request was reviewed. The tests passed. The feature worked perfectly on staging. Then it hit production and the new emails never went out, the uploaded images returned 404, and the error log was empty because the app couldn't write to it.

None of that was a code bug. It was the deploy.

Laravel makes building apps pleasant, but getting code onto a server has its own set of traps, and most of them fail quietly. Here are the ones I see most often, each with what goes wrong, why, and the fix.

Mistake 1: Forgetting that queue workers are still running yesterday's code

What happens: you deploy a fix to a job class. The bug keeps happening. Or you add a new job and it fails with strange errors about missing classes or methods.

Why: php artisan queue:work is a long-running process. It loaded your application into memory when it started and it doesn't notice new files on disk. Your web requests run the new code; your workers run the old one.

The fix: make this part of every deploy:

php artisan queue:restart

It tells each worker to exit gracefully after its current job, and your process manager (Supervisor, systemd) starts fresh ones with the new code. Two things to check: the workers must actually be supervised, or they won't come back, and the command needs a working cache driver, because that's where the restart signal is stored. On Horizon, use php artisan horizon:terminate instead.

Mistake 2: Not caching config, or caching it and then calling env()

What happens: either the app is noticeably slower than it should be, or, after you finally run config:cache, some settings suddenly come back as null.

Why: without caching, Laravel reads and merges every config file on every request. With caching, it loads one compiled file and stops reading .env altogether. Any env('SOMETHING') call outside the config/ folder then returns null.

The fix: only call env() inside config files, use config() everywhere else, and run this on every deploy:

php artisan optimize   # caches config, events, routes and views

A quick search for env( outside config/ before your first cached deploy saves a confusing afternoon.

Mistake 3: Running migrations blindly

What happens: php artisan migrate --force runs in the pipeline, and either a column the old code still uses disappears mid-deploy, or a big table locks while an index is built and requests start timing out.

Why: during a deploy, old and new code overlap for a while. And on large tables, some schema changes take far longer than they did on your small local database.

The fix:

  • Read what will run. php artisan migrate --pretend prints the SQL without executing it. Do this before deploying anything non-trivial.
  • Expand, then contract. Add new columns first and deploy code that uses them. Remove old columns in a later deploy, once nothing reads them.
  • Back up before destructive changes. A dropColumn has no undo button. Neither does a down() method that was never tested.
  • Treat big-table changes as their own task, scheduled for a quiet time, not hidden inside a feature release.

Rule for migrations: every migration in a deploy must be safe to run while the previous version of the code is still serving requests. If it isn't, split it across two deploys.

Mistake 4: The missing storage:link

What happens: users upload profile pictures successfully, and every one of them shows as a broken image.

Why: files on the public disk are saved to storage/app/public, but the web server serves files from public/. The bridge between them is a symbolic link that php artisan storage:link creates. Fresh server, no link.

The fix: run it once per server. With zero-downtime setups that deploy each release into a new folder, make sure storage/ is shared between releases and the link points to the shared one. Otherwise each release starts with an empty storage folder and yesterday's uploads seem to vanish.

Mistake 5: Permissions, and the root-owned log file

What happens: the site works, then suddenly shows a 500 error, and storage/logs has nothing useful in it.

Why: the web server user (often www-data) must be able to write to storage/ and bootstrap/cache/. A sneaky way to break this: someone runs an Artisan command as root, or a cron job runs as root, and Laravel creates today's log file owned by root. Now the web server can't write to the log, so the error about not being able to write is... not written anywhere useful.

The fix: give ownership to the deploy user and the web server group, and run Artisan as the same user as the app:

sudo chown -R deploy:www-data storage bootstrap/cache
sudo chmod -R ug+rwX storage bootstrap/cache
sudo -u deploy php artisan migrate --force

And please, not chmod -R 777. It makes the error go away by making every file writable by everyone on the machine.

Mistake 6: .env surprises

A few classics, all of them avoidable:

  • APP_DEBUG=true in production. Error pages then show stack traces, file paths and request details to anyone who triggers an error. Production should always have APP_ENV=production and APP_DEBUG=false.
  • New variables never added on the server. Someone adds a key locally and the deploy has no idea. Keep .env.example complete and review it in pull requests.
  • Running php artisan key:generate on a live app. The app key encrypts sessions, cookies and any data stored with Laravel's encrypter. Change it and every user is logged out, and encrypted values can no longer be decrypted.
  • Forgetting the cache. After editing .env on a server with cached config, nothing changes until you run php artisan config:cache again.

Mistake 7: composer update on the server

Production should install exactly the versions you tested. That's composer install, which reads composer.lock. composer update resolves new versions on the spot, which means production may run a combination of packages nobody has ever tested. Add --no-dev so test tools stay off the server, and --optimize-autoloader for faster class loading.

Put it in a script

Most of these mistakes come from steps someone forgot. Scripts don't forget. A simplified deploy for a single server:

#!/usr/bin/env bash
set -e  # stop at the first failing command

php artisan down --retry=60
git pull origin main
composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build
php artisan migrate --force
php artisan optimize
php artisan queue:restart
php artisan up

Whether this runs from a CI/CD pipeline, Laravel Forge, Envoyer or a plain shell script matters less than the fact that it runs the same way every time. The Laravel deployment docs are a good companion for server configuration.

Before your next deploy

  • Workers restarted, and actually supervised?
  • Config cached, and no env() outside config/?
  • Migrations read with --pretend and safe for the old code?
  • storage:link in place and storage shared between releases?
  • Permissions correct, Artisan run as the app user?
  • APP_DEBUG=false, .env complete, app key untouched?
  • composer install, never update?

Which of these has caught you out? And what's the one step you added to your deploy script after learning it the hard way?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close