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.
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.
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 inapi/app/logic/.
Shared by both
- One failure mechanism: throw
biz_exception.common\middleware\catcheris 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-cnanden, phpstan level 8, Pest tests.
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\smssends 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-workermanand registers theserver:*commands, but there is noconfig/server.phpand no server implementation. - Two-step verification validates but does not enrol. An account with
otp_enabledis 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.
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 --superThen 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.
Without Docker, running it yourself needs:
- PHP 8.2 to run it, 8.3 to develop it. 8.2 is the floor
composer.jsonstates and CI proves with acomposer install --no-devjob; 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 needspcntlandposix
composer create-project lumnd/platoadmin myappThe 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-runcp .env.example .env
php vendor/bin/plato key:generatekey: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.
php vendor/bin/plato migrate
php vendor/bin/plato admin:create --username=admin --superThere 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 -S — 127.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.
| 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-sourcewhich puts it at vendor/lumnd/platophp/docs/, entry point llms.txt.
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=xEndpoints 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:checkScheduled 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:sessionTests 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| 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 linksThe 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.
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 installCONTRIBUTING.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.jsoncarries norepositoriesat all; working on the framework means adding a localtype=pathentry pointing at the sibling checkout. Everyone remembers that line must not be committed, but thecomposer.lockit produces must not be either: thelumnd/*entries get written as"type": "path"withdev-main, which contradicts the~0.1constraint incomposer.jsonand stops somebody else'screate-projectdead withdoes not satisfy your constraint. After removing the path repository, runcomposer update lumnd/platophp lumnd/plato-api-contract lumnd/plato-workermanand 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.ymlruns style, static analysis and the full test suite against a real MySQL and Redis, plus a second job that doescomposer install --no-devon PHP 8.2 so the floor incomposer.jsonis a tested claim..github/workflows/docs.ymlrenders the Markdown to GitHub Pages;.gitlab-ci.ymldoes 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
PROFILESininstall.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.
- No account, password, token or certificate in plaintext, in the README, in configuration or in a log.
- The
settingtable 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=pubandSYS_DEBUG=false. - The framework is a Composer dependency: do not edit
vendor/, the nextcomposer installtakes it away. Framework bugs get fixed in the framework repository.
MIT, see LICENSE. PlatoPHP is MIT as well.




