Introduction
Most Laravel deployment procedures assume one of two extremes:
- full root access on a dedicated server (and run
composer installdirectly on the box) - cPanel-style shared host where you shuffle a
public/folder intopublic_htmland call it directly.
Neither model fits a workspace that hosts multiple in-flight branches of the same Laravel 11/12 application and needs to switch the live code version without restarting Apache or moving gigabytes of vendor/.
In complex deployment environments, specifically staging servers where multiple versions or "blue/green" instances of a Laravel application coexist, managing the public web root can become a bottleneck. This article explores a custom implementation used in this project to dynamically reconfigure the file system at runtime, allowing a single web entry point to switch between different backend application cores (private vs private2) using PHP-driven symlink orchestration.
This article describes other deployment topology where the application is split into a private core (above the web root) and a web entry point (the only thing the web server can see), connected by a small PHP orchestrator that rewires three symlinks during deployment of new version of the application. The result is a system where switching the running version of the application is a one-line constant change in a single file.
Prerequisites
Prerequisites for using the method described below:
- Linux server with PHP 8.2+ and SSH/bash access
- Usage of SSH access and execution of scripts (mostly Bash) on Linux webhosting
- The ability to place one directory (the private core) above the web server's document root. On a shared host that means one level above
public_html. - Separation of Laravel file structure into two parts - public access (web) from private access (core)
- Connecting key parts of the application using symbolic links
Note: The development setup for this workflow is a Debian 13 host with two Apache virtual servers (port 81 for development, port 82 for staging) running under server folder /var/www. The Development was carried out on Windows 11 Pro, originally using WSL2 and Docker Desktop, but after the consistent occurrence of problems with desktop stability on Windows (Docker Desktop from ver.4.40), development was switched to Oracle Virtual Box (ver.7.2.6) with SSH access to the Debian 13 server. Switching to Virtual Box proved to be extremely suitable for development using AI tools, for full separation of the server file space from other parts installed under Windows.
The Separation Model: Private vs Web
Every deployment workspace follows the same layout:
/var/www/<workspace>/ ├── private/ ← Application Core (Outside Web Root) │ ├── app/ │ ├── bootstrap/ │ ├── config/ │ ├── database/ │ ├── public/ ← Laravel's own public/ (used as symlink target) │ ├── resources/ │ ├── routes/ │ ├── storage/ │ ├── vendor/ │ ├── build/ ← Vite Asset Manifest & Bundles │ └── ... ├── private2/ ← A second full copy for app version switching │ (folder structure same as in `private` folder) │ ├── web/ ← Apache DocumentRoot (public_html) │ ├── index.php ← Modified entry point (with app version switch) │ ├── symlinksSetup.php ← Symlink Orchestrator │ ├── .htaccess │ ├── storage → '../upload/' (symlink, created by orchestrator) │ ├── build → '../private/build/' (symlink, created by orchestrator) │ └── ... └── upload/ ← Shared storage target (used for media assets)
During a development Vite emits its production bundle to public/build/. For deployment is a production bundle moved to private/build/ (next to the source code it belongs to 'private or private2'). The orchestrator mirrors that link into web/build so Blade views can resolve @vite() references normally.
Transformation script
Before anything can be switched at runtime, the private and web directories have to be populated.
Filtering loop
The deploy script walks the Laravel root and copies only what survives the filter rejected_dirs.conf:
# rejected_dirs.conf .github .vscode docs node_modules tests
This is the simplest way to keep dev tooling (
docs/,tests/,node_modules/) out of the deployable artefact without writing per-file exceptions.
REJECTED_DIRS=( .github .vscode docs node_modules tests )
for dir in "$LARAVEL_ROOT"/*/; do
dirname=$(basename "$dir")
rejected=false
for rejected_dir in "${REJECTED_DIRS[@]}"; do
if [[ "$dirname" == "$rejected_dir" ]]; then
rejected=true
break
fi
done
if ! $rejected; then
cp -r "$dir" "deploy/private/$dirname"
fi
done
Vite output handling
Laravel folder public/build/ contains the hashed, content-addressed bundles produced by npm run build.
The script moves (not copies) public/build/ into deploy/private/build/ — the same artefacts, now sitting next to the application core.
Production environment injection
The script also layers overrides from a root_files/ tree:
root_files/private_files/→ merged intodeploy/private/root_files/web_files/→ merged intodeploy/web/
This is how a deployment target gets its own .env, custom .htaccess, and the modified index.php that knows about APP_SWITCH — without polluting the source repository.
Timestamped archives
The final step produces two archives per environment:
TIMESTAMP=$(date +'%y%m%d')
tar -czf "private_${env_name}_${TIMESTAMP}.tar.gz" -C deploy private
tar -czf "web_${env_name}_${TIMESTAMP}.tar.gz" -C deploy web
These archives can be used on staging or production server with extraction into the target workspace with extract.sh script.
The Runtime Orchestrator: symlinksSetup.php
This is the code that turns a static private//private2/ pair into a switchable runtime. The version that ships as a test fixture in this repository lives at web/symlinksSetup.php on the host.
Three responsibilities
The function performs three jobs in order:
- Validate the request — refuse to do anything if
PRIVATE_CHANGEis not set in.envor if the requested switch value isn't recognised. - Map the requested instance (
privateorprivate2) to three concrete symlink definitions:web,storage, andbuild. - Reconcile the filesystem — if any of the three links is missing or points to the wrong target, tear down all six (the three for each instance) and rebuild the three for the active one.
Validation
$envVars = parse_ini_file($envFile);
$privateChange = $envVars['PRIVATE_CHANGE'] ?? false;
if (! $privateChange || ! in_array($appSwitch, ['private', 'private2'])) {
return;
}
The PRIVATE_CHANGE flag is the kill switch for the orchestrator. On a production site where you do not want PHP ever rewriting the filesystem, the flag stays off and the function returns immediately. On staging it is set to true, which is the entire configuration the operator needs to make the switching mechanism live.
Configuration map
$symlinkConfigs = [
'private' => [
'web' => ['link' => $basePath.'/private/public', 'target' => $basePath.'/web'],
'storage' => ['link' => $basePath.'/web/storage', 'target' => $basePath.'/upload'],
'build' => ['link' => $basePath.'/web/build', 'target' => $basePath.'/private/build'],
],
'private2' => [
'web' => ['link' => $basePath.'/private2/public', 'target' => $basePath.'/web'],
'storage' => ['link' => $basePath.'/web/storage', 'target' => $basePath.'/upload'],
'build' => ['link' => $basePath.'/web/build', 'target' => $basePath.'/private2/build'],
],
];
The pattern is uniform across instances: only the target of web and build changes; storage always points at the shared upload/ directory so user uploads survive a version switch.
Idempotent reconciliation
$webExists = is_link($cfg['web']['link']) && readlink($cfg['web']['link']) === $cfg['web']['target'];
$storageExists = is_link($cfg['storage']['link']) && readlink($cfg['storage']['link']) === $cfg['storage']['target'];
$buildExists = is_link($cfg['build']['link']) && readlink($cfg['build']['link']) === $cfg['build']['target'];
if ($webExists && $storageExists && $buildExists) {
return; // already in the desired state
}
// otherwise: unlink all six candidate links, then symlink the three we want
The idempotency check is important. Without it, every request would pay the cost of three unlink + symlink pairs and would briefly have no symlinks at all. With it, the steady-state path is just three lstat reads and a return.
When a switch is actually required, the function unlinks the three candidate links for both instances before creating the three it needs. That ordering matters: because web/build and web/storage are nested paths, any stale link from the inactive instance is removed first so we never create a symlink whose parent has already been claimed by the wrong target.
The Bootstrap Chain in index.php
The orchestrator is only useful if it runs before the Laravel kernel touches the filesystem. The entry point is the customised web/index.php:
define('LARAVEL_START', microtime(true));
require __DIR__.'/symlinksSetup.php';
const APP_SWITCH = 'private'; // Change this to 'private' or 'private2' as needed
symlinksSetup(APP_SWITCH);
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../'.APP_SWITCH.'/storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../'.APP_SWITCH.'/vendor/autoload.php';
// Bootstrap Laravel and handle the request...
(require_once __DIR__.'/../'.APP_SWITCH.'/bootstrap/app.php')
->handleRequest(Request::capture());
All three follow-up paths use APP_SWITCH. The maintenance check, the autoloader, and the bootstrap path all interpolate the constant. That uniformity is what makes the single-line switch effective: changing APP_SWITCH from 'private' to 'private2' rewires every subsequent file access with no other edits.
Summary
| Aspect | Standard shared-host deploy | This workspace's deploy |
|---|---|---|
| Layout | public_html/<laravel-public-contents> |
web/ + private/ (above web root) |
| Switching version | Re-upload files, restart Apache | Edit APP_SWITCH in web/index.php |
| Vendor dir | Often re-installed on host | Shipped as a tarball from dev |
storage/app/public |
Symlink created once via artisan storage:link |
Re-pointed by orchestrator to a shared upload/ |
build/ (Vite output) |
Lives inside public/ |
Lives inside private/build/, symlinked into web/build |
| Active instance visible? | No | Yes — admin dashboard renders APP_SWITCH |
| Required server capability | cPanel File Manager | SSH + ability to set ownership |
The orchestration is small on purpose: one PHP function, one .env flag, one constant. That is the entire surface area that turns a shared-host-friendly Laravel layout into a runtime-switchable blue/green workspace.