Why plugin-isolated tables
A plugin that wants persistent state could use the host's users, customers, or plugins tables — none of those would survive an upgrade or a rename. The plugin system instead reserves a portion of the same database for plugin-owned tables, isolated by name. Three properties fall out of that decision:
- Two plugins on the same install never collide. Every plugin's tables are prefixed with the plugin's
{vendor}_{name} identity, which the validator already constrains to lowercase letters and digits. acmecorp/loyalty's settings table is acmecorp_loyalty_settings; otherteam/loyalty's is otherteam_loyalty_settings. Same name, different prefix.
- Activation is the only thing that creates tables. The skeleton's service provider listens to the per-plugin
activate_plugin_{vendor}/{name} event and runs artisan migrate against the plugin's own migrations folder. Until an admin activates, the plugin's namespace is autoloaded but its tables do not exist.
- Deletion can be clean. The skeleton's service provider also listens to
delete_plugin_{vendor}/{name} and runs migrate:rollback. Plugins that own data the admin wants to keep across re-installs can opt out via the $keepData flag — see below.
Where migrations live
Plugin migrations live at storage/app/plugins/{vendor}/{name}/database/migrations/. They do not go into the host application's root database/migrations/ folder — they are completely separate. The host's php artisan migrate never looks at them, which is why activation has to do the work explicitly through the lifecycle hook.
The skeleton scaffolds one migration named after the plugin's settings table, with a 2000_01_01_000000_ timestamp prefix:
storage/app/plugins/acmecorp/loyalty/
└── database/
└── migrations/
└── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
The deliberate 2000_01_01 prefix sorts the scaffold migration first. Real migrations you add later get current-date timestamps and run in chronological order behind it — Laravel's normal migration ordering rules apply within the plugin's folder, isolated from the host's order.
Activate runs them; delete rolls them back
The skeleton's src/ServiceProvider.php contains two lifecycle listeners that wire the migration runner to the activate / delete events. Both belong in 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,
]);
});
The --path option tells artisan migrate to operate against this folder only — it leaves the host's migrations and any other plugin's migrations untouched. --force bypasses the production confirmation prompt that artisan migrate normally requires when APP_ENV=production; the lifecycle event is itself the user confirmation.
Activation is idempotent — running the migration block again on an already-activated plugin is safe. artisan migrate reads Laravel's migrations tracking table and skips files it has already run. So an admin who clicks Activate twice (or hits the activate REST endpoint by accident) gets the same end state.
Vendor-prefixed table names
Two conventions in the host codebase together prevent collisions:
- The plugin name itself is constrained to
^[a-z0-9]+\/[a-z0-9]+$ with 2-32 chars per side. So {vendor}_{name} as a prefix never contains a slash, dash, or underscore the SQL parser would object to.
- Every migration the plugin author writes uses the prefix. The scaffolder hard-codes this —
create_{vendor}_{name}_settings_table for the bundled migration. New tables follow: {vendor}_{name}_. Examples from acelle/ai: ai_conversations, ai_messages, ai_requests, ai_tool_calls, ai_feedback.
The acelle vendor uses a slightly looser convention — its tables are prefixed only by the plugin name (ai) instead of acelle_ai, because acelle itself is the host vendor. Third-party plugins should use the full {vendor}_{name} prefix to leave space for any future Acelle-internal plugin without colliding.
Your first migration + model
The skeleton's settings migration is enough to learn from. It uses the standard Laravel Schema builder with no plugin-specific wrappers:
// 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');
}
};
The matching model lives at src/Models/Setting.php in the plugin and binds explicitly to the prefixed table name:
namespace Acmecorp\Loyalty\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $table = 'acmecorp_loyalty_settings';
protected $fillable = ['name', 'value'];
}
Always set $table explicitly — Laravel's default snake_cased pluralization (Acmecorp\Loyalty\Models\Setting → settings) would point at a table that does not exist (or, worse, at the host's settings table if one exists).
Foreign keys to core tables
Plugin tables routinely reference the host's customers, users, or other domain tables. Add foreign keys the standard Laravel way — they live in your migration, point at the host table, and follow the host's column types:
$table->unsignedBigInteger('customer_id')->nullable()->index();
$table->foreign('customer_id')
->references('id')->on('customers')
->onDelete('set null');
Two operational notes worth remembering. First, keep the FK nullable when the relationship is optional — onDelete('set null') requires it. Second, do not cascade deletions on host tables unless your plugin's data should follow when an admin deletes a customer through the host UI. A loyalty plugin that cascades on customers would silently lose every account's points history when an admin removes a single test customer; soft-deleting on your own table or scrubbing through a queue job is usually the right call.
Real example — acelle/ai's fourteen migrations
The canonical complex plugin in the codebase, storage/app/plugins/acelle/ai, ships fourteen migrations against thirteen tables. They are a useful reading exercise for anyone planning a plugin with non-trivial schema:
| Filename | What it creates / changes |
2026_04_28_000001_create_ai_conversations_table.php | Multi-turn chat sessions — uid, customer_id FK, status enum, token / cost rollups |
2026_04_28_000002_create_ai_messages_table.php | Single user / agent turn — role, content JSON, tool-call FK, latency, model used |
2026_04_28_000003_create_ai_requests_table.php | One row per upstream API call — engine, prompt hash, latency, cost, error |
2026_04_28_000004_create_ai_tool_calls_table.php | Function-call invocations spawned by an agent turn — tool name, input/output JSON |
2026_04_28_000005_create_ai_feedback_table.php | Thumbs-up/down + free-text feedback per message + per conversation |
2026_04_28_000006_create_ai_raw_blobs_table.php | Original raw provider responses, kept for replay / audit |
2026_04_28_000007_create_ai_daily_rollup_table.php | Per-day aggregate for the admin dashboard — token totals, cost, error rate |
2026_04_29_000001_add_client_message_id_to_ai_messages.php | Cross-tab dedup column — additive, no defaults |
2026_04_30_000002_add_source_to_ai_tool_calls.php | Tracks whether a tool call came from agent vs support route |
2026_05_02_180000_widen_ai_conversations_client_session_uid.php | ULID / UUID width fix — column-altering migration |
2026_05_02_200000_create_ai_tool_undo_records_table.php | Tracks reversible tool actions for the "undo last" feature |
2026_05_03_000001_add_url_sanitization_to_ai_requests.php | Adds a JSON column for sanitised URL telemetry |
2026_05_04_000001_create_ai_settings_table.php | Plugin-level admin settings — kept separate from plugins.data so each row can be indexed |
Several patterns from this list translate directly into other plugins. Splitting "raw blobs" into a separate table from "rolled-up summary" lets the rollup table stay small enough to scan; nullable FKs to customers + users let the same row work for authenticated + anonymous traffic; one-row-per-day rollups give the admin dashboard cheap reads without a heavy JOIN against the activity tables.
Evolving the schema later
After the plugin is in production with active customers, schema changes are routine. The pattern is identical to a normal Laravel app — drop a new migration file with a current-date timestamp into database/migrations/, then run artisan migrate --path=.... Two ways to trigger that:
- Cold path (releases): deactivate the plugin, deploy the new migration file with the rest of the plugin update, reactivate. Reactivation fires the
activate_plugin_* event, which runs artisan migrate against the path, which picks up new files.
- Hot path (during normal operation): deploy the file, then call
artisan migrate --path=storage/app/plugins/{vendor}/{name}/database/migrations --force directly. The lifecycle hook is convenient but not magical — it is just a wrapper around the same command.
Schema-altering migrations on tables the plugin shares with the host (foreign-keyed columns, joined views) need the same care any production migration needs — additive columns first, deploy, backfill, then drop. The plugin lifecycle does not change the rules.
The $keepData flag — preserving data across re-installs
Some plugins own data the admin would not want to lose if the plugin is uninstalled and re-installed. Customer loyalty points, AI feedback history, payment-gateway audit logs — none of these belong in the "rollback the schema and forget" bucket. The plugin lifecycle handles this with a single boolean argument the host fires through the delete event:
// 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);
}
The plugin's listener decides what $keepData = true means in its own context. The skeleton's pattern — skip the rollback entirely — is one option. A more nuanced plugin might roll back operational tables but preserve customer-facing data:
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');
}
});
Whether the host UI surfaces a "keep data" checkbox is a per-host decision; the contract is in place either way. Plugins that have nothing to preserve can ignore the argument with the default — function ($keepData = false) { ... } works whether the host passes the flag or not.
Five anti-patterns
1. Writing into the host's settings, customers, or users tables
Tempting because the join is one less query, but it bakes the plugin into the host's schema forever. Any host upgrade that renames a column breaks the plugin silently. Fix: write into your own table, FK to the host table. The join is cheap, the coupling stays loose.
2. Forgetting $table on your model
Without an explicit $table property, Laravel pluralizes the lowercased class name. Acmecorp\Loyalty\Models\Account resolves to accounts, not acmecorp_loyalty_accounts. Fix: always set protected $table = '{vendor}_{name}_' on plugin models.
3. cascade deletes on host tables
An admin deletes a single test customer; your plugin loses every related row. Fix: use onDelete('set null') on optional FKs, soft-delete your own rows on host-table deletes through a queue job, and reserve cascade for your own internal child tables.
4. Hardcoding the migration --path in register()
register() runs early — before the host's storage path helpers are reliable. Fix: the activate_plugin_* listener belongs in boot(), where storage_path() and friends are wired up.
5. Mixing schema migrations and data migrations in the same file
A migration that creates a column and then backfills the value into every row makes deactivation flaky — rolling back has to reverse the backfill too. Fix: split into two timestamps. The schema migration is immediately reversible; the data migration is a separate file that the plugin's deactivate routine can choose to re-run, skip, or invert.
Where to go next
Models cover persistence; the next page is Translations, the indirect flow that lets admins edit plugin strings through the host's Languages UI without ever touching the plugin source. After that, Lifecycle covers the four-state boot/activate/disable/delete sequence in depth, and Testing covers the phpunit.xml wiring for plugin test suites.