An AI coding agent can open your repository, invent a migration class, rename a column in three models, and produce a green local test suite before lunch. That speed is useful. It is also the trap.
The dangerous output is not a broken SQL statement. The dangerous output is a tidy schema change that looks complete because php artisan migrate succeeded on a developer laptop. Production still has old queue workers, dual readers, partially backfilled rows, cached Eloquent attributes, and a rollback path that only exists as a chat transcript. The model guessed a destination schema. It did not own the compatibility contract that keeps live traffic safe while both shapes exist.
The question is not whether the agent demos well. The question is whether the change survives maintenance, handoff, and local constraints. If nobody can answer “what still works while old and new code run together,” the migration is not ready to merge.

Why agents keep collapsing five problems into one file
A human maintainer usually feels the seams. Renaming status to lifecycle_state is not one edit. It is at least five coupled problems:
| Problem | What can break | Evidence required before merge |
|---|---|---|
| Schema shape | Deploys, replicas, down() usefulness | Additive migration first; destructive steps deferred |
| Application writes | New API path writes one shape; old path writes another | Dual-write or single-writer boundary |
| Application reads | Old workers and new web processes disagree | Dual-read adapter with explicit preference order |
| Data movement | Historical rows still hold the old value | Backfill job with progress artifact and idempotency |
| Runtime topology | Queue workers keep old code and connection state | Restart/drain plan and a compatibility check |
An agent sees a repository and a prompt like “normalize invoice status naming.” That prompt rewards a single PR with one migration, model updates, factory updates, and a couple of feature tests. The PR looks coherent in Git. Operationally it can still delete the only safe intermediate state.
Martin Fowler’s Parallel Change pattern, also called expand and contract, exists exactly for this class of problem: break a backward-incompatible interface change into expand, migrate, and contract phases so clients can move deliberately. [Source: https://martinfowler.com/bliki/ParallelChange.html]
Prisma’s data guide states the same order for schema work: expand the schema, expand the interface, migrate data, then contract only after the new path has earned that right. [Source: https://www.prisma.io/dataguide/types/relational/expand-and-contract-pattern]
Use that order as the agent brief. Do not ask the model to “replace the column everywhere.” Ask it to stop after the expand step unless every compatibility check already has an artifact.
Give the agent a stop line, not a destination schema
The first control is the prompt boundary. A coding agent is good at drafting the expand migration and the dual-read helper. It is a poor owner of the contract phase because contraction depends on production evidence the repository cannot see.
A practical brief looks like this:
1Task: prepare an expand-only schema change for invoices.status -> lifecycle_state.
2
3Allowed work:
41. Additive migration that adds nullable lifecycle_state.
52. Dual-read helper that prefers lifecycle_state and falls back to status.
63. Dual-write helper used by the one approved writer path.
74. Feature tests for old-only rows, new-only rows, and dual-populated rows.
85. Backfill job stub with chunking and idempotent updates.
96. Pull request notes listing what is intentionally NOT done.
10
11Forbidden work:
12- dropColumn('status')
13- renameColumn('status', 'lifecycle_state')
14- mass-updating all readers/writers in one pass
15- claiming rollback is safe without a decision table
16
17Stop after expand + dual-read/write + tests. Do not implement the contract phase.
That stop line changes the review. Reviewers no longer argue with a 40-file “cleanup.” They check whether the expand artifacts are honest.
Laravel’s schema builder makes additive work easy: nullable() columns, separate renameColumn, and explicit dropColumn later. The existence of those APIs is not permission to compress them into one agent turn. [Source: https://laravel.com/docs/migrations]
Expand first: additive schema only
Here is an expand migration that an agent can draft safely. It adds a column. It does not rename or drop anything.
1<?php
2// database/migrations/2026_07_28_070000_add_lifecycle_state_to_invoices_table.php
3
4use Illuminate\Database\Migrations\Migration;
5use Illuminate\Database\Schema\Blueprint;
6use Illuminate\Support\Facades\Schema;
7
8return new class extends Migration
9{
10 public function up(): void
11 {
12 Schema::table('invoices', function (Blueprint $table) {
13 $table->string('lifecycle_state', 32)->nullable()->after('status');
14 $table->index('lifecycle_state');
15 });
16 }
17
18 public function down(): void
19 {
20 Schema::table('invoices', function (Blueprint $table) {
21 $table->dropIndex(['lifecycle_state']);
22 $table->dropColumn('lifecycle_state');
23 });
24 }
25};
Why this shape matters:
nullable()keeps old writers valid while new writers learn the field.down()only reverses the additive step. That is a real local recovery path for the expand batch.- No application behavior is forced by the schema alone. Behavior moves in later deploys.
Laravel documents that migrate:rollback rolls back the last batch, optionally by --step or --batch. That is useful for the expand file. It is not a substitute for operational recovery after a destructive contract step has already rewritten production data. [Source: https://laravel.com/docs/migrations]

Dual-read is the application contract the agent usually skips
Schema compatibility without read compatibility is theater. Old rows still have status. New rows may have lifecycle_state. Mixed rows appear during backfill. The application needs one boundary that understands all three states.
1<?php
2// app/Support/InvoiceLifecycle.php
3
4namespace App\Support;
5
6use App\Models\Invoice;
7use InvalidArgumentException;
8
9final class InvoiceLifecycle
10{
11 public static function read(Invoice $invoice): string
12 {
13 if (filled($invoice->lifecycle_state)) {
14 return (string) $invoice->lifecycle_state;
15 }
16
17 if (filled($invoice->status)) {
18 return self::mapLegacyStatus((string) $invoice->status);
19 }
20
21 throw new InvalidArgumentException("Invoice {$invoice->id} has no lifecycle value.");
22 }
23
24 public static function write(Invoice $invoice, string $state): void
25 {
26 $state = self::assertKnown($state);
27
28 // Dual-write during migrate phase. Contract phase removes the legacy field later.
29 $invoice->lifecycle_state = $state;
30 $invoice->status = self::toLegacyStatus($state);
31 }
32
33 private static function mapLegacyStatus(string $status): string
34 {
35 return match ($status) {
36 'open' => 'issued',
37 'paid' => 'settled',
38 'void' => 'cancelled',
39 default => throw new InvalidArgumentException("Unknown legacy status [{$status}]"),
40 };
41 }
42
43 private static function toLegacyStatus(string $state): string
44 {
45 return match ($state) {
46 'issued' => 'open',
47 'settled' => 'paid',
48 'cancelled' => 'void',
49 default => throw new InvalidArgumentException("Unknown lifecycle state [{$state}]"),
50 };
51 }
52
53 private static function assertKnown(string $state): string
54 {
55 self::toLegacyStatus($state);
56
57 return $state;
58 }
59}
This helper is deliberately boring. Boring is the point. An agent loves scattering lifecycle_state ?? status through controllers, jobs, policies, exports, and Vue props. Every scattered fallback becomes a future miss. One adapter gives the review one seam and gives incident response one place to patch.
For Laravel/Vue SaaS admin screens, the same rule applies on the API boundary: serialize through the helper, not through raw model attributes. That keeps mobile clients, Horizon jobs, and browser sessions on one compatibility story. See the broader boundary discipline in /laravel-vue-saas/ and the agent operations checklist in /ai-agent-operations/.
Compatibility tests the agent must not “summarize away”
A green factory test that only creates new-shape rows is a false comfort. Require fixtures for the three production states that actually exist during migration.
1<?php
2// tests/Feature/InvoiceLifecycleCompatibilityTest.php
3
4namespace Tests\Feature;
5
6use App\Models\Invoice;
7use App\Support\InvoiceLifecycle;
8use Illuminate\Foundation\Testing\RefreshDatabase;
9use Tests\TestCase;
10
11class InvoiceLifecycleCompatibilityTest extends TestCase
12{
13 use RefreshDatabase;
14
15 public function test_read_prefers_lifecycle_state_when_both_exist(): void
16 {
17 $invoice = Invoice::factory()->create([
18 'status' => 'open',
19 'lifecycle_state' => 'settled',
20 ]);
21
22 $this->assertSame('settled', InvoiceLifecycle::read($invoice));
23 }
24
25 public function test_read_maps_legacy_only_rows(): void
26 {
27 $invoice = Invoice::factory()->create([
28 'status' => 'paid',
29 'lifecycle_state' => null,
30 ]);
31
32 $this->assertSame('settled', InvoiceLifecycle::read($invoice));
33 }
34
35 public function test_write_dual_populates_both_columns(): void
36 {
37 $invoice = Invoice::factory()->create([
38 'status' => 'open',
39 'lifecycle_state' => null,
40 ]);
41
42 InvoiceLifecycle::write($invoice, 'cancelled');
43 $invoice->save();
44
45 $invoice->refresh();
46 $this->assertSame('cancelled', $invoice->lifecycle_state);
47 $this->assertSame('void', $invoice->status);
48 }
49}
If the agent cannot produce these three cases, the change is not “almost done.” It has not started the migrate phase.
Optional deep dive: what not to put in the first PR
Backfill is a job with an artifact, not a migration side effect
Agents often stuff data rewriting into up(). That couples schema deploy time to data volume and turns every rollback discussion into guesswork.
Prefer a chunked, idempotent job the operator can observe:
1<?php
2// app/Jobs/BackfillInvoiceLifecycleState.php
3
4namespace App\Jobs;
5
6use App\Models\Invoice;
7use App\Support\InvoiceLifecycle;
8use Illuminate\Bus\Queueable;
9use Illuminate\Contracts\Queue\ShouldQueue;
10use Illuminate\Foundation\Bus\Dispatchable;
11use Illuminate\Queue\InteractsWithQueue;
12use Illuminate\Queue\SerializesModels;
13use Illuminate\Support\Facades\Log;
14
15class BackfillInvoiceLifecycleState implements ShouldQueue
16{
17 use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
18
19 public int $tries = 3;
20
21 public function __construct(public readonly int $afterId = 0)
22 {
23 }
24
25 public function handle(): void
26 {
27 $rows = Invoice::query()
28 ->where('id', '>', $this->afterId)
29 ->whereNull('lifecycle_state')
30 ->orderBy('id')
31 ->limit(500)
32 ->get();
33
34 if ($rows->isEmpty()) {
35 Log::info('invoice_lifecycle_backfill_complete', ['after_id' => $this->afterId]);
36
37 return;
38 }
39
40 foreach ($rows as $invoice) {
41 // whereNull('lifecycle_state') above guarantees read() maps legacy status.
42 $invoice->lifecycle_state = InvoiceLifecycle::read($invoice);
43 $invoice->saveQuietly();
44 }
45
46 $lastId = (int) $rows->last()->id;
47 Log::info('invoice_lifecycle_backfill_chunk', [
48 'from' => $this->afterId,
49 'to' => $lastId,
50 'count' => $rows->count(),
51 ]);
52
53 self::dispatch($lastId)->delay(now()->addSeconds(2));
54 }
55}
Operational rules around that job:
- Dispatch only after the expand migration is live on every node that serves traffic.
- Log chunk progress as an artifact. “It ran” is not evidence.
- Keep the job idempotent: already-filled rows are skipped.
- Do not start the contract phase while the count of
lifecycle_state IS NULLis unknown.
Laravel’s queue docs warn that jobs dispatched inside database transactions can run before the parent transaction commits. Use after_commit on the connection or ->afterCommit() on the dispatch when backfill or follow-up work is tied to a transactional write. [Source: https://laravel.com/docs/queues]
Workers are part of the schema change
This is the failure mode teams rediscover after a clean web deploy: schema is new, HTTP code is new, queue workers are still old.
Laravel queue workers are long-lived processes. Deployment guidance exists because code loaded at worker boot is not magically replaced by a web release. Horizon or queue:work processes need a deliberate restart/drain plan. [Source: https://laravel.com/docs/queues]
For an agent-driven migration PR, require a short topology note in the pull request:
1## Runtime topology
2- Web/API: deploy release A with dual-read helper
3- Queue workers: drain or restart before relying on dual-write behavior
4- Scheduler: confirm no legacy command writes `status` directly
5- Compatibility check before contract:
6 - SELECT count(*) FROM invoices WHERE lifecycle_state IS NULL
7 - sample old worker job against dual-populated row
8 - rollback decision owner: @data-owner
Without that note, the agent’s “all references updated” claim is incomplete. References in source control are not the only consumers. Running processes are consumers too.

Rollback is a decision table, not a git revert slogan
Git can restore source. It cannot automatically un-backfill millions of rows or resurrect a dropped column’s meaning for every downstream report. Build a decision table before the agent is allowed near contraction.
| Situation | Safe response | Unsafe response |
|---|---|---|
| Expand migration fails locally | migrate:rollback for the expand batch | Hand-edit production |
| Dual-read bug in web only | Feature-flag or patch helper; keep column | Drop new column under traffic |
| Backfill mapping error | Stop job; fix map; re-run idempotent job | Contract early to “finish” |
| Old worker still writing legacy only | Restart/drain workers; keep dual-read | Rename/drop legacy column |
Contract already dropped status | Restore from backup/expand forward if needed | Assume git revert rebuilds data |
Laravel’s down() method should reverse the operations in up(). That statement is precise and limited. It is a migration-file concern, not a full incident recovery plan. [Source: https://laravel.com/docs/migrations]
For AI-generated changes, put the decision table in the PR template. The model can draft rows. A human owner still signs the “who decides to contract” cell. Related rollback discipline for broader AI coding changes lives in the earlier field note on recovery paths: /blog/ai-coding-rollback-path/.
What to demand in the pull request before anyone merges
Use this checklist as a hard gate. If an item is missing, the PR stays in expand draft mode.
- Prompt boundary attached — expand allowed; contract forbidden unless evidence links are present.
- Additive migration only — no
renameColumn/dropColumnon the live legacy field in the first PR. - Single read/write adapter — no scattered
??fallbacks across the codebase. - Three-state tests — legacy-only, new-only, dual-populated.
- Backfill job + progress artifact — chunk size, idempotency, completion log or metric.
- Worker/topology note — who restarts what, and in which order.
- Rollback decision table — owner named for go/no-go on contraction.
- Explicit non-goals — warehouse queries, one-off scripts, and external integrations listed as follow-ups.
This is also how you evaluate coding agents week to week. A model that produces a prettier destructive migration is not “better at backend work.” A model that respects the stop line and leaves clean expand artifacts is safer in a real repository. For harness thinking beyond schema work, see /ai-agent-operations/ and the resume-boundary audit pattern in /blog/audit-agent-resume/.
A short anti-pattern catalog from agent diffs
| Agent habit | Why it fails | Replace with |
|---|---|---|
| One migration that renames and rewrites data | No dual-running window | Expand migration + backfill job |
| “Updated all references” via global search | Misses runtime workers and SQL outside app code | Consumer checklist + topology note |
| Inline mapping copied into 12 files | Future mapping fix becomes a scavenger hunt | One adapter module |
down() empty on a destructive change | Local rollback theater | Expand-first design so down() stays real longer |
| Contract PR opened same day as expand | No evidence the migrate phase finished | Separate PR after metrics go to zero |

What you should do Monday morning
- Pick one schema change currently sitting in an agent branch or a human PR and label its true phase: expand, migrate, or contract.
- If it is still pretending to be “one migration,” split out an additive expand file and block destructive SQL.
- Introduce or extract a single dual-read/dual-write adapter; delete scattered fallbacks as you touch them.
- Add the three-state compatibility tests before any more feature work lands on that branch.
- Create or restore a chunked backfill job with a progress log; measure
NULLnew-column count. - Write the worker restart order into the PR and into the deploy checklist your team actually uses.
- Name a human owner for the contract go/no-go decision; do not leave that cell blank for the model.
- Update your coding-agent prompt library with an expand-only stop line for schema tasks.
- Link the PR to the rollback decision table and to one internal hub page your team already maintains.
- Only after the migrate-phase evidence is boring—near-zero NULL counts, restarted workers, green compatibility tests—open a separate contract PR.
Further reading
Source Martin Fowler — Parallel Change (expand and contract)
Source Prisma Data Guide — Expand and contract pattern for schema changes
Source Laravel docs — Database migrations
Source Laravel docs — Queues, after_commit, and workers
If you maintain agent workflows around repository evidence rather than chat confidence, continue with /blog/audit-agent-resume/, /blog/ai-coding-rollback-path/, and the operating hubs at /ai-agent-operations/ and /developer-tools/.
