Search by

modufolio / panel

modufolio

Schema-driven admin panel: resources, table schemas, drawers and blueprint forms, emitted as JSON for @modufolio/panel to render. Framework-agnostic; see modufolio/panel-module (appkit) and modufolio/panel-bundle (Symfony).

Package info

github.com/modufolio/panel

pkg:composer/modufolio/panel

Statistics

Installs: 34

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.10.0 2026-09-16 22:56 UTC

This package is not auto-updated.

Last update: 2026-09-16 22:58:41 UTC


README

CI PHPStan Packagist License: MIT

The PHP half of the Modufolio panel: resources, table schemas, drawers and blueprint forms, emitted as plain data for @modufolio/panel to render.

Nothing here renders HTML. A resource declares what a listing is — its columns, filters, drawer tabs, permissions — and the package serialises that to JSON. How it reaches a browser is the host application's business, expressed through two small interfaces.

final class EventResource extends PanelResource
{
    public function key(): string            { return 'events'; }
    public function entityClass(): string    { return Event::class; }
    public function menu(): Menu             { return Menu::make('Events', icon: 'calendar'); }

    public function table(): TableSchema
    {
        return TableSchema::make()
            ->filters([Filter::select('type')->options(EventType::class)])
            ->defaultSort('startsAt')
            ->columns([
                Column::make('title')->searchable()->linksToRecord()->weight('medium'),
                Column::make('when')->value('starts_at')->linksToRecord(),
            ]);
    }
}

No presenter and no list query class: the rows are read off the entity through the columns (when reads getStartsAt()), and sorting, search, the default order and the soft-delete scope come from the table, built from the same query objects a hand-written AbstractListQuery chains. Override present() or name a class with listQueryClass() when a resource needs what its columns cannot say.

What the host must provide

The package depends on no framework. It types against four contracts of its own, and a host fills them in its container:

  • Contracts\UserInterface — the viewer, a bearer of roles. The host's user class adds the interface; the one method, getRoles(), is what every security layer already exposes.
  • Contracts\CurrentUserInterface — who is asking, read from wherever the host keeps its authenticated token.
  • Contracts\PageRendererInterface — how a Http\Page (a component and its props) reaches the browser: an Inertia value the host's kernel finishes, or a response.
  • Contracts\ExportAdapterProviderInterface and Contracts\PermissionReportProviderInterface — optional, for downloads and the permission page.

The panel names components and props; how they reach the browser is the host's answer. The package never names Inertia, a template engine or a session. That is what lets one panel serve several applications, on several frameworks, that answer those questions differently.

Every generated route dispatches to Http\ResourceController, which the package ships — one class per operation under Http\Operation: index, show, create, store, edit, update, destroy, bulk delete, delete preview, export, relation lookups, relation rows, declared actions and board moves. The search and the permission page, which name no resource, are Http\PanelController's. Wiring both from a container is the job of a framework bridge: modufolio/panel-module for appkit (list its PanelModule in config/modules.php), modufolio/panel-bundle for Symfony. A bridge fills the contracts above, names the media entity for the FormResolver, and ships a no-formats export provider as a default the host may override. There is no controller to write.

Authorization

One class per resource, extending Resource\Permissions, whose every method answers "yes" until overridden. The resource returns it from permissions(), so a rule that needs a service takes it through the resource's constructor:

final class EventPermissions extends Permissions
{
    public function __construct() { parent::__construct(['ROLE_USER']); }

    public function delete(?object $record, ?object $user): bool { return false; }

    public function scope(QueryBuilder $qb, string $alias, ?object $user): void
    {
        $qb->andWhere("{$alias}.tenant = :t")->setParameter('t', $user?->tenant());
    }

    public function writable(string $field, ?object $user, ?object $record = null): bool
    {
        return $field !== 'notes' || $user?->isAdmin() === true;
    }
}
Layer Method
Route roles() — stored on every generated route, enforced by the kernel
Operation view() / create() / edit() / delete() / export() / useForm()
Row scope($qb, $alias, $user) — what the listing, the record lookup, every picker pointing at the resource and the delete plan can see at all
Field readable($field, $user, $record) / writable($field, $user, $record) — per user, and per record when there is one
Board move($record, $lane, $user) — which drags a workflow allows

A field this user may not read is never serialised; one they may not write renders disabled and has its submitted value dropped, so the disabled input is presentation and the server is the guard. See docs/fields.md.

The guards run inside the package's own form services: FormPresenter applies the read side when it serialises a form, and SubmissionHandler applies the write side, in a fixed order, before anything is validated. A host that routes its writes through SubmissionHandler gets them for free; a hand-written write path has to reproduce that order itself, or it yields a form that looks guarded and is not — the wiring.

The write side

A generated create, edit or delete route needs more than the declaration, and the package provides it as plain services a host controller composes:

Service Answers
Form\FormResolver which form a resource has — hand-written or guessed from Doctrine
Form\FormPresenter the fields this viewer may see, relations resolved, computed values filled
Form\SubmissionHandler a request body to a persisted record, or errors keyed by field; one row of a relation edited, removed or reordered
Delete\PlanExecutor carrying out what the delete Collector planned, in one transaction
Resource\RecordLocator the record a URL names, through the resource's own scope
Resource\Action something a viewer may do to a record or a selection, declared on the resource and routed, guarded, validated and run by the package
Resource\Scoped the one way to start a query over a resource's rows for a viewer

What stays in the host is HTTP: who is asking, and which redirect or JSON a refusal, a validation failure or a success becomes.

Read-only resources

Create, edit, update and delete routes are generated only when form() returns non-null. A resource that declares no form is index-and-show only, with no configuration.

Development

The package is developed beside its sibling packages and resolves them through composer path repositories, so it installs and tests on its own:

composer install
composer test        # unit and database suites, SQLite in memory
composer stan        # PHPStan, level 8

The Database suite runs the listing, guesser, relation and delete machinery against a real EntityManager over the fixture entities in tests/Fixture/Entity. It needs no setup: SQLite in memory is the default. The same DB_* variables the sibling packages read point it at a real engine, and docker-compose.yml has one of each ready:

docker compose up -d mysql postgres
DB_DRIVER=pdo_mysql DB_PORT=3309 DB_USER=root DB_PASSWORD=secret composer test:db
DB_DRIVER=pdo_pgsql DB_PORT=5435 DB_USER=postgres DB_PASSWORD=secret composer test:db

CI runs that suite against MySQL 8.4, PostgreSQL 16 and SQL Server 2022 on every push.

Those repositories entries apply only when this package is the root; a consuming application resolves the siblings its own way.

Documentation