Polymore is a lightweight PHP system engine: one structured definition file becomes a complete backend. This page explains how the core works, how to install and deploy it, and how to shape systems using seeds, themes, and config.
Seed defines structure → Polymore generates admin + APIs → storage persists records → theme controls look.
Core concepts
- Seed: a JSON file that describes your collections (entities) and their fields.
- Config: system settings/toggles (storage, feature flags, base paths, etc.).
- Theme: UI tokens (colors/typography) that become CSS variables.
- Generated screens: list, create, edit, show pages (plus actions, filters, search).
- JSONL storage: a simple file format used to store records without a database.
What it is / isn’t
It is
- an engine to build admin panels quickly
- a system generator for internal tools and MVP backends
- a definition-driven framework with a stable core
It isn’t
- a Laravel/Symfony replacement
- a CMS like WordPress
- a full-stack platform with a mandatory front-end
Requirements
- PHP 8+ recommended (works with typical shared-host PHP setups).
- Apache (or Nginx) with URL rewriting enabled.
- Writable data directory for JSONL storage, uploads, and sessions.
Local setup
- Point your web server document root to the
publicdirectory. - Ensure rewrite rules are enabled so requests route through the front controller.
- Confirm the data/storage directory is writable by PHP.
- Open
/adminand log in.
public/
index.php # front controller
.htaccess # rewrite rules
app/
Core/
Dashboard/
data/
system/
collections/
uploads/
First run checklist
- Can you load the landing route without 404?
- Does
/adminresolve when hosted in a subfolder (e.g./seed/public/admin)? - Do create/edit forms save records and show them in the list?
- Do uploads store files and render previews?
Deployment (shared hosting)
On shared hosting, the most reliable approach is to deploy the project and point your domain/subdomain to the
public folder. If your host does not allow changing the document root, use a rewrite rule that forwards
all requests to public/index.php.
Every request should go through the front controller so routing, auth, and 404 handling stay consistent.
Deployment (subfolder hosting)
Polymore is designed to work when hosted inside any folder (for example /seed).
Links and routing should resolve correctly without hardcoded bases. Make sure your rewrite rules do not force
a fixed RewriteBase /.
Examples:
http://localhost/seed/public/admin
http://example.com/tools/polymore/public/admin
Security notes
- Do not expose the data directory publicly.
- Uploads should be served from a controlled path (and validated by file type/size).
- Keep sessions stored server-side (file sessions) and avoid logging sensitive data.
API overview
Polymore exposes a REST-like JSON API under /api. The API is generated from the same collection schema
used by the admin dashboard. You enable the API per collection using "api": { "enabled": true }.
"posts": {
"fields": { "title": { "type": "text" } },
"api": { "enabled": true }
}
API access is controlled either by a Bearer token or by the current logged-in user session (with role checks).
API authentication
Polymore supports two API authentication modes:
- Bearer tokens: configure
api.tokensindefault.config.json, then sendAuthorization: Bearer <token>. - Dashboard session: if no valid bearer token is supplied, Polymore checks the current session user and applies per-collection API roles.
{
"api": {
"tokens": ["YOUR_LONG_RANDOM_TOKEN"]
}
}
If the request is authenticated using the dashboard session, Polymore enforces collection API roles
(list/show/create/update/delete). If no roles are defined, access is allowed.
API endpoints
For a collection posts with API enabled:
GET /api/posts # list records
POST /api/posts # create record
GET /api/posts/{id} # show record
PUT /api/posts/{id} # update record
PATCH /api/posts/{id} # partial update record
DELETE /api/posts/{id} # delete record
API responses are JSON objects with a consistent envelope:
{
"success": true,
"data": { ... },
"meta": { ... } // list only
}
Validation failures return 422 with an errors object keyed by field name.
Search, filters, pagination
Listing requests support pagination and query-based filtering:
GET /api/posts?page=1&per_page=25
GET /api/posts?search=invoice&sort=created_at&direction=desc
Filters are derived from your admin list filter fields (admin.list.filters).
For every filter field, Polymore reads a query param with the same name and performs an exact match.
// If admin.list.filters = ["status","user_id"]
GET /api/posts?status=active&user_id=usr_admin
Hooks overview
Hooks let you add custom logic without modifying the core engine. Hooks are plain PHP functions defined in
app/hooks/hooks.php and referenced by name from your seed under each collection.
The engine loads app/hooks/hooks.php once and calls a function if it exists. If the function is missing,
the hook is skipped.
Create a hook
- Open
app/hooks/hooks.php. - Add a new function with a unique name.
- Reference that function in your seed under the relevant collection’s
hooksblock.
<?php
declare(strict_types=1);
function posts_before_create(array $data, array $context): array
{
$data['title'] = trim((string) ($data['title'] ?? ''));
return $data;
}
"collections": {
"posts": {
"hooks": {
"before_create": "posts_before_create"
}
}
}
The $context argument provides runtime information. It includes collection, schema,
the authenticated user (if any), and a channel (either dashboard or api).
For API calls it also includes action, method, and optional id.
Supported events
Each collection can define any of the following keys under hooks:
- Note: These hooks run for both dashboard CRUD and API requests.
- before_list: receives a query array and may return a mutated query array (pagination/search/sort/filters).
- after_list: receives the pagination array and may return a mutated pagination array (for display/computed fields).
- before_show: receives the record array and may return a mutated record array (for display/computed fields).
- after_show: receives the record array (return value ignored).
- before_validate (create + update): receives raw input array and may return a mutated input array.
- after_validate (create + update): receives validated/sanitized data array (return value ignored).
- before_create: receives validated data array and may return a mutated data array.
- after_create: receives the created record array (return value ignored).
- before_update: receives validated data array and may return a mutated data array.
- after_update: receives the updated record array (return value ignored).
- before_delete: receives the existing record array (return value ignored).
- after_delete: receives the deleted record array (return value ignored).
Mutators: before_list, after_list, before_show, before_validate,
before_create, before_update.
All other hooks are “listeners” (their return value is ignored).
Examples
1) Default a value on create
function posts_before_create(array $data, array $context): array
{
if (!isset($data['status']) || $data['status'] === []) {
$data['status'] = ['draft'];
}
return $data;
}
2) Normalize input before validation
function posts_before_validate(array $input, array $context): array
{
if (isset($input['title'])) {
$input['title'] = trim((string) $input['title']);
}
return $input;
}
3) Hide fields in API responses
Use the same collection hooks that power the dashboard. For example, remove a field for record endpoints:
function posts_before_show(array $record, array $context): array
{
unset($record['internal_note']);
return $record;
}
And for list endpoints, remove it from every row:
function posts_after_list(array $pagination, array $context, array $query): array
{
$rows = $pagination['data'] ?? [];
if (is_array($rows)) {
foreach ($rows as $i => $row) {
if (is_array($row)) {
unset($row['internal_note']);
$rows[$i] = $row;
}
}
$pagination['data'] = $rows;
}
return $pagination;
}
Seed files
Seed files live at the project root. If multiple seeds exist, Polymore selects the one with the highest
version. A minimal seed can be used for quick testing.
default.seed.json
minimal.seed.json
clinic.seed.json
Collections
A collection defines an entity in the admin. Each collection has fields, list behavior, and optional API/admin flags.
{
"version": "2.0",
"collections": {
"posts": {
"fields": {
"title": { "type": "text", "required": true },
"status": { "type": "select", "options": ["draft","active"] }
}
}
}
}
Fields
Fields determine how data is collected, validated, displayed, and stored. Typical supported field types:
- text, textarea, number, email, password
- date, datetime (supports created_at/updated_at auto timestamps)
- boolean (list filters use Yes/No select)
- select (single or multiple)
- relation (points to another collection)
- image, file (uploads)
- json, tags
System timestamps
created_at and updated_at are system-managed fields:
they display in view mode (human-friendly), are not editable, and are updated automatically on create/update.
"created_at": { "type": "datetime", "auto": "created_at", "readonly": true },
"updated_at": { "type": "datetime", "auto": "updated_at", "readonly": true }
In view mode, timestamps render like “3 min ago”, with the exact ISO timestamp available on hover.
Select & multi-select
Use type: "select". For multi-select, set "multiple": true.
The admin renders a searchable, select2-like UI without external libraries.
"status": {
"type": "select",
"multiple": true,
"options": [
{ "value": "draft", "label": "Draft" },
{ "value": "active", "label": "Active" }
]
}
Options can be an array of {value,label}, an array of strings, or a value→label map.
Relations
A relation field stores an id (or key) referencing another collection. For example, connect posts to the built-in users collection.
"user_id": {
"type": "relation",
"collection": "users",
"display_field": "email",
"required": true
}
Defaults & rules
- If a field label is missing, Polymore uses a default label from the field name (ucfirst + underscores to spaces).
- If an
idfield is not defined, Polymore injects a hidden id field automatically. - The
userscollection is built-in and should not be defined in your seed.
App name is part of the seed (seed.app.name), while system toggles belong in config.
Theme files
Theme JSON lives at the project root and uses the naming pattern {slug}.theme.json
(for example default.theme.json). The seed/config chooses the theme using app.theme.
The theme is compiled into CSS variables and applied to the admin UI.
{
"colors": { "primary": "#46dfb1", "bg": "#f8faf9" },
"typography": { "font_family": "Inter, system-ui" }
}
You can also provide light/dark palettes explicitly:
{
"colors": {
"light": { "bg": "#f8faf9", "text": "#121715", "primary": "#46dfb1" },
"dark": { "bg": "#061b24", "text": "#eef2f7", "primary": "#46dfb1" }
},
"typography": { "font_family": "Inter, system-ui" }
}
Tokens
- Colors → background, text, borders, primary accents
- Typography → font family and sizing
- Spacing/radius → rounded corners and layout feel
Color keys become --color-* CSS variables (underscores become dashes). Typography becomes --font-family.
Dark / light
Polymore supports dark and light modes. The active mode is stored in session and applied using a theme attribute on the root element. Your theme can provide a dark palette so the colors stay consistent across modes.
- Theme mode is toggled inside the dashboard and persisted in the server-side session.
- Dark tokens are applied using
html[data-theme="dark"].
Config files
System configuration lives in root config JSON (for example default.config.json).
It contains toggles and runtime settings that apply to the whole system, not per collection.
{
"app": { "theme": "default", "timezone": "UTC", "debug": true },
"auth": { "enabled": true, "signup_enabled": true },
"uploads": { "root": "storage/uploads", "public_url": "/uploads" },
"data": { "storage": "jsonl" },
"api": { "tokens": [] }
}
The runtime merges config and seed:
seed.app overrides config app values (so you can ship a global config and customize per seed).
Data & files
Polymore stores runtime data under data/. Records are stored per collection and sessions are stored server-side:
data/
collections/
posts/records.jsonl
users/records.jsonl
system/
sessions/
Records are stored in JSONL: each line is a JSON object. This keeps storage simple and portable.
{"id":"pst_1a2b","title":"Hello","status":["draft"],"user_id":"usr_admin","created_at":"2026-04-30T10:12:00+00:00"}
{"id":"pst_3c4d","title":"Second","status":["active"],"user_id":"usr_admin","created_at":"2026-04-30T11:05:00+00:00"}
To back up Polymore, back up data/ and storage/uploads. Those contain your records and files.
Storage
Storage can be configured globally (for example JSONL). Collections do not need to repeat the storage type. Records are written to disk and displayed in the dashboard.
JSONL format means each line is a JSON object. It is easy to write, easy to move, and good for small to medium datasets.
Uploads
Uploads are configured globally under uploads:
"uploads": {
"root": "storage/uploads",
"public_url": "/uploads",
"max_size_mb": 10
}
The admin includes a styled browse button and shows an image preview before submission for image fields.
Upload fields store a relative path (for example products/images/prd_...jpg). Files are served by the runtime under:
/uploads/<relative_path>.
Sessions
Polymore uses file-based PHP sessions stored under data/system/sessions. This makes the app work on shared hosting without extra services.
- Theme mode is stored in session (
theme_mode). - Auth uses session
user_idto identify the current user.
Auth rules
- Auth URLs are not configurable to keep routing stable.
- Users are built-in; seeds should reference users via relation fields.
Auth endpoints (fixed):
/login
/logout (POST)
/signup
/forgot-password
/reset-password
Routing
Routing is handled through a front controller. This enables clean URLs, centralized auth checks, and consistent 404 pages. Rewrite rules must pass requests through the public entry point while preserving subfolder deployments.
To add a new field type, create a new field class (extends the base Field) and map the type in the field renderer. This keeps the core clean while allowing growth.