定制 ldiebold/isolate 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

ldiebold/isolate

Composer 安装命令:

composer require ldiebold/isolate

包简介

Isolate one Laravel checkout's runtime footprint (ports, prefixes, database name) from sibling checkouts of the same app.

README 文档

README

Run many checkouts of the same Laravel app side by side without them stepping on each other.

When you run the same app from several git worktrees or working copies at once, every copy fights over the same runtime footprint: they all try to php artisan serve on port 8000, share one database, and write to the same Redis / queue / cache prefixes. You get "address already in use" errors and copies clobbering each other's data.

This collides hard with AI coding agents. A common workflow now is to fan several agents out in parallel — each on its own git worktree or workspace — so they can build, boot, and test independently. Without isolation they all grab port 8000 and the same database and trip over one another.

php artisan isolate fixes this with one idempotent command per worktree. It picks a free instance number n and derives the whole runtime footprint from it — ports (base + n), the database name, and Redis / Horizon prefixes (base + suffix(n)) — verifies nothing is already in use, writes the app's .env, and creates the per-instance database. Point each agent at its own checkout, run isolate once, and every agent gets a clean, isolated environment:

Worktree Command App URL Database Prefix
Agent A php artisan isolate http://localhost:8000 app app
Agent B php artisan isolate http://localhost:8001 app_1 app_1
Agent C php artisan isolate http://localhost:8002 app_2 app_2

(The Database and Prefix columns share a base here for illustration. In practice the database name derives from your default connection's configured database and the prefixes from isolate.name, so their bases can differ.)

At n = 0 no suffix is added, so a checkout returns to vanilla defaults. Everything is idempotent and lock-guarded, and every seam is an interface you can swap.

Installation

Requires PHP 8.2+ and Laravel 11, 12, or 13.

composer require ldiebold/isolate

The service provider is auto-discovered. A typical worktree / agent setup runs isolate right after creating the checkout:

git worktree add ../app-feature-x
cd ../app-feature-x
composer install
php artisan isolate            # claim a free instance number for this worktree

Publish the config if you want to customise the resource map:

php artisan vendor:publish --tag=isolate-config

Documentation

Commands

php artisan isolate              # auto-select the next free instance number
php artisan isolate --auto       # same as above
php artisan isolate --number=3   # use instance 3 explicitly
php artisan isolate --reset      # forced return to vanilla (instance 0)
php artisan isolate --migrate    # isolate, then run migrations against the new database
php artisan isolate --seed       # isolate, then migrate + seed
php artisan isolate --restart    # fire any registered restart hooks after applying

Inspect the current state and candidate numbers:

php artisan isolate:status            # current number + resolved ports/names
php artisan isolate:list              # candidate numbers and detected conflict reasons
php artisan isolate:list --limit=20   # inspect more candidates (default 10, capped at max_instances)

Tear down per-instance resources (the inverse of isolation): drops the database(s) and flushes the Redis keyspace(s):

php artisan isolate:teardown 3              # drop instance 3's database and flush its Redis keys (asks to confirm)
php artisan isolate:teardown 3 --force      # skip the confirmation prompt
php artisan isolate:teardown 3 --keep-redis # drop the database only; leave Redis keys in place
php artisan isolate:teardown --all          # tear down every existing instance except vanilla (0) and the active one
php artisan isolate:teardown 3 --dry-run    # show what would be torn down (with Redis key counts), change nothing

isolate:teardown drops the per-instance database(s) and flushes every key under the instance's keyspace prefixes (REDIS_PREFIX, HORIZON_PREFIX) across all configured Redis connections — pass --keep-redis to leave Redis alone. It never rewrites .envexcept when you tear down the active instance with --force, where it then resets .env to vanilla so the app is not left pointing at dropped resources (pass --keep-env to opt out). It refuses to drop vanilla (instance 0) and refuses the active instance unless you name it explicitly with --force; under --all the active instance is always protected. A missing database is reported rather than treated as an error and an empty keyspace is simply "nothing to flush", so re-runs are idempotent; a failed drop or an unreachable Redis degrades to a warning and the command still succeeds. To clean up other coupled resources, use the afterDatabaseDropped / afterPrefixFlushed hooks (below).

Running isolate with no flags behaves like --auto. Re-running is idempotent: a recorded ISOLATE_NUMBER is preferred, so the same checkout keeps its number, existing databases are reused, and the resolved .env values stay stable — exactly what you want when an agent re-runs its setup script.

How it works

Every resource derives its value from one shared instance number n:

Resource type Example Value at n
port SERVER_PORT base 8000 8000 + n
name REDIS_PREFIX the configured prefix + suffix(n)
name (db) DB_DATABASE base + suffix(n), normalized + created
derived APP_URL the existing URL with its port rewritten

Redis/Horizon prefixes are marked as keyspaces ('keyspace' => 'redis'), which fixed-width zero-pads their suffix (instance 7 → …07) so one instance's keys can never be matched by a scan for another (7 vs 70), and flags them to be flushed on isolate:teardown.

At n = 0 no suffix is added, so names return to their base values. Fresh auto-selection only chooses a number whose browser-facing ports avoid Chrome's restricted-port set and whose actual resources (ports, databases, Redis prefixes) are free. Explicit --number choices and a recorded ISOLATE_NUMBER are treated as intentional self-claims: they may warn about detected conflicts, but restricted-port filtering is not applied to them. There is no sibling-checkout discovery; conflicts are detected from real resource state.

Configuration

config/isolate.php is pure, cacheable data:

'name'              => null,    // null ⇒ Str::slug(config('app.name'))
'suffix_format'     => '_{n}',  // n = 0 ⇒ no suffix
'band_size'         => 100,
'max_instances'     => 50,      // valid n: 0..49
'lock_path'         => null,    // null ⇒ storage/framework/cache/isolate.lock
'env_path'          => null,    // null ⇒ base_path('.env') (point elsewhere for monorepos)
'env_example_path'  => null,    // null ⇒ base_path('.env.example')
'throw_on_conflict' => env('ISOLATE_THROW_ON_CONFLICT', false),
'restricted_ports'  => [ /* Chrome ERR_UNSAFE_PORT set */ ],
'resources'         => [ /* the map below */ ],

Each resource declares an active_when predicate so the default map self-activates only what is present: 'always', ['env' => 'KEY'], ['config' => 'path'], ['package' => 'vendor/name'], ['any' => [...]], ['all' => [...]]. The {default} token in a config path resolves to the default database connection.

['type' => 'port', 'env' => 'SERVER_PORT', 'base' => 8000, 'browser_facing' => true, 'active_when' => 'always'],
['type' => 'derived', 'env' => 'APP_URL', 'rewrite_port_of' => 'APP_URL', 'port_from' => 'SERVER_PORT', 'active_when' => 'always'],
['type' => 'port', 'env' => ['REVERB_SERVER_PORT', 'REVERB_PORT'], 'base' => 8100, 'browser_facing' => true, 'active_when' => ['package' => 'laravel/reverb']],
['type' => 'name', 'env' => 'DB_DATABASE', 'config' => 'database.connections.{default}.database', 'side_effect' => 'create_database', 'normalize' => 'database_identifier', 'active_when' => ['config' => 'database.connections.{default}.database']],
['type' => 'name', 'env' => 'REDIS_PREFIX',   'config' => 'database.redis.options.prefix', 'keyspace' => 'redis', 'active_when' => ['config' => 'database.redis.options.prefix']],
['type' => 'name', 'env' => 'HORIZON_PREFIX', 'config' => 'horizon.prefix', 'keyspace' => 'redis', 'active_when' => ['package' => 'laravel/horizon']],

Band spacing (read this before customising the map)

Because the same n is added to every port base, distinct bases never collide within one instance. band_size exists to stop cross-instance overlap (baseA + nₐ == baseB + n_b). The invariant validated on every run is:

port bases must be at least band_size apart and ≥ 1024 (unprivileged), and max_instances must be band_size.

max_instances is a count, so valid numbers are 0 .. max_instances - 1.

Vanilla Laravel's serve (8000) and Reverb (8080) are only 80 apart and would fail validation at band_size = 100. The shipped map therefore spaces Reverb at 8100. If you add resources, keep their bases ≥ band_size apart (or lower band_size).

Extending

Config stays pure data; anything involving closures or runtime objects is registered on the Isolate facade, typically from a service provider's boot():

use Ldiebold\Isolate\Facades\Isolate;

// Add a port resource (e.g. Vite's dev server). Pass an array of keys to write
// several env keys for the same port.
Isolate::port('VITE_PORT', 8200, ['browser_facing' => true]);

// Add a per-instance name resource (prefix, queue name, etc.).
Isolate::name('PULSE_PREFIX');

// Compute a derived env value at runtime (closure or class-string DerivedResolver).
Isolate::derive('PUSHER_APP_CLUSTER', fn (array $env, int $n) => 'eu-'.$n);

// Run a callback after the plan is applied, or after a database is created / dropped,
// or after a Redis keyspace is flushed (isolate:teardown).
Isolate::after(fn ($plan, $result) => /* ... */);
Isolate::afterDatabaseCreated(fn ($result, $plan) => /* ... */);
Isolate::afterDatabaseDropped(fn ($result, $plan) => /* ... */);  // isolate:teardown; $result is a DropResult
Isolate::afterPrefixFlushed(fn ($result, $plan) => /* ... */);    // isolate:teardown; $result is a FlushResult ($result->keyCount)

// Fire a restart hook with `isolate --restart` (closure OR cache-safe class-string).
Isolate::restartUsing(RestartHorizon::class);

For deeper customisation you can register a custom applier or collision detector, or override a raw resource definition:

Isolate::applier(MyFrontendApplier::class);
Isolate::collisionDetector(MyServiceCollisionDetector::class);
Isolate::resource('VITE_PORT', ['type' => 'port', 'env' => 'VITE_PORT', 'base' => 8200, 'active_when' => 'always']);

Most seams are interfaces with shipped defaults and test fakes: PortChecker, EnvWriter, and PackageDetector are resolved from the container and can be rebound directly. Register custom CollisionDetector and Applier implementations through the facade methods above. Database creation and locking are internal defaults today rather than container-swappable contracts.

Running programmatically

php artisan isolate is a thin wrapper over the Isolate service, so you can run the same flow from your own code — an agent orchestrator, a custom command, a test — and inspect the result:

use Ldiebold\Isolate\Facades\Isolate;
use Ldiebold\Isolate\IsolationRequest;

$result = Isolate::run(IsolationRequest::auto());   // or ::for(3) / ::reset()

$result->number;        // the chosen instance number
$result->plan->envMap;  // every env value that was written
$result->warnings;      // non-fatal conflict / degradation messages

Events

Isolate dispatches the following events you can listen for:

  • Ldiebold\Isolate\Events\IsolationApplied — after a plan is applied ($plan, $result).
  • Ldiebold\Isolate\Events\DatabaseCreated — after a per-instance database is created ($result, $plan).
  • Ldiebold\Isolate\Events\DatabaseDropped — after a per-instance database is dropped by isolate:teardown ($result, $plan). The $plan carries the instance's env map (e.g. REDIS_PREFIX), so listeners can clean up coupled resources.
  • Ldiebold\Isolate\Events\PrefixFlushed — after a per-instance Redis keyspace is flushed by isolate:teardown ($result, $plan); fired once per prefix where keys were removed, with $result->keyCount.

The Isolate::after(...), Isolate::afterDatabaseCreated(...), Isolate::afterDatabaseDropped(...) and Isolate::afterPrefixFlushed(...) callbacks fire alongside these.

Conflict policy

By default conflicted candidate numbers are skipped (so --auto finds the next free one) and explicit --number / --reset selections warn about detected conflicts but proceed. Set throw_on_conflict (or ISOLATE_THROW_ON_CONFLICT=true) to fail fast on confirmed conflicts. Unavailable probes (DB down, Redis absent, no lock) always degrade with a warning, never a crash.

Testing

composer test          # Pest
composer lint          # Pint + PHPStan

Postgres / MySQL database-creation tests are gated behind INTEGRATION_DB=1 (configure the server with the ISOLATE_PG_* / ISOLATE_MYSQL_* env vars). Everything else, including SQLite creation, runs without external services.

License

MIT.

统计信息

  • 总下载量: 0
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 2
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-06-30