DEV Community

Cover image for Running parallel Claude Code agents on Laravel with Orca: my setup and archive scripts
Baptiste Bouillot
Baptiste Bouillot

Posted on

Running parallel Claude Code agents on Laravel with Orca: my setup and archive scripts

Running parallel Claude Code agents on Laravel with Orca: my setup and archive scripts

David Hemphill wrote a great post about getting started with Conductor using Laravel — how to wire up per-worktree setup and archive scripts so each parallel agent gets its own clean environment. I run the same idea, but with Orca instead of Conductor. This is my take, with the two scripts I actually use.

The problem: agents stepping on each other

The moment you run more than one coding agent at a time, they start fighting over shared state. Two agents against the same database means one's migrate:fresh --seed wipes the other's work mid-task. Two Laravel apps on the same local domain, the same queue, the same Reverb port — collisions everywhere.

Git worktrees fix the files part: each branch gets its own directory on disk. But a Laravel app isn't just files. It's a database, a signed local domain, a websocket port, a built frontend. If you don't isolate those too, "parallel" is a lie.

What Orca gives you

Orca is an agent development environment: it runs Claude Code, Codex, Gemini and other CLI agents in parallel, each in its own real git worktree. One worktree = one branch = one workspace. You review the diff, ship a PR, then archive the worktree in one click.

The part that makes it usable for Laravel is two lifecycle hooks in the repository settings:

  • a Setup Script that runs when a worktree is created, and
  • an Archive Script that runs right before a worktree is destroyed.

Inside those scripts Orca hands you two environment variables I lean on heavily:

  • ORCA_ROOT_PATH — the path to the main repo (the "real" checkout, where my root .env lives).
  • ORCA_WORKSPACE_NAME — the name of the current workspace/branch.

Both of my scripts fall back to plain git if those aren't set, so the exact same file also works when I run it by hand outside Orca.

The principle: one SQLite file and one Herd site per worktree

My whole convention fits in one sentence: inside a worktree, the database is always an isolated SQLite file, never the project's shared database. A local file that gets created with the worktree and deleted with it. No seed collisions between parallel agents, ever.

Local serving is Laravel Herd, one isolated, HTTPS-secured site per worktree. So agent A works on myapp-feature-invoices.test while agent B is on myapp-fix-webhooks.test, each on its own PHP version, its own database, its own everything.

The setup script

This runs on worktree creation. I keep the real thing in ~/.scripts/laravel/worktree-setup.sh and reference it from each project with a tiny shim (more on that at the end). Here's what matters.

Find the main repo, copy its .env:

if [ -n "${ORCA_ROOT_PATH:-}" ]; then
    main_root="$ORCA_ROOT_PATH"
else
    main_root="$(dirname "$(cd "$(git rev-parse --git-common-dir)" && pwd)")"
fi

if [ -f "$main_root/.env" ]; then
    cp "$main_root/.env" .env
else
    cp .env.example .env
fi
set -a && source .env && set +a
Enter fullscreen mode Exit fullscreen mode

The root .env holds my real secrets and API keys, so every worktree inherits a working config with zero manual copy-paste. set -a exports everything so I can use those values later in the script.

Build a unique, cert-safe site name:

workspace="${ORCA_WORKSPACE_NAME:-$(git rev-parse --abbrev-ref HEAD)}"
workspace_slug="$(echo "$workspace" | sed 's#[^a-zA-Z0-9]#-#g' | tr '[:upper:]' '[:lower:]')"
repo="$(basename "$main_root")"
site_name="${repo}-${workspace_slug}"
max_site_len=58
if [ "${#site_name}" -gt "$max_site_len" ]; then
    ws_hash="$(printf '%s' "$workspace" | cksum | awk '{print $1}')"
    keep=$(( max_site_len - ${#ws_hash} - 1 ))
    site_name="$(printf '%s' "$site_name" | cut -c1-"$keep")-${ws_hash}"
fi
Enter fullscreen mode Exit fullscreen mode

That 58-character cap is a gotcha I learned the hard way: the site name becomes the Common Name of the TLS certificate Herd generates, and a CN longer than 64 characters is invalid. Long branch names (feature/some-very-descriptive-thing) blow past that, so past the limit I truncate and append a short cksum hash to keep it unique.

Point .env at the isolated SQLite DB and neutralize mail:

sqlite_path="$(pwd)/database/database.sqlite"
set_env "APP_URL"       "https://${site_name}.test"
set_env "DB_CONNECTION" "sqlite"
set_env "DB_DATABASE"   "${sqlite_path}"
set_env "MAIL_MAILER"   "log"
Enter fullscreen mode Exit fullscreen mode

(set_env is a small helper that updates a key if present or appends it — BSD sed, since this is macOS.) Mail goes to the log so an agent can't accidentally send real email from a workspace.

A deterministic Reverb port, only if the project uses Reverb:

if [ -f config/reverb.php ] || grep -q '"laravel/reverb"' composer.json 2>/dev/null; then
    hash_val="$(echo -n "$site_name" | cksum | awk '{print $1}')"
    reverb_port=$((8100 + (hash_val % 100)))
    set_env "REVERB_SERVER_PORT" "${reverb_port}"
    set_env "REVERB_PORT"        "${reverb_port}"
    set_env "VITE_REVERB_PORT"   "${reverb_port}"
fi
Enter fullscreen mode Exit fullscreen mode

Two worktrees can't both bind 8080. Deriving the port from a hash of the site name gives each worktree a stable, collision-resistant port without me tracking anything.

Link the site to Herd, isolate the PHP version, secure it:

php_ver="$(grep -oE '"php"[^,}]*' composer.json 2>/dev/null | grep -oE '8\.[0-9]+' | head -1 || true)"

herd link "$site_name"
if [ -n "$php_ver" ]; then
    herd isolate "$php_ver" --site="$site_name" || true
fi
herd secure "$site_name"
herd restart
Enter fullscreen mode Exit fullscreen mode

The PHP version is read straight from composer.json, so a legacy project on 8.2 and a new one on 8.4 each get the right runtime automatically.

Fresh database, dependencies, and a built frontend:

mkdir -p "$(dirname "$sqlite_path")"
rm -f "$sqlite_path"
touch "$sqlite_path"

# copy git-ignored MCP config so the agent has its tools
[ -f "$main_root/.mcp.json" ] && cp "$main_root/.mcp.json" .mcp.json

herd composer install --no-interaction --prefer-dist --optimize-autoloader
php artisan config:clear
php artisan key:generate --force --no-interaction
php artisan migrate:fresh --seed --force
php artisan storage:link 2>/dev/null || true

if [ -f yarn.lock ]; then
    yarn && yarn build
elif [ -f pnpm-lock.yaml ]; then
    pnpm install && pnpm build
elif [ -f package.json ]; then
    npm install && npm run build
fi
Enter fullscreen mode Exit fullscreen mode

A couple of details I care about: I copy the git-ignored .mcp.json from the root so the agent inherits my MCP servers, and I detect the frontend package manager from the lockfile instead of hardcoding npm. I also gate private Composer auth (Flux Pro) behind "is it actually a dependency and are the creds present" so the script stays generic across projects.

By the time this finishes, the agent opens onto a fully working https://myapp-feature-x.test with its own seeded database. Zero manual setup.

The archive script

This runs before Orca destroys the worktree. Its whole job is to undo what setup did — and to never block the deletion, so every command is best-effort with || true.

#!/usr/bin/env bash
set -uo pipefail

if [ -n "${ORCA_ROOT_PATH:-}" ]; then
    main_root="$ORCA_ROOT_PATH"
else
    main_root="$(dirname "$(cd "$(git rev-parse --git-common-dir)" && pwd)")"
fi

# same site name derivation as setup
workspace="${ORCA_WORKSPACE_NAME:-$(git rev-parse --abbrev-ref HEAD)}"
workspace_slug="$(echo "$workspace" | sed 's#[^a-zA-Z0-9]#-#g' | tr '[:upper:]' '[:lower:]')"
repo="$(basename "$main_root")"
site_name="${repo}-${workspace_slug}"
# (same 58-char truncation as setup)

if [ -z "$workspace_slug" ]; then
    echo "⚠️  Empty workspace name, cleanup aborted for safety."
    exit 0
fi

herd unsecure "$site_name"  || true
herd unisolate "$site_name" || true
herd unlink "$site_name"    || true
rm -f "$(pwd)/database/database.sqlite" || true
herd restart || true
Enter fullscreen mode Exit fullscreen mode

It rebuilds the exact same site_name with identical logic, then tears the Herd site down (unsecure → unisolate → unlink), deletes the SQLite file, and restarts Herd to purge the config. The empty-name guard matters: if the branch name ever resolves to nothing, I'd rather abort than run herd unlink "" and nuke something I didn't mean to.

The one tip that makes this maintainable

I don't paste these scripts into every project. The source of truth lives once in ~/.scripts/laravel/, and each repo carries a two-line shim:

# scripts/worktree-setup.sh
#!/usr/bin/env bash
exec "$HOME/.scripts/laravel/worktree-setup.sh" "$@"
Enter fullscreen mode Exit fullscreen mode

In Orca's repository settings I point the Setup Script at scripts/worktree-setup.sh and the Archive Script at scripts/worktree-archive.sh. Improve the shared script once and every project — and every future worktree — gets the fix. The scripts auto-detect PHP version, frontend manager, Reverb and Flux, so the same file really does work everywhere.

Give it a try

If you're running agents in parallel on Laravel, the isolation is the whole game: one SQLite file and one Herd site per worktree, created on setup and torn down on archive. Once that's in place, spinning up a third or fourth agent costs nothing, and none of them can corrupt each other's state.

Credit where it's due — the pattern is straight out of David Hemphill's Conductor post; I just adapted it to Orca and my stack. If you've got a sharper version of any of these scripts, I'd genuinely like to see it.


Baptiste — freelance web dev for 20 years, building in Laravel/PHP and shipping small open-source tools.

Top comments (0)