Visitora Manual, as shipped in the download
Visitora — User Manual
A self-hosted visitor sign-in book: a kiosk at the door, a badge, a live list of who is inside the building, and an evacuation roll-call. Version 1.0.3 [src: app/controllers/api.php:23].
About this manual
Every statement here was written by reading Visitora'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.
⭐ One rule decides who is in the building
On site is derived, never stored. A visit is on site when it has a sign-in time and no sign-out time. There is no is_onsite column to go stale, no nightly job to forget, and no way for the evacuation list to disagree with the visit log [src: app/src/Visit.php:7].
The dashboard count, the CSV, the badge check and the API all read that same one rule [src: app/src/Visit.php:9]. It is written once, as a SQL fragment every reader and writer shares [src: app/src/Visit.php:127], and the source explains why the table alias is a parameter rather than hard-coded: the moment one caller hand-rolls the clause to fit its own FROM, there are two definitions of who is in the building. The test suite greps for a second copy [src: app/src/Visit.php:129].
⭐ Signing out cannot be broken by a double-tap
signOut() is a conditional update — WHERE signed_out_at IS NULL — so two taps, two devices or a double-submitting kiosk produce exactly one sign-out at one time [src: app/src/Visit.php:12]. It also reports whether it was the call that did it, so callers never announce an event that did not happen [src: app/src/Visit.php:15].
⭐ Two rows for one body is the error that leaves somebody inside
Signing in somebody who is already on site is refused outright, with the reason: "That person is already signed in. Sign them out first." [src: app/src/Visit.php:200]. The source says why in the line above: two rows for one body is exactly the error that gets somebody left in a building, so the door refuses rather than tidying up later [src: app/src/Visit.php:195].
⭐ What a visitor agreed to is self-contained
When a visitor accepts the site rules, the exact text and its SHA-256 are stored on the evidence row — not a pointer to a settings field somebody can edit afterwards [src: app/src/Visit.php:17].
Two consequences, both stated: changing the rules tomorrow cannot rewrite what somebody accepted today, and proving what they accepted needs no join [src: app/src/Visit.php:19].
Badges are reused, and what happens when they run out
A badge is physical: it comes back and goes out again. The allocator hands out the lowest number in the configured range that nobody on site is holding, so a desk with fifty badges does not run out on visitor fifty-one and two people on site never wear the same number [src: app/src/Visit.php:22].
Worth knowing exactly what "does not run out" means. The allocator walks the range and returns the first free number, or zero when every badge in the range is on somebody [src: app/src/Visit.php:110]. A sign-in with no free badge still records the visit — the badge row is only created when a number was actually issued [src: app/src/Visit.php:214] — and the printed on-site list shows an em-dash in the badge column for that person [src: app/views/onsite_print.php:34].
So the guarantee is reuse, not an infinite supply: nobody is turned away, and nobody is given a number somebody else is wearing, but a fifty-first simultaneous visitor is on the list without a badge.
The evacuation roll-call
Two states — accounted and missing [src: app/src/Visit.php:34] — and a tally that counts everyone on site, everyone marked accounted, and the difference [src: app/src/Visit.php:341]. Executed on four people, one accounted, one marked missing, one unmarked, one accounted:
4 on site 2 accounted 2 NOT accounted
An unmarked person counts as not accounted, which is the only safe direction for a fire drill: silence is not a headcount.
Retention, and a purge that does not keep a list of who it forgot
Which finished visits fall outside the retention window is computed from a cutoff date [src: app/src/Visit.php:399]. Executed:
today 2026-03-01 keep 30 days -> cutoff 2026-01-30
today 2026-01-01 keep 365 days -> cutoff 2025-01-01
today 2026-03-01 keep 1 day -> cutoff 2026-02-28
Three properties are worth stating plainly:
Open visits are never in scope. Somebody still inside the building cannot be forgotten, whatever the calendar says [src: app/src/Visit.php:396].
The purge returns a count, never a name — because "a purge log that lists who it forgot has not forgotten them" [src: app/src/Visit.php:415]. Visitors left with no visits go too, so no orphan identity survives [src: app/src/Visit.php:416].
It works on both database engines. The date extraction is spelled by the driver rather than hard-coded, because hard-coding either dialect makes the purge a silent no-op on the other — and "a retention job that silently deletes nothing is the worst kind of green" [src: app/src/Visit.php:405].
How long somebody has been in
Durations are formatted for a person rather than a log [src: app/src/Visit.php:485]. Executed across the boundaries:
59s -> 59s 60s -> 1m 3600s -> 1h 3660s -> 1h 1m
86399s -> 23h 59m 86400s -> 1d 200000s -> 2d 7h
An open visit is measured to now; a closed one to its sign-out [src: app/src/Visit.php:479].
Users and roles
Three roles [src: app/controllers/v3.php:68]. Signing in and out are separate permissions from viewing, and both belong to member and above [src: app/controllers/v3.php:66].
Note that a viewer is granted audit.view explicitly, listed by name in the role rather than picked up by a wildcard [src: app/controllers/v3.php:65]. In this product that reads as deliberate: a visitor book is a safety record, and who signed whom out is part of it. If you expected read-only accounts not to see the audit trail, change the role — but the grant here is stated, not accidental.
Backups are gated on settings.edit [src: app/controllers/api.php:223], which no role below admin holds [src: app/controllers/api.php:228]. The JSON backup — the copy you can hand to someone — leaves out every password, key and other credential, visitors' phone numbers and IP addresses, and each pre-registration's link [src: app/controllers/v3.php:42]; the outbox log keeps an invitation without its link [src: app/controllers/app.php:174]. The SQLite file is the whole database, unredacted.
Webhooks
Two events are offered for subscription [src: app/src/Webhook.php:18], and both are sent.
visit.signed_in goes when somebody signs in at the kiosk [src: app/controllers/app.php:534].
visit.signed_out goes however the visitor left: the kiosk's own sign-out, the staff page, the bulk end-of-day sweep, the REST route and the agent tool all send it through one helper [src: app/controllers/app.php:206]. If reception signs the building out at six o'clock, your integration hears about every visitor.
The API and agent access
Six tools [src: app/controllers/v3.php:129]: five read — who is on site, the visit list, one visit, hosts, and who is expected today — and exactly one write, signing a visitor out [src: app/controllers/v3.php:217].
The instruction explaining that asymmetry is the sharpest sentence in the product [src: app/controllers/v3.php:126]:
"Nothing in this tool set can sign a visitor IN: putting a name on a muster list without a person standing at the desk is exactly the error that gets somebody left in a burning building."
The agent is also told that on-site status is derived on every read and that signing out is idempotent, so calling it twice signs one person out once [src: app/controllers/v3.php:124].
What Visitora does not do
It does not store an on-site flag [src: app/src/Visit.php:7], so the evacuation list cannot drift from the visit log.
It does not let an agent sign anybody in [src: app/controllers/v3.php:126].
It does not sign the same person in twice [src: app/src/Visit.php:200].
It does not let an edited set of site rules rewrite what somebody already accepted [src: app/src/Visit.php:17].
It does not purge anybody who is still inside the building [src: app/src/Visit.php:396], and it does not keep a list of the people it purged [src: app/src/Visit.php:415].