Data storage
Four storage mechanisms for plugin data - choosing the right one avoids unnecessary migrations and keeps your plugin lean.
Plugins should use core shared tables first and only create their own tables when the data model genuinely requires it. Picking the right tier keeps your plugin lean, avoids migration overhead, and makes data cleanup on uninstall trivial.
Decision flowchart
Does the plugin just need settings or configuration?
YES -> Use PluginOptions
NO ->
Does it attach per-record data to posts, users, terms, or media?
YES -> Use contensio_meta
NO ->
Does it manage a simple list of items (one entity type, flat or lightly nested)?
YES -> Use contensio_plugin_entries
NO ->
Does it need multiple related tables, foreign keys, or high-volume indexed data?
YES -> Create plugin-specific tables with migrations
Tier 1 - PluginOptions
For plugin configuration: API keys, toggles, HTML snippets, URL prefixes. Stored as a single JSON blob in the core settings table. No migration needed.
use Contensio\Support\PluginOptions;
// Read all settings for your plugin
$settings = PluginOptions::get('acme/plugin-awesome');
// Read a single key with a default
$trackingId = PluginOptions::get('acme/plugin-awesome', 'tracking_id', '');
// Save (replaces the entire blob)
PluginOptions::save('acme/plugin-awesome', [
'tracking_id' => 'UA-12345',
'enabled' => true,
]);
The first argument is always the full Composer package name. On uninstall, the CMS runs DELETE FROM settings WHERE plugin = 'acme/plugin-awesome' automatically.
Wrapping PluginOptions in a config class
For convenience, most plugins wrap this in a static config class:
class AwesomeConfig
{
private const PLUGIN = 'acme/plugin-awesome';
private static ?array $cache = null;
public static function all(): array
{
if (static::$cache === null) {
try {
static::$cache = PluginOptions::get(static::PLUGIN) ?? [];
} catch (\Throwable) {
static::$cache = [];
}
}
return static::$cache;
}
public static function get(string $key, mixed $default = null): mixed
{
return static::all()[$key] ?? $default;
}
public static function save(array $data): void
{
static::$cache = null;
PluginOptions::save(static::PLUGIN, $data);
}
public static function flush(): void
{
static::$cache = null;
}
}
Use when: the plugin needs to save settings/configuration - tracking IDs, HTML snippets, feature toggles, URL prefixes.
Examples: Google Analytics (tracking ID), Robots.txt Editor (text content), Code Injection (head/body HTML), Read Progress Bar (color, height).
Tier 2 - contensio_meta
For attaching per-record data to posts, users, terms, or media. Like WordPress wp_postmeta / wp_usermeta, but unified into one table.
id | metable_type | metable_id | meta_key | meta_value (text) | plugin
use Contensio\Models\Meta;
// Write
Meta::updateOrCreate(
[
'metable_type' => 'content',
'metable_id' => $post->id,
'meta_key' => 'view_count',
'plugin' => 'acme/plugin-awesome',
],
['meta_value' => '1247']
);
// Read
$views = Meta::where('metable_type', 'content')
->where('metable_id', $post->id)
->where('meta_key', 'view_count')
->value('meta_value');
The plugin column is used for cleanup on uninstall.
Use when: the plugin adds a piece of data to an existing entity (a post, a user, etc.) rather than creating its own entity.
Examples:
| Plugin | meta_key | metable_type |
|---|---|---|
| View Counter | view_count |
content |
| Sticky Posts | is_sticky |
content |
| Star Ratings | avg_rating, rating_count |
content |
| Reading Time | reading_time |
content |
| Word Count | word_count |
content |
Tier 3 - contensio_plugin_entries
For plugins that manage a simple list of things. Each plugin gets its own rows scoped by plugin + type. The data JSON column holds plugin-specific fields - no migration required.
id | plugin | type | title | slug | content (text) | data (JSON) | status
| sort_order | parent_id | post_id | user_id | timestamps
Extend the Contensio\Models\PluginEntry base model:
namespace Acme\Awesome\Models;
use Contensio\Models\PluginEntry;
use Illuminate\Database\Eloquent\Builder;
class AwesomeItem extends PluginEntry
{
protected static function booted(): void
{
static::addGlobalScope('plugin', fn (Builder $q) =>
$q->where('plugin', 'acme/plugin-awesome')->where('type', 'item')
);
}
// Map JSON data fields as virtual attributes
public function getTaglineAttribute(): ?string
{
return $this->data['tagline'] ?? null;
}
public function setTaglineAttribute(?string $value): void
{
$this->data = array_merge($this->data ?? [], ['tagline' => $value]);
}
}
Save and query exactly like a normal Eloquent model:
AwesomeItem::create([
'title' => 'First item',
'slug' => 'first-item',
'status' => 'active',
'data' => ['tagline' => 'A short description'],
]);
$items = AwesomeItem::active()->ordered()->get();
The PluginEntry base class provides scopeActive(), scopeOrdered(), scopeForPost(), children(), parent(), and a get(key, default) helper for reading from the JSON data blob.
Use when: the plugin manages a flat or lightly nested list of one entity type.
Examples:
| plugin | type | data JSON contains |
|---|---|---|
contensio/plugin-faq |
group / item |
{answer} (items use parent_id for grouping) |
contensio/plugin-team |
member |
{role, bio, photo_url, social_links} |
contensio/plugin-testimonials |
testimonial |
{company, rating, avatar_url} |
contensio/plugin-changelog |
entry |
{version, type, released_at} |
contensio/plugin-downloads |
file |
{file_url, file_size, download_count} |
contensio/plugin-social-links |
link |
{url, icon, platform} |
contensio/plugin-short-links |
link |
{original_url, click_count} |
Slug uniqueness when scoping to your plugin:
$slugRule = Rule::unique('contensio_plugin_entries', 'slug')
->where('plugin', 'acme/plugin-awesome')
->where('type', 'item');
Tier 4 - Plugin-specific tables
When the data model genuinely doesn't fit a single-table-per-plugin model: multiple related tables, foreign keys, high-volume data with specific indexing.
Create standard Laravel migrations. Table names must be prefixed with the vendor name.
Schema::create('acme_awesome_orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('status', 20)->default('pending');
$table->unsignedInteger('total_cents')->default(0);
$table->timestamps();
$table->index(['user_id', 'status']);
});
The prefix is the first segment of the Composer name: acme/plugin-awesome -> prefix acme_.
See Migrations for the full guide.
Use when: the plugin needs multiple related tables, foreign keys between them, or high-volume data.
Examples that genuinely need own tables:
| Plugin | Why own tables |
|---|---|
| Polls | 3 tables: polls -> options -> votes |
| Newsletter | Subscribers + opt-in tokens + send history |
| Ads Manager | Zones + ad blocks + impression/click counters |
| Community Q&A | Questions + answers + votes + reputation |
| Booking | Services + availability slots + appointments |
| Advanced Forms | Forms + fields + submissions + field values |
Cleanup on uninstall
The plugin column present on tier 1-3 tables means the CMS can clean up automatically:
| Table | Cleanup action |
|---|---|
settings (tier 1) |
Deleted: WHERE plugin = 'acme/plugin-awesome' |
contensio_meta (tier 2) |
Deleted: WHERE plugin = 'acme/plugin-awesome' |
contensio_plugin_entries (tier 3) |
Deleted: WHERE plugin = 'acme/plugin-awesome' |
| Plugin-specific tables (tier 4) | Dropped only if admin checks "Also remove database tables" |
For tier 4, implement down() in every migration correctly - it runs in production.