From c76f361a037a546f921ab69e1fcb907df95b8e69 Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 7 Oct 2026 20:26:50 +0200 Subject: [PATCH 1/3] Ensure Laravel support tables exist during upgrades --- CHANGELOG.md | 4 + src/Cms.php | 2 +- src/Database/LaravelMigrations.php | 84 +++++++++++++++++ ...000_00_00_000000_create_laravel_tables.php | 16 ++++ ...006_create_password_reset_tokens_table.php | 11 +-- ..._00_00_000007_migrate_to_laravel_queue.php | 3 +- src/Database/Migrations/Install.php | 78 +--------------- .../Migrations/CreateLaravelTablesTest.php | 93 +++++++++++++++++++ 8 files changed, 202 insertions(+), 89 deletions(-) create mode 100644 src/Database/Migrations/0000_00_00_000000_create_laravel_tables.php create mode 100644 tests/Feature/Database/Migrations/CreateLaravelTablesTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f518224988..0aca9081619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release Notes for Craft CMS 6 +## Unreleased + +- Fixed an error that could occur after upgrading to Craft 6 when Laravel queue or other supporting database tables were missing. + ## 6.0.0-alpha.20 - 2026-10-07 ### Content Management 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..b847512724d --- /dev/null +++ b/tests/Feature/Database/Migrations/CreateLaravelTablesTest.php @@ -0,0 +1,93 @@ +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() + ->and($cache->lock('upgrade-lock', 60)->get())->toBeFalse(); + + $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(); +}); From 21831ad3262ceea55aef723cb7007c2fb4dd628b Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 7 Oct 2026 20:27:41 +0200 Subject: [PATCH 2/3] Add changelog link for #19889 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aca9081619..e1efba379fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Fixed an error that could occur after upgrading to Craft 6 when Laravel queue or other supporting database tables were missing. +- 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)) ## 6.0.0-alpha.20 - 2026-10-07 From 2dc462592c3785c2446324f739f039724ae78f46 Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 7 Oct 2026 21:23:01 +0200 Subject: [PATCH 3/3] Avoid lock contention inside the PostgreSQL test transaction --- tests/Feature/Database/Migrations/CreateLaravelTablesTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Feature/Database/Migrations/CreateLaravelTablesTest.php b/tests/Feature/Database/Migrations/CreateLaravelTablesTest.php index b847512724d..9001d49868f 100644 --- a/tests/Feature/Database/Migrations/CreateLaravelTablesTest.php +++ b/tests/Feature/Database/Migrations/CreateLaravelTablesTest.php @@ -65,8 +65,7 @@ expect($cache->get('upgrade'))->toBe('complete'); $lock = $cache->lock('upgrade-lock', 60); - expect($lock->get())->toBeTrue() - ->and($cache->lock('upgrade-lock', 60)->get())->toBeFalse(); + expect($lock->get())->toBeTrue(); $lock->release(); expect($cache->lock('upgrade-lock', 60)->get())->toBeTrue();