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/loyalty là acmecorp_loyalty_settings; của otherteam/loyalty là otherteam_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:
- 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.
- 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\Setting → settings) 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à optional — onDelete('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 file | Tạo / thay đổi cái gì |
2026_04_28_000001_create_ai_conversations_table.php | Chat session đa lượt — uid, FK customer_id, status enum, rollup token / cost |
2026_04_28_000002_create_ai_messages_table.php | Một lượt user / agent — role, content JSON, FK tool-call, latency, model dùng |
2026_04_28_000003_create_ai_requests_table.php | Một row mỗi API call upstream — engine, prompt hash, latency, cost, error |
2026_04_28_000004_create_ai_tool_calls_table.php | Invocation function-call sinh ra từ một lượt agent — tool name, input/output JSON |
2026_04_28_000005_create_ai_feedback_table.php | Thumbs-up/down + feedback tự do per message + per conversation |
2026_04_28_000006_create_ai_raw_blobs_table.php | Raw provider response gốc, giữ lại để replay / audit |
2026_04_28_000007_create_ai_daily_rollup_table.php | Aggregate per-day cho admin dashboard — total token, cost, error rate |
2026_04_29_000001_add_client_message_id_to_ai_messages.php | Column dedup cross-tab — additive, không default |
2026_04_30_000002_add_source_to_ai_tool_calls.php | Track xem tool call đến từ agent hay support route |
2026_05_02_180000_widen_ai_conversations_client_session_uid.php | Fix độ rộng ULID / UUID — migration alter column |
2026_05_02_200000_create_ai_tool_undo_records_table.php | Track tool action có thể đảo ngược cho feature "undo last" |
2026_05_03_000001_add_url_sanitization_to_ai_requests.php | Thêm column JSON cho telemetry URL đã sanitised |
2026_05_04_000001_create_ai_settings_table.php | Settings 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.