Migrations
Ship database tables with your plugin. They run automatically when a user enables it.
Plugin migrations are standard Laravel migrations. Contensio's innovation: they run automatically when a user enables the plugin from the admin - no CLI step required.
Deciding whether you need migrations at all
Before writing a migration, check whether a core shared table covers your use case. Most plugins don't need their own tables:
| Use case | Storage mechanism |
|---|---|
| Plugin settings and configuration | PluginOptions (DB settings table, no migration needed) |
| Per-post or per-user metadata | contensio_meta (shared polymorphic table) |
| A simple list of items (team members, FAQ entries, testimonials) | contensio_plugin_entries (shared generic table) |
| Complex relational data (polls + votes, zones + ad blocks) | Plugin-specific tables - use migrations |
See Data storage for the full decision guide.
Directory
acme/plugin-awesome/
└── database/
└── migrations/
└── 2026_05_01_000001_create_contensio_awesome_items_table.php
Registering the directory
In your service provider's boot():
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
}
That's all the code you write for migrations. Everything else is standard Laravel.
Table naming - vendor prefix is required
Plugin table names must be prefixed with the vendor name - the first segment of your Composer package name:
| Package | Vendor | Table prefix | Example table |
|---|---|---|---|
contensio/plugin-ads-manager |
contensio | contensio_ |
contensio_ad_blocks |
acme/plugin-awesome |
acme | acme_ |
acme_awesome_items |
myco/plugin-shop |
myco | myco_ |
myco_shop_orders |
This is a hard requirement. A short prefix like awesome_items is not acceptable - another plugin vendor could create a table with the same name, and they would silently collide.
The vendor prefix comes from your Composer package name, not from the plugin slug.
Example migration
<?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('acme_awesome_items', function (Blueprint $table) {
$table->id();
$table->string('name', 200);
$table->text('description')->nullable();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index('is_active');
});
}
public function down(): void
{
Schema::dropIfExists('acme_awesome_items');
}
};
The down() method is not decorative - it is called in production when an admin uninstalls the plugin and checks "Also remove database tables." Always implement it correctly.
When migrations run
- On enable - when a user clicks Enable in the admin, Contensio runs the pending migrations from your plugin's directory. Previously-run migrations are skipped.
- On CLI migrate -
php artisan migratepicks them up too, sinceloadMigrationsFrom()registers them with Laravel's migrator.
When they do NOT run
- Installed but not enabled - plugin is on disk but admin hasn't enabled it.
- Disabled - disabling a plugin does not roll back migrations. Data stays in place.
- Uninstalled without the checkbox - same as disabled; data is preserved.
Rollback on uninstall
When an admin uninstalls a plugin from the admin panel, they see:
"Also remove database tables" - unchecked by default
- Unchecked (default): only plugin files, permissions, roles, and settings are removed. Database tables are preserved so the admin can re-install and recover data.
- Checked: Contensio runs
migrate:rollbackon the plugin's migrations directory, calling everydown()method and dropping the tables.
This means down() will run in production. Implement it every time.
Uninstall cleanup via onUninstall()
For non-table resources (uploaded files, cache keys, external API tokens), use the service provider's onUninstall() lifecycle method. It fires before the plugin files are deleted, regardless of whether the admin checked "remove tables":
public function onUninstall(): void
{
// Delete user-uploaded files
Storage::deleteDirectory('awesome-uploads');
// Revoke an external API registration
// Cache::forget('acme-awesome:api-token');
}
Seeding default data
Use the onInstall() lifecycle method - it's called once, right after migrations run on first enable:
public function onInstall(): void
{
\Acme\Awesome\Models\AwesomeCategory::firstOrCreate(
['slug' => 'general'],
['name' => 'General', 'sort_order' => 0]
);
}
This is cleaner than seeding inside the migration (which couples schema and data) and safer than a cache guard in boot().
Conflict handling
If your migration references a core table (e.g. users, contents), make sure to use a foreign key that handles deletion gracefully. Core migrations always run before plugin migrations, so the referenced tables exist.
Never reference another plugin's tables via foreign key - the order of enable is not guaranteed.
Troubleshooting
Migration didn't run after enable
Check the admin flash message - if a migration fails, enable succeeds but a warning is shown. Check storage/logs/laravel.log for the exception.
"Base table already exists"
Your migration already ran (perhaps via php artisan migrate) but the record is missing from the migrations table. Either drop the table or add an if (Schema::hasTable(...)) guard to the up() method.