Admin UI hooks
UI render injection points in the admin panel — dashboard stat cards, widgets, action buttons, content editor sidebar panels, and more.
These are UI render hooks — they use the Hook::add() / Hook::render() API rather than add_action / do_action. Callbacks must return an HTML string (not echo it). Multiple callbacks are concatenated in priority order.
use Contensio\Support\Hook;
Hook::add('contensio/admin/dashboard-widgets', function () {
return '<div class="bg-white rounded-xl border border-gray-200 p-5">...</div>';
}, priority: 10);
Dashboard hooks
Four injection points are available on the main dashboard screen (/account). Together they give plugins full control over the dashboard — from stat cards to full widget panels to header buttons.
┌─────────────────────────────────────────────────────────────┐
│ Good morning, Ana [New page] [New post] [Upload media]│◄─ dashboard-quick-actions
├─────────────────────────────────────────────────────────────┤
│ Pages Posts Media Users Drafts │
├─────────────────────────────────────────────────────────────┤
│ [plugin stat row here] │◄─ dashboard-stats
├──────────────────────────────┬──────────────────────────────┤
│ Recently published │ Drafts in progress │
│ ... │ ... │
├──────────────────────────────┴──────────────────────────────┤
│ [plugin widget panels here] │◄─ dashboard-widgets
├─────────────────────────────────────────────────────────────┤
│ Activity │
│ ... │
├─────────────────────────────────────────────────────────────┤
│ [plugin content here] │◄─ dashboard-after
└─────────────────────────────────────────────────────────────┘
contensio/admin/dashboard-quick-actions
Since: 1.0.0
Location: admin/dashboard.blade.php — header action bar, appended after the "Upload media" button
Add buttons to the dashboard header. Use this for primary plugin shortcuts — "New Order", "View Orders", "Add Product", etc. Return standard <a> or <button> elements styled to match core.
Arguments
None.
Example
Hook::add('contensio/admin/dashboard-quick-actions', function () {
return <<<HTML
<a href="/account/shop/orders"
class="inline-flex items-center gap-1.5 border border-gray-300 bg-white
hover:bg-gray-50 text-gray-700 text-sm font-medium px-3 py-2 rounded-lg transition-colors">
<i class="bi bi-bag text-sm"></i>
Orders
</a>
HTML;
});
contensio/admin/dashboard-stats
Since: 1.0.0
Location: admin/dashboard.blade.php — immediately below the built-in stat cards row
Add your own row of stat cards below the core stats (Pages, Posts, Media, Users, Drafts). Render a complete <div class="grid ..."> row — the built-in row always stays intact, your output appears beneath it.
Arguments
None.
Example — shop stats row
Hook::add('contensio/admin/dashboard-stats', function () {
$orders = \MyShop\Models\Order::count();
$revenue = \MyShop\Models\Order::sum('total');
$pending = \MyShop\Models\Order::where('status', 'pending')->count();
return view('my-shop::admin.dashboard-stats', compact('orders', 'revenue', 'pending'))->render();
});
{{-- my-shop::admin.dashboard-stats --}}
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
<a href="/account/shop/orders"
class="group bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-sm p-4 transition-all">
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-gray-500 uppercase tracking-wide">Orders</span>
<div class="w-8 h-8 bg-blue-50 group-hover:bg-blue-100 rounded-lg flex items-center justify-center transition-colors">
<i class="bi bi-bag text-blue-600"></i>
</div>
</div>
<p class="text-2xl font-bold text-gray-900">{{ number_format($orders) }}</p>
</a>
{{-- Revenue and Pending cards follow the same pattern --}}
</div>
Notes
- Use the same card markup as core stats for visual consistency (see above).
- Each plugin gets its own row — they do not share a grid with each other or with core stats.
contensio/admin/dashboard-widgets
Since: 1.5.0
Location: admin/dashboard.blade.php — below the built-in content + drafts panels, before the Activity log
Inject full-width widget panels. This is the right place for a "Recent orders" list, "Top products", "Support tickets", or any panel that needs more vertical space than a stat card.
Arguments
None.
Example — recent orders panel
Hook::add('contensio/admin/dashboard-widgets', function () {
$orders = \MyShop\Models\Order::with('customer')->latest()->limit(5)->get();
return view('my-shop::admin.widgets.recent-orders', compact('orders'))->render();
});
Styling guide
<div class="bg-white rounded-xl border border-gray-200 mb-5">
<div class="px-5 py-4 border-b border-gray-100">
<h2 class="text-base font-bold text-gray-900">Recent Orders</h2>
<p class="text-xs text-gray-500 mt-0.5">Last 5 orders placed.</p>
</div>
<div class="divide-y divide-gray-100">
<!-- rows -->
</div>
</div>
contensio/admin/dashboard-after
Since: 1.0.0
Location: admin/dashboard.blade.php — below the Activity log, at the very bottom of the page
The lowest-priority injection point on the dashboard. Use for secondary or supplementary content that should not compete visually with the main widgets — for example a "Getting started" checklist, a plugin-specific notice, or a chart that's useful but not critical at a glance.
Arguments
None.
Example
Hook::add('contensio/admin/dashboard-after', function () {
if (\MyShop\Models\Product::count() === 0) {
return view('my-shop::admin.getting-started')->render();
}
return '';
});
contensio/admin/content-edit-sidebar
Since: 1.5.0
Location: admin/content/edit.blade.php — sidebar column, below the Comments panel, above the Info panel
Inject additional sidebar panels on the content edit screen. The sidebar is 320px wide (expandable on wide screens). Each panel should be a self-contained card.
Arguments
| # | Name | Type | Description |
|---|---|---|---|
| 1 | $content |
Content|null |
The content entry being edited, or null when creating a new entry. |
Example — SEO score panel
use Contensio\Support\Hook;
use Contensio\Models\Content;
Hook::add('contensio/admin/content-edit-sidebar', function (?Content $content) {
if (! $content) {
return ''; // not available for new entries
}
$score = SeoAnalyser::score($content);
return view('seo-plugin::admin.seo-score-panel', compact('content', 'score'))->render();
});
Styling guide
<div class="bg-white rounded-md border border-gray-200">
<div class="px-4 py-3 border-b border-gray-100">
<h3 class="text-base font-bold text-gray-800">Panel Title</h3>
</div>
<div class="p-4">
<!-- panel content -->
</div>
</div>
Notes
- Receives
nullwhen the user is on the "Create new" screen. Guard appropriately if your panel requires an existing entry. - The sidebar is inside the main edit form — form inputs added here will be submitted with the content form.
contensio/admin/content-edit-after-body
Since: 1.5.0
Location: admin/content/edit.blade.php — main column, below the block editor, still inside the General tab
Inject additional panels below the block editor in the main content column. Useful for word-count boxes, readability scores, related content pickers, or any tool that operates on the content body.
Arguments
| # | Name | Type | Description |
|---|---|---|---|
| 1 | $content |
Content|null |
The content entry being edited, or null for new entries. |
Example — word count + reading time
use Contensio\Support\Hook;
use Contensio\Models\Content;
Hook::add('contensio/admin/content-edit-after-body', function (?Content $content) {
if (! $content) {
return '';
}
$text = $content->blocks->reduce(fn ($carry, $block) => $carry . ' ' . strip_tags($block['data']['content'] ?? ''), '');
$words = str_word_count($text);
$readMins = max(1, (int) round($words / 200));
return <<<HTML
<div class="bg-white rounded-md border border-gray-200 p-4 text-sm text-gray-500">
<span class="font-medium text-gray-700">{$words} words</span>
· ~{$readMins} min read
</div>
HTML;
});
Notes
- Like
content-edit-sidebar, this is inside the form — inputs here will be submitted with the content save. - Only visible on the General tab, not on the Custom Fields tab.
contensio/admin/content-row-badges
Since: 1.6.0
Location: admin/content/index.blade.php, admin/posts/index.blade.php — status badges cell, after the built-in Published / Scheduled / Draft and Review status badges
Inject additional badge chips into the status column of any content or posts listing row. Each registered callback receives the current Content model instance and should return a badge HTML string, or an empty string if the badge does not apply to that item.
The built-in badges use Bootstrap Icons and Tailwind color utilities. Match their markup to keep the listing visually consistent:
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold
bg-{color}-50 text-{color}-700 border border-{color}-200">
<i class="bi bi-{icon}"></i> Label
</span>
Arguments
| # | Name | Type | Description |
|---|---|---|---|
| 1 | $item |
Content |
The content entry for this row. |
Example — mark fake / test posts
use Contensio\Support\Hook;
use Illuminate\Support\Facades\DB;
Hook::add('contensio/admin/content-row-badges', function ($item) {
$isFake = DB::table('content_meta')
->where('content_id', $item->id)
->where('meta_key', '_fake_post')
->where('meta_value', '1')
->exists();
if (! $isFake) {
return '';
}
return '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold '
. 'bg-purple-50 text-purple-700 border border-purple-200">'
. '<i class="bi bi-magic"></i> Fake</span>';
});
Example — highlight sponsored content
Hook::add('contensio/admin/content-row-badges', function ($item) {
$isSponsored = DB::table('content_meta')
->where('content_id', $item->id)
->where('meta_key', 'sponsored')
->where('meta_value', '1')
->exists();
return $isSponsored
? '<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-semibold '
. 'bg-yellow-50 text-yellow-700 border border-yellow-200">'
. '<i class="bi bi-star-fill"></i> Sponsored</span>'
: '';
});
Notes
- Called once per row — keep callbacks fast. Avoid N+1 queries: if you need meta values for many rows, eager-load them before the listing renders (e.g. via a
Hook::addFilteron the query, or by caching the set of matching IDs upfront). - Return
''(empty string) when the badge does not apply — never returnnull. - Multiple plugins can register on this hook simultaneously; badges are concatenated in priority order (lower number = earlier).
contensio/plugin-update-info
Type: Filter
Since: 1.6.0
Source: PluginUpdateChecker::fetch()
Allows any plugin to inject or override update availability information. This is the primary integration point for paid or license-gated plugins whose update packages are not publicly available on GitHub.
The filter receives the current updates array (already populated by GitHub and custom-URL checks) and must return it — modified or unchanged.
Signature
use Contensio\Support\Hook;
Hook::addFilter('contensio/plugin-update-info', function (array $updates): array {
// add, modify, or remove entries
return $updates;
});
Argument shape
// $updates — keyed by vendor/name
[
'contensio/plugin-newsletter' => [
'latest_version' => '1.2.0',
'download_url' => 'https://...zip', // must be https://
'changelog_url' => 'https://...', // optional
],
// ...
]
Example — paid plugin with license server
use Contensio\Support\Hook;
use Contensio\Support\PluginOptions;
use Illuminate\Support\Facades\Http;
Hook::addFilter('contensio/plugin-update-info', function (array $updates): array {
$licenseKey = PluginOptions::get('acme/plugin-awesome', 'license_key');
if (! $licenseKey) {
return $updates; // No license entered yet
}
try {
$response = Http::timeout(5)->post('https://store.acme.example/api/update-check', [
'license' => $licenseKey,
'plugin' => 'acme/plugin-awesome',
'current_version' => '1.0.0',
]);
if ($response->ok() && $response->json('has_update')) {
$updates['acme/plugin-awesome'] = [
'latest_version' => $response->json('version'),
'download_url' => $response->json('download_url'), // token-gated URL
'changelog_url' => $response->json('changelog_url'),
];
}
} catch (\Throwable) {
// Silently skip — don't crash the update check for all other plugins
}
return $updates;
});
Notes
- This filter runs after the GitHub and custom-URL checks, so it can override results from those sources.
- The filter runs inside the daily
contensio:check-plugin-updatescommand, not on every page load. Keep HTTP calls inside it — the 24-hour cache means they run at most once per day. - If your license server is unreachable, return
$updatesunchanged. Never throw — an exception will prevent other plugins from reporting their updates. download_urlmust start withhttps://. HTTP URLs are rejected for security reasons.- The filter is only called for enabled plugins. If a plugin is disabled, its update info is not checked or displayed.