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](https://www.deployhq.com), 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](https://www.deployhq.com/blog/how-to-deploy-laravel-zero-downtime-build-pipelines-and-best-practices), [automating WordPress deployments from Git](https://www.deployhq.com/blog/automate-wordpress-deployments-with-git), and [encrypted env-vars with Dotenvx](https://www.deployhq.com/blog/how-to-deploy-php-applications-with-encrypted-environment-variables-using-dotenvx-and-deployhq). For background on the `.env` pattern itself, see [understanding .env files and environment variables](https://www.deployhq.com/blog/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](https://www.deployhq.com/features/automatic-deployments) to your server, with no human in the loop.

**How it works in DeployHQ:**

- **Connect your repo.** Use the dedicated [deploy-from-GitHub](https://www.deployhq.com/deploy-from-github) or [deploy-from-GitLab](https://www.deployhq.com/deploy-from-gitlab) integration, or any Bitbucket/SVN/Mercurial repo. [DeployHQ](https://www.deployhq.com) clones over HTTPS or deploy keys — you don't need to give it admin rights.
- **Configure a server.** Point [DeployHQ](https://www.deployhq.com) 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](https://www.deployhq.com/features/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-dev` strips 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-dist` pulls zipped releases instead of cloning each package's Git history — faster and smaller.
- `npm ci` is strictly correct for CI/CD: it deletes `node_modules`, installs from `package-lock.json`, and fails if the lockfile drifts. `npm install` mutates the lockfile and is the wrong choice here.

Then mark `vendor/` and `node_modules/` as **excluded files** in [DeployHQ](https://www.deployhq.com) 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](https://www.deployhq.com/blog/config-files) so it lives on the build container only, never in Git.

For the full production Composer flag set — autoloader optimisation, lockfile discipline in CI, audit gates, and private-package auth patterns — see our [Composer cheatsheet](https://www.deployhq.com/cheatsheets/composer).

### 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](https://www.deployhq.com/blog/essential-ssh-commands-in-php-frameworks-a-developer-s-guide) 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](https://www.deployhq.com) 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](https://www.deployhq.com/features/zero-downtime-deployments) work for PHP:**

1. The new release is uploaded into a fresh, timestamped directory like `releases/20260513-143000/`.
2. Composer, npm, and migrations all run inside that directory — the live site keeps serving the previous release.
3. Once everything succeeds, the `current` symlink is atomically flipped to point at the new directory. The `rename(2)` syscall is atomic on POSIX filesystems, so there is no observable in-between state.
4. 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 flipping `current`, FPM workers may still resolve `current/` to the _previous_ release's real path for up to `realpath_cache_ttl` seconds (default 120). A `php-fpm reload` clears it instantly.
- **OPcache and absolute paths** : if you've set `opcache.validate_root` or 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](https://www.deployhq.com/features/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](https://www.deployhq.com) 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 to `main` is the deploy trigger.
- **Per-environment config files.** Use DeployHQ's config-files feature to inject a different `.env` (or `app/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](https://www.deployhq.com/blog/managing-multiple-environments-with-deployhq-dev-staging-and-production) for a full walkthrough of the patterns described here.
- **Per-environment SSH commands.** Staging gets `php artisan migrate --force` on every deploy; production gates it behind a manual flag, or only runs it when a `migrations/` 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](https://www.deployhq.com) 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](https://www.deployhq.com/blog/deployhq-vs-deployer-a-comparative-analysis-of-automated-deployment-tools) 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:

1. **Push-to-deploy first.** Get every deploy tied to a Git SHA. This alone eliminates 90% of the what changed? incidents.
2. **Add the build pipeline.** Stop committing `vendor/`. Faster uploads, cleaner diffs.
3. **Move post-deploy steps into SSH commands.** Migrations, cache, FPM reload — automate the parts you currently SSH in and run by hand.
4. **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.
5. **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](https://www.deployhq.com) 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](https://www.deployhq.com/signup)[DeployHQ](https://www.deployhq.com) account and connect your first repository in about ten minutes. Compare plans on the [DeployHQ pricing page](https://www.deployhq.com/pricing), or read the [end-to-end PHP deployment walkthrough](https://www.deployhq.com/blog/deploying-your-php-site-with-deployhq) for a full project setup.

Questions, edge cases, or migration help? Reach out at [support@deployhq.com](mailto:support@deployhq.com) or [@deployhq on X](https://x.com/deployhq) — we read everything.

