Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,45 @@ use Difflock\Database\FixedTableStatistics;
$statistics = new FixedTableStatistics(['orders' => 8_421_392]);
```

## AI agents

An agent writing a migration cannot see what Difflock can see. It does not know the table has eight million rows, that two indexes are built on the column it is about to drop, or that the schema drifted last Tuesday. So it writes the migration that passes review and takes production down — the same failure as always, generated faster.

Difflock ships an MCP server that closes the loop.

```jsonc
// .mcp.json — Claude Code, Cursor, Laravel Boost, anything speaking MCP
{
"mcpServers": {
"difflock": { "command": "php", "args": ["artisan", "difflock:mcp"] }
}
}
```

Three tools, in the order a careful developer would use them:

| Tool | Answers |
| --- | --- |
| `difflock_table_context` | What does this table look like — rows, columns, indexes, foreign keys? |
| `difflock_lint_migration` | I just wrote this migration; what is wrong with it? |
| `difflock_schema_drift` | Has this database already diverged from the baseline? |

It is a **standalone stdio server**, not a Boost plugin. Boost publishes no documented API for third-party tool registration, and writing against an undocumented internal is how a package breaks on someone else's patch release. This works with Boost and with everything else.

### A skill for coding agents

`skills/difflock/SKILL.md` teaches an agent the workflow — check the table, write the migration, lint it, fix, *then* show the user — and the things it must not do, such as silencing a finding to make a check pass. Copy it into `.claude/skills/`.

### `difflock:explain`

```bash
php artisan difflock:explain 2026_08_11_120000_drop_legacy_token
```

A Markdown briefing on one migration: what it touches, the live state of every table involved, every finding, and what the analysis could not see.

**Nothing in it is generated.** This does not ask a language model whether your migration is safe — that would be the unfalsifiable guessing this package exists to argue against. Difflock supplies the facts; you or your agent supply the judgement. No API key, no network call, no model provider in a package whose whole argument is that it only says what it can check.

## Programmatic API

```php
Expand Down
78 changes: 78 additions & 0 deletions skills/difflock/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
name: difflock
description: Use when writing, editing or reviewing a Laravel database migration — before showing it to the user. Checks the migration against the live database for destructive operations, lock risk, cascading deletes and columns that will fail on populated tables. Also use before schema work to check whether the database has already drifted.
---

# Writing safe Laravel migrations with Difflock

You cannot see what the database looks like. Difflock can. A migration that is
correct in isolation — `dropColumn('legacy_token')`, `$table->string('status')` —
is a data-loss incident or a failed deploy depending on facts that exist only in
the database: how many rows the table holds, what is indexed, what points at it.

**Never present a migration to the user without checking it first.** Getting this
wrong is not a style problem; it is how production columns get dropped.

## The loop

```
1. difflock_table_context → what am I dealing with?
2. write the migration
3. difflock_lint_migration → what's wrong with it?
4. fix and repeat until nothing is above `low`
5. show the user, quoting anything that remains
```

If the MCP tools are unavailable, the same facts come from the CLI:

```bash
php artisan difflock:lint --path=database/migrations/2026_08_11_x.php --realpath
php artisan difflock:explain 2026_08_11_x
```

## Reading a finding

Each carries a **risk** (`safe` → `critical`) and two facts that are not opinions:

- `destructive` — this removes data or structure.
- `reversible` — a `down()` with a body exists. **It does not mean the data comes
back.** A dropped column's `down()` recreates the column and none of its rows.

`context` holds the facts about that specific occurrence — `82,325 rows`,
`covered by users_email_index`. That is usually the field that decides what to do.

## Rules that most often change what you write

| Finding | What to do instead |
| --- | --- |
| `add-not-null-column` on a table with rows | Add `->nullable()` or `->default(...)`. A NOT NULL column with no default has nothing to put in existing rows and most engines refuse the statement. |
| `drop-column` / `drop-table` | Split it: stop reading the column, deploy, drop it in a later migration. Say plainly that the data does not come back. |
| `foreign-key` with a cascade | Prefer `restrictOnDelete()` or `nullOnDelete()` unless children are worthless without the parent. Cascades run inside the database — no model events, no observers, no soft deletes. |
| `rename-column` | The zero-downtime shape is add / write both / backfill / switch reads / drop. During a rolling deploy the old release is still querying the old name. |
| `unindexed-foreign-key` | Add `$table->index('customer_id')` next to `constrained()`. PostgreSQL indexes neither side automatically; MySQL does. |
| `add-index` on a large table | Consider building it outside the deploy with the engine's concurrent form. |
| `sensitive-column` | Ask before storing it: encryption, retention, and whether it belongs in the database at all. |

## Never do these

- **Do not silence a finding to make the check pass.** Not `--fail-on`, not
`ignore`, not `--accept`. Those are the user's decisions, not yours. Fix the
migration or explain why the finding is acceptable and let them choose.
- **Do not run `difflock:migrate` without `--dry-run`** unless the user has asked
you to migrate. It writes to their database.
- **Do not treat an empty findings list as "safe".** Read `warnings` first — a
migration that builds table names from config, loops, or calls `DB::statement()`
is one Difflock could only partly read, and it says so there.
- **Do not report a row count of `null` as zero.** `null` means the engine would
not say. The distinction is the difference between "nothing to backfill" and "we
have no idea".

## Saying it to the user

Lead with what will happen, not with the rule name:

> This drops `users.legacy_token`, which holds 82,325 rows. The data is not
> recoverable from `down()` — that recreates the column empty. Two indexes are
> built on it and go with it.

Then the options. Difflock reports risk, not permission — the decision is theirs.
Loading
Loading