*/ private array $filesToDelete = []; public function handle(): int { $mode = $this->option('orphaned') ? 'orphaned' : ($this->option('superseded') ? 'superseded' : ($this->option('duplicates') ? 'duplicates' : ($this->option('pending') ? 'pending' : 'all'))); if (! Schema::hasTable('migrations')) { $this->error('The migrations table does not exist. Run `php artisan migrate` first.'); return self::FAILURE; } $ranMigrations = DB::table('migrations')->pluck('migration')->toArray(); if ($mode === 'pending' || $mode === 'all') { $this->checkPending($ranMigrations); } if ($mode === 'orphaned' || $mode === 'all') { $this->checkOrphaned(); } if ($mode === 'superseded' || $mode === 'all') { $this->checkSuperseded(); } if ($mode === 'duplicates' || $mode === 'all') { $this->checkDuplicates(); } $this->newLine(); if ($this->option('delete') && $this->filesToDelete !== []) { $this->deleteFiles(); } if ($this->errors > 0) { $this->error("Found {$this->errors} error(s) and {$this->warnings} warning(s)."); return self::FAILURE; } if ($this->warnings > 0) { $this->warn("Found {$this->warnings} warning(s), no errors."); return self::SUCCESS; } $this->info('No issues found.'); return self::SUCCESS; } private function checkPending(array $ran): void { $files = File::files(database_path('migrations')); $pending = 0; foreach ($files as $f) { $name = str_replace('.php', '', $f->getFilename()); if (! in_array($name, $ran)) { $pending++; } } if ($pending > 0) { $this->warn("{$pending} pending migration(s) found. Run `php artisan migrate`."); $this->errors++; } else { $this->info('All migrations have been run.'); } } private function checkOrphaned(): void { $migrationsPath = database_path('migrations'); $files = File::files($migrationsPath); foreach ($files as $file) { $content = File::get($file->getPathname()); $createdTables = $this->extractCreatedTables($content); foreach ($createdTables as $table) { if (! Schema::hasTable($table)) { $this->warn("[Orphaned] {$file->getFilename()} creates table `{$table}` which no longer exists."); $this->warnings++; $this->addFileToDelete($file->getFilename()); } } $modifiedTables = $this->extractModifiedTables($content); foreach ($modifiedTables as $table => $columns) { if (! Schema::hasTable($table)) { $this->warn("[Orphaned] {$file->getFilename()} modifies table `{$table}` which no longer exists."); $this->warnings++; $this->addFileToDelete($file->getFilename()); continue; } foreach ($columns as $column) { if (! Schema::hasColumn($table, $column)) { $this->warn("[Orphaned] {$file->getFilename()} adds column `{$column}` to `{$table}` but column no longer exists."); $this->warnings++; $this->addFileToDelete($file->getFilename()); } } } } } private function checkSuperseded(): void { $migrationsPath = database_path('migrations'); $files = File::files($migrationsPath); $createdIn = []; $droppedIn = []; foreach ($files as $file) { $content = File::get($file->getPathname()); $name = $file->getFilename(); $upContent = $this->extractUpMethod($content); $createdTables = $this->extractCreatedTables($upContent); $droppedTablesInUp = $this->extractDroppedTables($upContent); foreach ($createdTables as $table) { $createdIn[$table][] = $name; } foreach ($droppedTablesInUp as $table) { $droppedIn[$table][] = $name; } } foreach ($createdIn as $table => $creators) { if (isset($droppedIn[$table]) && $droppedIn[$table] !== []) { $droppers = $droppedIn[$table]; $stillExists = Schema::hasTable($table); if (! $stillExists) { $this->warn('[Superseded] Table `' . $table . '` created in ' . implode(', ', $creators) . ' and dropped in ' . implode(', ', $droppers) . '.'); $this->warnings++; foreach (array_merge($creators, $droppers) as $f) { $this->addFileToDelete($f); } } if (count($creators) > 1) { $this->warn('[Duplicate] Table `' . $table . '` was created multiple times across migrations.'); $this->warnings++; } } } } private function extractUpMethod(string $content): string { if (preg_match('/function\s+up\s*\(\s*\)\s*(?::\s*\w+\s*)?\s*\{.*?\n\s*\}/s', $content, $m)) { return $m[0]; } return $content; } private function extractDownMethod(string $content): string { if (preg_match('/function\s+down\s*\(\s*\)\s*(?::\s*\w+\s*)?\s*\{.*?\n\s*\}/s', $content, $m)) { return $m[0]; } return ''; } private function checkDuplicates(): void { $migrationsPath = database_path('migrations'); $files = File::files($migrationsPath); $tableCreations = []; foreach ($files as $file) { $content = File::get($file->getPathname()); $createdTables = $this->extractCreatedTables($content); foreach ($createdTables as $table) { if (! isset($tableCreations[$table])) { $tableCreations[$table] = []; } $tableCreations[$table][] = $file->getFilename(); } } foreach ($tableCreations as $table => $creators) { if (count($creators) > 1) { $this->warn('[Duplicate] Table `' . $table . '` created in: ' . implode(', ', $creators)); $this->warnings++; $existing = Schema::hasTable($table); if ($existing) { $toDelete = array_slice($creators, 0, -1); foreach ($toDelete as $f) { $this->addFileToDelete($f); } } else { foreach ($creators as $f) { $this->addFileToDelete($f); } } } } } private function extractCreatedTables(string $content): array { preg_match_all('/Schema::create\([\'"]([^\'"]+)[\'"]/', $content, $matches); return array_unique($matches[1]); } private function extractModifiedTables(string $content): array { $tables = []; preg_match_all('/Schema::table\([\'"]([^\'"]+)[\'"]/', $content, $tableMatches); if (empty($tableMatches[1])) { return $tables; } preg_match_all('/\$table->(' . implode('|', $this->columnMethods()) . ')\([\'"]([^\'"]+)[\'"]\)/', $content, $colMatches); foreach ($colMatches[1] as $i => $method) { $table = $tableMatches[1][0]; $column = $colMatches[2][$i]; if (! isset($tables[$table]) || ! in_array($column, $tables[$table])) { $tables[$table][] = $column; } } return $tables; } private function extractDroppedTables(string $content): array { $tables = []; if (preg_match('/Schema::dropIfExists\([\'"]([^\'"]+)[\'"]/', $content, $m)) { $tables[] = $m[1]; } if (preg_match('/Schema::drop\([\'"]([^\'"]+)[\'"]/', $content, $m)) { $tables[] = $m[1]; } return $tables; } private function columnMethods(): array { return [ 'bigInteger', 'binary', 'boolean', 'char', 'date', 'dateTime', 'datetime', 'decimal', 'double', 'enum', 'float', 'foreignId', 'foreignIdFor', 'foreignUlid', 'foreignUuid', 'geometry', 'geometryCollection', 'id', 'integer', 'json', 'jsonb', 'lineString', 'longText', 'macAddress', 'mediumInteger', 'mediumText', 'morphs', 'nullableMorphs', 'nullableUuidMorphs', 'nullableTimestamps', 'point', 'polygon', 'rememberToken', 'set', 'smallInteger', 'softDeletes', 'softDeletesTz', 'string', 'text', 'time', 'timestamp', 'timestamps', 'timestampsTz', 'tinyInteger', 'tinyText', 'unsignedBigInteger', 'unsignedInteger', 'unsignedMediumInteger', 'unsignedSmallInteger', 'unsignedTinyInteger', 'uuid', 'year', ]; } private function addFileToDelete(string $filename): void { if (! in_array($filename, $this->filesToDelete)) { $this->filesToDelete[] = $filename; } } private function deleteFiles(): void { $this->newLine(); $this->warn('The following migration files will be deleted:'); foreach ($this->filesToDelete as $f) { $this->line(" - {$f}"); } $this->newLine(); if (! $this->option('force')) { $this->line('Records will also be removed from the `migrations` database table.'); if (! $this->confirm('Are you sure you want to delete these migration files?', false)) { $this->info('Deletion cancelled.'); return; } } $deleted = 0; $migrationsPath = database_path('migrations'); $names = array_map(fn ($f) => str_replace('.php', '', $f), $this->filesToDelete); foreach ($this->filesToDelete as $filename) { $path = $migrationsPath . '/' . $filename; if (File::exists($path)) { File::delete($path); $this->line("[Deleted] {$filename}"); $deleted++; } } DB::table('migrations')->whereIn('migration', $names)->delete(); $this->line('[Cleanup] Removed ' . count($names) . ' record(s) from migrations table.'); $this->newLine(); $this->info("Deleted {$deleted} migration file(s) and cleaned up the migrations table."); } }