diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a9beec89a4..eb0040e2db5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Fixed an error that could occur after upgrading to Craft 6 when Laravel queue or other supporting database tables were missing. ([#19889](https://github.com/craftcms/cms/pull/19889)) - Fixed a bug where new control panel assets weren’t getting published when updating via the control panel or `craft:update` command. ## 6.0.0-alpha.20 - 2026-10-07 diff --git a/src/Cms.php b/src/Cms.php index 423aa401e36..7dd52526f17 100644 --- a/src/Cms.php +++ b/src/Cms.php @@ -32,7 +32,7 @@ public const string VERSION = '6.0.0-alpha.20'; - public const string SCHEMA_VERSION = '6.0.0.22'; + public const string SCHEMA_VERSION = '6.0.0.23'; public const string MIN_VERSION_REQUIRED = '5.9.0'; diff --git a/src/Database/LaravelMigrations.php b/src/Database/LaravelMigrations.php index 5029251da96..7adc2df3737 100644 --- a/src/Database/LaravelMigrations.php +++ b/src/Database/LaravelMigrations.php @@ -16,6 +16,90 @@ #[Singleton] class LaravelMigrations { + public function ensureTables(): void + { + $this->ensurePasswordResetTokensTable(); + $this->ensureCacheTables(); + $this->ensureQueueTables(); + $this->ensureSessionsTable(); + $this->ensureNotificationsTable(); + $this->ensurePassportTables(); + } + + public function ensurePasswordResetTokensTable(): void + { + if (Schema::hasTable(Table::PASSWORD_RESET_TOKENS)) { + return; + } + + Schema::create(Table::PASSWORD_RESET_TOKENS, function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } + + public function ensureCacheTables(): void + { + if (! Schema::hasTable(Table::CACHE)) { + Schema::create(Table::CACHE, function (Blueprint $table) { + $table->string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + } + + if (! Schema::hasTable('cache_locks')) { + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + } + + public function ensureQueueTables(): void + { + if (! Schema::hasTable('jobs')) { + Schema::create('jobs', function (Blueprint $table) { + $table->id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedSmallInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + + if (! Schema::hasTable('job_batches')) { + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + } + + if (! Schema::hasTable('failed_jobs')) { + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + } + public function ensurePassportTables(): void { if (! Schema::hasTable(Table::OAUTH_AUTH_CODES)) { diff --git a/src/Database/Migrations/0000_00_00_000000_create_laravel_tables.php b/src/Database/Migrations/0000_00_00_000000_create_laravel_tables.php new file mode 100644 index 00000000000..93233645a7d --- /dev/null +++ b/src/Database/Migrations/0000_00_00_000000_create_laravel_tables.php @@ -0,0 +1,16 @@ +ensureTables(); + } + + public function down(): void {} +}; diff --git a/src/Database/Migrations/0000_00_00_000006_create_password_reset_tokens_table.php b/src/Database/Migrations/0000_00_00_000006_create_password_reset_tokens_table.php index febed6db7da..d6c054d4937 100644 --- a/src/Database/Migrations/0000_00_00_000006_create_password_reset_tokens_table.php +++ b/src/Database/Migrations/0000_00_00_000006_create_password_reset_tokens_table.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Database\LaravelMigrations; use CraftCms\Cms\Database\Migration; use CraftCms\Cms\Database\Table; use Illuminate\Database\Schema\Blueprint; @@ -11,15 +12,7 @@ { public function up(): void { - if (Schema::hasTable(Table::PASSWORD_RESET_TOKENS)) { - return; - } - - Schema::create(Table::PASSWORD_RESET_TOKENS, function (Blueprint $table) { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); + app(LaravelMigrations::class)->ensurePasswordResetTokensTable(); $columns = array_filter([ 'verificationCode', diff --git a/src/Database/Migrations/0000_00_00_000007_migrate_to_laravel_queue.php b/src/Database/Migrations/0000_00_00_000007_migrate_to_laravel_queue.php index 869a3df82c7..23ad55a828b 100644 --- a/src/Database/Migrations/0000_00_00_000007_migrate_to_laravel_queue.php +++ b/src/Database/Migrations/0000_00_00_000007_migrate_to_laravel_queue.php @@ -13,8 +13,7 @@ * - Drops the legacy `queue` table (used by Yii2 Queue) * - Creates the `jobprogress` table (used by Laravel Queue for progress tracking) * - * The Laravel Queue system uses its own tables (jobs, failed_jobs) which are - * managed by the host application, not by Craft. + * Laravel's queue tables are provisioned separately. */ return new class extends Migration { diff --git a/src/Database/Migrations/Install.php b/src/Database/Migrations/Install.php index 2818cf997f5..5e28eb53c28 100644 --- a/src/Database/Migrations/Install.php +++ b/src/Database/Migrations/Install.php @@ -133,83 +133,7 @@ public function up(): void */ public function createLaravelTables(): void { - if (! Schema::hasTable(Table::PASSWORD_RESET_TOKENS)) { - Schema::create(Table::PASSWORD_RESET_TOKENS, function (Blueprint $table) { - $table->string('email')->primary(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - } - - if (! Schema::hasTable(Table::CACHE)) { - Schema::create(Table::CACHE, function (Blueprint $table) { - $table->string('key')->primary(); - $table->mediumText('value'); - $table->bigInteger('expiration')->index(); - }); - } - - if (! Schema::hasTable('cache_locks')) { - Schema::create('cache_locks', function (Blueprint $table) { - $table->string('key')->primary(); - $table->string('owner'); - $table->bigInteger('expiration')->index(); - }); - } - - if (! Schema::hasTable('jobs')) { - Schema::create('jobs', function (Blueprint $table) { - $table->id(); - $table->string('queue')->index(); - $table->longText('payload'); - $table->unsignedSmallInteger('attempts'); - $table->unsignedInteger('reserved_at')->nullable(); - $table->unsignedInteger('available_at'); - $table->unsignedInteger('created_at'); - }); - } - - if (! Schema::hasTable('job_batches')) { - Schema::create('job_batches', function (Blueprint $table) { - $table->string('id')->primary(); - $table->string('name'); - $table->integer('total_jobs'); - $table->integer('pending_jobs'); - $table->integer('failed_jobs'); - $table->longText('failed_job_ids'); - $table->mediumText('options')->nullable(); - $table->integer('cancelled_at')->nullable(); - $table->integer('created_at'); - $table->integer('finished_at')->nullable(); - }); - } - - if (! Schema::hasTable('failed_jobs')) { - Schema::create('failed_jobs', function (Blueprint $table) { - $table->id(); - $table->string('uuid')->unique(); - $table->text('connection'); - $table->text('queue'); - $table->longText('payload'); - $table->longText('exception'); - $table->timestamp('failed_at')->useCurrent(); - }); - } - - if (! Schema::hasTable(Table::SESSIONS)) { - Schema::create(Table::SESSIONS, function (Blueprint $table) { - $table->string('id')->primary(); - $table->foreignId('user_id')->nullable()->index(); - $table->string('ip_address', 45)->nullable(); - $table->text('user_agent')->nullable(); - $table->longText('payload'); - $table->integer('last_activity')->index(); - }); - } - - $laravelMigrations = app(LaravelMigrations::class); - $laravelMigrations->ensureNotificationsTable(); - $laravelMigrations->ensurePassportTables(); + app(LaravelMigrations::class)->ensureTables(); } /** diff --git a/tests/Feature/Database/Migrations/CreateLaravelTablesTest.php b/tests/Feature/Database/Migrations/CreateLaravelTablesTest.php new file mode 100644 index 00000000000..9001d49868f --- /dev/null +++ b/tests/Feature/Database/Migrations/CreateLaravelTablesTest.php @@ -0,0 +1,92 @@ +where('track', 'craft') + ->whereIn('migration', $pendingMigrations) + ->delete(); + + app(Updates::class)->runMigrations(['craft']); + + $queue = Queue::connection('database'); + expect($queue->size())->toBe(0); + + $payload = json_encode(['job' => 'ExampleJob', 'data' => ['message' => 'Upgrade complete']], JSON_THROW_ON_ERROR); + $queue->pushRaw($payload); + + DB::table(Table::MIGRATIONS) + ->where('track', 'craft') + ->where('migration', '0000_00_00_000000_create_laravel_tables') + ->delete(); + + app(Updates::class)->runMigrations(['craft']); + + expect($queue->size())->toBe(1) + ->and($queue->pop()->getRawBody())->toBe($payload); +})->with([ + 'pending queue transition' => true, + 'previously applied queue transition' => false, +]); + +test('upgrades provision database cache and lock stores', function () { + Schema::drop(Table::CACHE); + Schema::drop('cache_locks'); + + DB::table(Table::MIGRATIONS) + ->where('track', 'craft') + ->where('migration', '0000_00_00_000000_create_laravel_tables') + ->delete(); + + app(Updates::class)->runMigrations(['craft']); + + $cache = Cache::store('database'); + $cache->put('upgrade', 'complete', 60); + + expect($cache->get('upgrade'))->toBe('complete'); + + $lock = $cache->lock('upgrade-lock', 60); + expect($lock->get())->toBeTrue(); + + $lock->release(); + expect($cache->lock('upgrade-lock', 60)->get())->toBeTrue(); +}); + +test('password reset upgrades remove legacy verification columns when the token table already exists', function () { + Schema::table(Table::USERS, function (Blueprint $table) { + $table->string('verificationCode')->nullable(); + $table->dateTime('verificationCodeIssuedDate')->nullable(); + }); + + DB::table(Table::MIGRATIONS) + ->where('track', 'craft') + ->whereIn('migration', [ + '0000_00_00_000000_create_laravel_tables', + '0000_00_00_000006_create_password_reset_tokens_table', + ]) + ->delete(); + + app(Updates::class)->runMigrations(['craft']); + + expect(Schema::hasColumn(Table::USERS, 'verificationCode'))->toBeFalse() + ->and(Schema::hasColumn(Table::USERS, 'verificationCodeIssuedDate'))->toBeFalse(); +});