Skip to content

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

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'),
                ],
            ],
        ];
    }
}

Each item in adminMenuItems() follows this structure:

PropertyTypeDescription
groupstringMenu group: dashboard, workspaces, services, settings, admin
priorityintOrder within group (use PRIORITY_* constants)
entitlementstringOptional workspace feature code for access
permissionsarrayOptional user permission keys required
adminboolRequires Hades/admin user
itemClosureLazy-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
GroupDescriptionRendering
dashboardPrimary entry pointsStandalone items
workspacesWorkspace managementGrouped dropdown
servicesApplication servicesStandalone items
settingsUser/account settingsGrouped dropdown
adminPlatform administrationGrouped 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');
    }
}
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>
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'));
    }
}
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

Released under the EUPL-1.2 License.