Laravel¶
Three ready-made scripts, depending on how PHP is served:
| Script | PHP served by | Pick it when |
|---|---|---|
deploy-laravel-fpm.sh |
the system's PHP-FPM behind nginx | the usual production setup; recommended |
deploy-laravel-frankenphp.sh |
FrankenPHP on a local port, under pm2 | no PHP-FPM available and no root to set it up |
deploy-laravel.sh |
the host or a Docker Compose service (Sail) | the app runs in containers |
All of them deploy the exact pushed commit, keep untracked files (.env,
storage/), stop with a clear message if .env is missing, and run
composer install --no-dev, migrate --force, storage:link and the
artisan caches.
Migrations are not rolled back
If a migration fails, the deploy stops there and the database stays as the
failed migration left it. Review long or locking migrations (new NOT NULL
columns, indexes on big tables) before merging, and keep database
snapshots.
PHP-FPM¶
deploy/examples/deploy-laravel-fpm.sh:
nginx passes PHP to a PHP-FPM pool running as its own user (e.g. laravel),
while deploys run as the service account (e.g. deploy). Both share
storage/ and bootstrap/cache/ through a group.
What a deploy does:
git fetch+git reset --hard <pushed commit>inAPP_DIR(first run: clones next to any existing files, so a.envput there beforehand is kept).- Stops if
.envdoesn't exist: the code is in place, create.envand runnimdeploy run -f <app>. - Shared permissions:
storage/andbootstrap/cache/get the pool's group, group write and setgid;.envbecomes640with the pool's group (PHP-FPM reads it, never writes it). The script runs withumask 0002. composer install --no-dev --optimize-autoloader,artisan migrate --force,storage:linkif missing,artisan optimize(config, routes, events, views).- Optional queue worker and scheduler under pm2, reloaded with the new code
(
queue:restartfor the worker). - Optional health check against a public URL.
PHP-FPM is not restarted: with opcache.validate_timestamps=1 (the
default) it serves the new files within opcache.revalidate_freq seconds
(2 s by default).
#!/usr/bin/env bash
export PATH="$HOME/.local/bin:$PATH"
export APP_NAME=agency-backend
export APP_DIR=/var/www/backend/agency
export REPO_URL=git@github-agency-backend:acme/agency-backend.git
export WEB_GROUP=laravel # PHP-FPM pool group; deploy must be a member
export SCHEDULER=1 # artisan schedule:work under pm2
export HEALTH_URL=https://example.com/agency/up
exec /home/deploy/bin/deploy-laravel-fpm.sh
| Variable | Default | |
|---|---|---|
APP_NAME |
required | prefix of the pm2 names (-queue, -scheduler) |
APP_DIR |
required | the checkout; .env lives here |
REPO_URL |
required | |
WEB_GROUP |
laravel |
the pool's group. Empty: leave groups alone |
PHP |
php |
PHP CLI for composer and artisan |
QUEUE |
0 |
1: artisan queue:work under pm2 |
SCHEDULER |
0 |
1: artisan schedule:work under pm2 |
HEALTH_URL |
– | checked at the end; any answer below 500 counts as alive |
Server setup (admins, once)¶
[laravel]
user = laravel
group = laravel
listen = /run/php-fpm/laravel.sock
listen.owner = nginx
listen.group = nginx
listen.mode = 0660
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 4
pm.max_requests = 500
request_terminate_timeout = 120s
php_admin_value[error_log] = /var/log/php-fpm/laravel-error.log
security.limit_extensions = .php
useradd --system --no-create-home --shell /sbin/nologin laravel
usermod -aG laravel deploy # then: systemctl restart nimdeploy
systemctl daemon-reload && systemctl restart php-fpm
UMask=0002 makes the files PHP-FPM creates (logs, cache, sessions,
compiled views) group-writable, so the next deploy can update or delete them.
nginx¶
A site where Laravel answers the whole domain:
root /var/www/backend/shop/public;
index index.php;
location / { try_files $uri $uri/ /index.php?$query_string; }
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm/laravel.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
}
Laravel under a path prefix next to other apps (e.g. /agency/api and
/agency/admin, while /agency/ is a Nuxt frontend): send those paths to
index.php and tell PHP the app lives in /agency. Laravel keeps its own
routes (/api, /admin) and generates links, redirects and assets with the
prefix, without code changes:
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm/laravel.sock;
fastcgi_param SCRIPT_FILENAME /var/www/backend/agency/public/index.php;
fastcgi_param SCRIPT_NAME /agency/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/backend/agency/public;
fastcgi_param HTTPS on;
location = /agency/api { include /etc/nginx/agency-laravel.fastcgi; }
location ^~ /agency/api/ { include /etc/nginx/agency-laravel.fastcgi; }
location = /agency/admin { include /etc/nginx/agency-laravel.fastcgi; }
location ^~ /agency/admin/ { include /etc/nginx/agency-laravel.fastcgi; }
location ^~ /agency/storage/ { alias /var/www/backend/agency/public/storage/; }
location ^~ /agency/css/ { alias /var/www/backend/agency/public/css/; }
FrankenPHP¶
deploy/examples/deploy-laravel-frankenphp.sh
serves the app on a local port (e.g. 127.0.0.1:8000) with
FrankenPHP in classic mode (one fresh PHP
request per HTTP request, like PHP-FPM), managed by pm2. No root needed:
FrankenPHP is downloaded to ~/.local/bin on the first deploy. Same steps as
above, then pm2 startOrReload (which also clears OPcache) and a health check
on HEALTH_PATH (default /up). Variables: APP_NAME, APP_DIR,
REPO_URL, PORT, HOST, PHP, FRANKENPHP, QUEUE, SCHEDULER,
HEALTH_PATH. nginx then proxies the Laravel paths to that port.
Classic mode on purpose
FrankenPHP's worker mode keeps the app in memory between requests. It is faster, but state can leak between requests (singletons, static properties, connections) and not every package supports it. Validate your app thoroughly before enabling it.
Docker or host¶
deploy/examples/deploy-laravel.sh
runs in the deploy's working_directory (the checkout): fast-forward to the
pushed commit, composer install, npm ci && npm run build, migrate,
storage:link, the artisan caches, queue:restart (and horizon:terminate
if Horizon is installed). PHP/Node commands run on the host or inside a
docker compose service (Laravel Sail). Settings go in the deploy's env:
[deploy.shop]
path = "/hooks/shop"
repository = "acme/shop"
branch = "main"
secret_env = "SHOP_WEBHOOK_SECRET"
working_directory = "/srv/shop" # the git checkout, with docker-compose.yml
command = "/usr/local/bin/deploy-laravel.sh"
timeout = "20m"
env = [
"COMPOSE_SERVICE=laravel.test", # empty/absent: run on the host
"RESTART_SERVICES=laravel.worker", # workers that ignore queue:restart
"OWNER=www-data:www-data", # chown storage/ bootstrap/cache/
"EXTRA_MIGRATIONS=tenants:migrate", # e.g. stancl/tenancy
"WWWUSER=1000", "WWWGROUP=1000", # used by Sail's docker-compose.yml
]
Other options: PHP_FPM_SERVICE=php8.3-fpm (host only, reloads PHP-FPM to
clear OPcache; needs a sudoers rule such as
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm),
MAINTENANCE=1 (artisan down around the migrations, up even if they
fail), ARTISAN_CACHE="config:cache route:cache view:cache event:cache"
(the default leaves out route:cache and event:cache, which fail with
closure routes), COMPOSER_NO_DEV=0.
Docker means root
To run docker compose the service user must be in the docker group,
which is equivalent to root. Only do this on servers where that is
acceptable.
Scheduler: pm2 or cron¶
Laravel's scheduler can run as a cron entry or as a long-running process. Use one, never both, or every task runs twice:
artisan schedule:work under pm2, reloaded by each deploy, restarted if
it dies, started at boot by pm2's unit. Needs nothing from root, which is
why the scripts offer it (Amazon Linux 2023, for example, ships without
cron).
Private packages (Backpack PRO, Nova, Spark…)¶
Store the credentials in the service account's Composer config, never in the repository:
sudo -iu deploy
composer config -g http-basic.repo.backpackforlaravel.com <user> <password>
chmod 600 ~/.config/composer/auth.json
Some vendors use more than one host (Backpack: repo.backpackforlaravel.com
and backpackforlaravel.com); add an entry for each one the lock file uses.
PHP version mismatch¶
If composer.lock is generated on a newer PHP than the server's (8.4 locally,
8.3 on the server), pin the platform in composer.json so every machine
resolves dependencies for production's PHP: