Web Development Blog

Developer Remarks | Know & How

Laravel 11+ on a Hosting Server with Dynamic Application Version Switching

Created At: Updated At:

Introduction

Most Laravel deployment procedures assume one of two extremes:

  • full root access on a dedicated server (and run composer install directly on the box)
  • cPanel-style shared host where you shuffle a public/ folder into public_html and 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:

  1. Linux server with PHP 8.2+ and SSH/bash access
  2. Usage of SSH access and execution of scripts (mostly Bash) on Linux webhosting
  3. 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.
  4. Separation of Laravel file structure into two parts - public access (web) from private access (core)
  5. 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 into deploy/private/
  • root_files/web_files/ → merged into deploy/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:

  1. Validate the request — refuse to do anything if PRIVATE_CHANGE is not set in .env or if the requested switch value isn't recognised.
  2. Map the requested instance (private or private2) to three concrete symlink definitions: web, storage, and build.
  3. 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.

Random Posts

Import Obsidian Zip File From Laravel 11 Dashboard

Published At:

Introduction

Obsidian is a note-taking application suitable for creating a knowledge base for web application developers. Obsidian, especially when using many plugins, creates a very effective environment for writing, collecting, and searching for notes. If we want to publish these notes on a web blog, we need to solve the bridging between Obsidian and Laravel. One of the forms is to create a suitable export from Obsidian and solve the import into the resulting form when creating posts in...

Read More

Random Categories

Web Development - Web Development Blog
Web Development
Web blog development and deployment based on Laravel framework is not easy and requires other approach to application  structure and development. Laravel is not best suited for web blogs but the application was chosen here for practical evaluation and comparision.
Web Design - Web Development Blog
Web Design
Blog category dealing with Web structure, Web templates, Navigation, Elements, Typography, Media, Icons, Components, Forms, Responsive design and Dynamic content.
Open Source Software - Web Development Blog
Open Source Software
Remarks and notes to a practical usage of OSS for web frontend and backend. Many years of experience with Joomla, Drupal, Wordpress, Laravel, javascript, Vue.js opens very interesting views on development and deployment details.