Admin UI foundation for Vortex apps: same integration pattern as vortexphp/live.
- PHP 8.2+
- vortexphp/framework ^0.12 (application
Packagesupport,publish:assets, Twig loader paths) - Tailwind (optional for consumers): the published
resources/admin.cssis pre-built. To change admin styles, use Node 18+, runnpm installandnpm run buildin this package (seepackage.json).
composer require vortexphp/adminIn config/app.php:
'packages' => [
\Vortex\Live\LivePackage::class,
\Vortex\Admin\AdminPackage::class,
],Publish static assets:
php vortex publish:assetsThis copies package assets (see AdminPackage::publicAssets()): admin.css, JS helpers, and the default panel logo img/vortexadmin.svg. CSS is generated from resources/admin.src.css via Tailwind (npm run build in vortexphp/admin).
Vortex\Admin\Navigation is registered as a container singleton. In your app Package::boot() (after admin routes exist), register entries:
$nav = $container->make(\Vortex\Admin\Navigation::class);
$nav->link('Posts', route('admin.resource.index', ['slug' => 'posts']), icon: '📝');
$nav->link('Site', '/', iconClass: 'size-4 opacity-80'); // decorative span classes (icon font / SVG mask / etc.)
$nav->group('Content', function (\Vortex\Admin\NavGroup $g): void {
$g->link('Notes', route('admin.resource.index', ['slug' => 'notes']));
$g->add(\Vortex\Admin\NavLink::route('Dashboard', 'admin.dashboard'));
});
$nav->add(\Vortex\Admin\NavGroup::make('System', function (\Vortex\Admin\NavGroup $g): void {
$g->link('Settings', '/admin/settings');
}));icon— optional short text or emoji (escaped in Twig).iconClass— optional classes on an empty<span>for your own icon setup (include size utilities here).Navigation::group()/NavGroup— labeled sections in the header; groups containNavLinkrows only.
Arbitrary URLs use NavLink or $nav->link('Label', 'https://…'). Everything renders beside Admin on the dashboard, resource index, and resource forms.
Vortex\Admin\DashboardWidgets is a container singleton. The admin home (/admin, DashboardController) renders dashboardWidgets. Defaults: NoticeWidget (welcome) → AdminOverviewStatsWidget (registered resource count) → TextWidget (config hint) → ResourceLinksWidget. Replace or extend in Package::boot():
use Vortex\Admin\DashboardWidgets;
use Vortex\Admin\Widgets\LinkListWidget;
use Vortex\Admin\Widgets\NoticeTone;
use Vortex\Admin\Widgets\NoticeWidget;
use Vortex\Admin\Widgets\ResourceLinksWidget;
use Vortex\Admin\Widgets\StatsGridWidget;
use Vortex\Admin\Widgets\TextWidget;
$dash = $container->make(DashboardWidgets::class);
$dash->clear()
->add(new NoticeWidget(NoticeTone::Info, 'Welcome back.', 'Hello'))
->add(new StatsGridWidget('Today', [
['label' => 'Orders', 'value' => '12', 'hint' => 'since midnight'],
]))
->add(new LinkListWidget('Tools', [
['label' => 'Reports', 'href' => '/admin/reports', 'description' => 'CSV exports'],
]))
->add(new TextWidget(null, "Plain copy with line breaks.\nSecond line."))
->add(new ResourceLinksWidget('CRUD'));Each widget exposes a kind (Twig partial name under admin/widgets/). Add your own by implementing Vortex\Admin\Widgets\Widget and a matching admin/widgets/{kind}.twig in your app’s Twig paths.
- Add
config/admin.php:
<?php
declare(strict_types=1);
return [
// Scan app/Admin/Resources/*.php (PSR-4 class per file from composer.json "autoload")
'discover' => true,
// Optional: extra classes or duplicates (explicit entries win on slug conflicts)
'resources' => [
// App\Admin\Resources\PostResource::class,
],
// Instead of or in addition to true, use path(s) relative to the project root:
// 'discover' => ['app/Admin/Resources', 'src/More/AdminResources'],
// Absolute paths are allowed when needed.
// Scan app/Admin/Pages/*.php for AdminPage subclasses (same idea as resources).
'page_discover' => true,
// Optional: page classes when discovery is off or for extras (first registration wins on slug)
'pages' => [
// App\Admin\Pages\ReportsPage::class,
],
// Sidebar header (logo + name), document title suffix, footer line — see AdminBranding
'branding' => [
'name' => 'Admin',
'logo' => '/img/vortexadmin.svg', // empty string = icon only; or https://… / absolute path
'logo_alt' => 'Admin',
'footer_vendor' => 'Vortex',
'footer_tagline' => 'control panel',
],
];Custom screens use class-based Vortex\Admin\AdminPage (like Resource): slug(), view(), optional title(), description(), navigationIcon(), showInNavigation(), routeName(). They register GET /admin/{slug} before resource routes. A page slug must not match a Resource::slug() you rely on (the resource is omitted from the registry when the slug collides).
<?php
declare(strict_types=1);
namespace App\Admin\Pages;
use Vortex\Admin\AdminPage;
final class ReportsPage extends AdminPage
{
public static function slug(): string
{
return 'reports';
}
public static function view(): string
{
return 'admin.pages.reports';
}
public static function title(): string
{
return 'Reports';
}
public static function description(): string
{
return 'Optional subtitle shown under the heading and as the sidebar link tooltip.';
}
public static function navigationIcon(): ?string
{
return 'document';
}
}Twig lives at resources/views/admin/pages/reports.twig (for admin.pages.reports). adminPage in the view data equals slug() for sidebar highlighting.
- Scaffold a resource from a model (uses
$fillable+$castsfor column/field types):
php vortex make:admin-resource Post [--slug=posts] [--force]Writes app/Admin/Resources/PostResource.php. Register it under admin.resources in config/admin.php (or rely on discover). The model must have a non-empty $fillable.
Scaffold a page:
php vortex make:admin-page Reports [--slug=reports] [--label=…] [--description=…] [--icon=document] [--hidden] [--no-view] [--force]Creates app/Admin/Pages/ReportsPage.php and resources/views/admin/pages/reports.twig. With page_discover => true, no config line is required; otherwise add the class to pages.
- Implement a resource class extending
Vortex\Admin\Resource(or adjust generated output):
<?php
declare(strict_types=1);
namespace App\Admin\Resources;
use App\Models\Post;
use Vortex\Admin\Forms\Form;
use Vortex\Admin\Forms\TextareaField;
use Vortex\Admin\Forms\TextField;
use Vortex\Admin\Resource;
use Vortex\Admin\Tables\Columns\DatetimeColumn;
use Vortex\Admin\Tables\Columns\TextColumn;
use Vortex\Admin\Tables\Table;
use Vortex\Admin\Tables\TextFilter;
final class PostResource extends Resource
{
public static function model(): string
{
return Post::class;
}
public static function slug(): string
{
return 'posts'; // /admin/posts
}
public static function tablePerPage(): int
{
return 25;
}
/**
* @return list<int>
*/
public static function tablePerPageOptions(): array
{
return [25, 50, 100];
}
public static function table(): Table
{
return Table::make(
TextColumn::make('id'),
TextColumn::make('title'),
DatetimeColumn::make('created_at', 'Created', 'Y-m-d H:i'),
)->withFilters(
TextFilter::make('title', 'Title'),
);
}
public static function form(): Form
{
return Form::make(
TextField::make('title'),
TextareaField::make('body'),
);
}
// Optional: label(), pluralLabel()
}- Your
Modelshould list assignable attributes in$fillableto match what you persist fromform()(and avoid mass-assignment surprises).
Routes (registered by AdminPackage):
| Method | Path | Name |
|---|---|---|
| GET | /admin |
admin.dashboard |
| GET | /admin/{slug} |
admin.resource.index |
| GET | /admin/{slug}/create |
admin.resource.create |
| POST | /admin/{slug} |
admin.resource.store |
| GET | /admin/{slug}/{id}/edit |
admin.resource.edit |
| POST | /admin/{slug}/{id} |
admin.resource.update |
| POST | /admin/{slug}/{id}/delete |
admin.resource.destroy |
Forms include _csrf; invalid CSRF redirects back without flash error (harden further as needed).
Table API
Table::make(...)takes subclasses ofTableColumn(each in its own file underVortex\Admin\Tables\Columns\):TextColumn::make('attr', 'Optional label', maxLength: 80)— default string cell; truncates long values for the grid.NumericColumn::make('price', 'Price', decimals: 2)—->withThousandsSeparator(',')optional.BooleanColumn::make('active')—->labels('Yes', 'No', '—')for display.DatetimeColumn::make('created_at', 'Created', 'Y-m-d H:i')EmailColumn::make('email'),UrlColumn::make('website')— links in the index; anchor text can truncate whilehrefstays full.BadgeColumn::make('status', 'Status', ['draft' => ['label' => 'Draft', 'tone' => 'warning'], ...])— pills; tones:neutral,success,warning,danger.
- Column
displayKind()maps toresources/views/admin/resource/cells/{kind}.twig(add your own column class + partial to extend). - Filters (optional): chain
->withFilters(TextFilter::make('title', 'Contains'), SelectFilter::make('status', ['draft' => 'Draft'], 'Status'), ...). Index uses GET query keysf_{column}(e.g.f_title).TextFilterusesLIKE %…%(wildcards in the value are escaped).SelectFilterwhitelists values against its options map. - Row actions:
Table::make()addsEditRowActionandDeleteRowActionby default. Replace with->withActions(EditRowAction::make('Modify'), ...)or->withActions()(empty — no actions column). ImplementTableRowAction::resolve($slug, $row)to returnkind(link/post/modal),label,routename, androuteParamsfor custom links or POST forms (delete uses POST + CSRF in the template). SeeModalRowActionfor dialogs. - Custom index data:
->records(fn (): array => [...])supplies rows as a list of associative arrays or an id-keyed map (missingiduses each key). Sorting uses the active column’s array values;per_page/pagestill apply. Table filters and global search do not filter this dataset (narrow in the callback or use the DB path). Relation columns on static rows read$row[$columnName](e.g. pre-resolved labels). - Pagination: index uses
QueryBuilder::paginate()with querypagewhenrecords()is not set; withrecords(), pagination usesArrayIndexPaginator. OverridetablePerPage(): intfor the default page size whenper_pageis missing or invalid (clamped1…100). OverridetablePerPageOptions(): array(list of ints) for the “Per page” dropdown; one value hides the control; iftablePerPage()is not in that list, it is merged in. Page links preserveper_pageand filter query params when multiple sizes exist.
Form API (same idea as the table)
Form::make(...)— field order; each field is its own class underVortex\Admin\Forms\.- Built-ins:
TextField,TextareaField,PasswordField,EmailField,NumberField,HiddenField,CheckboxField,ToggleField(switch UI),SelectField::make('role', ['a' => 'A'], 'Label'),DateField,UploadField(multipart; stores underpublic/{dir}/via->to('uploads/posts'), optional->maxKb(),->allowedMimes(),->allowedExtensions(),->accept(),->discardExistingWhenEmpty()),MarkdownField(EasyMDE from CDN),HtmlField(Quill WYSIWYG from CDN — sanitize HTML on output in your app),TagsField(Tagify;->asJson()or CSV->delimiter(',')). - Rich editors load JS/CSS from jsDelivr on resource forms only (
layout{% block scripts %}). Internet access is required in the browser for first load (or vendor assets locally if you replaceform_rich_assets.twig). - Any
UploadFieldsets the form’senctype="multipart/form-data"automatically. - Each field’s
inputKind()maps toresources/views/admin/resource/fields/{kind}.twig. ImplementFormField::toViewArray()/normalizeRequestValue()(andUploadField::normalizeUpload()) when you add types. ResourceControllermerges POST withnormalizeRequestValue(); file fields useRequest::file()and keep the previous path on edit whenkeepExistingOnEmptyis true.
| Piece | Purpose |
|---|---|
AdminPackage::boot() |
Package Twig views + dashboard + resource CRUD routes |
DashboardController |
Lists configured resources |
ResourceController |
Index / create / store / edit / update / destroy |
| CSS | Minimal dark shell (publish:assets) |
| Concern | live | admin |
|---|---|---|
| Composer type | library |
library |
| Entry class | LivePackage |
AdminPackage |
| Extends | Vortex\Package\Package |
same |
publicAssets() |
live.js → public/js/ |
admin.css → public/css/ |
boot() |
Routes + Twig extension | Routes + Factory::addTemplatePath() |
| Views / JS | Package-owned | Package-owned |
Override routes or controllers in your app by registering another package later or by not loading this package and copying the pattern.
