lumnd/platoadmin

Back office scaffold on PlatoPHP: an admin application plus a contract-generated JSON API

Maintainers

Package info

github.com/lumnd/platoadmin

Type:project

pkg:composer/lumnd/platoadmin

Transparency log

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.1 2026-08-24 12:41 UTC

This package is not auto-updated.

Last update: 2026-08-25 07:40:28 UTC


README

English · 中文

A back office scaffold on PlatoPHP, and the reference application for it: everything the framework documentation describes in the abstract is here as code that runs.

Two applications share one tree. admin renders Smarty templates; api is a JSON service whose controllers are generated from the contracts in api/contracts/. Both sit on the models, services and language packs under common/. Start a project from it and the parts every back office rewrites from scratch — accounts, roles, permissions, sessions, logs, menus, settings, scheduled tasks — are already there.

MIT licensed, like the framework.

The dashboard: account and sign-in counters over the recent sign-in log

Roles, each with the number of actions it grants and the number of accounts in it Accounts, with role, status, two-step verification and last sign-in
Roles — a role is a set of ct:ac permissions Accounts — status and two-step state at a glance
The operation log: who did what, tied to the session that did it Live sessions, each of which can be ended
Operation log — no delete; pruning is by age and is itself logged Sessions — the authority is the database row, not the cookie

Screens shown with seeded demo data. More: docs/assets/screenshots/

Try it: https://admin.platophp.com/ — sign in as demo / platoadmin.

The demo signs in to a read-only role, so every screen and every form opens and nothing writes; it resets to the seeded state every half hour. Two behaviours you will meet and which are not faults: a write comes back 403, and opening the form of a role that holds permissions the demo does not is refused, because an account cannot grant what it does not hold itself.

What is in it

Back office (admin)

  • Accounts and roles — a role is a set of permissions, written as ct:ac / ct:* / *. An account cannot grant a permission it does not hold itself.
  • Sign-in — password plus optional two-step verification, rate limited per source address. An account can be made to change its password on first use, restricted to a set of addresses, or given an expiry date.
  • Sessions — the authority is the database row, not the cookie, so any live session can be signed out from the list and a suspension takes effect on the next request.
  • Logs — a sign-in log and an operation log, with each operation tied to the session that performed it. Neither has a delete: pruning is by age and is itself logged.
  • Menu — a tree, maintained in the back office, whose items line up with controller actions.
  • Settings and scheduled tasks — tasks are defined in configuration; the screen can disable and restore one and shows how long each run took and whether it succeeded.

API (api)

  • The auth chain: send_code, login, refresh, logout.
  • Sign-in by email code, SMS code, Google or Apple. Deliberately no password sign-in.
  • The access token is a JWT (HS256, two hours). The refresh token is stored as a sha256 digest, used once, and rotated.
  • Controllers and the OpenAPI document are both generated from api/contracts/. The code a person writes lives in api/app/logic/.

Shared by both

  • One failure mechanism: throw biz_exception. common\middleware\catcher is the only place that turns an exception into a response — there is no sentinel return value and no error map.
  • Deterministic field encryption (so an encrypted column can still be matched on equality), soft deletes, and one response envelope: {code, msg, data, timestamp}.
  • Language packs for zh-cn and en, phpstan level 8, Pest tests.

What is deliberately not in it

Not omissions — decisions, listed so nobody goes looking:

  • No product domain. User profiles, content, subscriptions and payment are not settled, so the main tree has neither their tables nor their endpoints.
  • The SMS channel is a stub. common\integration\sms sends nothing, and an SMS code request answers honestly that it could not be sent rather than pretending it went out.
  • No resident server runs. The full profile installs lumnd/plato-workerman and registers the server:* commands, but there is no config/server.php and no server implementation.
  • Two-step verification validates but does not enrol. An account with otp_enabled is asked for a code at sign-in; the screen that generates a secret and confirms a scan is not written yet.
  • The Google and Apple adapters have never seen a real token. They are verified as far as refusing a deployment that has no client id configured.

Quick start

With Docker, from nothing to a back office you can sign in to:

composer create-project lumnd/platoadmin myapp && cd myapp
docker compose up -d
docker compose exec php83 composer install
docker compose exec php83 cp .env.example .env   # then set DB_PASSWORD=root
docker compose exec php83 php vendor/bin/plato key:generate
docker compose exec php83 php vendor/bin/plato migrate
docker compose exec -it php83 php vendor/bin/plato admin:create --username=admin --super

Then open http://admin.platoadmin.localhost:8080/. No hosts file entry is needed — the .localhost suffix is reserved for the loopback address and resolvers answer it on their own.

The compose stack is PHP 8.3-fpm, nginx, MySQL 8 and Redis 7, and it is for development only: the source is a bind mount, opcache is off and the database password is in the compose file in plain sight. docs/running.md covers it properly, including how to use an nginx you run yourself instead.

Requirements

Without Docker, running it yourself needs:

  • PHP 8.2 to run it, 8.3 to develop it. 8.2 is the floor composer.json states and CI proves with a composer install --no-dev job; the dev toolchain needs 8.3, because Pest does
  • Composer 2
  • MySQL 8.0
  • Redis
  • Extensions: pdo_mysql, redis, mbstring, openssl, json, zlib; a resident process also needs pcntl and posix

Install

composer create-project lumnd/platoadmin myapp

Profiles

The install asks which profile you want, deletes what the answer excludes, and removes itself:

Profile What it is
minimal Back office only. No api/, no contract generation, no resident server. The dependencies come down to lumnd/platophp plus smarty/smarty and chillerlan/php-qrcode
dsl Adds api/, where endpoints are declared in api/contracts/ and both the controllers and the OpenAPI document are generated
full Adds resident server support through lumnd/plato-workerman

Answer up front to skip the question, and add --dry-run to see what would go without touching anything:

php install.php --profile=minimal --dry-run

Configure

cp .env.example .env
php vendor/bin/plato key:generate

key:generate fills the three secrets that have to differ in every installation. It only writes the lines that are empty, so running it twice is safe:

  • CSRF_SECRET — signs the CSRF cookie. An empty value makes the back office refuse to start.
  • JWT_SECRET — signs api access tokens. Replacing it signs every api client out at once, which is the emergency stop when one leaks.
  • DB_CRYPT_KEY — field encryption. There is no recovery path if it is lost, only re-encryption from a plaintext source, so it must never be swapped out under an environment that already has data.

To replace one that already has a value, name it: key:generate --force=JWT_SECRET, which asks first. --show prints without writing.

SYS_ENV is one of dev / pre / pub. SYS_DEBUG must be false in production — the debug panel prints the session and the cookies verbatim.

.env goes in no repository, no image and no release archive. Only .env.example and .env.testing are committed, and neither holds a real credential.

Run

php vendor/bin/plato migrate
php vendor/bin/plato admin:create --username=admin --super

There is no seeded administrator, and there will not be one. An account committed to a repository means the password is in the source and every deployment that ran the seeder shares it. In a container, pass -it to docker exec — without a tty the prompt cannot turn echo off and the password ends up in the shell history.

The two applications are two document roots: admin/public and api/public. The vhosts are in deploy/nginx/ — the compose stack mounts that directory straight into nginx, and the same files copy into an nginx you run yourself. The crontab entry is in deploy/cron/.

CSRF compares Origin against the request host and the cookie is set on that host, so serve it under a hostname rather than with php -S127.0.0.1:8080 works, but it is not the path production takes. A non-default port is fine: Origin and HTTP_HOST both carry it, so the two still match.

The full walk-through — the compose stack, bringing your own nginx, the database, the first account, the debug panel and a symptom table — is in docs/running.md.

Layout

Where What
admin/ Back office application, entry point admin/public/index.php
api/ API application, entry point api/public/index.php; controllers generated from api/contracts/
common/ Models, services, support and integrations shared by both, plus the language packs
database/migrations/ Schema and upgrades, the only source of the table structure
database/seeders/ Built-in data (the menu tree). No accounts
deploy/ The nginx vhosts, the crontab entry, and the images the compose stack builds
docker-compose.yml The development stack: PHP 8.3-fpm, nginx, MySQL 8, Redis 7
docs/ Project documentation
vendor/lumnd/platophp/ The framework, a Composer dependency. Never edited

What belongs in each directory, and where new code goes, is docs/structure.md. The layering and writing rules are docs/conventions.md.

The framework's own documentation is at https://platophp.com. It is not in vendor/ after a normal install — the published tags strip docs/ with .gitattributes, so the zipball Composer unpacks does not carry it. To read it offline, fetch that package from source instead:

composer reinstall lumnd/platophp --prefer-source

which puts it at vendor/lumnd/platophp/docs/, entry point llms.txt.

Commands

php vendor/bin/plato key:generate
php vendor/bin/plato migrate
php vendor/bin/plato migrate:status
php vendor/bin/plato admin:create --username=x --super
php vendor/bin/plato admin:password --username=x

Endpoints change in the contract and are then regenerated. Do not hand-edit api/app/control/api/manifest.json will stop you:

vendor/bin/plato api:lint
vendor/bin/plato api:generate
vendor/bin/plato api:check

Scheduled tasks are driven by a single crontab entry on one host; locally the scheduler does not run by default:

php vendor/bin/plato schedule:list
php vendor/bin/plato schedule:run --force
php vendor/bin/plato schedule:exec --task=prune:session

Tests and static analysis:

composer style     # phpcs: zero errors is the gate; the line length limit stays a warning
composer analyse   # phpstan level 8
composer test      # Pest; the Feature suite needs a real database and skips itself without one

Documentation

File Answers
docs/running.md How to run it, run the tests, and create the first account
docs/structure.md Which directory holds what, and where new code goes
docs/conventions.md Layering, exceptions, error codes, routing and auth, secret handling
docs/api-contract.md How a contract is written, what it generates, which files are hand-written
docs/schema.md Table conventions and the tables that exist
docs/frontend.md Back office front-end conventions
docs/storage.md File storage, uploads and bucket configuration
docs/api/openapi.json The endpoint list, generated from the contracts

The documentation is written in Chinese and published at https://lumnd.github.io/platoadmin/, which is the readable copy — it has a sidebar, a pager and a Swagger UI page for the api. docs/*.md reads fine on GitHub as it is; the same sources also build into a navigable static site with a Swagger UI page for the api. docs/site/ is not committed — CI regenerates and publishes it on every push, so changing documentation means changing Markdown, never HTML:

php docs/build.php          # writes docs/site/
php docs/build.php --check  # renders and validates structure, section counts and relative links

The build needs Node (through npx marked, pinned so two machines produce the same HTML). The Swagger UI assets are vendored in docs/assets/swagger/ rather than loaded from a CDN.

Working on PlatoAdmin itself

A plain checkout never triggers post-create-project-cmd, so install.php does not run and you get the whole tree:

git clone https://github.com/lumnd/platoadmin.git
cd platoadmin
docker compose up -d
docker compose exec php83 composer install

CONTRIBUTING.md has the rest: the three gates, what gets a change sent back, and the style and language rules. Security problems go through SECURITY.md rather than the issue tracker.

Three things worth knowing before the first pull request:

  • When you develop against a sibling checkout of the framework, do not let the lock file out. Everything resolves from Packagist normally, so composer.json carries no repositories at all; working on the framework means adding a local type=path entry pointing at the sibling checkout. Everyone remembers that line must not be committed, but the composer.lock it produces must not be either: the lumnd/* entries get written as "type": "path" with dev-main, which contradicts the ~0.1 constraint in composer.json and stops somebody else's create-project dead with does not satisfy your constraint. After removing the path repository, run composer update lumnd/platophp lumnd/plato-api-contract lumnd/plato-workerman and check that the lock is back on the tags Packagist resolves before committing it.
  • CI runs the three gates and builds the documentation. .github/workflows/ci.yml runs style, static analysis and the full test suite against a real MySQL and Redis, plus a second job that does composer install --no-dev on PHP 8.2 so the floor in composer.json is a tested claim. .github/workflows/docs.yml renders the Markdown to GitHub Pages; .gitlab-ci.yml does the same on GitLab Pages.
  • Every change has to be thought about in three profiles. A new file that only serves the api or only the resident server has to be registered in PROFILES in install.php, or the minimal profile ends up with a file referencing a class that was deleted. Verify all three afterwards, not just the one you touched.

The release process — build an artefact, migrate, move a symlink, restart the processes — is not implemented. The intended shape is current -> releases/<id> alongside a shared/, with the production .env written into shared/.env by a secret service or a protected CI variable.

Lines that do not get crossed

  • No account, password, token or certificate in plaintext, in the README, in configuration or in a log.
  • The setting table never holds a secret, a connection string or a third party credential — databases get exported, backed up, and copied into development environments.
  • Production runs SYS_ENV=pub and SYS_DEBUG=false.
  • The framework is a Composer dependency: do not edit vendor/, the next composer install takes it away. Framework bugs get fixed in the framework repository.

License

MIT, see LICENSE. PlatoPHP is MIT as well.