Skip to main content
Laravel, shipping fast.
Chapter 18 · Shipping Templates to Live Sites

Install-Time Baking Beats Hand-Copied Files

Julian Beaujardin

Splitting into tiers only pays off if getting a new version of the package into an existing instance is actually cheap. The naive approach is to treat the package tier as a folder of files you diff and reapply by hand.

// Bad: reconciling a new template version by hand, one instance at a time
foreach (Site::pluck('repo_path') as $repoPath) {
    exec("cd {$repoPath} && git apply /patches/theme-v1.1.85.diff");
    exec("cd {$repoPath} && git add -A && git commit -m 'apply theme patch' && git push");
}

This works for three customers. It stops working at thirty: every instance drifts a little from every other one, someone edited a Blade file directly on one box during an incident, another instance is two versions behind because its patch conflicted and nobody finished it. Six months in, "upgrade the theme" means opening a dozen repos and reading diffs before you trust any of them. You're not shipping a version, you're doing archaeology.

The fix is to make the package install itself. webplo/templates ships as a Composer library whose service provider registers real Artisan commands, and the installer, not a human with a patch file, decides what gets copied where.

// src/TemplatesServiceProvider.php (Webplo\Templates)
public function boot(): void
{
    if ($this->app->runningInConsole()) {
        $this->commands([
            InstallWebploTheme::class,
            InstallEditableFiles::class,
            // ...
        ]);
    }
    // ...
}

InstallWebploTheme is the command that does the actual work. It resolves a theme name, checks the theme has the one file every theme is required to have before it touches anything, and copies files into the skeleton:

// src/Commands/InstallWebploTheme.php (Webplo\Templates\Commands)
public function handle(): int
{
    $theme = $this->argument('theme') ?? env('SITE_THEME', 'template-1');
    $themePath = __DIR__.'/../../resources/stubs/themes/'.$theme;

    if (! is_dir($themePath)) {
        $this->error("Theme '{$theme}' not found.");

        return self::FAILURE;
    }

    $viewsBase = is_dir($themePath.'/views') ? $themePath.'/views' : $themePath;
    $home = $viewsBase.'/pages/home.blade.php';

    if (! file_exists($home)) {
        $this->error("Theme '{$theme}' is missing pages/home.blade.php — aborting to avoid a broken install.");

        return self::FAILURE;
    }

    // ... copyFile()/copyDir() the theme's views into resource_path(), each
    // one skipping existing files unless --force was passed

    $this->call('webplo:install-editable', ['--force' => $this->option('force')]);

    $this->callSilent('cache:clear');
    $this->callSilent('config:clear');

    return self::SUCCESS;
}

Walk through what this buys you:

  • A required-file guard before any copy happens. The command checks for pages/home.blade.php and refuses to proceed without it. A malformed theme fails loud and early, instead of leaving a site half-installed.
  • A --force flag that defaults to safe. Copies skip anything that already exists unless --force is passed, so re-running the installer on an instance with local, uncommitted edits doesn't clobber them by accident.
  • A second command called from inside the first. webplo:install-editable is its own installer, and webplo:install simply calls it. The theme installer doesn't need to know how the editable system's files are laid out.
  • Cache clearing baked in, not left for whoever runs it to remember. cache:clear and config:clear run inside the command itself.

Here's the rule: the package should own the act of installing itself. The moment "upgrade a theme" means "bump a version constraint and run one command," upgrading thirty instances is thirty identical, boring, scriptable operations instead of thirty judgment calls about which patch applies cleanly to which slightly-different tree.