Fleetora Manual
Fleetora — User Manual
A fleet register: vehicles, servicing, documents, defects, mileage and who is driving what. Version 4.1.3 [src: app/controllers/api.php:16].
About this manual
Every statement here was written by reading Fleetora'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.
⭐ The rule the product lives or dies on
A service is due at whichever comes first — the date, or the odometer reading [src: app/src/Fleet.php:7].
Either leg can be switched off by setting its interval to zero, which means "not tracked that way" [src: app/src/Fleet.php:8]. Executed, with today at 2026-09-05:
both legs, date sooner state ok due by both 24 days, 8,000 to run
both legs, distance sooner state soon due by distance 299 days, 200 to run
both legs overdue state overdue due by both −66 days, −1,000
date leg only state ok due by date 26 days
distance leg only state critical due by distance 100 to run
neither tracked state none —
Two things to read out of that. due_by names which leg won, so a scheduler knows whether to book by calendar or by mileage. And a vehicle with nothing tracked comes back none rather than ok — the source is explicit that it must "say so, do not pretend it is healthy" [src: app/src/Fleet.php:132].
⭐ An unknown is reported as unknown
This is the sharp edge of the same rule. When a distance interval is set but no mileage has ever been logged, the distance leg is unknown, and never quietly treated as "not due" [src: app/src/Fleet.php:9].
The trap is that the date leg may look perfectly healthy. Executed — a service with a 365-day interval done four days ago, a 10,000-unit interval, and no odometer reading on file:
date leg alone would say: ok (361 days to run)
what Fleetora reports: unknown
The source names that case in the code: a distance interval whose reading is missing must not be hidden by a healthy date leg [src: app/src/Fleet.php:162].
⭐ "I cannot tell you" sorts above "this is fine"
The ordering of the what-expires-next list is a deliberate judgment [src: app/src/Fleet.php:171]: a row whose urgency is unknown sorts after everything actionable but before the healthy, because an operator should see "I cannot tell you" above "this is fine".
Executed:
expired 0 overdue 0 critical 1 soon 2 unknown 3 ok 4 none 5
Document expiry
A date, a warning window and a status you can read in one glance [src: app/src/Fleet.php:12]. Five states, and "no date recorded" is one of them rather than a blank [src: app/src/Fleet.php:84]. Executed with a 30-day warning and a 7-day critical window:
expires 2026-09-04 -> expired expires 2026-09-25 -> soon
expires 2026-09-05 -> critical expires 2026-12-01 -> ok
expires 2026-09-10 -> critical no date -> none
Reminders by email
Off until you switch them on, and always through your own SMTP server. The reminder run [src: app/src/Reminders.php:38] reads the same ranked list the dashboard shows and sends:
- the assigned driver, about their own vehicle only, when "Tell the assigned driver when something on their vehicle is about to expire" is on [src: app/src/Reminders.php:58]. Each item is told at most three times per round — entering the warning window, entering the critical window, and once when it has passed. Renewing the document or completing the service starts a new round [src: app/src/Reminders.php:125]. A service notice names the service and its due line in the vehicle's own units.
- the office address, one digest of everything inside its window (and anything the register cannot tell for want of an odometer reading), at most once a day and only when the list is not empty [src: app/src/Reminders.php:74].
The master switch "Allow Fleetora to send mail at all" gates both, and with no SMTP server set up the run sends nothing and says so [src: app/src/Reminders.php:47]. Every notice has a reference, so a run repeated the same day writes to nobody twice.
Fleetora runs no background job of its own, so something has to start the run: schedule php /path/to/app/cron/expiry.php once a day (--dry-run lists who would be written to), or press Send reminders now in Settings, which runs the same code [src: app/controllers/app.php:738]. The script answers only on the command line [src: app/cron/expiry.php:22]. Without either, the calendar feed is the reminder.
⭐ An odometer does not run backwards
A lower reading is refused with the reason, because silently accepting it corrupts every distance-based service due date that follows [src: app/src/Fleet.php:183]. Executed:
last 52,000 new 52,100 -> accepted
last 52,000 new 51,999 -> REFUSED — "That is lower than the last reading on file (52000).
Odometers do not run backwards — log a correction against the
vehicle instead."
last 52,000 new −1 -> REFUSED — "A reading cannot be negative."
no reading new 30,000 -> accepted
The refusal tells you what to do instead, which is the difference between a validation message and a dead end.
Defects
Four severities, worst first, and immobilise means the vehicle must not be driven [src: app/src/Fleet.php:26].
The lifecycle has one unusual edge: a fixed defect can be reopened, and the source calls it an honest reopen — the record of the first fix stands [src: app/src/Fleet.php:34]. Dismissed is terminal in the same way, and also reopenable [src: app/src/Fleet.php:28].
Units are per vehicle
Miles or kilometers, stored per vehicle because a mixed fleet is normal [src: app/src/Fleet.php:23], and distances are always printed with the unit that vehicle actually uses [src: app/src/Fleet.php:199].
Ownership — owned, leased or hired — is recorded, never inferred [src: app/src/Fleet.php:20].
Users and roles
Four roles, and the extra one is the point [src: app/controllers/v3.php:26]:
A driver is a real role here: they report a fault and log a reading on the vehicle they hold, and they must not be able to edit the fleet or read its costs.
| Permission | viewer | driver | member | admin |
|---|---|---|---|---|
fleet.read | yes | yes | yes | yes |
defect.report · mileage.log · fuel.log | — | yes | yes | yes |
cost.read (fuel costs, 1.1) | — | — | yes | yes |
fleet.write · defect.triage · service.write · document.write · assign.write | — | — | yes | yes |
fleet.dispose · doctype.write · settings.write | — | — | — | yes |
The ladder is enumerated in full, with no wildcard [src: app/controllers/v3.php:32] — a new permission is not granted to anybody until it is added to a role deliberately.
The audit trail is admin-only: it requires settings.write [src: app/controllers/v3.php:156], which no role below admin holds. Unlike much of this family, a read-only account here cannot see it.
Backups are admin-only
Both backup downloads require settings.write, the admin permission — the JSON export [src: app/controllers/api.php:297] and the SQLite file [src: app/controllers/api.php:307]. A backup contains every table, so the fleet, its costs, its defect history and the driver assignments come out together; it sits with the same role as restore. Before 1.0.3 both downloads checked sign-in alone, and a driver account was enough.
One server name on every door
The REST interface [src: app/controllers/api.php:16], the MCP specification [src: app/controllers/v3.php:200] and the unauthenticated transport probe [src: app/controllers/v3.php:357] all name the server fleetora. Before 1.0.3 the two MCP doors said Fleetora.
An API key carries its holder's role at the REST door as it does at the agent door: adding a vehicle needs fleet.write, logging a reading mileage.log, reporting a fault defect.report [src: app/controllers/api.php:60]. Before 1.0.3 the REST writes checked only the key's read/write scope.
Webhooks
Four events are offered [src: app/src/Webhook.php:18], and all four genuinely fire — each from every interface that can perform the action:
| event | browser | REST | agent |
|---|---|---|---|
vehicle.created | [src: app/controllers/app.php:293] | [src: app/controllers/api.php:88] | no such tool |
defect.reported | [src: app/controllers/app.php:548] | [src: app/controllers/api.php:141] | [src: app/controllers/v3.php:343] |
vehicle.disposed | [src: app/controllers/app.php:341] | disposal is browser-only | — |
service.completed | [src: app/controllers/app.php:476] | completion is browser-only | — |
The two browser-only rows are not gaps. The REST write surface is three routes — create a vehicle, log mileage, report a defect [src: app/index.php:198] — and the agent tool set is the same two writes, so there is no other path that could have fired those events.
The API and agent access
Six tools [src: app/controllers/v3.php:211]: four read — the vehicle list, what expires next, one vehicle's record and the document types — and two write, logging mileage [src: app/controllers/v3.php:295] and reporting a defect [src: app/controllers/v3.php:321].
Note what an agent cannot do: it cannot edit the fleet, complete a service, triage a defect or dispose of a vehicle. The tool set is deliberately the driver's half of the product rather than the manager's.
⭐ The paper, the fuel, the walk-round and imported readings (1.1)
- The file behind each document. Attach the certificate, the policy schedule or the inspection sheet to the document it proves, on the vehicle's page. The file's own bytes decide what it is (PDF, PNG, JPEG or WebP, up to 10 MB), the name on disk is generated, and it is served through a permission check [src: app/src/FleetOps.php:47]. Deleting the document deletes its files.
- One rule for readings that arrive with something else. A fill-up, a walk-round or an imported file carries an odometer. The newest one is logged as the vehicle's reading; one dated earlier than the newest on file is kept as history without moving the odometer; one lower than the reading on file is refused [src: app/src/FleetOps.php:21].
- Fuel. Log each fill-up with its odometer, amount and cost, and whether the tank was filled. Economy is worked out between two full tanks: the distance between them and everything put in after the first up to the second [src: app/src/FleetOps.php:135]. It is shown in mpg for vehicles in miles (US mpg when fuel is bought in US gallons, set in Settings) and in L/100 km for vehicles in kilometers [src: app/src/FleetOps.php:119]. A fill-up with an odometer lower than the reading on file is refused as a typo [src: app/src/FleetOps.php:170]. Fuel & cost lists each vehicle's fill-ups, amount, cost, economy and cost per mile or kilometer over a period, with a CSV. Costs are for
cost.read(members and administrators): a driver logs fill-ups and never sees what they cost. - The walk-round check. A driver answers each check OK, defect or not applicable, and a defect needs a few words [src: app/src/FleetOps.php:242]. Every defect becomes an ordinary reported fault with the severity the driver chose, and an unsafe one puts the vehicle's "reported unfit to drive" banner up [src: app/src/FleetOps.php:293]. The checks are yours to edit in Settings → Walk-round checks and fuel. Drivers signed in to the app do the check from the vehicle's page. Switch on the cab link and each vehicle gets a QR (print the walk-round QR sheet) that opens the check with no login. It stays off until switched on, opens only with the current key (a new key closes every printed QR) [src: app/src/FleetOps.php:227], has a trap field, and takes at most 10 checks an hour from one address.
- Readings from a tracker. Vehicles → Import readings takes a tracker's CSV export. The columns are found by name (registration, date, odometer), and registrations match with spaces and dashes ignored. Kilometers and miles are converted to each vehicle's own unit, and dates are day first unless you tick month first. You see every row's outcome before anything is written [src: app/src/FleetOps.php:379]: for one vehicle and one day only the highest reading counts, a reading already on file or older than the newest is left out, and a lower one is refused.
POST /api/readingsdoes the same for a tracker or your own code.
A walk-round survives a dropped signal (1.2)
A driver doing the check in a yard or a car park loses signal, and a half-done check should not be lost with it.
- The answers are kept on the phone as they are given. Both walk-round forms (the cab link and the signed-in one) keep the driver's name, the odometer, every answer, note and severity in that phone's browser storage [src: app/assets/keep-answers.js:1]. Opened again after a reload or a lost signal, the form has them back and says so, with Start again to throw them away.
- Pressing the button with no signal keeps the page and says "No signal right now. Nothing is lost". Press it again once back online.
- A check sent twice is recorded once. Each form carries a one-off send reference that the phone keeps with the answers. The walk-round row carries it (
send_ref, UNIQUE [src: app/src/Database.php:220]) and is written before anything else [src: app/src/FleetOps.php:293]. Say the first send arrived, the answer was lost, and the driver presses again: the second send gets the first check back, marked "already received". Nothing is written twice: no second check, no second defect, no second webhook. - On the cab link, the resend is answered before the per-hour limit is counted [src: app/controllers/v11.php:160]. - Once it has arrived, the "check received" page (or, signed in, the vehicle page it lands on) clears the phone's copy of that send, and only that one.
- What this is not: Fleetora is not an offline app. Nothing is recorded until the form reaches your server. The kept answers live in that phone's browser only.
Data requests: export or erase a driver (4.1.1)
An administrator opens Data requests (next to Backup & restore). A driver is found by the email on their assignments, or by their name where an assignment has no email:
- Download their data writes one JSON file with every assignment of theirs, every email sent to them, and the walk-round checks and fault reports that carry their name.
- Erase this driver replaces the name, the email and the note on every assignment of theirs with
[removed], and blanks every email Fleetora sent them [src: app/controllers/v3.php:1017].
The vehicles, their dates and their costs stay. Walk-round checks and fault reports keep the name they were signed with: who checked a vehicle and who saw a fault is its roadworthiness record. Erasure is refused while the driver still holds a vehicle, because the register must show who has the keys [src: app/controllers/v3.php:1066]. The audit trail records that an erase happened and how many rows it touched, never the driver.
Kept: the driver's name on the walk-round checks they did and the fault reports they made, and the audit trail's line written when a vehicle was handed to them, because those are the vehicle's roadworthiness record and the audit trail cannot be edited.
What Fleetora does not do
It does not treat a missing odometer reading as "not due" [src: app/src/Fleet.php:9].
It does not let a healthy date leg hide an unknown distance leg [src: app/src/Fleet.php:162].
It does not report an untracked service as healthy [src: app/src/Fleet.php:132].
It does not accept an odometer reading lower than the last one [src: app/src/Fleet.php:183].
It does not infer ownership [src: app/src/Fleet.php:20].
It does not erase the record of a fix when a defect is reopened [src: app/src/Fleet.php:34].
Looking after your install (from 4.0)
Updates from inside Fleetora
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; Fleetora 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. Fleetora is unavailable for about a minute while this runs. The update replaces Fleetora'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 Fleetora 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 Fleetora 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 fleet with vehicles in every service and document state to try things on. You can remove it in one click. [src: app/install/index.php:150]. 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 vehicles, services and documents? 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 & restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:88]. 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 backups stay in data/backups/ (14 unless you change Backups to keep), 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 Fleetora 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 Fleetora 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/fleetora/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)
Fleetora 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 Fleetora 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. Accepting an invite starts a fresh session. Single sign-on starts a fresh session. 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 Fleetora'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.
Accepting an invite now starts a fresh session (a new session id), as every other sign-in already did [src: app/controllers/v3.php:909].
Single sign-on now starts a fresh session too [src: app/controllers/v3.php:1001].
On Ownware Cloud, opening Fleetora from the console starts a fresh session [src: app/index.php:63].
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 Fleetora'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 Fleetora 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 Fleetora 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 Fleetora'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.