php-admin/docs/creating-admin-panels.md
Snider a91849fb53 docs: add package documentation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:47:50 +00:00

931 lines
26 KiB
Markdown

# 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 items
```
### Menu 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>
@endcannot
```
### Creating 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.php
```
### Boot.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>
@endif
```
### 4. 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>
```
## Learn More
- [Admin Menus](/packages/admin/menus)
- [Livewire Modals](/packages/admin/modals)
- [Form Components](/packages/admin/forms)
- [Authorization](/packages/admin/authorization)
- [HLCRF Layouts](/packages/admin/hlcrf-deep-dive)