hostkurd / flocms-admin
Admin panel builder for FloCMS: resources declared in PHP, server-rendered CRUD pages, translatable fields and RTL support.
Requires
- php: ^8.1
- ext-json: *
- ext-mbstring: *
- ext-pdo: *
- hostkurd/flocms-core: ^2.3.2
- hostkurd/flocms-reactive: ^0.3.0
Requires (Dev)
- hostkurd/flocms-cli: ^2.0.1
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-13 19:27:08 UTC
README
The admin panel for FloCMS: a dashboard, users management, a profile page, and a builder for your own content. You describe a database table as a PHP class (a resource) and get a list page with search, sorting and pagination, plus create, edit and delete pages, under clean URLs such as /admin/properties/edit/5.
FloCMS provides the security (login, sessions, roles and permissions); flocms-admin provides the screens and always asks FloCMS who may do what.
- Server-rendered Reactive lists and dialogs, plus panel navigation without full document reloads. Compiled assets ship with Composer: no hosting-side build, including on cPanel.
- Translatable fields: one value per site language, stored as JSON in the field's column, edited through a language badge and a dialog.
- Right-to-left admin from the start (Arabic, Kurdish, …), with the right
diron every language input. - Uses FloCMS's own login, CSRF protection, permissions and validation rules.
- Optional and removable: no file of the site is edited.
Requirements
- PHP 8.1+
hostkurd/flocms-core^2.3.2 andhostkurd/flocms-reactive^0.3.0 (installed by Composer)- A stable secret
APP_KEYof at least 32 bytes for signed Reactive snapshots - MySQL 5.7+ or MariaDB 10.3+
Installation
composer require hostkurd/flocms-admin php flo admin:install
admin:install creates two things and changes nothing else:
config/admin.php, the package's settings (deleting it switches the panel off);admin/Resources/, where your resource classes live.
Then create a resource and its table:
php flo make:admin-resource Property --migration php flo migrate
Log in and you land on the dashboard (/admin/dashboard; on skeletons before 1.8, open it yourself or link it from views/pages/admin_index.html). php flo admin:doctor checks the setup at any time.
Create the first admin with php flo user:create if you have not yet.
How it plugs in
FloCMS 2.3 asks controller resolvers for a controller when a URL has no application controller. This package registers one resolver from Composer's autoloader. It answers only for /admin/<slug> where <slug> is one of your resources, and only after FloCMS's admin login check. The skeleton's own /admin dashboard, /admin/users and the login page are untouched; application controllers always win.
That last point has a consequence. If your site has controllers/PropertiesController.php (for a public /properties page), FloCMS uses it for /admin/properties too. Give the resource another slug, e.g. protected static string $slug = 'listings';. admin:doctor reports such collisions, and in debug mode they are logged to storage/logs/app.log.
Dashboard, users and profile
| Page | URL | Who |
|---|---|---|
| Dashboard | /admin/dashboard |
Every logged-in admin user; one card per resource they may view, with its number of records |
| Users | /admin/accounts |
users.manage (Admins and Super Admins in the skeleton) |
| Profile | /admin/profile |
Every logged-in admin user, for their own account |
/admin opens the dashboard on skeleton 1.8+: the package sets Config 'admin.home' and the skeleton's /admin page follows it. Set admin.home in your config/config.php to send /admin elsewhere, or to null to keep the skeleton's page.
Users manages the skeleton's users table with the skeleton's own rules (FloCMS\Core\Auth):
- a role can only be given up to your own (
Auth::canAssignRole()); - only users of a lower role can be edited or deleted, unless you hold
users.manage_any(Auth::canManageUser()); - nobody can delete themselves, change their own role or suspend themselves;
- passwords are hashed like
php flo user:create, never shown, and kept when left empty; - users created here are active and verified (no email is sent); the login defaults to the email;
- suspending a user ends their access on their next request (core's session refresh).
Profile edits your own name, email, login and password. Changing the email or the password asks for the current password; role and status are shown, not editable.
Set 'users' => false in config/admin.php to switch users management and the profile page off. The URLs dashboard, accounts and profile belong to the panel and cannot be used as resource slugs.
Resources
A resource is one table, with the fields of its form and the columns of its list:
<?php declare(strict_types=1); namespace App\Admin\Resources; use FloCMS\Admin\Form\Fields\{Date, Select, Slug, Textarea, TextInput, Toggle}; use FloCMS\Admin\Form\Form; use FloCMS\Admin\Resource; use FloCMS\Admin\Table\Columns\{BadgeColumn, BooleanColumn, DateColumn, TextColumn}; use FloCMS\Admin\Table\Table; final class PropertyResource extends Resource { protected static string $table = 'properties'; protected static string $slug = 'properties'; // URL: /admin/properties protected static bool $timestamps = true; // fills created_at / updated_at protected static string $label = 'Property'; // or a lang key: 'admin.property' protected static string $pluralLabel = 'Properties'; protected static ?string $icon = 'building'; protected static int $navigationSort = 10; public function form(Form $form): Form { return $form->schema([ TextInput::make('title')->translatable()->rules('required|max:190')->columnSpan(2), Slug::make('slug')->from('title')->unique()->rules('required|max:190'), Select::make('type')->options(['sale' => 'For sale', 'rent' => 'For rent'])->required(), TextInput::make('price')->numeric()->rules('min:0'), Date::make('available_from'), Textarea::make('description')->translatable()->rows(8), Toggle::make('published'), ]); } public function table(Table $table): Table { return $table ->columns([ TextColumn::make('id')->sortable(), TextColumn::make('title')->searchable()->sortable(), BadgeColumn::make('type')->colors(['sale' => 'green', 'rent' => 'blue']) ->labels(['sale' => 'For sale', 'rent' => 'For rent']), TextColumn::make('price')->sortable()->format(fn ($value) => number_format((float) $value) . ' €'), BooleanColumn::make('published'), DateColumn::make('updated_at')->sortable(), ]) ->defaultSort('id', 'desc') ->perPage(25); } }
Files named *Resource.php in admin/Resources/ are loaded automatically (the package autoloads the App\Admin\Resources namespace, so the site's composer.json needs no change). To load only some, list the classes under resources in config/admin.php.
Settings
| Property | Default | Meaning |
|---|---|---|
$table |
(required) | Database table |
$slug |
(required) | URL segment: lower case letters, digits and single hyphens |
$primaryKey |
'id' |
Primary key column |
$timestamps |
false |
Fill created_at on insert and updated_at on every save |
$label, $pluralLabel |
from the table / slug | Text, or a key of the site's lang files |
$icon |
'list' |
list, file, building, home, users, tag, image, calendar, mail, box, star, settings |
$navigationSort |
0 |
Order in the sidebar |
$permissionPrefix |
'<permission_prefix>.<slug>' |
See Permissions |
$permissions |
[] |
Per-ability overrides: ['delete' => 'settings.delete'] |
Optional methods:
| Method | |
|---|---|
query(Database $query): Database |
Narrow every query, e.g. ->where('site_id', '=', 1) |
canCreate(): bool |
False hides New and answers 403 |
canEdit(object $record): bool |
False hides Edit for that record and answers 403 |
canDelete(object $record): bool |
False hides Delete and answers 403 |
validate(array $state, ?object $record): array |
Checks after the field rules; return field => message |
normalizeState(array $state, ?object $record): array |
Normalize editable state before field/custom validation and password hashing |
beforeSave(array $data, ?object $record): array |
Change dehydrated data inside the save transaction |
afterSave(object $record): void |
Runs after the write, inside the transaction and before commit |
Only the fields declared in form() are ever written. The primary key, created_at and updated_at never come from the request.
Form fields
| Field | Input | Notes |
|---|---|---|
TextInput |
text | ->email(), ->url(), ->tel(), ->numeric(), ->password(), ->maxLength(190); text and URL inputs can be translatable |
Textarea |
textarea | ->rows(8); translatable; full width by default |
Select |
select | ->options([...]) or ->options(fn () => [...]); only listed values are accepted |
Toggle |
checkbox | Stored as 1 / 0; an unchecked box saves 0 |
Date |
date | Stored as Y-m-d, empty as NULL |
DateTime |
datetime-local | Stored as Y-m-d H:i:s, empty as NULL |
Slug |
text | ->from('title') fills it from the default-language title while empty (in the browser and on save) |
Hidden |
hidden | Written, not shown |
Placeholder |
read-only text | ->content(fn (?object $record) => ...); never written |
Every field: ->label(), ->rules('required|max:190'), ->required(), ->requiredOnCreate(), ->default(), ->help(), ->placeholder(), ->disabled() (shown, never written), ->nullable(), ->unique(), ->columnSpan(2).
Passwords are hashed with password_hash() (use ->password(false) to store them as typed), are never sent back to the browser, and an empty password on the edit page keeps the stored one.
Validation
The rules are FloCMS's own: required, string, integer, numeric, min, max, in. min/max compare numbers when the field also has integer or numeric, and characters otherwise. On top of those, fields check their type (email, URL, date, select option), and ->unique() checks the table, ignoring the record being edited. Messages are shown in the admin's language.
Table columns
| Column | Shows | Options |
|---|---|---|
TextColumn |
text | ->limit(60) |
BooleanColumn |
Yes / No badge | |
DateColumn |
a date | ->dateFormat('Y-m-d H:i') |
BadgeColumn |
a coloured badge | ->colors([...]) (gray, green, blue, yellow, red, purple), ->labels([...]) |
Every column: ->label(), ->sortable(), ->searchable(), ->format(fn ($value, $record) => ...) (the result is escaped), ->translatable(). Search and sort only use columns marked searchable() / sortable().
Translatable fields
Mark a text field ->translatable() and it gets one value per site language, from LANGUAGES and DEFAULT_LANG in .env:
LANGUAGES=en,ar,ku DEFAULT_LANG=en
The form shows the default-language input with a language badge beside it. The badge shows which languages are filled; clicking it opens a dialog with one input per language. Arabic, Kurdish and the other right-to-left languages get dir="rtl". Everything is a real form field, so the page also works without JavaScript.
required applies to the default language only. The other languages are optional, and every other rule (max:190, …) applies to them when they are filled.
Storage
Each translatable field is one column holding a JSON object:
{"en":"Sea view villa","ar":"فيلا بإطلالة على البحر","ku":"ڤیلای ڕووەو دەریا"}
Use a JSON column (on MariaDB this is LONGTEXT with a validity check) or any text column. Empty languages are not stored; a field with no value at all is stored as NULL. Search looks in every language; sorting uses the admin's language.
Existing columns. A column that already holds plain text can be switched to translatable without a data migration: plain text is read as the default language and saved as JSON the next time the record is saved. (Change the column to TEXT first if it is shorter; it must not be a JSON column until all rows are JSON.)
On the website
{{ localized($property->title) }} // current language, falls back to the default language
{{ localized($property->title, 'ar') }} // a given language
localized() returns the value in the requested language, else in the default language, else in the first filled language, else ''. It accepts the raw column value, a decoded array or plain text. The same function is available as FloCMS\Admin\Translation\Translations::get(), plus Translations::all() for every language. If you later remove the package, replace these calls.
Languages, RTL and the panel's own words
- The panel's language is the language of the URL:
/ku/admin/propertiesshows the panel in Kurdish. - Its direction comes from
lng.dirin the site'slang/<code>.php(the skeleton's files have it), else from the package's list of right-to-left languages. - The package ships its words in English, Arabic and Kurdish (Sorani), the languages of the skeleton. A site changes any of them by adding an
admin.<key>line to its own lang file, e.g.'admin.actions.create' => 'Add'. Seeresources/lang/en.phpfor the keys. - Language names in the dialog come from the site's
lng.<code>keys, else the name in that language. - Labels you write (
$label, field labels, options) are shown as written, unless they are keys of the site's lang file, which are translated.
Permissions
Each action needs a permission, checked by FloCMS through Controller::$actionPermissions:
| Page | Permission |
|---|---|
| List | content.<slug>.view |
| Create | content.<slug>.create |
| Edit | content.<slug>.update |
| Delete | content.<slug>.delete |
With the skeleton's default roles, Editors (content.*), Admins and Super Admins can use every resource. Change the content prefix in config/admin.php, or per resource with $permissionPrefix / $permissions. Sites without a permissions map fall back to FloCMS's admin_access check. The sidebar and the Edit / Delete buttons follow the same permissions.
Deleting is a POST with a CSRF token (FloCMS rejects it otherwise) and asks for confirmation first.
Configuration
config/admin.php:
| Key | Default | |
|---|---|---|
resources |
[] |
Classes to load; empty loads every *Resource.php in resources_path |
resources_path |
'admin/Resources' |
Folder of the resource classes |
resources_namespace |
'App\Admin\Resources' |
Their namespace |
brand |
SITE_NAME_ADMIN |
Name at the top of the sidebar |
per_page |
25 |
Rows per page |
users |
true |
Users management (/admin/accounts) and the profile page |
rtl_languages |
ar, ku, ckb, fa, he, ur |
Content languages written right to left |
permission_prefix |
'content' |
Prefix of the permission names |
The panel is drawn inside the active template's layouts/admin.html, which must print $data['content'] (the skeleton's does). Its CSS and JavaScript are printed inline, so there is nothing to publish and nothing goes stale after composer update.
Commands
| Command | |
|---|---|
php flo admin:install |
Create config/admin.php and admin/Resources/ (never overwrites) |
php flo make:admin-resource Property [--table=properties] [--slug=properties] [--migration] [--force] |
Create a resource class, and optionally a migration |
php flo admin:doctor |
Check config, languages, layout, the panel's pages, slug collisions, tables and columns |
Removing the package
composer remove hostkurd/flocms-admin
Then delete config/admin.php and admin/ (your own code, so the package never deletes it). The package creates no tables. Templates that call localized() need a replacement.
Roadmap
- 0.3: Reactive resource dialogs and lists, shared form submission, panel navigation and browser coverage
- Next: richer Actions/Forms/Tables, then filters, image/file fields, bulk operations, notifications and dashboard widgets
- Later: relationships, a settings page, more generators
Development
composer install vendor/bin/phpunit npm ci npm run typecheck npm run build npx playwright install chromium npm run test:browser
The MySQL / MariaDB tests run when FLO_ADMIN_TEST_DB_NAME (and _HOST, _PORT, _USER, _PASS) are set; the rest use SQLite.
License
MIT
Upgrading to 0.3.0
See UPGRADING.md for dependencies, key setup, form limits, concurrency, and retained 0.2.1 correctness fixes. See the Reactive Admin guide for the runnable resource example and browser checks.