Skip to content

Parallel Claude Code Sessions in Laravel: A Copied .env Shares Your MySQL Database

Package

mozex/laravel-worktree

Create and tear down isolated Laravel Herd git worktrees, each with its own site URL and databases

17,519
downloads
4
stars
Read the Docs

I'd have a Claude Code session working in a project and want to get on with something else while it ran. A second session in the same checkout wasn't the answer: neither session would know the other was working, so they'd interfere with each other's work and end up reverting each other's changes. Running two test commands at once caused problems too, because both ran against one database.

Git worktrees fix the files: each session gets its own directory and branch. In a worktree with a copied .env, one migrate:fresh took the main checkout's users table in my test app from 1,000 rows to 0. I built mozex/laravel-worktree for this problem: one command gives each worktree its own database, test database, .env and Herd site. If you'd rather script it yourself, read the section on unsetting the parent's environment first, because the obvious Artisan command still emptied the main database in my test.

Everything here ran on a fresh Laravel 13.33 app with MySQL 8.4 and PHP 8.5, served by Laravel Herd 1.30 on Windows 11, with version 1.6.0 of the package (it supports Laravel 11 to 13, PHP 8.2+, MariaDB and PostgreSQL; WORKTREE_HERD=none skips Herd).

On MySQL, a copied .env shares the main database

Claude Code creates worktrees itself. claude --worktree feature-a checks out a new branch under .claude/worktrees/feature-a and starts a session there. A fresh checkout has no .env, so the Claude Code docs suggest a .worktreeinclude file that copies gitignored files like .env into every new worktree.

A copy is the problem: the worktree's .env still says DB_DATABASE=wtpost and APP_URL=https://wtpost.test, the same as the main checkout. On Laravel's default SQLite, an unset DB_DATABASE resolves to database/database.sqlite inside each checkout, so the worktree points at its own file, not the main checkout's.

I built the MySQL case by hand on a test app: git worktree add into .claude/worktrees/feature-a, the .env copied in, composer install, and 1,000 users in the main app's database. The app has a /whoami route that prints its checkout directory, database name, user count and APP_URL. Then I ran migrate:fresh inside the worktree:

$ curl -s https://wtpost.test/whoami
{"checkout":"wtpost","database":"wtpost","users":1000,"app_url":"https:\/\/wtpost.test"}

$ cd .claude/worktrees/feature-a && php artisan migrate:fresh --force

 Dropping all tables .. 76.34ms DONE
 ...

$ curl -s https://wtpost.test/whoami
{"checkout":"wtpost","database":"wtpost","users":0,"app_url":"https:\/\/wtpost.test"}

Nothing in the migrate:fresh output names the database it dropped.

The URL is shared as well. The worktree's APP_URL points at wtpost.test, which Herd serves from the main checkout, and the worktree itself isn't served at all: Herd serves parked paths and linked directories, and .claude/worktrees/ is neither. A session that opens the app in a browser to check its change is looking at the main checkout's code.

Two test runs, one test database

Tests collide the same way when phpunit.xml names a real database. I pointed the test app's suite at wtpost_testing: 302 tests, 300 of them using RefreshDatabase, which runs migrate:fresh before a run's first test. On its own the suite passed. Then I started it in the main checkout and in the worktree at the same moment, three times. Five of the six runs failed, with 2 to 55 errors per run, each one about a table that didn't exist or already existed:

SQLSTATE[42S02]: Base table or view not found: 1146 Table 'wtpost_testing.migrations' doesn't exist
SQLSTATE[42S01]: Base table or view already exists: 1050 Table 'users' already exists

Two runs inside one checkout need a different fix, which became mozex/laravel-test-lanes.

One command gives each worktree its own databases and Herd site

Cloning the project again for every feature would have meant creating its databases and linking a new Herd site each time. I found that frustrating and automated it. From the main checkout:

composer require mozex/laravel-worktree --dev
php artisan worktree:setup feature/login

worktree:setup creates the worktree next to the main checkout and links it in Herd, so wtpost on feature/login becomes wtpost-feature-login.test. It copies .env, rewrites the database name and the main host (wtpost.test, but not subdomains of it), and installs dependencies. Then it creates an application database and, when phpunit.xml points at a database server, a test database whose name it writes into that file. Last, it migrates and runs your build steps. On the test app it ended with this table (path shortened, borders removed):

  Path            .../scratch/wtpost-feature-login
  Branch          feature/login
  URL             https://wtpost-feature-login.test
  Database        wtpost_feature_login
  Test database   wtpost_feature_login_testing

The phpunit.xml change is marked skip-worktree in the worktree's own git index, so git status stays empty and the test database name never lands in a commit.

Then I repeated both experiments in the new worktree. migrate:fresh there left the main app's users table at the 1,594 rows I'd reseeded it with. The two suites, started together three more times, passed six runs out of six, the main checkout's on wtpost_testing and the worktree's on wtpost_feature_login_testing.

Three setups on the same app, with warm Composer and npm caches and the Herd step left out (more on that below), took 24.8 to 25.9 seconds, most of it composer install and npm ci. With dependency copying turned on (WORKTREE_COPY_VENDOR and WORKTREE_COPY_NODE_MODULES), vendor and node_modules are copied from the main checkout whenever the lock files match byte for byte, and three setups took 10.6 to 11.0 seconds.

Finishing a branch drops what setup created

worktree:teardown asks how you want to finish, or takes a flag:

Flag What happens before cleanup
--pr Commits pending changes, pushes, opens a pull request with gh
--into=main Merges the branch into main from the main checkout
--abandon --force Throws the branch away

Cleanup is the same each time: it drops the worktree's application and test databases, removes the Herd site and the worktree, and deletes the branch, except after --pr, where the pull request still needs it. It also drops the _test_1 and _test_lane1 style databases that php artisan test --parallel and Test Lanes create beside the test database. The names come from the worktree, not from the copied .env, and teardown refuses to drop a database that matches the main .env's DB_DATABASE. Six teardowns, also without Herd, took 7.5 to 7.9 seconds, and afterwards the only wtpost databases on the server were wtpost and wtpost_testing.

If you script it yourself, unset the parent's environment

This looks like a job for a small Artisan command: add the worktree, write its .env with a new DB_DATABASE, create that database, then run composer install and migrate:fresh inside the worktree with the Process facade. I wrote that command in the test app's routes/console.php:

Artisan::command('naive:worktree {branch}', function (string $branch) {
    $path = dirname(base_path())."/wtpost-{$branch}";
    $database = "wtpost_{$branch}";

    Process::run(['git', 'worktree', 'add', $path, '-b', $branch])->throw();

    $env = file_get_contents(base_path('.env'));
    file_put_contents("{$path}/.env", preg_replace('/^DB_DATABASE=.*$/m', "DB_DATABASE={$database}", $env));

    DB::statement("CREATE DATABASE IF NOT EXISTS `{$database}`");

    Process::path($path)->run(['composer', 'install'])->throw();
    Process::path($path)->run(['php', 'artisan', 'migrate:fresh', '--force'])->throw();
});

php artisan naive:worktree leak exited 0. The new .env said DB_DATABASE=wtpost_leak, wtpost_leak had no tables, and the main app's users table had gone from 1,594 rows to 0.

Three things line up to cause it. Laravel's environment loader (phpdotenv, set up in Env::getRepository()) writes the .env values into $_ENV, $_SERVER and, through putenv(), the process environment. Symfony's Process, which sits under Laravel's facade, builds a child's environment from $_ENV and getenv(), so the child starts with all of them. And phpdotenv, as Laravel sets it up, doesn't overwrite a variable that's already set, so the child php artisan keeps the parent's DB_DATABASE. matt-h reported this in 2022 as laravel/framework#44278, with a workaround; nunomaduro of the Laravel team replied that processes inherit the environment by default, and that false removes a variable, as the Process docs also say.

That issue's workaround is the fix. Unset every key of the parent's .env for the child:

$unset = array_fill_keys(array_keys(Dotenv::parse($env)), false);

Process::path($path)->env($unset)->run(['php', 'artisan', 'migrate:fresh', '--force'])->throw();

I reseeded the main table with 500 users and ran a copy of the command with use Dotenv\Dotenv; at the top and ->env($unset) on both calls. It migrated the new database and left all 500 in place. The package does the same for every command it runs inside a worktree.

On Windows, Herd creates a site link from an elevated PowerShell (Start-Process ... -Verb RunAs in its CLI; Herd's Windows changelog for 1.0.1 says "herd link now prompts for elevated permissions"). With Windows' default UAC settings, expect a prompt each time a worktree is linked. That's why my timings leave the Herd step out.

And the package doesn't know about your Redis prefix. Two worktrees with the same REDIS_PREFIX share Redis keys. After php artisan vendor:publish --tag=worktree-config, env.replace in config/worktree.php rewrites any key you list:

'replace' => [
    'REDIS_PREFIX' => '{value}{slug}_',
],

{value} is the key's current value and {slug} the worktree's name with underscores, so REDIS_PREFIX=wtpost_ in the main .env became wtpost_wtpost_feature_login_ in the worktree.

Starting a Claude Code session in the new worktree

--print-path sends progress to stderr and prints only the new path, so this creates the worktree and moves you into it:

cd "$(php artisan worktree:setup feature/login --print-path)"
claude

Claude Code's own --worktree flag, its subagent worktrees and the desktop app's parallel sessions still make plain worktrees and copy files through .worktreeinclude, so with .env in that file they share the main MySQL database. Start a session that needs its own database with the two lines above. I create my worktrees from a Warp tab button that runs the same setup-and-cd step in Git Bash on Windows (the config is in the docs); if Claude Code feels slow in Git Bash, I measured two fixes for that. The package also ships a Laravel Boost skill that gives Claude Code the package's commands and tells it to prefer them over setting up a worktree by hand.

What else do your parallel sessions trip over? If it's something the package should handle, I'd like to hear about it.

Common questions

Does a git worktree get its own database in Laravel?
Not on a database server. A worktree is a second checkout of the files. If you copy .env into it, as a Claude Code .worktreeinclude file does, it keeps the same DB_DATABASE and APP_URL, so on MySQL or PostgreSQL a migrate:fresh run in the worktree drops the main app's tables. On Laravel's default SQLite, an unset DB_DATABASE resolves to a file inside each checkout.
Can two Laravel test runs use the same test database at once?
Not safely with RefreshDatabase on a real database, because each run starts with migrate:fresh. Run at the same time from two checkouts of a test app, 5 of 6 runs failed with tables that didn't exist or already existed. Give each worktree its own test database in phpunit.xml. Within one checkout, mozex/laravel-test-lanes handles it.
Why does php artisan, started through Laravel's Process facade, use the parent app's database?
Laravel puts the parent's .env values into the process environment with putenv(), Symfony's Process passes them to the child, and phpdotenv as Laravel configures it doesn't overwrite a variable that is already set. Pass false for every key of the parent's .env with ->env().
How do I remove a Laravel worktree's databases when I'm done?
With mozex/laravel-worktree, php artisan worktree:teardown drops the worktree's application and test databases and the _test_ databases parallel runs made, removes the Herd site and the worktree, deletes the branch (except after --pr), and refuses to drop the main .env's DB_DATABASE.

Share this post

Share on X
Share on LinkedIn
Share on Reddit
Share on Hacker News
Copy link

Stay in the Loop

Get the latest posts delivered to your inbox - on your schedule.

No spam. Unsubscribe anytime.

Scroll to top