Wu-Hsien Yu/ 文章
文章

從 Migrations 走回 Schema Dumps:Laravel 內建的那條路

一年前寫了〈從 Schema Dumps 到 Migrations〉,記錄一個看似簡單的任務如何吃掉一整天:把正式環境的 schema dump 載入 Orchestra Testbench 的測試環境。當時的結論是不要直接執行 dump 檔,改把它包進一個 migration 裡。

最近替另一個套件建立測試環境,又走了一次同樣的路。這次沒有繞過去,而是把 migrate 內部實際發生的事一路追完。當年的診斷依然成立,當年的解法也依然有效,但如果今天重做一次,我會改用一個 Laravel 從頭到尾都內建、而且寫在官方文件裡的機制。


當初說對的部分

當年找到的根因是對的:schema dump 不能在 migration 生命週期之外執行,它必須進入生命週期之內。在 setUp() 裡呼叫 DB::unprepared() 之所以失敗,追到底全是時機問題:

  • 它會在每一個測試執行一次,而不是每個 process 一次。
  • 它跟 RefreshDatabase 打架:dump 裡的 DDL 語句會觸發 MySQL 的 implicit commit,把 trait 包住測試的 transaction 提前結束,測試之間開始互相汙染。
  • PDO::exec() 收到多語句字串時只回報第一句的錯誤,dump 中段壞掉會無聲死去,留下半套匯入的 schema。

把 dump 包進 0000_00_00_000000_import_schema.php 之所以有效,正是因為它解掉了兩個時機問題:migrations 每個 process 只跑一次,而且跑在 RefreshDatabase 替每個測試開 transaction 之前。事後看來,這也正是 Laravel 自己的機制在做的事。

當年漏掉的:Laravel 已經內建

Laravel 的 migration 官方文件在 Squashing Migrations 一節記錄著:

When you attempt to migrate your database and no other migrations have been executed, Laravel will first execute the SQL statements in the schema file of the database connection you are using. After executing the schema file's SQL statements, Laravel will execute any remaining migrations that were not part of the schema dump.

文件甚至明確提到測試情境(so that your tests are able to build your database),也註明這個功能是用資料庫的 command-line client 執行的。

當初沒有意識到的有三件事:

  1. 載入是靠慣例路徑觸發的:database_path('schema/{連線名}-schema.sql')
  2. 它在 migrations table 為空時觸發。在 migrate:fresh 之下,等於每個 process 恰好一次,時間點在 RefreshDatabase 開始用 transaction 包測試之前。
  3. 檔案是由 mysql CLI client 執行的,不是 PDO。

也就是說:

當初打造的那個方式,本來就是內建功能,只是執行通道不同。

為什麼在 Testbench 裡看起來不可用

這個機制在套件測試裡看起來派不上用場,是有原因的。Testbench 執行時的應用程式是 vendor/orchestra/testbench-core/laravel/ 裡的骨架,所以 database_path() 解析到的是一個放不了檔案的目錄,Composer 隨時會把它砍掉重建。

缺的設定只有一行。Illuminate\Foundation\Application 有一個 useDatabasePath() 方法,可以把 database_path()(以及 container 裡的 path.database 綁定)改指到任何地方:

php
<?php

declare(strict_types=1);

namespace Vendor\Package\Tests;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase as Orchestra;
use Vendor\Package\PackageServiceProvider;

abstract class TestCase extends Orchestra
{
    use RefreshDatabase;

    protected function getPackageProviders($app): array
    {
        return [
            PackageServiceProvider::class,
        ];
    }

    protected function defineEnvironment($app): void
    {
        $app->useDatabasePath(__DIR__.'/database');
    }
}
tests/
├── TestCase.php
└── database/
    └── schema/
        └── mysql-schema.sql

資料庫設定照舊放在 phpunit.xml.distuseDatabasePath() 沒有出現在官方文件裡,但它是框架自己也依賴的穩定公開 API。其餘的一切,載入條件、執行順序、與 RefreshDatabase 的互動,都是 Laravel 有文件說明的行為,在 Testbench 開機的那個 Laravel 應用程式裡照常運作。

差異:由誰執行這些 SQL

當初的 migration 包裝法和原生路徑,都會在生命週期的正確時間點把 dump 載入一次。差別在執行者:我的方式把整個檔案交給 PDO::exec(),Laravel 的 MySqlSchemaState::load() 則交給真正的 client,mysql ... < schema.sql。這個差別有兩個後果:

  • 錯誤回報:PDO::exec() 會吞掉多語句字串裡第一句之後的錯誤,所以第 500 句失敗時,留下的是半套 schema 和一堆指向錯誤方向的測試失敗。CLI 是逐句執行,失敗就以非零的 exit code 中止。
  • 檔案大小:file_get_contents() 把整份 dump 讀進 PHP 記憶體,再當成單一封包送出,一邊受 memory_limit 限制,另一邊受伺服器的 max_allowed_packet 限制。CLI 走的是 streaming。

與 Workbench 疊加

如果測試加上了 WithWorkbench,執行順序是 testbench-core 保證的:

  1. workbench 的 migration 路徑與 seeders 先註冊。
  2. RefreshDatabase 執行 migrate:fresh,先載入 dump,再跑剩餘的 migrations(包括 workbench 的)。
  3. 最後在 DatabaseRefreshed 事件上跑 seeders。

Workbench 的素材會疊在 dump 之上。有一點要注意:schema 檔必須帶著 migrations table 的狀態,schema:dump 產出的檔案有;否則每個 migration 看起來都沒跑過,會全部重跑一遍,跟已經建好的資料表衝突。另一條路是像下一節那樣把 migrations 整個關掉,當 dump 擁有全部 layout 時,這個問題就消失了。

實戰之後:三個文件沒寫的坑

寫完上面那些的隔天,我把這條路真的走完了:在一個新套件裡,把 170 張表的正式環境 schema dump 接進測試環境。原生機制如預期運作,但沿路踩到三個文件沒寫的坑。

WithWorkbench 之下,provider 只認 testbench.yaml

前面的範例用 getPackageProviders() 手動註冊 provider。實務上若改用 WithWorkbench,讓測試與 workbench 共用一份設定,要知道測試裡的 package discovery 不供應 provider:root package 的 extra.laravel 只在 is_testbench_cli() 成立的情境(也就是 vendor/bin/testbench)才會被併入 manifest。provider 必須明確列在 testbench.yaml:

yaml
providers:
  - Khia\KhiaServiceProvider

漏了這行,測試會在套件根本沒載入的狀態下安靜地跑,而且不碰套件功能的測試還會照樣通過,直到某個 assertion 開始回傳 null 才會發現。

migrations 要設 false,不是空陣列

testbench.yaml 的 workbench.install: true 有個未文件化的副作用:Testbench 會自動把 skeleton 的預設 migrations(users、cache、jobs)推進 migrator 路徑。正式環境的 dump 裡早就有 users 表,於是 migrate 一跑就是 1050 Table 'users' already exists

關掉它的方式很講究。LoadMigrationsFromArray 的實作長這樣:

php
if ($this->migrations !== false) {      // 閘門只認 false
    $this->bootstrapMigrations($app);   // 預設 migrations 在這裡面被 push
}

預設路徑的注入點在 bootstrapMigrations() 裡面,所以 migrations: [] 擋不住它,空陣列的語意是「沒有額外路徑」,預設照樣進來。只有 migrations: false 能在閘門就短路:

yaml
migrations: false

原始碼裡還有一個 TESTBENCH_WITHOUT_DEFAULT_MIGRATIONS 環境變數能達到同樣效果,但它不在官方文件裡;而 migrations 接受 boolean 是 Config 型別契約明定的(array<int, string>|bool|string)。選有契約的那條路。

這也順帶解掉了前一節的注意事項:raw mysqldump 沒有 migrations table 的狀態沒關係,既然 dump 擁有整個 schema、migrations 又整個關掉,就沒有重跑可言。

phpunit.xml 會整份遮蔽 .dist

連線設定分兩層,phpunit.xml.dist 放可提交的預設值,本地的 phpunit.xml(gitignored)放真實密碼:

xml
<php>
    <env name="DB_CONNECTION" value="mysql"/>
    <env name="DB_HOST" value="127.0.0.1"/>
    <env name="DB_PORT" value="3306"/>
    <env name="DB_DATABASE" value="khia_test"/>
    <env name="DB_USERNAME" value="root"/>
    <env name="DB_PASSWORD" value=""/>
</php>

要記得的是:

PHPUnit 一旦發現 phpunit.xml.dist 就完全不被讀取。不是合併,是取代。

之後往 .dist 加的任何設定,在有本地檔的機器上都會安靜地失效,而你會對著一個根本沒被讀的檔案除錯。

還有一個相關的行為:<env value="true"/> 會被 PHPUnit 的 XML loader 轉型成 boolean,再經 putenv() 變回字串 "1",遇到框架端 !== true 的嚴格比較就永遠對不上。要讓 boolean 活著抵達,得寫成 value="(true)",那是 Laravel 的 Env::get() 認得、而 PHPUnit 不會動手腳的寫法。

一年後的教訓

當初看起來像缺少的功能,其實只是路徑指錯了方向。真正動手實作時擋路的,也都不是機制本身,而是各層工具的預設值與型別轉換疊在一起留下的縫隙。

相關文章

2026.07.18重構 03:不要叫它 Core2026.07.16重構 02:從模組化單體到獨立 Package2026.04.20Kindie - 開發日誌 01