laravel-worktrees maintained by synxs-ar
Laravel Worktrees
Persistent, isolated Laravel dev environments — "desks" — backed by git worktrees.
Run several branches of the same Laravel app at the same time, each with its own port, database, storage and encryption key, without them stepping on each other.
Think of your project as a workshop and each worktree as a numbered
desk (wt-desk-01, wt-desk-02, …). A desk is a permanent workbench: it
keeps its own vendor/, node_modules/, APP_KEY, database and storage. Mount
any branch or experiment on a desk, work on it, leave it — the bench stays put,
the work comes and goes.
php artisan wt:new # build the next desk
php artisan wt:up 1 # configure (first run) & serve it — PHP + Vite, free ports
Built for parallel coding agents
Modern AI coding agents make it easy to work on several features at once — until they're all editing the same checkout. They overwrite each other's files, can't hold separate branches, and fight over a single dev server and database.
laravel-worktrees gives each agent — or each feature — its own isolated
desk: a real git worktree with its own branch, ports, database and storage.
Point one agent at wt-desk-01, another at wt-desk-02, label each with the
feature it's building, and they code in parallel without ever stepping on
one another. It's the foundation for running many branches at the same time —
ideal for vibe coding at scale.
Why
Spinning up a second copy of a Laravel app for a parallel branch is more painful than it should be:
php artisan servealways wants port8000→ collisions.- Every copy points at the same database → data bleeds across branches.
- A fresh worktree has no
.env, novendor/, nonode_modules/, nostoragelink, and a stalevite.config.js→ manual setup every time. - Copy the
.envby hand and every desk shares oneAPP_KEY→ leaked sessions and encryption between environments.
laravel-worktrees automates all of it behind four commands, and lets you
decide — per desk, interactively — whether the database and storage are
isolated or shared with your main checkout.
Requirements
- PHP
^8.1 - Laravel
10,11or12 giton thePATH- For an isolated database: a reachable PostgreSQL (default) or MySQL server
Install
composer require --dev synxs-ar/laravel-worktrees
Optionally publish the config:
php artisan vendor:publish --tag=worktrees-config
Add the desk folders to your app's .gitignore:
/worktrees
/.wt-desks
Commands
| Command | What it does |
|---|---|
wt:new [--ref=HEAD] |
Provision the next desk (worktree + deps + env). Does not touch the database. |
wt:up {desk} [label] [--no-vite] [--reconfigure] |
Configure the desk on first run (database + storage), then serve PHP + Vite. An optional label is applied to APP_NAME. |
wt:label {desk} [label] [--clear] |
Set, show or clear a desk's display label. |
wt:list |
List every desk, its label and preferred-port status. |
wt:rm {desk} [--force] |
Tear a desk down (worktree + git metadata) and free its slot. |
{desk} accepts the full slug or the bare number — wt:up wt-desk-01 and
wt:up 1 are equivalent.
Labels
By default a desk tags its APP_NAME with the slug — MyApp [wt-desk-01] — so
you can tell environments apart in the browser, logs and mail. Give it a
human-friendly label to make that obvious at a glance:
php artisan wt:up 1 checkout-redesign # APP_NAME -> "MyApp [checkout-redesign]", then serves
php artisan wt:label 1 billing-fix # rename it any time (no serving)
php artisan wt:label 1 # show the current label
php artisan wt:label 1 --clear # back to "MyApp [wt-desk-01]"
Labels are remembered in the registry and re-applied on every wt:up.
How it works
1. wt:new — build the bench
✓ Creating git worktree git worktree add --detach worktrees/wt-desk-01
✓ Installing dependencies (composer) an isolated vendor/
✓ Installing node modules (npm) an isolated node_modules/ (if package.json exists)
✓ Materializing .env derived from your base .env, APP_NAME tagged [wt-desk-01]
✓ Bootstrapping local config (vite, …) copies gitignored *.example files (e.g. vite.config.js)
✓ Generating APP_KEY a UNIQUE key — sessions & encryption stay isolated
The worktree is detached — a desk is branch-agnostic. Check out whatever you want inside it; the desk's identity (port, database, storage) never moves.
2. wt:up — choose & serve
The first time you bring a desk up (or any time with --reconfigure) it asks
you two questions and remembers the answers:
? Database for this desk? [isolated / shared]
isolated → prompts host / port / user / password / name
offers to CREATE the database if it doesn't exist yet
runs your migrations against it
shared → uses the project's database from the base .env (never migrated)
? Storage for this desk? [isolated / shared]
shared → media in storage/app/public is shared with your main checkout
(logs, cache and sessions stay isolated)
isolated → the desk gets its own storage
After that (and on every later wt:up) it just serves, on dynamically
resolved free ports:
PHP http://127.0.0.1:18001
Vite http://127.0.0.1:27501
wt-desk-01 is up. Ctrl+C to stop.
Ctrl+C cleanly stops the whole process tree (PHP server and Vite/esbuild) —
no orphans left holding your files.
Smart ports
Each desk has preferred ports (base + desk number, e.g. PHP 18001, Vite
27501), but the preferred port is only a hint: wt:up probes for a real free
port at launch, so a port held by Docker or another desk never blocks you.
All ports stay above a configurable floor (default 10001) — on Windows
with Hyper-V / WSL2 / Docker the ranges below 10000 are reserved and throw error
10013 on bind.
Vite
If the desk has a package.json, wt:up runs npm run dev -- --port <free> --strictPort alongside the PHP server. The port is forced via the CLI (which
beats vite.config.js), so the project needs no config changes. Skip it with
--no-vite.
Database strategies
Chosen per desk at first wt:up:
- Isolated — a dedicated database (default driver
pgsql, ormysql), created automatically from your base credentials. If the server is up but the database doesn't exist, you're offered to create it. Then your migrations run against it. Real isolation, your data can't leak. - Shared — the desk uses your project's existing database from the base
.env. Migrations are never auto-run against a shared database.
The isolated database name defaults to {base}_{slug} (e.g.
myapp_wt_desk_01).
Why Postgres by default? PostgreSQL has full
ALTER TABLEsupport, so your existing migrations run unchanged. SQLite's limitedALTER TABLE(it can't drop a column referenced by a foreign key) breaks many real-world migration suites.
Storage strategies
Chosen per desk at first wt:up:
- Isolated — the desk has its own
storage/.storage:linkas usual. - Shared — the desk's
storage/app/publicis junctioned to your main checkout's, so uploaded media is shared. Logs, cache and sessions stay isolated.
Configuration
config/worktrees.php:
| Key | Env | Default | Notes |
|---|---|---|---|
host |
WORKTREES_HOST |
127.0.0.1 |
Bind host for serving & port probing. |
port_floor |
WORKTREES_PORT_FLOOR |
10001 |
No resolved port goes below this. |
php_base_port |
WORKTREES_PHP_BASE_PORT |
18000 |
Preferred PHP port = base + desk number. |
vite_base_port |
WORKTREES_VITE_BASE_PORT |
27500 |
Preferred Vite port = base + desk number. |
database.engine |
WORKTREES_DB_ENGINE |
pgsql |
Driver for an isolated database (pgsql / mysql). |
database.name |
WORKTREES_DB_NAME |
{base}_{slug} |
Isolated database name template. |
bootstrap_files |
— | vite.config.js ⇐ vite.config.example.js |
Gitignored files seeded from a committed example. |
registry_path |
— | .wt-desks/registry.json |
The "workshop blueprint" (keep out of git). |
worktrees_path |
— | worktrees |
Where desks are created. |
php / composer / npm |
WORKTREES_PHP / …COMPOSER / …NPM |
php / composer / npm |
Binaries used to drive desks. |
Isolation, the careful bits
- Unique
APP_KEYper desk, so sessions and encrypted payloads never cross. - Environment stripping — desk child processes are spawned by your main
app's artisan command and would otherwise inherit its
putenv()'d values (DB,APP_KEY,APP_URL); Laravel's immutable Dotenv then refuses to override them. Each desk-owned key is stripped from the inherited environment so the worktree.envalways wins. APP_URLis written into the desk.envto match the resolved port (php artisan serveforwards only a whitelist of env vars to itsphp -Sworker, so a runtime-injected value never arrives), and also injected into the Vite process (Node does inherit, so it must be overridden there).
Windows notes
Everything works on Windows out of the box:
storage:linkfalls back to a directory junction (mklink /J) when the symlink needs elevation it doesn't have.wt:rmremoves storage junctions as links (never following them into their target) and handlesnode_modulespaths that exceedMAX_PATH.Ctrl+Cterminates the full child process tree (taskkill /T).
License
MIT © Synxs