+
# Difflock
[](https://packagist.org/packages/heyosseus/difflock)
@@ -9,40 +13,13 @@
Difflock reads your migrations and your database, tells you what is about to change, how badly it could go, and stops the changes that should not run unattended.
-```
-Diff what changed. Analyze the risk. Lock dangerous changes.
-```
+
```bash
composer require heyosseus/difflock --dev
php artisan difflock:lint
```
-```text
- Difflock · Migration Analysis
- ────────────────────────────────────────
-
- 2026_08_10_120000_remove_legacy_token
-
- ✗ CRITICAL DROP COLUMN users.legacy_token
- drop-column:14 · destructive, not reversible
- Dropping a column destroys the values in it. A `down()` that adds the
- column back gives you the column and not one row of what was in it.
- The table holds 4,921,000 rows.
- → Stop reading and writing the column in application code first,
- deploy that, and drop it in a later migration once you are sure
- nothing needs it.
-
- Risk
- ✓ Safe: 0
- ⚠ Low: 1
- ⚠ Medium: 0
- ✗ High: 0
- ✗ Critical: 1
-
- 3 migrations analysed.
-```
-
---
## Contents
@@ -70,21 +47,7 @@ php artisan difflock:lint
## Overview
-Difflock has three jobs, and it keeps them separate.
-
-```text
- DIFFLOCK
- │
- ┌────────────┼────────────┐
- │ │ │
- DIFF ANALYZE LOCK
- │ │ │
- What changed? Is it risky? Should it run?
- │ │ │
- └────────────┼────────────┘
- │
- DATABASE
-```
+Difflock has three jobs, and it keeps them separate. The diff engine does not know how findings are rendered; the rules do not know Artisan exists; the guard consumes analysis rather than repeating it. [Architecture tests](tests/ArchTest.php) enforce each of those boundaries.
| Command | Answers |
| --- | --- |
@@ -242,6 +205,10 @@ php artisan difflock:lint --accept # record what it found as accepted
php artisan difflock:lint --path=database/migrations/legacy
```
+
+
+*Real output from a 170-migration production application: 251 findings on one screen. The summary's length does not grow with the number of findings — `-v` expands every one, `--rule=` takes them a rule at a time.*
+
Only pending migrations are analysed by default. A migration that has already run cannot be made safer by a finding, and a build that fails over a drop committed two years ago is a build nobody keeps green.
When nothing is pending — which is the normal state of a machine that is up to date — it audits every migration instead of printing nothing, and says that is what it did.
diff --git a/art/pillars.svg b/art/pillars.svg
new file mode 100644
index 0000000..0c182e6
--- /dev/null
+++ b/art/pillars.svg
@@ -0,0 +1,40 @@
+
diff --git a/art/social.svg b/art/social.svg
new file mode 100644
index 0000000..956acce
--- /dev/null
+++ b/art/social.svg
@@ -0,0 +1,45 @@
+
diff --git a/art/terminal.svg b/art/terminal.svg
new file mode 100644
index 0000000..792a55f
--- /dev/null
+++ b/art/terminal.svg
@@ -0,0 +1,46 @@
+
diff --git a/src/Console/Commands/MigrateCommand.php b/src/Console/Commands/MigrateCommand.php
index 01166ae..3870785 100644
--- a/src/Console/Commands/MigrateCommand.php
+++ b/src/Console/Commands/MigrateCommand.php
@@ -96,7 +96,7 @@ public function handle(MigrationGuard $guard, Repository $config, ReportRenderer
Banner::render($this->output, 'Difflock · Migration Guard');
- $renderer->render($this->output, $decision->report);
+ $this->report($renderer, $decision);
if ($this->option('dry-run') === true) {
$this->note('Dry run. Nothing was written to the database.');
@@ -117,6 +117,44 @@ public function handle(MigrationGuard $guard, Repository $config, ReportRenderer
return $this->migrate($decision);
}
+ /**
+ * Show what the guard found, in the detail the situation deserves.
+ *
+ * Everywhere else in Difflock a summary is right, because the reader is browsing.
+ * Here they are not: the guard has stopped them writing to a database, and the
+ * findings that caused it are the whole reason to read the output. So a blocked
+ * run prints every blocking finding in full and collapses only the rest.
+ *
+ * When nothing is in scope the renderer is skipped entirely — it would say "no
+ * migrations were found to analyse" and suggest fixing `--path`, which on an
+ * application whose migrations have all simply been applied is untrue.
+ */
+ private function report(ReportRenderer $renderer, GuardDecision $decision): void
+ {
+ if ($decision->report->migrations === []) {
+ $this->note('No migrations are pending, so there was nothing to analyse.');
+
+ return;
+ }
+
+ if (! $decision->blocked) {
+ $renderer->render($this->output, $decision->report, 'difflock:migrate');
+
+ return;
+ }
+
+ $blocking = $decision->report->only(atLeast: $decision->threshold);
+
+ $renderer->detail($this->output, $blocking);
+
+ $remaining = $decision->report->summary()->total - $blocking->summary()->total;
+
+ if ($remaining > 0) {
+ $this->note($remaining.' further finding'.($remaining === 1 ? '' : 's').' below '
+ .$decision->threshold->label().' are not shown — php artisan difflock:lint');
+ }
+ }
+
/**
* Hand over to Laravel's own migrator.
*
diff --git a/src/Console/Renderers/CheckupRenderer.php b/src/Console/Renderers/CheckupRenderer.php
index f79fd45..30f1926 100644
--- a/src/Console/Renderers/CheckupRenderer.php
+++ b/src/Console/Renderers/CheckupRenderer.php
@@ -37,8 +37,13 @@ public function render(OutputInterface $output, CheckupResult $result, bool $ter
$this->diffs->render($output, $result->drift);
}
- if (! $terse || $result->report->fails($result->threshold)) {
- $this->reports->render($output, $result->report);
+ // Nothing in scope is already stated by the migration line above. Letting the
+ // report renderer speak here would have it say "no migrations were found to
+ // analyse" and suggest fixing `--path` — which on an application whose 170
+ // migrations have simply all been applied is false, and sends the reader
+ // debugging something that is not wrong.
+ if ($result->report->migrations !== [] && (! $terse || $result->report->fails($result->threshold))) {
+ $this->reports->render($output, $result->report, 'difflock:check');
}
$output->writeln($result->failed()
@@ -75,7 +80,7 @@ public function overview(OutputInterface $output, CheckupResult $result): void
if ($summary->total > 0) {
$worst = $summary->highest;
- $this->reports->render($output, $result->report->only(atLeast: $worst));
+ $this->reports->render($output, $result->report->only(atLeast: $worst), 'difflock:lint');
$remaining = $summary->total - $summary->count($worst);
diff --git a/tests/Feature/GuardDetailTest.php b/tests/Feature/GuardDetailTest.php
new file mode 100644
index 0000000..c5a26ae
--- /dev/null
+++ b/tests/Feature/GuardDetailTest.php
@@ -0,0 +1,81 @@
+id();
+ $table->string('legacy_token')->nullable();
+ });
+
+ Schema::create('orders', function (Blueprint $table): void {
+ $table->id();
+ $table->unsignedBigInteger('customer_id');
+ });
+
+ config()->set('difflock.migrations.paths', [fixtures()]);
+});
+
+/**
+ * Everywhere else a summary is right, because the reader is browsing. Here they are
+ * not: the guard has just stopped them writing to a database, and the findings that
+ * caused it are the entire reason to read the output.
+ */
+it('prints every blocking finding in full when it blocks', function (): void {
+ [$exit, $output] = runCommand('difflock:migrate');
+
+ expect($exit)->toBe(1)
+ ->and($output)->toContain('Migration blocked')
+ ->toContain('DROP COLUMN users.legacy_token')
+ // The explanation, not just the headline — this is the detail view.
+ ->toContain('Dropping a column destroys the values in it')
+ ->and(Schema::hasColumn('users', 'legacy_token'))->toBeTrue();
+});
+
+it('collapses the findings that are not blocking', function (): void {
+ [, $output] = runCommand('difflock:migrate');
+
+ expect($output)->toContain('below CRITICAL are not shown')
+ ->toContain('difflock:lint');
+});
+
+it('summarises rather than expanding when nothing blocks', function (): void {
+ [$exit, $output] = runCommand('difflock:migrate', [
+ '--dry-run' => true,
+ '--path' => [fixtures('safe')],
+ '--realpath' => true,
+ ]);
+
+ expect($exit)->toBe(0)
+ ->and($output)->toContain('Dry run')
+ ->and($output)->not->toContain('Migration blocked');
+});
+
+/**
+ * An application whose migrations have all been applied has an empty pending scope.
+ * Saying "no migrations were found to analyse" there is false, and the advice that
+ * followed it — check your `--path` — sends the reader debugging nothing.
+ */
+it('says nothing is pending rather than claiming it found no migrations', function (): void {
+ runCommand('migrate', ['--path' => [fixtures()], '--realpath' => true, '--force' => true]);
+
+ [, $output] = runCommand('difflock:migrate', ['--dry-run' => true]);
+
+ expect($output)->toContain('No migrations are pending')
+ ->and($output)->not->toContain('No migrations were found to analyse')
+ ->and($output)->not->toContain('--path');
+});
+
+it('does not tell difflock:check to fix its --path either', function (): void {
+ runCommand('migrate', ['--path' => [fixtures()], '--realpath' => true, '--force' => true]);
+ runCommand('difflock:diff', ['--save' => true]);
+
+ [$exit, $output] = runCommand('difflock:check');
+
+ expect($exit)->toBe(0)
+ ->and($output)->toContain('No pending migrations')
+ ->and($output)->not->toContain('No migrations were found to analyse');
+});
diff --git a/tests/Feature/ProtectionTest.php b/tests/Feature/ProtectionTest.php
index 72e25ce..e70b8ff 100644
--- a/tests/Feature/ProtectionTest.php
+++ b/tests/Feature/ProtectionTest.php
@@ -184,6 +184,8 @@
[$exit, $output] = runCommand('difflock:migrate', ['--path' => ['/definitely/not/a/directory'], '--realpath' => true]);
expect($exit)->toBe(0)
- ->and($output)->toContain('No migrations were found to analyse');
+ // The guard says nothing is pending — not that it could not find any
+ // migration files, which would send the reader debugging their --path.
+ ->and($output)->toContain('No migrations are pending');
});
});