Laravel Herd + Git Worktrees
Run multiple branches of a Laravel project in parallel, each reachable at its own *.test URL through Laravel Herd, with isolated dependencies, env, database, and caches.
Mental Model
- A worktree is a sibling directory of the main checkout with its own
HEAD. Composer/Node deps,vendor/,node_modules/,storage/, andbootstrap/cache/are per-worktree: never share via symlink. - Herd's parked directory auto-serves every immediate subdirectory as
<dir>.test. Park the parent of all worktrees once and every new worktree gets a URL for free. .envis untracked, so it must be re-created in each worktree.APP_URL,DB_DATABASE, cache prefixes must differ per worktree to prevent cross-branch bleed.
When to Use This Skill
- User asks to create a worktree for a Laravel app served by Herd.
- User reports
vendor/, migrations, cache, or.envproblems after switching worktrees. - User wants per-branch URL like
myapp-feat.testwithout manual Nginx/Valet config. - User wants the same Laravel project on multiple PHP versions simultaneously.
Workflow
1. One-time setup
Park the parent directory that holds the main checkout (worktrees become siblings of the main checkout, so they land in the same parked dir):
cd ~/Code # parent of your laravel project
herd park
Verify:
herd parked
Each immediate subdir is now <name>.test. Adding a worktree at ~/Code/myapp-feat makes myapp-feat.test resolve immediately.
If parking the parent is not desired (other non-Laravel dirs live there), use explicit herd link per worktree instead: see references/herd-commands.md.
2. Create a new worktree
Use the helper script. It creates the worktree, copies .env, rewrites APP_URL + DB_DATABASE + cache prefixes, installs deps, generates app key, and runs migrations.
bash scripts/worktree-new.sh <branch-suffix> <base-branch> [--driver sqlite|mysql|pgsql]
Example:
bash scripts/worktree-new.sh feat-billing main --driver sqlite
Result: ../myapp-feat-billing/ exists, myapp-feat-billing.test works, isolated DB.
See scripts/worktree-new.sh for the exact transformations applied to .env.
3. Remove a worktree
bash scripts/worktree-rm.sh <branch-suffix> [--drop-db]
Runs git worktree remove, optionally drops the per-worktree DB, and herd unlinks if a manual link exists.
4. Switch PHP version per worktree (optional)
cd ../myapp-legacy
herd isolate php@8.1
Site uses 8.1; sibling worktrees keep their isolated or default version. herd isolated lists all overrides. herd unisolate reverts.
Per-Worktree Isolation Rules
| Concern | Rule |
|---|---|
vendor/ |
Always per-worktree. Never symlink. |
node_modules/ |
Always per-worktree. Never symlink. Use pnpm for fastest installs (content-addressable store). |
.env |
Copy from main, rewrite APP_URL, DB_DATABASE, CACHE_PREFIX, SESSION_COOKIE, REDIS_PREFIX. |
APP_KEY |
Run php artisan key:generate in each worktree. |
| Database | SQLite: per-worktree file. MySQL/Postgres: per-worktree DB name <app>_<suffix>. |
| Redis | Set unique REDIS_PREFIX (or different REDIS_DB index). |
storage/, bootstrap/cache/ |
Per-worktree. Run php artisan optimize:clear after install. |
storage/app/public symlink |
Re-run php artisan storage:link in the new worktree. |
Full rationale: references/isolation.md. DB strategy details (sharing seeds, schema dumps, fresh vs. migrate): references/db-strategies.md.
Common Pitfalls
- Sharing
vendor/via symlink: breaks when branches have different package versions; autoload class maps drift. - Forgetting
php artisan key:generate: encrypted cookies/sessions fail silently across worktrees that shareAPP_KEY. - Same
SESSION_COOKIEacross*.testsiblings: cookies leak between branches because they share the.testparent domain. Set a uniqueSESSION_COOKIEper worktree. - Same Redis DB without prefix: cache keys collide; one branch invalidates another's cache.
- Running
composer installagainst a stalecomposer.lockafter rebasing: always re-run install after switching base branches inside a worktree. - Herd not picking up new worktree: happens if the parent isn't parked or if the worktree directory name contains characters Herd rejects (stick to
[a-z0-9-]).
Quick Diagnostics
herd parked # confirm parent is parked
herd isolated # list per-site PHP overrides
herd which-php # PHP version Herd resolves for current dir
git worktree list # all worktrees + branches
ls -1 ../ | grep <app> # worktree dirs as Herd sees them
If a .test URL 404s: check herd parked includes the parent, dir name is lowercase, and php artisan route:list works inside the worktree.
Out of Scope
- Valet: different tool. Herd-only here.
- Linux Herd: paths and CLI differ; this skill assumes macOS Herd.
- Docker / Sail: covered by other workflows. Worktrees still work but routing is via Docker, not Herd.