modufolio / panel
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).
Requires
- php: >=8.4
- doctrine/orm: ^3.0
- modufolio/http: ^0.2
- modufolio/json-api: ^0.9
- psr/clock: ^1.0
- psr/http-message: ^2.0
- symfony/config: ^7.0
- symfony/http-foundation: ^7.0
- symfony/options-resolver: ^7.0
- symfony/routing: ^7.0
- symfony/uid: ^7.0
- symfony/validator: ^7.0
Requires (Dev)
- phpstan/phpstan: ^2.2
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^12.0
- ramsey/uuid-doctrine: ^2.1
- symfony/cache: ^7.0
- symfony/clock: ^7.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
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 aHttp\Page(a component and its props) reaches the browser: an Inertia value the host's kernel finishes, or a response.Contracts\ExportAdapterProviderInterfaceandContracts\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:
FormPresenterapplies the read side when it serialises a form, andSubmissionHandlerapplies the write side, in a fixed order, before anything is validated. A host that routes its writes throughSubmissionHandlergets 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
- docs/adding-a-resource.md — the recipe, and the failures that are silent
- docs/graduating-a-resource.md — the ladder from a generated resource to a fully custom page, one rung at a time
- docs/panel-resources.md — why resources are
composed rather than inherited, and what
ResourceListingemits - docs/fields.md — blueprint forms: field types, conditions, defaults, per-field access, and the guards an application has to call
- docs/table-schema.md — columns, filters, groups, constraints