# My Zero-Downtime Deployment Setup for Laravel

**Author:** Mozex | **Published:** 2026-04-01 | **Tags:** Laravel, PHP, Tutorial, DevOps | **URL:** https://mozex.dev/blog/8-my-zero-downtime-deployment-setup-for-laravel

---


I've been deploying Laravel applications for over a decade. Started with custom bash scripts. Moved to Laravel Envoy. Then Laravel Envoyer, which worked great for a long time. Eventually moved away from it to Ploi.io so I could manage deployments and servers in one place.

Each tool taught me something the previous one didn't. And the biggest lesson wasn't about tools at all. It was about the small practices around deployment that separate "mostly works" from "actually works every time."

Zero-downtime deployment is widely available now. Most hosting panels and deployment services support it out of the box. But to get the most out of it, there are practices you need to get right. That's what this post covers.

<!--more-->

## How Zero-Downtime Deployment Works

If you already know this, skip ahead. For everyone else, here's the short version.

Traditional deployment updates files on your server while users are actively hitting it. For a moment, things can break. Assets don't match the code. Cached configs point to files that no longer exist. Database queries fail because the schema changed mid-request.

Zero-downtime deployment solves this with a directory structure:

```
/home/user/example.com/
├── current -> releases/20260331120000
├── releases/
│   ├── 20260331120000/   ← active release
│   ├── 20260330150000/   ← previous release
│   └── 20260329090000/   ← older release
└── storage/              ← shared across all releases
```

Each deployment creates a new directory under `releases/`. Your code goes there. Dependencies get installed. Everything gets built and configured. Only when it's all ready, the `current` symlink switches to point at the new release. The switchover is atomic. Users on the old release finish their requests normally. New requests hit the new code.

The `storage/` directory is shared between releases. Logs, uploaded files, disk-based cache: they persist across deployments because every release symlinks its own `storage/` directory to this shared location.

Simple concept. The complexity is in what happens between "code is ready" and "symlink switches."

## The Timing Problem

Here's where most deployments leave room for improvement, or introduce brief windows of breakage that nobody notices until they do.

When deploying a Laravel application, you need to run artisan commands. Cache your config. Cache your routes. Clear your application cache. Run migrations. The question nobody talks about enough: when do you run each of these?

I experimented with this more than I'd like to admit. The answer comes down to one distinction.

**Commands that create files inside the release directory run before activation.** Config caching, route caching, event caching: these create files inside `bootstrap/cache/` in your Laravel application. That directory lives inside the release, not in shared storage. If you run these after activation, there's a window where the application is live but uncached. Depending on your app, that means slower responses or outright errors if your config depends on cached values.

**Commands that affect the shared storage directory run after activation.** View caching writes compiled Blade templates to `storage/framework/views/`. Cache clearing wipes `storage/framework/cache/`. These operate on the shared directory, so running them before activation would affect the currently live release. You don't want to clear the production cache while users are still on the old code.

Get this ordering right and your deployments become predictable.

## Composer Scripts: Deployment Logic in Your App

Most deployment tools give you a text box to paste your commands. Works fine. But I don't like it.

Your deployment logic ends up living separately from your application. When you add a package that needs a cache command during deployment, you have to remember to also update your hosting panel. Set up a second server or switch providers, and you're recreating all those commands from memory.

My approach: define two composer scripts inside `composer.json`.

```json
{
    "scripts": {
        "deploy:before": [
            "@decrypt-as-main",
            "php artisan storage:link --force",
            "npm install",
            "npm run build",
            "php artisan modules:cache",
            "php artisan config:cache",
            "php artisan event:cache",
            "php artisan route:cache",
            "php artisan icons:cache",
            "php artisan filament:cache-components",
            "php artisan migrate --force",
            "php artisan sitemap:generate"
        ],
        "deploy:after": [
            "php artisan view:cache",
            "php artisan cache:clear",
            "php artisan horizon:terminate",
            "php artisan reverb:restart",
            "php artisan schedule-monitor:sync",
            "php artisan telegram:deploy",
            "php artisan telegram:restart-server"
        ]
    }
}
```

Now your deployment on any platform boils down to: install dependencies, run `composer deploy:before`, activate the release, run `composer deploy:after`. The details live in your codebase. Version-controlled. Portable.

A simple app might have five commands. A complex one with Horizon, Reverb, and custom integrations might have fifteen. The point is that the application itself declares what it needs for deployment.

## Walking Through the Commands

Not every command in those scripts is obvious. Here's the reasoning behind the order.

### Before Activation

**`@decrypt-as-main`** decrypts the encrypted environment file. I'll explain this in the next section. It runs first because everything else depends on having the correct `.env` in place.

**`php artisan storage:link --force`** creates the symlink from `public/storage` to the shared storage directory. The `--force` flag recreates it even if one exists. This needs to happen early because later commands might reference storage paths.

**`npm install` and `npm run build`** compile your frontend assets. They run before activation because the compiled files need to exist in the release directory before users start hitting it.

**The cache commands** (`modules:cache`, `config:cache`, `event:cache`, `route:cache`, `icons:cache`, `filament:cache-components`) all create static files inside the release directory's `bootstrap/cache/`. They run after the npm build because some depend on compiled assets or config values being resolved. These commands are specific to my stack. Your app probably won't use all of them. Add or remove based on what packages you use.

**`php artisan migrate --force`** runs database migrations. This is intentionally the last major step before activation.

Why last? Think about what happens if you put migrations at the top and then spend two minutes building npm assets. Your old code is running against the new database schema for those two minutes. New columns, renamed tables, dropped fields. The old code doesn't know about any of it. That's how you get 500 errors during a "zero-downtime" deployment.

By running migrations last, the window between "schema changed" and "new code is live" shrinks to seconds.

**`php artisan sitemap:generate`** comes after migrations. Sitemap generation queries the database for URLs, and if your migrations added new models or tables that feed into the sitemap, you need the updated schema first.

### After Activation

At this point, the symlink has switched. Your new code is live.

**`php artisan view:cache`** compiles all Blade views into the shared `storage/framework/views/` directory. Runs after activation because writing compiled views while the old release is still serving them would overwrite what it needs.

**`php artisan cache:clear`** clears the application cache in shared storage. Same reason: don't wipe the cache out from under the old release while it's still active.

**`php artisan horizon:terminate`** gracefully stops Horizon workers so they restart with the new code. **`php artisan reverb:restart`** does the same for WebSocket connections. **`php artisan schedule-monitor:sync`** updates the schedule monitor with any new or changed scheduled tasks.

**Custom commands come last.** In my case, `telegram:deploy` sends a deployment notification and `telegram:restart-server` restarts a local API container. Your app is fully deployed at this point. Add whatever you need: Slack notifications, webhook pings, cache warmers, anything. These don't hold up the deployment. They run after everything is done.

## Encrypted Environment Variables

This part is optional. If your Laravel version doesn't support `env:encrypt`, or you prefer managing environment variables through your hosting panel, skip ahead. Everything else in this post works without it.

That said, I think environment encryption is one of the best quality-of-life features for deployment workflows.

The problem is familiar. Your production `.env` has secrets: database passwords, API keys, encryption keys. Can't commit it to version control. So you manage it through a web UI or SSH into the server every time something changes. New service integration? Open the hosting panel. Rotate a key? SSH in. Forgot what value you set three months ago? Good luck.

Laravel lets you encrypt the entire `.env` file. The encrypted version is safe to commit because it's unreadable without the encryption key. During deployment, you decrypt it on the server.

Here's my setup. I keep a `.env.production` file locally, gitignored. My encryption key lives as a system environment variable called `LARAVEL_ENV_ENCRYPTION_KEY` on both my local machine and the server. Then I use these composer scripts:

```json
{
    "scripts": {
        "encrypt": [
            "bash -c 'php artisan env:encrypt --env=production --force --key=$LARAVEL_ENV_ENCRYPTION_KEY'"
        ],
        "decrypt": [
            "bash -c 'php artisan env:decrypt --env=production --force --key=$LARAVEL_ENV_ENCRYPTION_KEY'"
        ],
        "decrypt-as-main": [
            "@decrypt",
            "bash -c 'rm -f .env'",
            "bash -c 'cp .env.production .env'"
        ]
    }
}
```

The workflow:

1. Edit `.env.production` locally
2. Run `composer encrypt` to create `.env.production.encrypted`
3. Commit and push `.env.production.encrypted`
4. During deployment, `@decrypt-as-main` decrypts it on the server and copies it to `.env`

The decryption key is set as an environment variable on the server through your hosting panel or server config. The `decrypt` command reads that key and restores the original file.

What makes this worth the setup: your environment variables travel with your code. Deploy to a new server and the env is already there, ready to decrypt. No more recreating values from memory or pasting them into a hosting panel. Your commit history tells you when env changes were made, which is helpful when something breaks and you need to trace it back.

One thing to be aware of: the encrypted file is opaque. You can't `git diff` it and see which values changed. If you need to compare two versions, you'd check out both commits, decrypt each one, and diff the decrypted files. It's an extra step, but the tradeoff is worth it. Having a recoverable, version-controlled backup of your production environment beats managing it through a web UI.

## Fresh Clones Over Copies

Most zero-downtime deployment tools take a shortcut: they copy your last successful release directory, then run `git pull` to apply the changes. It's faster because you only download the diff, and the `vendor/` directory from the last deployment is already there.

I prefer starting fresh. Every deployment downloads the entire repository from scratch.

Slower? Yes. But a fresh download means every deployment starts from a clean, known state. No leftover files from previous releases. No cached artifacts that survived across deploys. No subtle corruption that built up over months. If your repository is the source of truth, your release should be an exact mirror of it.

This preference created a challenge when I moved to Ploi.io, which copies the last release by default. The server needed to download my code, but I didn't want to store SSH keys or GitHub credentials on the server beyond what's strictly necessary.

The solution: use the GitHub API to download a tarball of the exact commit being deployed.

```bash
wget -O- --header="Authorization: token $GITHUB_TOKEN" \
  "https://api.github.com/repos/user/repo/tarball/$COMMIT_HASH" \
  | tar -xz --strip-components=1
```

One HTTP request. Authenticated with a fine-grained GitHub personal access token scoped to read-only access on the specific repository. Downloads exactly the commit that triggered the deployment. No git clone needed, no SSH keys on the server, no stored credentials.

## My Ploi.io Setup

Everything above works on any hosting platform. Composer scripts, command ordering, environment encryption: none of it is Ploi-specific. But since I use Ploi.io for server management, here's how I wire it all together.

Ploi's zero-downtime deployment has three script sections:

![Ploi.io deployment interface showing three script phases: Pre deploy, Main deploy, and Post deploy](https://mozex.nbg1.your-objectstorage.com/posts/2026/03/UnavoRgrhx3Kf7DkUYKpRQ7ryyxYtYvpDYPllyyc.webp)

Instead of pasting commands into each section, I keep the scripts in a [GitHub repository](https://github.com/mozex/deploy-scripts) and load them with a single `curl` per section. One source of truth, and any update to the repository affects all my sites automatically.

### Pre Deploy Script

```bash
export LARAVEL_ENV_ENCRYPTION_KEY="base64:..."
export GITHUB_TOKEN="github_pat_..."
export RELEASE="{RELEASE}"
export REPOSITORY_USER="{REPOSITORY_USER}"
export REPOSITORY_NAME="{REPOSITORY_NAME}"
export COMMIT_HASH="{COMMIT_HASH}"
export RELOAD_PHP_FPM="{RELOAD_PHP_FPM}"

curl -fsSL https://raw.githubusercontent.com/mozex/deploy-scripts/main/01-pre.sh | bash
```

The variables in curly braces are automatically populated by Ploi. You only need to set `LARAVEL_ENV_ENCRYPTION_KEY` and `GITHUB_TOKEN` with your own values.

This script validates that all required environment variables are present, then initializes a minimal git repository in the release directory. That last part is a workaround: Ploi expects a git repo for its internal processes, but since we're downloading a fresh tarball instead of using git, we satisfy Ploi's check with an empty repo.

Here's what `01-pre.sh` does:

```bash
#!/bin/bash
set -e

cd "$RELEASE"

: "${RELEASE:?RELEASE not set}"
: "${REPOSITORY_USER:?REPOSITORY_USER not set}"
: "${REPOSITORY_NAME:?REPOSITORY_NAME not set}"
: "${COMMIT_HASH:?COMMIT_HASH not set}"
: "${GITHUB_TOKEN:?GITHUB_TOKEN not set}"
: "${LARAVEL_ENV_ENCRYPTION_KEY:?LARAVEL_ENV_ENCRYPTION_KEY not set}"

git init -b main -q
git remote add origin "https://github.com/${REPOSITORY_USER}/${REPOSITORY_NAME}.git"
```

The `: "${VAR:?message}"` lines are bash parameter validation. If any variable is missing, the script exits immediately with an error instead of silently failing halfway through deployment.

### Main Deploy Script

```bash
curl -fsSL https://raw.githubusercontent.com/mozex/deploy-scripts/main/02-main.sh | bash
```

Here's the full `02-main.sh`:

```bash
#!/bin/bash
set -e

cd "$RELEASE"

echo ""
echo "🏃  Starting deployment..."
shopt -s dotglob
rm -rf "$RELEASE"/*
shopt -u dotglob
wget --progress=dot:mega -O- --header="Authorization: token $GITHUB_TOKEN" \
  "https://api.github.com/repos/${REPOSITORY_USER}/${REPOSITORY_NAME}/tarball/${COMMIT_HASH}" \
  | tar -xz --strip-components=1

echo ""
echo "🔗  Linking Storage Directory..."
STORAGE_PATH="$(realpath ../..)/storage"
[ ! -d "$STORAGE_PATH" ] && [ -d storage ] && mv storage "$STORAGE_PATH"
rm -rf storage
ln -s "$STORAGE_PATH" storage

echo ""
echo "🚚  Running Composer..."
composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev

echo ""
echo "📦  Preparing For Activation..."
composer deploy:before

echo ""
echo "🚀 Activating App!"
```

This does the heavy lifting. It clears the release directory (including dotfiles with `shopt -s dotglob`), downloads a fresh tarball from the GitHub API, links the shared storage directory, runs `composer install --no-dev`, and executes `composer deploy:before`. When the script finishes, Ploi activates the release by swapping the symlink.

The storage linking logic is worth noting: if the shared storage directory doesn't exist yet (first deployment), it moves the repository's `storage/` directory to the shared location. On subsequent deployments, it just creates the symlink.

### Post Deploy Script

```bash
curl -fsSL https://raw.githubusercontent.com/mozex/deploy-scripts/main/03-post.sh | bash
```

Here's `03-post.sh`:

```bash
#!/bin/bash
set -e

cd "$RELEASE"

echo ""
echo "🔄  Reloading PHP-FPM..."
eval "$RELOAD_PHP_FPM"

echo ""
echo "🌅  Optimizing Activation..."
composer deploy:after
```

Reloads PHP-FPM and runs `composer deploy:after`. Short and simple.

The full scripts are at [github.com/mozex/deploy-scripts](https://github.com/mozex/deploy-scripts). Fork the repository and point your Ploi setup at your own copy. Or copy the contents directly into Ploi's text fields if you prefer.

One thing worth mentioning: loading deployment scripts from someone else's GitHub means those scripts run with full access on your production server. That's a serious trust decision. Always review the code, fork it to your own account, and use your fork's URL. The repository is public so you can read everything, verify it, and make it yours.