Skip to content
LibxaFrame

LibAdmin

The admin panel.

Version
0.6.0
Category
Admin
Requires
PHP 8.3, libxa/framework ^0.11.1
Install
composer require libxa/lib-admin

A security-first admin panel for LibxaFrame: resources, pages, widgets and a plugin system.

Installing

composer require libxa/lib-admin
php libxa migrate
php libxa admin:make-user

The panel is served at /admin by default. Change it with ADMIN_PATH if something else already uses that prefix.

ADMIN_PATH=panel

Resources

A resource is CRUD over a model, declared rather than written:

final class PostResource extends AdminResource
{
    protected static string|null $model = Post::class;
    protected static string $icon = 'file-text';

    public function fields(): array
    {
        return [
            TextInput::make('title')->required(),
            Textarea::make('body'),
            Toggle::make('published'),
        ];
    }
}

The fields you declare are the only ones that can be written. That is the write allow-list, not a form hint. A form that posts is_admin writes nothing unless the resource says it is a field.

Two rules worth knowing

Both exist because the panel got them wrong, and both are the kind of mistake that produces no error.

A URL segment never names a table. The resource slug is resolved against the registered resources; the table comes from the resolved resource's model. Before 0.2.0 the segment was used directly as a table name, so /admin/resources/<any table> read, wrote and deleted any table in the database, and one path interpolated it into raw SQL.

A request value never lands where an identifier goes. Table and column names cannot be bound as parameters, so a column taken from the query string for sorting or filtering is checked against the resource's own columns first.

Plugins

A plugin is a Composer package that adds to the panel without editing it.

final class BlogPlugin implements Plugin
{
    public function id(): string { return 'acme/blog'; }

    public function register(AdminPanel $panel): void
    {
        $panel->registerResources([PostResource::class]);
    }

    public function boot(AdminPanel $panel): void
    {
        $panel->registerNavigation([
            ['label' => 'Blog', 'url' => '/admin/resources/posts'],
        ]);
    }
}

Every plugin registers before any boots, so a plugin inspecting the panel sees all of it rather than whatever Composer autoloaded first. Dependencies are declared and ordered; a missing one skips the plugin with a message naming it.

A plugin that throws is recorded and skipped. An admin panel is what you open when something is already wrong, and a third-party package must not be the reason you cannot look.

Full guide: docs/PLUGINS.md

Commands

php libxa admin:make-user
php libxa admin:make-resource PostResource
php libxa admin:make-widget
php libxa admin:roles
php libxa admin:assign-role
php libxa admin:sync-permissions

Media

Uploads are checked by their contents, not by the name or the type the browser reports:

app('admin.media')->store($request->file('file'), $actorId);

UploadedFile::getMimeType() returns $_FILES['type'], which the client sets, so the type is detected from the bytes instead. The extension and the detected type must then agree: an extension proves nothing on its own, and a valid image signature on a file called shell.php is a polyglot.

The stored filename is generated, so the client's name never becomes a path. The original is kept as a label, where it is data rather than a path.

Accepted: jpg, jpeg, png, gif, webp, avif, pdf, txt, csv, zip. An allow-list, because deny-lists lose to .php5, .phtml, .phar and whatever the next handler mapping adds.

SVG is excluded deliberately. It is XML, it can carry script, and the browser runs it in the origin that served it, so an SVG upload is stored XSS on your own admin domain.