DEV Community

ahmet gedik
ahmet gedik

Posted on

Multi-Site Architecture with a Shared PHP Codebase

Running multiple sites from a single codebase sounds like a maintenance nightmare, but with the right architecture it can be remarkably efficient. I run 4 production video discovery sites from the same PHP codebase, each targeting different geographic regions. Here's how the architecture works at DailyWatch.

The Problem

I wanted to target different video markets (US/Europe, Asia, etc.) with separate sites for SEO and branding purposes, but I didn't want to maintain 4 separate codebases.

The Solution: Config-Driven Differentiation

The codebase is identical across all sites. Differences are controlled entirely by per-site configuration files:

// .env file - unique per site, excluded from deployment
SITE_NAME=DailyWatch
SITE_URL=https://dailywatch.video
SITE_TAGLINE=Your daily dose of trending videos

# Regions to fetch (unique per site)
FETCH_REGIONS=US,GB,DE,FR,IN,BR,AU,CA
FIXED_REGIONS=US,GB

# Cron frequency
FETCH_INTERVAL=7200

# Database (local per site)
DB_PATH=data/videos.db
Enter fullscreen mode Exit fullscreen mode

Configuration Loading

class Config {
    private static array $config = [];

    public static function load(string $envPath = '.env'): void {
        if (!file_exists($envPath)) {
            throw new \RuntimeException("Config file not found: {$envPath}");
        }

        $lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

        foreach ($lines as $line) {
            if (str_starts_with(trim($line), '#')) continue;
            if (!str_contains($line, '=')) continue;

            [$key, $value] = explode('=', $line, 2);
            self::$config[trim($key)] = trim($value);
        }
    }

    public static function get(string $key, string $default = ''): string {
        return self::$config[$key] ?? getenv($key) ?: $default;
    }

    public static function getArray(string $key): array {
        $value = self::get($key);
        return $value ? array_map('trim', explode(',', $value)) : [];
    }

    public static function getBool(string $key, bool $default = false): bool {
        $value = self::get($key);
        if ($value === '') return $default;
        return in_array(strtolower($value), ['true', '1', 'yes', 'on']);
    }
}
Enter fullscreen mode Exit fullscreen mode

Deployment Architecture

Each site has its own hosting account with its own domain. The deploy script pushes the same code to all of them:

#!/bin/bash
# ops.sh deploy-all - Deploy to all sites in parallel

DEPLOY_HOSTS="deploy_hosts.conf"
EXCLUDE=".env,data/*,*.log,*.db"

while IFS='|' read -r host user pass path; do
    [[ "$host" =~ ^#.*$ ]] && continue  # Skip comments
    [[ -z "$host" ]] && continue

    echo "Deploying to ${host}..."
    lftp -u "${user},${pass}" "${host}" <<LFTP &
        set ssl:verify-certificate no
        mirror --reverse --verbose --ignore-time \
            --exclude-glob .env \
            --exclude-glob data/ \
            --exclude-glob *.log \
            . ${path}
        rm -rf ${path}/lscache
        quit
LFTP

done < "$DEPLOY_HOSTS"

wait
echo "All deployments complete"
Enter fullscreen mode Exit fullscreen mode

What Gets Shared vs. What's Per-Site

Shared (deployed) Per-site (excluded)
All PHP source code .env configuration
Templates and CSS data/ directory
Public assets SQLite databases
.htaccess API keys file
composer.json Log files

Handling Site-Specific Branding

Templates use config values for all site-specific text:

// In templates
<title><?= Config::get('SITE_NAME') ?> - <?= Config::get('SITE_TAGLINE') ?></title>
<link rel="canonical" href="<?= Config::get('SITE_URL') . $_SERVER['REQUEST_URI'] ?>">

<footer>
    <p>&copy; 2026 <?= Config::get('SITE_NAME') ?>. All rights reserved.</p>
</footer>
Enter fullscreen mode Exit fullscreen mode

Database Per Site

Each site maintains its own SQLite database, which means:

  • Different content based on different region configurations
  • Independent FTS indexes
  • No cross-site data dependencies
  • Easy backup (just copy the .db file)
// Database path from config
$dbPath = Config::get('DB_PATH', 'data/videos.db');
$db = new PDO("sqlite:{$dbPath}");
Enter fullscreen mode Exit fullscreen mode

Operational Considerations

Running 4 sites from one codebase means:

  1. A bug affects all sites. Test thoroughly before deploying.
  2. A fix reaches all sites. One deploy command updates everything.
  3. Feature flags let you roll out changes selectively:
// Feature flags in .env
ENABLE_BLOG=true
ENABLE_SEARCH_SUGGESTIONS=false

// In code
if (Config::getBool('ENABLE_BLOG')) {
    $router->addRoute('/blog', BlogController::class);
}
Enter fullscreen mode Exit fullscreen mode

This architecture powers all 4 DailyWatch sites from the same codebase. The total operational overhead is minimal — one deploy command, 4 cron configurations, and 4 .env files. Everything else is shared.

See the main site at dailywatch.video.

Top comments (0)