Creating Admin Panels
This guide covers the complete process of creating admin panels in the Core PHP Framework, including menu registration, modal creation, and authorization integration.
Overview
Admin panels in Core PHP use:
- AdminMenuProvider - Interface for menu registration
- Livewire Modals - Full-page components for admin interfaces
- Authorization Props - Built-in permission checking on components
- HLCRF Layouts - Composable layout system
Menu Registration with AdminMenuProvider
Implementing AdminMenuProvider
The AdminMenuProvider interface allows modules to contribute navigation items to the admin sidebar.
php
<?php
namespace Mod\Blog;
use Core\Events\AdminPanelBooting;
use Core\Front\Admin\Concerns\HasMenuPermissions;
use Core\Front\Admin\Contracts\AdminMenuProvider;
use Core\Front\Admin\AdminMenuRegistry;
use Illuminate\Support\ServiceProvider;
class Boot extends ServiceProvider implements AdminMenuProvider
{
use HasMenuPermissions;
public static array $listens = [
AdminPanelBooting::class => 'onAdminPanel',
];
public function onAdminPanel(AdminPanelBooting $event): void
{
// Register views and routes
$event->views('blog', __DIR__.'/View/Blade');
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
// Register menu provider
app(AdminMenuRegistry::class)->register($this);
}
public function adminMenuItems(): array
{
return [
// Dashboard item in standalone group
[
'group' => 'dashboard',
'priority' => self::PRIORITY_HIGH,
'item' => fn () => [
'label' => 'Blog Dashboard',
'icon' => 'newspaper',
'href' => route('admin.blog.dashboard'),
'active' => request()->routeIs('admin.blog.dashboard'),
],
],
// Service item with entitlement
[
'group' => 'services',
'priority' => self::PRIORITY_NORMAL,
'entitlement' => 'core.srv.blog',
'item' => fn () => [
'label' => 'Blog',
'icon' => 'newspaper',
'href' => route('admin.blog.posts'),
'active' => request()->routeIs('admin.blog.*'),
'color' => 'blue',
'badge' => Post::draft()->count() ?: null,
'children' => [
['label' => 'All Posts', 'href' => route('admin.blog.posts'), 'icon' => 'document-text'],
['label' => 'Categories', 'href' => route('admin.blog.categories'), 'icon' => 'folder'],
['label' => 'Tags', 'href' => route('admin.blog.tags'), 'icon' => 'tag'],
],
],
],
// Admin-only item
[
'group' => 'admin',
'priority' => self::PRIORITY_LOW,
'admin' => true,
'item' => fn () => [
'label' => 'Blog Settings',
'icon' => 'gear',
'href' => route('admin.blog.settings'),
'active' => request()->routeIs('admin.blog.settings'),
],
],
];
}
}Menu Item Structure
Each item in adminMenuItems() follows this structure:
| Property | Type | Description |
|---|---|---|
group | string | Menu group: dashboard, workspaces, services, settings, admin |
priority | int | Order within group (use PRIORITY_* constants) |
entitlement | string | Optional workspace feature code for access |
permissions | array | Optional user permission keys required |
admin | bool | Requires Hades/admin user |
item | Closure | Lazy-evaluated item data |
Priority Constants
php
use Core\Front\Admin\Contracts\AdminMenuProvider;
// Available priority constants
AdminMenuProvider::PRIORITY_FIRST // 0-9: System items
AdminMenuProvider::PRIORITY_HIGH // 10-19: Primary navigation
AdminMenuProvider::PRIORITY_ABOVE_NORMAL // 20-39: Important items
AdminMenuProvider::PRIORITY_NORMAL // 40-60: Standard items (default)
AdminMenuProvider::PRIORITY_BELOW_NORMAL // 61-79: Less important
AdminMenuProvider::PRIORITY_LOW // 80-89: Rarely used
AdminMenuProvider::PRIORITY_LAST // 90-99: End itemsMenu Groups
| Group | Description | Rendering |
|---|---|---|
dashboard | Primary entry points | Standalone items |
workspaces | Workspace management | Grouped dropdown |
services | Application services | Standalone items |
settings | User/account settings | Grouped dropdown |
admin | Platform administration | Grouped dropdown (Hades only) |
Using MenuItemBuilder
For complex menus, use the fluent MenuItemBuilder:
php
use Core\Front\Admin\Support\MenuItemBuilder;
public function adminMenuItems(): array
{
return [
MenuItemBuilder::make('Commerce')
->icon('shopping-cart')
->route('admin.commerce.dashboard')
->inServices()
->priority(self::PRIORITY_NORMAL)
->entitlement('core.srv.commerce')
->color('green')
->badge('New', 'green')
->activeOnRoute('admin.commerce.*')
->children([
MenuItemBuilder::child('Products', route('admin.commerce.products'))
->icon('cube'),
MenuItemBuilder::child('Orders', route('admin.commerce.orders'))
->icon('receipt')
->badge(fn () => Order::pending()->count()),
['separator' => true],
MenuItemBuilder::child('Settings', route('admin.commerce.settings'))
->icon('gear'),
])
->build(),
MenuItemBuilder::make('Analytics')
->icon('chart-line')
->route('admin.analytics.dashboard')
->inServices()
->entitlement('core.srv.analytics')
->adminOnly() // Requires admin user
->build(),
];
}Permission Checking
The HasMenuPermissions trait provides default permission handling:
php
use Core\Front\Admin\Concerns\HasMenuPermissions;
class BlogMenuProvider implements AdminMenuProvider
{
use HasMenuPermissions;
// Override for custom global permissions
public function menuPermissions(): array
{
return ['blog.view'];
}
// Override for custom permission logic
public function canViewMenu(?object $user, ?object $workspace): bool
{
if ($user === null) {
return false;
}
// Custom logic
return $user->hasRole('editor') || $user->isHades();
}
}Creating Livewire Modals
Livewire modals are full-page components that provide seamless admin interfaces.
Basic Modal Structure
php
<?php
namespace Mod\Blog\View\Modal\Admin;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Mod\Blog\Models\Post;
#[Title('Edit Post')]
#[Layout('admin::layouts.app')]
class PostEditor extends Component
{
public ?Post $post = null;
public string $title = '';
public string $content = '';
public string $status = 'draft';
protected array $rules = [
'title' => 'required|string|max:255',
'content' => 'required|string',
'status' => 'required|in:draft,published,archived',
];
public function mount(?Post $post = null): void
{
$this->post = $post;
if ($post) {
$this->title = $post->title;
$this->content = $post->content;
$this->status = $post->status;
}
}
public function save(): void
{
$validated = $this->validate();
if ($this->post) {
$this->post->update($validated);
$message = 'Post updated successfully.';
} else {
Post::create($validated);
$message = 'Post created successfully.';
}
session()->flash('success', $message);
$this->redirect(route('admin.blog.posts'));
}
public function render(): View
{
return view('blog::admin.post-editor');
}
}Modal View with HLCRF
blade
{{-- resources/views/admin/post-editor.blade.php --}}
<x-hlcrf::layout>
<x-hlcrf::header>
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold">
{{ $post ? 'Edit Post' : 'Create Post' }}
</h1>
<a href="{{ route('admin.blog.posts') }}" class="btn-ghost">
<x-icon name="x" class="w-5 h-5" />
</a>
</div>
</x-hlcrf::header>
<x-hlcrf::content>
<form wire:submit="save" class="space-y-6">
<x-forms.input
id="title"
label="Title"
wire:model="title"
placeholder="Enter post title"
/>
<x-forms.textarea
id="content"
label="Content"
wire:model="content"
rows="15"
placeholder="Write your content here..."
/>
<x-forms.select
id="status"
label="Status"
wire:model="status"
>
<flux:select.option value="draft">Draft</flux:select.option>
<flux:select.option value="published">Published</flux:select.option>
<flux:select.option value="archived">Archived</flux:select.option>
</x-forms.select>
<div class="flex gap-3">
<x-forms.button type="submit">
{{ $post ? 'Update' : 'Create' }} Post
</x-forms.button>
<x-forms.button
variant="secondary"
type="button"
onclick="window.location.href='{{ route('admin.blog.posts') }}'"
>
Cancel
</x-forms.button>
</div>
</form>
</x-hlcrf::content>
<x-hlcrf::right>
<div class="p-4 bg-gray-50 rounded-lg">
<h3 class="font-medium mb-2">Publishing Tips</h3>
<ul class="text-sm text-gray-600 space-y-1">
<li>Use descriptive titles</li>
<li>Save as draft first</li>
<li>Preview before publishing</li>
</ul>
</div>
</x-hlcrf::right>
</x-hlcrf::layout>Modal with Authorization
php
<?php
namespace Mod\Blog\View\Modal\Admin;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class PostEditor extends Component
{
use AuthorizesRequests;
public Post $post;
public function mount(Post $post): void
{
// Authorize on mount
$this->authorize('update', $post);
$this->post = $post;
// ... load data
}
public function save(): void
{
// Re-authorize on save
$this->authorize('update', $this->post);
$this->post->update([...]);
}
public function publish(): void
{
// Different authorization for publish
$this->authorize('publish', $this->post);
$this->post->update(['status' => 'published']);
}
public function delete(): void
{
$this->authorize('delete', $this->post);
$this->post->delete();
$this->redirect(route('admin.blog.posts'));
}
}Modal with File Uploads
php
<?php
namespace Mod\Blog\View\Modal\Admin;
use Livewire\Component;
use Livewire\WithFileUploads;
class MediaUploader extends Component
{
use WithFileUploads;
public $image;
public string $altText = '';
protected array $rules = [
'image' => 'required|image|max:5120', // 5MB max
'altText' => 'required|string|max:255',
];
public function upload(): void
{
$this->validate();
$path = $this->image->store('media', 'public');
Media::create([
'path' => $path,
'alt_text' => $this->altText,
'mime_type' => $this->image->getMimeType(),
]);
$this->dispatch('media-uploaded');
$this->reset(['image', 'altText']);
}
}Authorization Integration
Form Component Authorization Props
All form components support authorization via canGate and canResource props:
blade
{{-- Button disabled if user cannot update post --}}
<x-forms.button
canGate="update"
:canResource="$post"
>
Save Changes
</x-forms.button>
{{-- Input disabled if user cannot update --}}
<x-forms.input
id="title"
wire:model="title"
label="Title"
canGate="update"
:canResource="$post"
/>
{{-- Textarea with authorization --}}
<x-forms.textarea
id="content"
wire:model="content"
label="Content"
canGate="update"
:canResource="$post"
/>
{{-- Select with authorization --}}
<x-forms.select
id="status"
wire:model="status"
label="Status"
canGate="update"
:canResource="$post"
>
<flux:select.option value="draft">Draft</flux:select.option>
<flux:select.option value="published">Published</flux:select.option>
</x-forms.select>
{{-- Toggle with authorization --}}
<x-forms.toggle
id="featured"
wire:model="featured"
label="Featured"
canGate="update"
:canResource="$post"
/>Blade Conditional Rendering
blade
{{-- Show only if user can create --}}
@can('create', App\Models\Post::class)
<a href="{{ route('admin.blog.posts.create') }}">New Post</a>
@endcan
{{-- Show if user can edit OR delete --}}
@canany(['update', 'delete'], $post)
<div class="actions">
@can('update', $post)
<a href="{{ route('admin.blog.posts.edit', $post) }}">Edit</a>
@endcan
@can('delete', $post)
<button wire:click="delete">Delete</button>
@endcan
</div>
@endcanany
{{-- Show message if cannot edit --}}
@cannot('update', $post)
<p class="text-gray-500">You cannot edit this post.</p>
@endcannotCreating Policies
php
<?php
namespace Mod\Blog\Policies;
use Core\Mod\Tenant\Models\User;
use Mod\Blog\Models\Post;
class PostPolicy
{
/**
* Check workspace boundary for all actions.
*/
public function before(User $user, string $ability, mixed $model = null): ?bool
{
// Admins bypass all checks
if ($user->isHades()) {
return true;
}
// Enforce workspace isolation
if ($model instanceof Post && $user->workspace_id !== $model->workspace_id) {
return false;
}
return null; // Continue to specific method
}
public function viewAny(User $user): bool
{
return $user->hasPermission('posts.view');
}
public function view(User $user, Post $post): bool
{
return $user->hasPermission('posts.view');
}
public function create(User $user): bool
{
return $user->hasPermission('posts.create');
}
public function update(User $user, Post $post): bool
{
return $user->hasPermission('posts.edit')
|| $user->id === $post->author_id;
}
public function delete(User $user, Post $post): bool
{
return $user->hasRole('admin')
|| ($user->hasPermission('posts.delete') && $user->id === $post->author_id);
}
public function publish(User $user, Post $post): bool
{
return $user->hasPermission('posts.publish')
&& $post->status !== 'archived';
}
}Complete Module Example
Here is a complete example of an admin module with menus, modals, and authorization.
Directory Structure
Mod/Blog/
├── Boot.php
├── Models/
│ └── Post.php
├── Policies/
│ └── PostPolicy.php
├── View/
│ ├── Blade/
│ │ └── admin/
│ │ ├── posts-list.blade.php
│ │ └── post-editor.blade.php
│ └── Modal/
│ └── Admin/
│ ├── PostsList.php
│ └── PostEditor.php
└── Routes/
└── admin.phpBoot.php
php
<?php
namespace Mod\Blog;
use Core\Events\AdminPanelBooting;
use Core\Front\Admin\AdminMenuRegistry;
use Core\Front\Admin\Concerns\HasMenuPermissions;
use Core\Front\Admin\Contracts\AdminMenuProvider;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Mod\Blog\Models\Post;
use Mod\Blog\Policies\PostPolicy;
class Boot extends ServiceProvider implements AdminMenuProvider
{
use HasMenuPermissions;
public static array $listens = [
AdminPanelBooting::class => 'onAdminPanel',
];
public function boot(): void
{
// Register policy
Gate::policy(Post::class, PostPolicy::class);
}
public function onAdminPanel(AdminPanelBooting $event): void
{
// Views
$event->views('blog', __DIR__.'/View/Blade');
// Routes
$event->routes(fn () => require __DIR__.'/Routes/admin.php');
// Menu
app(AdminMenuRegistry::class)->register($this);
// Livewire components
$event->livewire('blog.admin.posts-list', View\Modal\Admin\PostsList::class);
$event->livewire('blog.admin.post-editor', View\Modal\Admin\PostEditor::class);
}
public function adminMenuItems(): array
{
return [
[
'group' => 'services',
'priority' => self::PRIORITY_NORMAL,
'entitlement' => 'core.srv.blog',
'permissions' => ['posts.view'],
'item' => fn () => [
'label' => 'Blog',
'icon' => 'newspaper',
'href' => route('admin.blog.posts'),
'active' => request()->routeIs('admin.blog.*'),
'color' => 'blue',
'badge' => $this->getDraftCount(),
'children' => [
[
'label' => 'All Posts',
'href' => route('admin.blog.posts'),
'icon' => 'document-text',
'active' => request()->routeIs('admin.blog.posts'),
],
[
'label' => 'Create Post',
'href' => route('admin.blog.posts.create'),
'icon' => 'plus',
'active' => request()->routeIs('admin.blog.posts.create'),
],
],
],
],
];
}
protected function getDraftCount(): ?int
{
$count = Post::draft()->count();
return $count > 0 ? $count : null;
}
}Routes/admin.php
php
<?php
use Illuminate\Support\Facades\Route;
use Mod\Blog\View\Modal\Admin\PostEditor;
use Mod\Blog\View\Modal\Admin\PostsList;
Route::middleware(['web', 'auth', 'admin'])
->prefix('admin/blog')
->name('admin.blog.')
->group(function () {
Route::get('/posts', PostsList::class)->name('posts');
Route::get('/posts/create', PostEditor::class)->name('posts.create');
Route::get('/posts/{post}/edit', PostEditor::class)->name('posts.edit');
});View/Modal/Admin/PostsList.php
php
<?php
namespace Mod\Blog\View\Modal\Admin;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
use Mod\Blog\Models\Post;
#[Title('Blog Posts')]
#[Layout('admin::layouts.app')]
class PostsList extends Component
{
use WithPagination;
public string $search = '';
public string $status = '';
public function updatedSearch(): void
{
$this->resetPage();
}
#[Computed]
public function posts()
{
return Post::query()
->when($this->search, fn ($q) => $q->where('title', 'like', "%{$this->search}%"))
->when($this->status, fn ($q) => $q->where('status', $this->status))
->orderByDesc('created_at')
->paginate(20);
}
public function delete(int $postId): void
{
$post = Post::findOrFail($postId);
$this->authorize('delete', $post);
$post->delete();
session()->flash('success', 'Post deleted.');
}
public function render(): View
{
return view('blog::admin.posts-list');
}
}View/Blade/admin/posts-list.blade.php
blade
<x-hlcrf::layout>
<x-hlcrf::header>
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold">Blog Posts</h1>
@can('create', \Mod\Blog\Models\Post::class)
<a href="{{ route('admin.blog.posts.create') }}" class="btn-primary">
<x-icon name="plus" class="w-4 h-4 mr-2" />
New Post
</a>
@endcan
</div>
</x-hlcrf::header>
<x-hlcrf::content>
{{-- Filters --}}
<div class="mb-6 flex gap-4">
<x-forms.input
id="search"
wire:model.live.debounce.300ms="search"
placeholder="Search posts..."
/>
<x-forms.select id="status" wire:model.live="status">
<flux:select.option value="">All Statuses</flux:select.option>
<flux:select.option value="draft">Draft</flux:select.option>
<flux:select.option value="published">Published</flux:select.option>
</x-forms.select>
</div>
{{-- Posts table --}}
<div class="bg-white rounded-lg shadow">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse($this->posts as $post)
<tr>
<td class="px-6 py-4">{{ $post->title }}</td>
<td class="px-6 py-4">
<span class="badge badge-{{ $post->status === 'published' ? 'green' : 'gray' }}">
{{ ucfirst($post->status) }}
</span>
</td>
<td class="px-6 py-4">{{ $post->created_at->format('M d, Y') }}</td>
<td class="px-6 py-4 text-right space-x-2">
@can('update', $post)
<a href="{{ route('admin.blog.posts.edit', $post) }}" class="text-blue-600 hover:text-blue-800">
Edit
</a>
@endcan
@can('delete', $post)
<button
wire:click="delete({{ $post->id }})"
wire:confirm="Delete this post?"
class="text-red-600 hover:text-red-800"
>
Delete
</button>
@endcan
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-6 py-12 text-center text-gray-500">
No posts found.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{-- Pagination --}}
<div class="mt-4">
{{ $this->posts->links() }}
</div>
</x-hlcrf::content>
</x-hlcrf::layout>Best Practices
1. Always Use Entitlements for Services
php
// Menu item requires workspace entitlement
[
'group' => 'services',
'entitlement' => 'core.srv.blog', // Required
'item' => fn () => [...],
]2. Authorize Early in Modals
php
public function mount(Post $post): void
{
$this->authorize('update', $post); // Fail fast
$this->post = $post;
}3. Use Form Component Authorization Props
blade
{{-- Declarative authorization --}}
<x-forms.button canGate="update" :canResource="$post">
Save
</x-forms.button>
{{-- Not manual checks --}}
@if(auth()->user()->can('update', $post))
<button>Save</button>
@endif4. Keep Menu Items Lazy
php
// Item closure is only evaluated when rendered
'item' => fn () => [
'label' => 'Posts',
'badge' => Post::draft()->count(), // Computed at render time
],5. Use HLCRF for Consistent Layouts
blade
{{-- Always use HLCRF for admin views --}}
<x-hlcrf::layout>
<x-hlcrf::header>...</x-hlcrf::header>
<x-hlcrf::content>...</x-hlcrf::content>
</x-hlcrf::layout>