Trainora Manual
Trainora — User Manual
A training matrix and expiry register: who holds which competency, when they achieved it, when it runs out, and what needs attention today. Release 4.1.3 [src: app/src/Domain.php:22].
About this manual
Every statement here was written by reading Trainora's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug.
Nothing is described that the code does not do. Where something is deliberately limited, or currently wrong, that is said plainly rather than left out.
What the version number means
Trainora reports 3.2.0 on every surface — the ping, the OpenAPI document, the health check, the agent endpoint and the stamp inside a backup all read one constant [src: app/src/Domain.php:22], so they cannot drift apart.
The source is careful about what that number claims: it is the platform release the product ships on, the same number every product in the catalog carries, so a buyer comparing two of them is comparing the same platform [src: app/src/Domain.php:18]. It is not a claim that Trainora had a 1.x — this is its first release.
⭐ The grid, and the four states
Every cell in the matrix is one subject against one requirement, and it holds one of four states [src: app/src/Matrix.php:31]: valid, expiring, expired, or none — meaning the person has never held it at all.
They are ordered for reading rather than alphabetically — worst first [src: app/src/Matrix.php:155]. Executed:
failed , expired , none , expiring , valid
The shared engine knows a fifth state, failed, for a record whose result is a fail [src: app/src/Matrix.php:38]. Only a product that records a result can write one; Trainora records completions, every record here is a pass, and no Trainora cell is ever failed.
Note that "never recorded" ranks above "expiring": a competency nobody ever had is a bigger hole than one that lapses next month.
⭐ The expiry day itself is still valid
This is the detail the engine is most emphatic about. A certificate that says "valid to 31 December" is valid on 31 December, and treating that day as expired is the kind of off-by-one that makes an operator distrust the whole grid [src: app/src/Matrix.php:109].
Executed against a warning window of thirty days, with today at 2026-09-05:
expires 2026-12-31 -> valid valid, 117d left
expires 2026-10-05 -> expiring in 30d
expires 2026-10-04 -> expiring in 29d
expires 2026-09-05 -> expiring expires today ← the expiry day itself
expires 2026-09-04 -> expired 1d overdue
expires 2026-08-01 -> expired 35d overdue
Two more behaviors, both executed:
A record with no expiry date is valid forever [src: app/src/Matrix.php:109] — it comes back valid rather than being treated as missing data.
A cell with no record at all is none, phrased "never recorded" [src: app/src/Matrix.php:130], which is a different fact from an expired one and is colored and sorted differently.
Setting the warning window to zero narrows "expiring" to the expiry day alone: 2026-09-05 is expiring and 2026-09-06 is valid.
The wording never invents a number it was not given — the phrase falls back to a bare "expired" or "expiring" when no day count is available [src: app/src/Matrix.php:141].
The engine does not know what it is counting
Matrix.php knows that a subject holds a requirement, that holding it has a date and may expire, that rounds are planned events producing outcomes, and that evidence attaches to a record. It does not know whether a subject is a person or a fire extinguisher [src: app/src/Matrix.php:14].
Every user-facing word and every table name arrives from Domain [src: app/src/Matrix.php:15]. For Trainora, subjects are people, requirements are competencies, rounds are sessions and outcomes are attendance [src: app/src/Domain.php:31].
That separation has a practical consequence for anyone customizing the product: if you are adding a word to a screen, add it to Domain and read it from the view — a hardcoded "competency" in a template is how the vocabulary drifts [src: app/src/Domain.php:13].
The engine is byte-identical to another product's, and the suite enforces it
The source declares a canonical copy at tools/matrix-engine/Matrix.php and says both products' suites md5-compare their copy against it, so editing one product's copy turns both suites red [src: app/src/Matrix.php:5]. Verified independently:
7cdb042d4b4bffb17328d182ee31b57b tools/matrix-engine/Matrix.php
7cdb042d4b4bffb17328d182ee31b57b 43-trainora/app/src/Matrix.php
The stated reason is worth repeating: two codebases for one engine is an expensive mistake, and the md5 check is the tripwire against making it by accident [src: app/src/Matrix.php:7].
SQL safety
Table names are the only identifiers interpolated into a query; everything else is a bound parameter [src: app/src/Matrix.php:17]. Those names come from a hardcoded map in Domain, never from input, and are whitelisted again before they reach a query — a Domain that returns rubbish fails in one place rather than producing a query nobody reads [src: app/src/Matrix.php:56].
Users and roles
Three roles [src: app/controllers/v3.php:24]. A viewer reads the grid and the reports; a member can also record a completion and run a round [src: app/controllers/v3.php:25]; admin holds everything [src: app/controllers/v3.php:26].
Backups are for administrators
Both backup downloads ask for the administrator role — the JSON export and the SQLite file [src: app/controllers/api.php, backup_json() and backup_sqlite()]. A viewer or member who opens either address is refused, and the Security page shows the backup buttons to administrators only [src: app/views/security.php].
A backup contains every table: the full training record for every person named in the system, including who failed to hold what and when. The JSON export redacts credential hashes and the SMTP password; the SQLite file is the database itself, byte for byte. Before 3.1.3 a sign-in alone was enough to download either.
Webhooks: one event, fired from every door
The one event offered for subscription is record.created [src: app/src/Webhook.php], and it fires from every path that records a completion: the grid, a session's attendance, the REST API and the agent tool. Before 3.1.3 the settings screen offered three leave-management events this product never fires, and record.created could not be ticked.
Reminders and the digest
Two switches under Settings → Email, both off on a fresh install, both sent only through the SMTP server you enter there:
- "email me a digest of what is expiring" — one message a day, to the address in "Send my notifications to", listing everything expired or expiring within the warning window.
- "tell them when something of theirs is expiring" — a note to each person in the register who has an email address and something expired or expiring, never more often than the "remind the same person at most every N days" setting.
They go out when cron/reminders.php runs. Schedule it once a day, for example 15 7 * * * php /path/to/app/cron/reminders.php, and try --dry-run first: it lists who would be written to and sends nothing. On a host without cron, Settings → Run reminders now runs the same pass. Running it twice in a day sends nothing the second time: every note is claimed in a ledger before the mail server is contacted, and a send the server refuses gives its claim back so the next run retries [src: app/controllers/app.php, reminder_run()].
Settings shows when the pass last ran and what it did. When a switch is on and nothing has run the pass for more than two days, the dashboard says so.
The API and agent access
Five tools [src: app/controllers/v3.php:64]: four read — the whole grid, what needs attention, one subject in detail and the requirement list — and exactly one write, recording a completion [src: app/controllers/v3.php:155].
The instructions tell an agent two things worth knowing yourself [src: app/controllers/v3.php:60]:
"Read the matrix before you write to it. Recording a completion is the only write here; it goes through the same engine call the screens use, so the refusals are identical."
That second clause is the useful guarantee: there is no separate API validation to drift out of step with what the screens enforce.
What Trainora does not do
It does not treat the expiry day as expired [src: app/src/Matrix.php:109].
It does not confuse "never recorded" with "expired" [src: app/src/Matrix.php:130] — they are different states, differently ranked.
It does not invent a day count it was not given [src: app/src/Matrix.php:141].
It does not let a template decide the vocabulary [src: app/src/Domain.php:13].
It does not interpolate anything into SQL except a whitelisted table name [src: app/src/Matrix.php:17].
New in 3.2
My training record. An administrator makes a private link for a person from their page [src: app/controllers/trainora.php, tr_my_link_create()]. The link is a long random token; only its SHA-256 is stored, it is shown once, and a new link or "Switch the link off" stops the old one. The page lists every competency that applies to that person, worst first, with its state and the certificate on file, and lets them send a new certificate (PDF, PNG, JPEG or WebP under 10 MB; the file's bytes decide the type). A certificate sent in changes nothing until an administrator confirms it under Sent in: confirming records the completion through Matrix::recordCompletion() — the same engine every other door uses — on the date the person gave, and moves the file into that record's evidence; declining keeps the reason, which the person sees [src: app/controllers/trainora.php, tr_submission_apply()].
Supervisor sign-off. A member or administrator can sign off a passing record at one of four levels — aware, can do it with supervision, competent, can train others — naming the assessor; the level, the name and the time are stored on the record and audited [src: app/controllers/trainora.php, tr_record_signoff()].
Rules by role and location. A competency can apply to one team or role, one location, or both together; a person must match both to hold it [src: app/src/Matrix.php, applies()].
The matrix report. Settings → The matrix report sends a plain-text summary — the counts by state, then each person and competency that needs attention — weekly or monthly, to the addresses you give, when the reminder run runs [src: app/controllers/trainora.php, tr_report_run()]. It is stamped only when at least one copy went, so a mail failure retries on the next run.
Certora. With your own Certora's address, an API key and a template id saved in Settings, a record has Issue a certificate in Certora: it posts the person's name and the course to Certora's POST /api/certificates and keeps the verify link Certora returns [src: app/controllers/trainora.php, tr_record_certora()]. Nothing is sent without that button.
Privacy (GDPR): what an erase removes and what it keeps (4.1.1)
A person's page carries Privacy (GDPR) for an admin: export their data as JSON, or anonymize them (refused while they are booked onto a future session). Anonymizing removes their name, email, reference and notes, the notes on their records and attendance, and what they sent through their own link (their note, the file's name, the certificate number they typed, and a scan that was never filed as evidence).
Kept: the name they typed to sign their own record, the certificate numbers and links on their training records, the evidence filed on those records with its files, and the audit trail's lines that name them, because these are the compliance evidence and the audit trail cannot be edited.
Looking after your install (from 4.0)
Updates from inside Trainora
Updates in the menu opens the Updates page (/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Trainora never checks by itself.
When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Trainora is unavailable for about a minute while this runs. The update replaces Trainora's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Trainora shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Trainora is hosted for you, the host updates it and the page says so.
Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.
Starting with sample data
The installer offers Start with sample data: a restaurant group's people, competencies and training records to try things on. You can remove it in one click. [src: app/install/index.php:118]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.
When you are done with it, press the remove button in the sample banner. It asks "Remove the sample people, competencies and training records? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].
Backups by themselves, and a copy somewhere else
On the backup page (/restore, linked from Settings), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:74]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.
To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Trainora reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].
On Nginx: keep data/ private
On Apache, the .htaccess that ships with Trainora keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):
location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }
Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.
A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):
server {
listen 80;
server_name example.com;
root /var/www/trainora/app;
index index.php;
location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }
location = /install/ { rewrite ^ /install/index.php last; }
location / { try_files $uri /index.php?$query_string; }
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
The Health page
Health in the menu (/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].
Connect it to other tools (Zapier, Make, n8n)
Trainora sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Trainora on the store shows the same file [src: app/API.md].
New in 4.1.2: sign-in limits
New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. On Ownware Cloud, opening the app from the console starts a fresh session.
Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].
Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.
If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Trainora's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.
On Ownware Cloud, opening Trainora from the console starts a fresh session [src: app/index.php:54].
The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:21].
New in 4.1.3: security update
New in 4.1.3 (security update): no other site can put Trainora's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.
No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Trainora in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Trainora framed inside another site of your own, that frame now stays empty; link to it instead.
/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Trainora's own address [src: app/src/Mcp.php:272].
Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.
The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].
Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].
Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.