Contensio logo

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.