What is it? #
Deploying a PHP application means getting the code in place, installing dependencies, warming caches, running migrations and reloading FPM.
Two things differ from other stacks. The web server needs write access to specific directories, and the framework has caches that must be rebuilt on every deployment.
The permissions question causes more Laravel deployment problems than anything else: storage and bootstrap/cache must be writable by the FPM user, and nothing else should be.
The FPM reload matters because OPcache with timestamp validation disabled will otherwise keep serving the previous code.
Think of it like this #
Restocking a shop where the till also needs a fresh printout of the price list.
Putting the stock out is not enough. Until the price list is reprinted and the till restarted, it rings up yesterday's prices.
Simple example #
A Laravel application deployed into release directories, with shared storage, cached configuration and routes, migrations, restarted queue workers and an FPM reload.
Code #
#!/usr/bin/env bash
# /srv/app/deploy.sh
set -euo pipefail
APP=/srv/app
STAMP=$(date +%Y%m%d-%H%M%S)
RELEASE="<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>r</mi><mi>e</mi><mi>l</mi><mi>e</mi><mi>a</mi><mi>s</mi><mi>e</mi><mi>s</mi><mi mathvariant="normal">/</mi></mrow><annotation encoding="application/x-tex">APP/releases/</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.0197em;">l</span><span class="mord mathnormal">e</span><span class="mord mathnormal">a</span><span class="mord mathnormal">ses</span><span class="mord">/</span></span></span></span>STAMP"
git clone --depth 1 --branch main [email protected]:org/shop.git "$RELEASE"
# Shared, persistent state
ln -s "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>s</mi><mi>h</mi><mi>a</mi><mi>r</mi><mi>e</mi><mi>d</mi><mi mathvariant="normal">/</mi><mi mathvariant="normal">.</mi><mi>e</mi><mi>n</mi><mi>v</mi><mi mathvariant="normal">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">APP/shared/.env" "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal">s</span><span class="mord mathnormal">ha</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord">/.</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord mathnormal" style="margin-right:0.0359em;">v</span><span class="mord">""</span></span></span></span>RELEASE/.env"
rm -rf "$RELEASE/storage"
ln -s "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>s</mi><mi>h</mi><mi>a</mi><mi>r</mi><mi>e</mi><mi>d</mi><mi mathvariant="normal">/</mi><mi>s</mi><mi>t</mi><mi>o</mi><mi>r</mi><mi>a</mi><mi>g</mi><mi>e</mi><mi mathvariant="normal">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">APP/shared/storage" "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal">s</span><span class="mord mathnormal">ha</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord">/</span><span class="mord mathnormal">s</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.0278em;">or</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.0359em;">g</span><span class="mord mathnormal">e</span><span class="mord">""</span></span></span></span>RELEASE/storage"
cd "$RELEASE"
composer install --no-dev --optimize-autoloader --no-interaction
# Warm the framework caches — do this AFTER .env is linked
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Migrations: backwards compatible, old code still serving
php artisan migrate --force
# Permissions: only what must be writable
sudo chown -R appuser:www-data "$RELEASE"
sudo find "$RELEASE" -type d -exec chmod 755 {} \;
sudo find "$RELEASE" -type f -exec chmod 644 {} \;
sudo chmod -R 775 "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>s</mi><mi>h</mi><mi>a</mi><mi>r</mi><mi>e</mi><mi>d</mi><mi mathvariant="normal">/</mi><mi>s</mi><mi>t</mi><mi>o</mi><mi>r</mi><mi>a</mi><mi>g</mi><mi>e</mi><mi mathvariant="normal">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">APP/shared/storage" "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal">s</span><span class="mord mathnormal">ha</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord">/</span><span class="mord mathnormal">s</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.0278em;">or</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.0359em;">g</span><span class="mord mathnormal">e</span><span class="mord">""</span></span></span></span>RELEASE/bootstrap/cache"
# Switch
ln -sfn "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>R</mi><mi>E</mi><mi>L</mi><mi>E</mi><mi>A</mi><mi>S</mi><mi>E</mi><mi mathvariant="normal">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">RELEASE" "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.6944em;"></span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal">L</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.0576em;">S</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord">""</span></span></span></span>APP/current.new"
mv -T "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>c</mi><mi>u</mi><mi>r</mi><mi>r</mi><mi>e</mi><mi>n</mi><mi>t</mi><mi mathvariant="normal">.</mi><mi>n</mi><mi>e</mi><mi>w</mi><mi mathvariant="normal">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">APP/current.new" "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal">c</span><span class="mord mathnormal">u</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord mathnormal">t</span><span class="mord">.</span><span class="mord mathnormal">n</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.0269em;">w</span><span class="mord">""</span></span></span></span>APP/current"
# Reload FPM — required with opcache.validate_timestamps=0
sudo systemctl reload php8.3-fpm
# Restart queue workers so they load the new code
php artisan queue:restart
curl -fsS https://shop.example.com/health > /dev/null && echo "healthy"
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf
# /etc/systemd/system/laravel-worker.service — queue workers
[Unit]
Description=Laravel queue worker
After=network.target
[Service]
User=appuser
WorkingDirectory=/srv/app/current
ExecStart=/usr/bin/php /srv/app/current/artisan queue:work \
--sleep=3 --tries=3 --max-time=3600 --timeout=90
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=120 # let the current job finish
[Install]
WantedBy=multi-user.target
What must be writable, and what must not
writable by www-data storage/ (logs, cache, sessions, uploads)
bootstrap/cache/
NOT writable app/, config/, routes/, vendor/, public/ code
never web-accessible .env, storage/, vendor/, the project root itself
The web root is public/ — this is the most important single setting.
Scheduled tasks
* * * * * cd /srv/app/current && php artisan schedule:run >> /dev/null 2>&1
Laravel's scheduler runs every minute and decides internally what is due.
How it works #
composer install --no-dev --optimize-autoloader installs production dependencies and builds a class map, which noticeably reduces per-request autoloading work.
Config and route caching compile those files into a single cached file each. They must run after the env file is linked, because config caching bakes in the current environment values — and it means later env changes require re-caching.
Migrations run before the switch with the old code still live, so they must be backwards compatible, exactly as in the Python deployment.
The permission commands set directories to 755 and files to 644, then make only storage and bootstrap/cache group-writable. The application user owns the files and the web server group can write where it must, which is the least-privilege arrangement.
Symlinking storage to a shared directory keeps uploads, logs and sessions across deployments and rollbacks.
systemctl reload php8.3-fpm is required whenever OPcache timestamp validation is off. Without it, the switch is invisible to PHP and the old code continues to run.
queue:restart signals workers to exit after their current job. systemd restarts them, and they pick up the new code — without this, workers keep running the old release indefinitely.
--max-time=3600 makes workers exit hourly, which is a simple defence against memory growth in long-running PHP processes.
Real-world use #
Laravel's own deployment tooling automates this sequence, and understanding the steps matters when something fails halfway.
Permission problems are the most common Laravel deployment issue. Errors about failing to write to storage/logs or bootstrap/cache nearly always trace back to ownership.
Config caching catches people out: a change to .env has no effect until the config cache is rebuilt, which is confusing the first time.
Exposing the project root instead of public/ is the most serious misconfiguration, because .env becomes downloadable. It is worth verifying explicitly after any web server change.
Queue workers must be restarted on deployment. Forgetting means jobs continue running old code, which produces behaviour that does not match the deployed version and is difficult to diagnose.
Common mistakes #
- Serving the project root instead of the public directory, exposing .env.
- Forgetting to reload FPM, so OPcache keeps serving the previous code.
- Not restarting queue workers, leaving them on the old release.
- Making the entire project writable to fix a permissions error.
- Caching config before the env file is linked, baking in wrong values.
Practice #
Deploy a Laravel application using release directories with shared storage. Include composer install, config and route caching, migrations, correct permissions, an FPM reload and a queue restart. Then change a value in .env and confirm it has no effect until config is re-cached.