Table riêng theo plugin. Tên có vendor-prefix. Tự động migrate khi activate.

Dữ liệu của một plugin sống trong các table riêng của nó, cùng database mà host application dùng, nhưng tên có prefix là identity {vendor}_{name} của plugin để hai plugin không bao giờ collide. Migration nằm bên trong folder plugin, chạy khi admin click Activate, và rollback khi admin click Delete. Có một knob chính tắc duy nhất — flag $keepData — cho plugin sở hữu dữ liệu user mà admin muốn giữ lại qua các lần re-install.

Vì sao table phải isolated theo plugin

Một plugin muốn có state persistent có thể dùng table users, customers, hay plugins của host — nhưng không cái nào trong số đó sống sót qua một upgrade hay một lần đổi tên. Plugin system thay vào đó dành ra một phần của cùng database cho các table plugin sở hữu, cô lập theo tên. Ba thuộc tính rơi ra từ quyết định đó:

  • Hai plugin trên cùng một install không bao giờ collide. Mọi table của plugin đều được prefix bằng identity {vendor}_{name} của plugin, vốn đã bị validator constrain về chữ thường và chữ số. Settings table của acmecorp/loyaltyacmecorp_loyalty_settings; của otherteam/loyaltyotherteam_loyalty_settings. Cùng tên, khác prefix.
  • Activation là thứ duy nhất tạo table. Service provider của skeleton lắng nghe event per-plugin activate_plugin_{vendor}/{name} và chạy artisan migrate đối với folder migration riêng của plugin. Cho đến khi admin activate, namespace của plugin được autoload nhưng các table của nó không tồn tại.
  • Deletion có thể clean. Service provider của skeleton cũng lắng nghe delete_plugin_{vendor}/{name} và chạy migrate:rollback. Plugin sở hữu data mà admin muốn giữ qua re-install có thể opt out qua flag $keepData — xem bên dưới.

Migration nằm ở đâu

Migration của plugin sống tại storage/app/plugins/{vendor}/{name}/database/migrations/. Chúng không nằm trong folder database/migrations/ root của host application — chúng hoàn toàn tách biệt. php artisan migrate của host không bao giờ đụng tới chúng, đó là lý do activation phải làm việc đó tường minh qua lifecycle hook.

Skeleton scaffold một migration đặt tên theo settings table của plugin, với prefix timestamp 2000_01_01_000000_:

storage/app/plugins/acmecorp/loyalty/
└── database/
    └── migrations/
        └── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php

Prefix 2000_01_01 có chủ ý sort scaffold migration đứng đầu. Migration thật bạn thêm về sau lấy timestamp ngày hiện tại và chạy theo thứ tự thời gian phía sau — luật ordering migration thông thường của Laravel áp dụng bên trong folder của plugin, cô lập khỏi thứ tự của host.

Activate chạy migration; delete rollback chúng

src/ServiceProvider.php của skeleton chứa hai lifecycle listener nối migration runner vào event activate / delete. Cả hai thuộc về boot():

// Run plugin migrations when the plugin is activated.
Hook::on('activate_plugin_acmecorp/loyalty', function () {
    \Artisan::call('migrate', [
        '--path'  => 'storage/app/plugins/acmecorp/loyalty/database/migrations',
        '--force' => true,
    ]);
});

// Roll back plugin migrations when the plugin is deleted.
Hook::on('delete_plugin_acmecorp/loyalty', function ($keepData = false) {
    if ($keepData) {
        return;
    }
    \Artisan::call('migrate:rollback', [
        '--path'  => 'storage/app/plugins/acmecorp/loyalty/database/migrations',
        '--force' => true,
    ]);
});

Option --path bảo artisan migrate chỉ thao tác trên folder này — nó để yên migration của host và migration của bất kỳ plugin nào khác. --force bypass prompt confirmation production mà artisan migrate thường yêu cầu khi APP_ENV=production; bản thân lifecycle event chính là user confirmation.

Activation là idempotent — chạy lại migration block trên một plugin đã activated là an toàn. artisan migrate đọc table tracking migrations của Laravel và skip các file đã chạy. Vì vậy một admin click Activate hai lần (hoặc lỡ hit endpoint REST activate) sẽ có cùng end state.

Table name có vendor-prefix

Hai convention trong codebase host cùng nhau ngăn collision:

  1. Bản thân plugin name đã bị constrain về ^[a-z0-9]+\/[a-z0-9]+$ với 2-32 ký tự mỗi vế. Vì thế {vendor}_{name} dùng làm prefix không bao giờ chứa slash, dash, hay underscore mà SQL parser sẽ phản đối.
  2. Mọi migration plugin author viết đều dùng prefix. Scaffolder hard-code điều này — create_{vendor}_{name}_settings_table cho migration bundled. Table mới theo: {vendor}_{name}_. Ví dụ từ acelle/ai: ai_conversations, ai_messages, ai_requests, ai_tool_calls, ai_feedback.

Vendor acelle dùng một convention hơi lỏng hơn — table của nó chỉ được prefix bằng plugin name (ai) thay vì acelle_ai, vì acelle chính là host vendor. Plugin third-party nên dùng full prefix {vendor}_{name} để chừa chỗ cho bất kỳ plugin first-party / host-vendor tương lai nào mà không collide.

Migration + model đầu tiên của bạn

Migration settings của skeleton đủ để học từ đó. Nó dùng Schema builder chuẩn của Laravel không có wrapper riêng cho plugin:

// storage/app/plugins/acmecorp/loyalty/database/migrations/2000_01_01_000000_create_acmecorp_loyalty_settings_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('acmecorp_loyalty_settings', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->text('value')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('acmecorp_loyalty_settings');
    }
};

Model tương ứng sống tại src/Models/Setting.php trong plugin và bind tường minh tới table name có prefix:

namespace Acmecorp\Loyalty\Models;

use Illuminate\Database\Eloquent\Model;

class Setting extends Model
{
    protected $table = 'acmecorp_loyalty_settings';
    protected $fillable = ['name', 'value'];
}

Luôn set $table tường minh — pluralization snake_cased mặc định của Laravel (Acmecorp\Loyalty\Models\Settingsettings) sẽ trỏ tới một table không tồn tại (hoặc, tệ hơn, trỏ tới table settings của host nếu có).

Foreign key tới table của core

Table của plugin thường xuyên reference table customers, users, hay các table domain khác của host. Thêm foreign key theo cách Laravel chuẩn — chúng sống trong migration của bạn, trỏ tới table host, và tuân theo column type của host:

$table->unsignedBigInteger('customer_id')->nullable()->index();
$table->foreign('customer_id')
      ->references('id')->on('customers')
      ->onDelete('set null');

Hai ghi chú vận hành đáng nhớ. Thứ nhất, giữ FK ở dạng nullable khi relationship là optionalonDelete('set null') yêu cầu điều đó. Thứ hai, đừng cascade deletion trên table của host trừ khi data của plugin bạn nên đi theo khi admin xoá một customer qua host UI. Một plugin loyalty cascade trên customers sẽ âm thầm mất sạch lịch sử points của mọi account khi admin remove một test customer; soft-delete trên table riêng của bạn hoặc scrub qua một queue job thường là quyết định đúng.

Ví dụ thực tế — mười bốn migration của acelle/ai

Plugin phức tạp chính tắc trong codebase, storage/app/plugins/acelle/ai, ship mười bốn migration trên mười ba table. Chúng là một bài đọc hữu ích cho ai đang lên kế hoạch một plugin có schema không tầm thường:

Tên fileTạo / thay đổi cái gì
2026_04_28_000001_create_ai_conversations_table.phpChat session đa lượt — uid, FK customer_id, status enum, rollup token / cost
2026_04_28_000002_create_ai_messages_table.phpMột lượt user / agent — role, content JSON, FK tool-call, latency, model dùng
2026_04_28_000003_create_ai_requests_table.phpMột row mỗi API call upstream — engine, prompt hash, latency, cost, error
2026_04_28_000004_create_ai_tool_calls_table.phpInvocation function-call sinh ra từ một lượt agent — tool name, input/output JSON
2026_04_28_000005_create_ai_feedback_table.phpThumbs-up/down + feedback tự do per message + per conversation
2026_04_28_000006_create_ai_raw_blobs_table.phpRaw provider response gốc, giữ lại để replay / audit
2026_04_28_000007_create_ai_daily_rollup_table.phpAggregate per-day cho admin dashboard — total token, cost, error rate
2026_04_29_000001_add_client_message_id_to_ai_messages.phpColumn dedup cross-tab — additive, không default
2026_04_30_000002_add_source_to_ai_tool_calls.phpTrack xem tool call đến từ agent hay support route
2026_05_02_180000_widen_ai_conversations_client_session_uid.phpFix độ rộng ULID / UUID — migration alter column
2026_05_02_200000_create_ai_tool_undo_records_table.phpTrack tool action có thể đảo ngược cho feature "undo last"
2026_05_03_000001_add_url_sanitization_to_ai_requests.phpThêm column JSON cho telemetry URL đã sanitised
2026_05_04_000001_create_ai_settings_table.phpSettings admin mức plugin — tách khỏi plugins.data để mỗi row có thể được index

Vài pattern từ list này dịch thẳng sang các plugin khác. Tách "raw blob" thành table riêng khỏi "rolled-up summary" cho phép table rollup đủ nhỏ để scan; FK nullable tới customers + users cho cùng row hoạt động được cả cho traffic authenticated + anonymous; rollup one-row-per-day cho admin dashboard có read rẻ mà không cần JOIN nặng vào các table activity.

Tiến hoá schema về sau

Sau khi plugin đã production với customer đang dùng, thay đổi schema là chuyện thường. Pattern giống hệt một app Laravel bình thường — drop một file migration mới với timestamp ngày hiện tại vào database/migrations/, rồi chạy artisan migrate --path=.... Hai cách để trigger:

  • Cold path (release): deactivate plugin, deploy file migration mới cùng với phần còn lại của plugin update, reactivate. Reactivation fire event activate_plugin_*, event này chạy artisan migrate đối với path, và bắt được file mới.
  • Hot path (trong vận hành bình thường): deploy file, rồi gọi trực tiếp artisan migrate --path=storage/app/plugins/{vendor}/{name}/database/migrations --force. Lifecycle hook tiện lợi nhưng không phải phép màu — nó chỉ là wrapper quanh cùng command đó.

Migration alter schema trên table mà plugin chia sẻ với host (column có foreign key, view có join) cần cùng sự cẩn trọng mà bất kỳ migration production nào cần — additive column trước, deploy, backfill, rồi drop. Lifecycle plugin không đổi luật chơi.

Flag $keepData — giữ data qua re-install

Một số plugin sở hữu data mà admin không muốn mất nếu plugin bị uninstall rồi re-install. Loyalty point của customer, lịch sử AI feedback, audit log payment-gateway — không cái nào trong số đó thuộc nhóm "rollback schema và quên". Plugin lifecycle xử lý chuyện này với một argument boolean duy nhất mà host fire qua event delete:

// app/Model/Plugin.php — when the host deletes a plugin
public function deleteAndCleanup(bool $keepData = false)
{
    Hook::fire('delete_plugin_'.$this->name, [$keepData]);
    $this->deletePluginDirectory();
    $this->delete();
    self::updatePluginMasterFile($this->name, null);
}

Listener của plugin tự quyết định $keepData = true nghĩa là gì trong context riêng. Pattern của skeleton — skip rollback hoàn toàn — là một lựa chọn. Một plugin tinh tế hơn có thể rollback table vận hành nhưng giữ data customer-facing:

Hook::on('delete_plugin_acmecorp/loyalty', function ($keepData = false) {
    \Artisan::call('migrate:rollback', [
        '--path'  => 'storage/app/plugins/acmecorp/loyalty/database/migrations',
        '--force' => true,
    ]);

    if (! $keepData) {
        // Drop the customer-facing tables only when the admin
        // confirmed they want to start over.
        \Schema::dropIfExists('acmecorp_loyalty_accounts');
        \Schema::dropIfExists('acmecorp_loyalty_transactions');
    }
});

Việc host UI có expose checkbox "keep data" hay không là quyết định per-host; contract đã sẵn ở đó hai bên. Plugin không có gì cần giữ có thể ignore argument bằng default — function ($keepData = false) { ... } hoạt động dù host có pass flag hay không.

Năm anti-pattern

1. Ghi vào table settings, customers, hay users của host

Hấp dẫn vì join bớt một query, nhưng nó nướng plugin vào schema của host vĩnh viễn. Bất kỳ upgrade host nào đổi tên column sẽ phá plugin âm thầm. Fix: ghi vào table riêng của bạn, FK tới table host. Join rẻ, coupling giữ lỏng.

2. Quên $table trên model

Không có property $table tường minh, Laravel pluralize tên class viết thường. Acmecorp\Loyalty\Models\Account resolve về accounts, không phải acmecorp_loyalty_accounts. Fix: luôn set protected $table = '{vendor}_{name}_' trên model plugin.

3. cascade delete trên table host

Admin xoá một test customer; plugin của bạn mất sạch row liên quan. Fix: dùng onDelete('set null') trên FK optional, soft-delete row riêng khi table host bị delete qua một queue job, và dành cascade cho child table nội bộ của riêng bạn.

4. Hard-code migration --path trong register()

register() chạy sớm — trước khi helper storage path của host đáng tin cậy. Fix: listener activate_plugin_* thuộc về boot(), nơi storage_path() và bạn bè đã được wire.

5. Trộn schema migration và data migration trong cùng một file

Một migration tạo một column rồi backfill giá trị vào mọi row làm deactivation flaky — rollback phải đảo cả backfill. Fix: tách thành hai timestamp. Schema migration reversible ngay; data migration là file riêng mà routine deactivate của plugin có thể chọn chạy lại, skip, hay invert.

Đi tiếp đến đâu

Model lo persistence; trang tiếp theo là Translations, flow gián tiếp cho phép admin sửa string của plugin qua UI Languages của host mà không cần đụng tới source plugin. Sau đó, Lifecycle đi sâu vào sequence four-state boot/activate/disable/delete, và Testing nói về wiring phpunit.xml cho test suite của plugin.