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.