Deploying a PHP application means more than copying files to a server — it means orchestrating Composer, environment configs, database migrations, OPcache, and PHP-FPM around a Git push so users never see a half-deployed site. This guide walks through the five deployment workflows that cover almost every PHP project we see at DeployHQ, from a one-server WordPress install to multi-environment Laravel and Symfony stacks. Every step is concrete: real flags, real gotchas, and the order they belong in.
If you want a framework-specific walkthrough instead, see our deep dives on zero-downtime Laravel deployments, automating WordPress deployments from Git, and encrypted env-vars with Dotenvx. For background on the .env pattern itself, see understanding .env files and environment variables.
1. Push-to-deploy from Git (the baseline every PHP project should have)
The first workflow to replace any scp/FTP habit is Git-driven deployment: a push to main triggers an automatic deployment from your Git repository to your server, with no human in the loop.
How it works in DeployHQ:
- Connect your repo. Use the dedicated deploy-from-GitHub or deploy-from-GitLab integration, or any Bitbucket/SVN/Mercurial repo. DeployHQ clones over HTTPS or deploy keys — you don't need to give it admin rights.
- Configure a server. Point DeployHQ at your target host over SSH/SFTP and set the deployment path (e.g.
/var/www/myapp/). For shared hosting that only exposes FTP, the same flow works — just slower. - Wire up the webhook. Paste DeployHQ's webhook URL into your repo's webhook settings. Every push to the deployment branch fires a deploy.
Why it matters: every deploy is tied to a Git SHA, so what's on production right now?
has a one-command answer (git log <sha>). And because the source of truth is the repo, anyone who can push can deploy — no shared FTP credentials, no final_v2_REALLY_FINAL.zip.
Common gotcha: people forget to set up branch filtering and end up pushing feature branches to production. Always restrict the deployment server to a single branch (typically main or production) and use a separate server config for staging.
2. Build pipelines: install Composer and compile assets before the upload
Almost every modern PHP project depends on Composer; most also have a JavaScript build step (Vite, Mix, esbuild, Webpack) for Tailwind, Livewire, Inertia, or admin UIs. Committing vendor/ and node_modules/ to Git is the wrong answer — instead, run them through DeployHQ's build pipelines.
Recommended build commands for production PHP deploys:
# PHP dependencies — production-only, optimized autoloader, no prompts
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
# JS/CSS assets (Laravel Vite example)
npm ci --no-audit --no-fund
npm run build
A few things worth knowing:
--no-devstrips PHPUnit, Mockery, and other dev-only packages. Production servers should never have them.--optimize-autoloader(or-o) converts PSR-4 lookups into a class map. On a real Laravel project this typically saves 10–30 ms per request.--prefer-distpulls zipped releases instead of cloning each package's Git history — faster and smaller.npm ciis strictly correct for CI/CD: it deletesnode_modules, installs frompackage-lock.json, and fails if the lockfile drifts.npm installmutates the lockfile and is the wrong choice here.
Then mark vendor/ and node_modules/ as excluded files in DeployHQ so they're never uploaded — the build artifacts that are needed (the rebuilt vendor/ and the compiled public/build/ output) get transferred instead. The result is a faster, cleaner upload and a repository that stays under control.
Private Composer packages? Generate an auth.json containing your Packagist/GitHub token and inject it via DeployHQ config files so it lives on the build container only, never in Git.
3. SSH commands: migrations, cache warming, and PHP-FPM reloads
A PHP deployment isn't finished when files land on disk. You still need to run database migrations, clear/warm caches, and tell PHP-FPM about the new code. DeployHQ's pre- and post-deploy SSH commands script all of it.
A realistic post-deploy sequence for a Laravel app:
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan queue:restart
sudo systemctl reload php8.3-fpm
For Symfony you'd swap in bin/console doctrine:migrations:migrate --no-interaction, bin/console cache:clear --env=prod, and bin/console cache:warmup. WordPress sites typically just need an OPcache reset and a queue flush.
The OPcache gotcha nobody warns you about. PHP's opcode cache stores compiled bytecode keyed by file path. If your deploy overwrites files in place (workflow #1 above), OPcache happily keeps serving the old bytecode until you reset it or PHP-FPM restarts. Always either:
sudo systemctl reload php8.3-fpm(graceful — finishes in-flight requests), or- call
opcache_reset()via a maintenance endpoint or CLI script.
Pair this with the Essential SSH commands every PHP developer should know for the full toolkit.
Conditional commands. Most teams want migrations to run on production but not on staging, or only on the first server in a multi-server group. DeployHQ exposes both options as checkboxes — use them. The cost of accidentally double-running a migration on a 200 GB production database is hours, not minutes.
4. Atomic releases: real zero-downtime PHP deployments
If your service-level objective is no failed requests during a deploy
— and for any production e-commerce, SaaS, or member-area site it should be — overwriting files in place is unsafe. Atomic deployments solve it.
How DeployHQ's zero-downtime deployments work for PHP:
- The new release is uploaded into a fresh, timestamped directory like
releases/20260513-143000/. - Composer, npm, and migrations all run inside that directory — the live site keeps serving the previous release.
- Once everything succeeds, the
currentsymlink is atomically flipped to point at the new directory. Therename(2)syscall is atomic on POSIX filesystems, so there is no observable in-between state. - Your web server (Nginx or Apache) is configured to serve from
current/public/, so it picks up the new release on the next request.
A standard layout looks like:
/var/www/myapp/
├── current -> releases/20260513-143000 # symlink (atomic switch target)
├── releases/
│ ├── 20260513-093200/
│ ├── 20260513-143000/
│ └── ...
└── shared/ # persists across releases
├── .env
├── storage/ # Laravel writable dirs
└── public/uploads/ # user uploads
Anything that needs to survive a release — uploaded user files, the .env, Laravel's storage/ directory, session files — lives under shared/ and is symlinked into each release.
PHP-specific atomic-deploy gotchas:
realpath_cache: PHP caches resolved symlink paths per process. After flippingcurrent, FPM workers may still resolvecurrent/to the previous release's real path for up torealpath_cache_ttlseconds (default 120). Aphp-fpm reloadclears it instantly.- OPcache and absolute paths: if you've set
opcache.validate_rootor use absolute paths in autoloaders, OPcache may key entries by the resolved (post-symlink) path. Reloading FPM is again the safe answer. - In-flight requests: graceful reload (not restart) ensures requests that started against the old release complete cleanly.
The payoff is the other half of zero-downtime: one-click rollback. If the new release breaks, you flip the symlink back. Rollback time becomes a one-second ln -sfn instead of a redeploy. That's a genuine Recovery Time Objective (RTO) measured in seconds.
5. Multi-environment workflows: dev → staging → production
Real PHP projects ship through at least staging and production, often with a feature/QA environment in front. DeployHQ models each environment as a separate server
inside one project, sharing the same build pipeline.
A workflow that scales:
- Branch-to-environment mapping.
develop→ staging server,main→ production server. Pull requests get reviewed; merging tomainis the deploy trigger. - Per-environment config files. Use DeployHQ's config-files feature to inject a different
.env(orapp/config/production.php) onto each server during the deploy. Database hosts, API keys, mail credentials never touch Git. See managing dev, staging, and production environments with DeployHQ for a full walkthrough of the patterns described here. - Per-environment SSH commands. Staging gets
php artisan migrate --forceon every deploy; production gates it behind a manual flag, or only runs it when amigrations/directory has changed since the last release. - Per-environment notifications. Production deploys should ping a Slack channel and your status page; staging deploys should be silent.
- Backup before production. Add a pre-deploy SSH command that snapshots the database (
mysqldump/pg_dump) to a directory excluded from the deploy. Combined with atomic releases, this gives you a true 3-2-1 backup pattern for each deploy window.
This is also where DeployHQ pays for itself versus a bespoke GitHub Actions workflow: every environment shares the same build pipeline, the same SSH commands, the same release directory layout — there is no drift between staging and production, which is where most but it worked in staging
bugs come from. If you're currently weighing build-it-yourself versus a managed deploy tool, our breakdown of DeployHQ versus Deployer for PHP teams covers the trade-offs.
A sensible adoption order
If your PHP app currently lives behind FTP and a manual git pull, don't try to land all five workflows in one weekend. The order that minimises risk:
- Push-to-deploy first. Get every deploy tied to a Git SHA. This alone eliminates 90% of the
what changed?
incidents. - Add the build pipeline. Stop committing
vendor/. Faster uploads, cleaner diffs. - Move post-deploy steps into SSH commands. Migrations, cache, FPM reload — automate the parts you currently SSH in and run by hand.
- Switch to atomic releases once you have a staging environment to test the symlink layout against. This is the step that needs an Nginx/Apache config change.
- Promote dev → staging → production workflow last. By this point you already have the building blocks; you're just adding more servers to the same project.
Each step is independently useful, so you can stop at any point without leaving the project in a half-finished state.
Try DeployHQ for your PHP app
DeployHQ has been deploying PHP applications since 2009 — Laravel, Symfony, WordPress, Drupal, Magento, custom CMSes, you name it. If you'd like to wire up your project, sign up for a free DeployHQ account and connect your first repository in about ten minutes. Compare plans on the DeployHQ pricing page, or read the end-to-end PHP deployment walkthrough for a full project setup.
Questions, edge cases, or migration help? Reach out at support@deployhq.com or @deployhq on X — we read everything.