Skip to content

Extending the admin ​

Your app can add its own screens to the admin, such as reports or a booking calendar, with links in its sidebar, add tabs to the page editor, and change how the page tree shows your pages. A screen is a Laravel route with a Blade view, and only users with the permission you name can open it.

A new screen ​

php
use LynxCms\Admin\Admin;
use LynxCms\Admin\AdminAccess;
use LynxCms\Admin\Navigation;

Admin::routes(function (): void {
    // The view, for users with the permission; 403 for others.
    Admin::screen('reports', 'reports::index', 'reports.view')->name('reports'); // lynx.admin.reports
    Route::get('reports/{report}', ShowReportController::class)->name('reports.show');
});

app(Navigation::class)->add('Reports', 'lynx.admin.reports', order: 70, icon: 'chart-bar',
    visible: fn (): bool => app(AdminAccess::class)->allows('reports.view'));

Setup screens belong in a section of the sidebar: group: Navigation::SETTINGS (fields, templates, languages, SEO, sitemap) or Navigation::ACCESS (roles, users). Sections stay closed until opened, unless the current screen is in them, and each browser remembers the ones it opened. order then places the link within its section. Modules can add sections of their own with app(Navigation::class)->group('reports', 'Reports', order: 70, icon: 'chart-bar').

A label can be a translation key, such as 'reports::nav.reports', so the link follows the admin's language.

badge: shows a count or short text beside the link, such as new form submissions; 0 and null hide it:

php
app(Navigation::class)->add('Forms', 'lynx.admin.forms', icon: 'inbox',
    badge: fn (): int => Submission::query()->where('status', 'new')->count());

Links under the page's title in the editor for some pages, such as "Preview form" on form pages, come from EditorLinks:

php
app(EditorLinks::class)->add(fn (Page $page): ?array => $page->template()->name === 'report'
    ? ['label' => 'Open report', 'url' => route('lynx.admin.reports', $page->id)]
    : null);

Tabs in the page editor come from EditorTabs. A tab shows after the template's tabs and before Settings, on the pages its closure accepts, and renders a Livewire component that receives the page's id as pageId. The page cache's Cache tab is one:

php
app(EditorTabs::class)->add('reports', 'Reports', 'reports.page-tab',
    fn (Page $page): bool => $page->template()->hasUrl() && app(AdminAccess::class)->allows('reports.view'));

The key is the tab's value in the URL (?tab=reports). A template's own tab with the same key wins, and content, texts and settings are reserved.

A module that keeps its pages in a hidden branch registers it with AdminBranches: the page tree leaves the branch out, and the closure says where its pages open (null keeps a page in the page editor).

Admin::routes() adds the admin prefix, middleware and access gate. Route to Admin::screen() or controllers, not closures: php artisan route:cache stores a closure by its line, so a closure route written inside the Admin::routes() closure on one line comes back as the wrong closure, and the screen fails once routes are cached.

Wrap the view in the admin layout and use the BlatUI components:

blade
<x-lynx-admin::layout title="Reports" :crumbs="['Pages' => route('lynx.admin.pages')]">
    <x-lynx-admin::ui.page-header title="Reports" description="What happened this month." />
    <livewire:reports />
</x-lynx-admin::layout>

Colours come from theme tokens (bg-primary, text-muted-foreground), never raw palette classes. Writes go through Lynx actions.

Example: reviewing blog posts ​

This example adds a review step to the blog from Your First Site. Editors who hold a new blog.review permission get three things. A Review screen lists the posts with something to fix, and its sidebar link shows how many there are. Each post gets a Checklist tab with a button that writes a missing summary. MCP clients and the agent get the same list as a read tool. The test suite runs every file below.

Write the checks once, in a class that each part of the example uses:

php
/**
 * What a blog post still needs before an editor publishes it. The admin's Review screen, the
 * post's Checklist tab and the MCP tool all ask this class, so they never disagree.
 */
final class PostChecks
{
    /** Search engines cut descriptions at about this many characters. */
    public const int SUMMARY_LIMIT = 160;

    /** @return list<string> what to fix, in an editor's words */
    public static function problems(Page $post): array
    {
        $summary = trim((string) $post->summary);

        return array_values(array_filter([
            $summary === '' ? 'Add a summary.' : null,
            mb_strlen($summary) > self::SUMMARY_LIMIT ? 'Shorten the summary to '.self::SUMMARY_LIMIT.' characters.' : null,
            $post->published_on === null ? 'Set a publication date.' : null,
        ]));
    }

    /**
     * Every post with something to fix, unpublished and hidden ones included, newest first.
     *
     * @return list<array{post: Page, problems: list<string>}>
     */
    public static function queue(): array
    {
        $queue = [];

        foreach (lynx()->pages->find('template=post, include=all, sort=-created') as $post) {
            $problems = self::problems($post);

            if ($problems !== []) {
                $queue[] = ['post' => $post, 'problems' => $problems];
            }
        }

        return $queue;
    }
}

Register everything in a service provider. The permission and the tool work without the admin, so the admin part only runs when the admin is installed:

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Blog\PostChecks;
use App\Blog\ReviewQueueTool;
use App\Livewire\ChecklistTab;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use LynxCms\Admin\Admin;
use LynxCms\Admin\AdminAccess;
use LynxCms\Admin\EditorTabs;
use LynxCms\Admin\Navigation;
use LynxCms\Core\Agents\ReadToolRegistry;
use LynxCms\Core\Pages\Page;

/**
 * Blog review: a permission, an admin screen with a badge, a Checklist tab on posts and an MCP tool.
 */
final class BlogReviewServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // #region permission
        lynx()->permissions()->register('blog.review', 'Review blog posts', requires: ['pages.view']);
        // #endregion permission

        // #region tool
        app(ReadToolRegistry::class)->register(ReviewQueueTool::class);
        // #endregion tool

        // The admin is optional: a site without it still gets the permission and the tool.
        if (class_exists(Admin::class)) {
            $this->registerAdmin();
        }
    }

    private function registerAdmin(): void
    {
        // #region admin
        $canReview = fn (): bool => app(AdminAccess::class)->allows('blog.review');

        Admin::routes(fn () => Admin::screen('blog/review', 'blog.review', 'blog.review')->name('blog.review'));

        // The badge runs on every admin page: cache the count once the blog has thousands of posts.
        app(Navigation::class)->add('Review', 'lynx.admin.blog.review', order: 20, icon: 'clipboard-check',
            visible: $canReview, badge: fn (): int => count(PostChecks::queue()));

        Livewire::component('blog.checklist-tab', ChecklistTab::class);
        app(EditorTabs::class)->add('checklist', 'Checklist', 'blog.checklist-tab',
            fn (Page $page): bool => $page->template()->name === 'post' && $canReview());
        // #endregion admin
    }
}

Admin::screen() renders the view only for users with blog.review; others get a 403. The screen's view wraps the list in the admin layout:

blade
{{-- The Review screen: every post with something to fix, linked to its editor. --}}
<x-lynx-admin::layout title="Review" :crumbs="['Pages' => route('lynx.admin.pages')]">
    <x-lynx-admin::ui.page-header title="Review" description="Posts with something to fix before they go out." />

    @forelse (\App\Blog\PostChecks::queue() as ['post' => $post, 'problems' => $problems])
        <section class="bg-card grid gap-2 rounded-xl border p-4">
            <a href="{{ route('lynx.admin.pages.edit', [$post->id, 'tab' => 'checklist']) }}" class="font-medium">{{ $post->title }}</a>
            <ul class="text-muted-foreground list-disc pl-5 text-sm">
                @foreach ($problems as $problem)
                    <li>{{ $problem }}</li>
                @endforeach
            </ul>
        </section>
    @empty
        <p class="text-muted-foreground text-sm">Every post is ready.</p>
    @endforelse
</x-lynx-admin::layout>

The tab is a Livewire component. Livewire sends every click to its own endpoint, which is outside the admin's routes and their middleware. So the component checks access itself in boot(), which runs on every request, not only the first one:

php
/**
 * The page editor's Checklist tab on posts: what the post still needs, and a button that writes a
 * summary from the start of the body.
 */
final class ChecklistTab extends Component
{
    public int $pageId;

    /**
     * Livewire sends every click to its own endpoint, outside the admin's routes, so the
     * component checks access itself, on every request.
     */
    public function boot(): void
    {
        Gate::authorize(AdminServiceProvider::GATE);
        abort_unless(app(AdminAccess::class)->allows('blog.review'), 403);
    }

    /** A save like any other: the action checks pages.edit and logs the change. */
    public function summarizeFromBody(): void
    {
        $post = $this->post();
        $post->summary = Str::limit(trim(strip_tags((string) $post->body)), PostChecks::SUMMARY_LIMIT - 1, '…', preserveWords: true);
        $post->save();
    }

    public function render(): View
    {
        return view('blog.checklist-tab', ['problems' => PostChecks::problems($this->post())]);
    }

    private function post(): Page
    {
        $post = lynx()->pages->get($this->pageId);
        abort_unless($post instanceof Page && $post->template()->name === 'post', 404);

        return $post;
    }
}
blade
<section aria-labelledby="post-checklist" class="bg-card grid max-w-3xl gap-3 rounded-xl border p-5">
    <h2 id="post-checklist" class="font-medium">Ready to publish?</h2>

    @if ($problems === [])
        <p class="text-sm">Yes. This post has everything it needs.</p>
    @else
        <ul class="grid list-disc gap-1 pl-5 text-sm">
            @foreach ($problems as $problem)
                <li>{{ $problem }}</li>
            @endforeach
        </ul>
        <div>
            <x-lynx-admin::ui.button variant="outline" size="sm" wire:click="summarizeFromBody">Summarize from the body</x-lynx-admin::ui.button>
        </div>
    @endif
</section>

The button saves the post like any other write, so the save action checks pages.edit on that post. If a reviewer has blog.review but not pages.edit, they see the checklist, but the button gets a 403 and writes nothing. The change also shows in the post's history under the reviewer's name.

Field types the field form offers ​

The New field screen asks what a field holds, and lists each type by a name for people ("Paragraphs", "Blocks"), in a group, with an icon and one sentence. Give your type the same, or it shows its type name under Other:

php
app(\LynxCms\Admin\Editor\FieldKinds::class)->register('rating', 'Star rating', 'Choices', 'star', 'One to five stars.');

The icon is a Lucide name. The groups are Text, Numbers and dates, Choices, Pages and media, and Structure; a group of your own comes after them.

A module whose field type only its own schema creates can leave it out of the choices, as the forms module does with options. The type still works through the API and MCP, and existing fields of it still open in the field form.

php
app(\LynxCms\Admin\Editor\FieldTypeChoices::class)->exclude('rating');

A branch the module edits itself ​

A module that keeps its content as pages under a system page (as menus do under /_menus/) registers the branch. The page tree leaves it out, and the page editor sends its pages to the module's screen.

php
app(\LynxCms\Admin\AdminBranches::class)->register('reports-root',
    fn ($page, $root): string => route('lynx.admin.reports', ['page' => $page->id]));

Give the root page the system status so it stays after the other pages.

Page tree rows ​

Implement LynxCms\Admin\Tree\TreePresenter (title(), detail()) and pass it to <livewire:lynx-admin.page-tree :presenter="MyPresenter::class" />.

Released under the MIT License.