Home›Safora›Manual
Safora · Manual

Safora Manual

Safora — User Manual

A food-safety diary you own, keep and can hand to an inspector. Version 4.1.3 [src: app/controllers/api.php:19].

About this manual

Every statement here was written by reading Safora'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 — the markers exist so you can check rather than take our word for it.

Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out — which matters more here than in most products, because somebody may be relying on the record.

Read this before anything else

Safora is a record-keeping and compliance-documentation tool. It does not certify compliance and it is not professional food-safety advice [src: app/src/FoodSafety.php:13].

The temperature limits it ships with are sensible HACCP-aligned defaults, and every one of them is configurable — per business and per piece of equipment [src: app/src/FoodSafety.php:24]. Several carry an explicit "verify against your local rules" note in the source [src: app/src/FoodSafety.php:29].

That matters because the defaults are drawn from UK guidance. The cooking and reheating minimums cite UK and Scottish guidance [src: app/src/FoodSafety.php:25], and hot-holding and cooling cite the UK Food Standards Agency [src: app/src/FoodSafety.php:30]. If you are not in the UK, treat them as a starting point to be replaced rather than as your rules.

What gets recorded

Six kinds of record [src: app/src/FoodSafety.php:42]: temperature readings, cooking, cooling, deliveries, cleaning and checklists.

Equipment is one of five types [src: app/src/FoodSafety.php:41], and each piece can carry its own range rather than inheriting the business default [src: app/src/FoodSafety.php:99].

The whole rule engine is offline and pure

Every rule the product enforces — the ranges, the cooking and cooling limits, the delivery checks, the corrective-action rule, the daily color and the temperature conversion — lives in one place with no database and no network, so it can be proven in isolation and reused identically by the browser, the seeder and the audit exporter [src: app/src/FoodSafety.php:3].

Temperatures

Everything is stored in Celsius. The display unit only affects rendering and the parsing of what you type [src: app/src/FoodSafety.php:10], so switching between °C and °F never changes a stored reading.

A reading sitting exactly on a limit counts as in range, by way of a deliberate float tolerance [src: app/src/FoodSafety.php:21].

Readings outside a physically sane window are rejected before they reach the database, which also keeps every stored value inside its column and stops an absurd input overflowing the database [src: app/src/FoodSafety.php:35].

The rule that makes the diary worth keeping

An out-of-range reading cannot be saved without a corrective action [src: app/src/FoodSafety.php:179]. Attempting it returns a refusal saying exactly that [src: app/src/FoodSafety.php:187].

This is the invariant the whole product is built around, and it is what turns a list of numbers into a document an inspector can use: every deviation on the record carries what was done about it.

And the second half — verification

Recording the action at the time is one half of HACCP. The other is somebody senior confirming afterward that the action actually worked, and that was added as an open-to-verified lifecycle on any out-of-range entry [src: app/src/Database.php:155].

The save-time rule is untouched by it [src: app/src/Database.php:154] — verification is a follow-up the product did not previously have, not a relaxation of the original guard.

Verifying in bulk runs in a transaction, because verifying half a list and then failing is worse than verifying none [src: app/src/Database.php:66].

The daily color

Each day gets a color worked out from that day's readings and its scheduled checks [src: app/src/FoodSafety.php:231]:

ColorMeaning
greenEvery scheduled check done and every reading in range
amberOut-of-range readings that have a corrective action, or checks still pending today
redAn out-of-range reading with no corrective action, or a required check missed on a day already past
noneNothing scheduled and nothing recorded

The precedence is strict, and the order is the point: an unresolved breach is red before anything else is considered, and a missed check only hardens to red once the day is in the past [src: app/src/FoodSafety.php:233].

Closed days

A day you declare closed cannot be missing a check, because nobody was there to make it [src: app/src/FoodSafety.php:254].

But closing does not erase what was recorded. An unresolved breach on a closed day is still red, and the source gives the reason in one line: "we were shut" is not a corrective action [src: app/src/FoodSafety.php:256].

Scheduled checks — knowing something is due

Recording what already happened is the easy half. The half an inspector actually asks about is "how do you know the closing fridge check got done on the 14th?" [src: app/src/Checks.php:6].

A schedule says this check, this often, by this time [src: app/src/Checks.php:7], from which a due list can be derived for any date and published as a calendar feed [src: app/index.php:138].

There is deliberately no cron job. Due-ness is computed from the schedule and the log at the moment you read it, and the reasoning is worth understanding: a missed check is not an event that has to be caught as it happens — it is the absence of a matching log entry, and that absence is just as visible tomorrow as it was at 09:01 [src: app/src/Checks.php:10].

The practical consequence is that nothing can be lost because a scheduled task failed to run, and an installation that was switched off for a week still shows exactly which checks were missed.

A check counts as done because a matching record exists, not because somebody ticked a box.

Bank holidays

If a date is an England and Wales bank holiday, the sign-off card asks whether the kitchen was shut rather than assuming it [src: app/controllers/app.php:277]. The source describes the behavior as "offered, never assumed", and the intent is to save a manager explaining a red day later.

The holiday list is England and Wales only [src: app/src/FoodSafety.php:371]. Scotland, Northern Ireland and every other jurisdiction differ, and the prompt simply will not appear on their holidays. It changes nothing but that prompt — no status, no deadline and no rule depends on it.

Users and roles

Three roles [src: app/src/Perms.php:17]. The source names the line it draws, and it is the right one for this product:

who can change what "safe" means. Kitchen staff record readings and the corrective action they took; only a manager edits a threshold, signs off a day, or verifies that a corrective action worked. Moving a threshold retroactively turns yesterday's failures into passes, which is precisely what an inspection is looking for [src: app/src/Perms.php:5].

This table was produced by executing the product's own permission function against every permission the routes ask for.

Permissionviewermemberadmin
dashboard.viewyesyesyes
logs.viewyesyesyes
equipment.viewyesyesyes
reports.viewyesyesyes
schedules.viewyesyesyes
logs.create—yesyes
entry.photo—yesyes
actions.verify——yes
day.signoff——yes
equipment.manage——yes
schedules.manage——yes
settings.view / settings.manage——yes
team.view / team.manage——yes
audit.view——yes

Note that verifying a corrective action and signing off a day are both admin-only, which is the docblock's rule enforced rather than merely stated. Grants below admin are enumerated rather than wildcarded [src: app/src/Perms.php:11].

Backups are admin-only

Both /backup.json and /backup.sqlite need the settings permission, which only an admin holds [src: app/controllers/api.php:326], [src: app/controllers/api.php:332]. Before 3.1.5 they were gated by sign-in alone, so a viewer could download them.

The API and agent access

Seven tools are exposed to an agent, of which two write [src: app/controllers/v3.php:24]: the monitored equipment, the open corrective actions, a compliance report, the delivery trace and the allergen matrix on the read side (the last two since 3.2.0) [src: app/controllers/v3.php:186]; recording a reading and closing an action on the write side.

The instructions given to an agent are the plainest in the catalog, and they matter:

An out-of-range reading cannot be saved without a corrective action — that refusal is the product working, not an error to route around. Safora documents records; it does not certify compliance [src: app/controllers/v3.php:22].

Temperatures may be sent in Fahrenheit and are converted on the way in [src: app/controllers/v3.php:21], so an agent reading a probe in °F never has to do the arithmetic itself.

Recording a reading from something other than a person

Safora does not talk to probes or sensors — there is no driver and nothing polls hardware.

It does, however, accept a reading over its own API: POST /api/readings [src: app/index.php:196], and record_reading over the agent endpoint [src: app/controllers/v3.php:57]. Anything that can make an authenticated HTTP request — a sensor gateway, a script, a building-management system — can therefore put a reading into the diary.

A reading posted without source keeps the person's rule: out of range with no corrective action is refused [src: app/src/FoodSafety.php:183]. A reading posted with "source": "sensor" — a probe cannot know what was done about a warm fridge — is stored as an open breach instead [src: app/src/FoodSafety.php:198]: the day turns red [src: app/src/FoodSafety.php:241], the breach chase picks it up, and a manager cannot verify it until a person records the corrective action on the Corrective actions screen [src: app/src/Checks.php:220], [src: app/index.php:150].

Worth being precise about the distinction: Safora does not connect out to sensors, but it does accept readings in.

How long a checklist took

A checklist run records the seconds between the checklist page opening and the run being saved; re-saving a run records the latest. The checklist page and the day view show it ("ticked in 6 s for 5 items"), and mark a run of three items or more saved in under 2 seconds per item as quick [src: app/controllers/kitchen.php:525], so the manager signing the day off can ask about it. It is a prompt, not proof: a well-run kitchen can tick a short list quickly. A run saved from a page that was not opened in that session (an old tab after signing in again) has no time and shows none.

The inspection folder (3.2.0)

An inspector asks for more than the temperature diary. Since 3.2.0 Safora keeps five more records beside it, under Inspection folder in the menu. They are the kitchen's own records: Safora stores and prints them, it does not decide what is safe, lawful or enough.

The morning digest

Off by default, like every other message Safora sends. Switch it on in Settings (under the email settings) and choose the hour; from that hour, the first visit to the dashboard sends one email to the notification address [src: app/controllers/app.php:292], or the cron URL digest/run?t=… does, with the same token as the breach chase [src: app/index.php:120]. It lists the checks due today and which are done, what was not recorded yesterday, out-of-range readings still awaiting a verified action, documents expiring within 14 days, and open recalls [src: app/controllers/kitchen.php:26]. On a day with nothing to say it sends nothing.

It is sent once a day, never twice: the day is claimed before the send [src: app/controllers/kitchen.php:72]. Safora reports a digest as sent only when your mail server accepted it; a refused send is written to the audit trail as notify_failed, with the server's reason, and is not retried that day. Without a mail server and a notification address nothing is sent and the day is not used up.

Staff training

Staff training keeps your own instructions per task (a topic: "Probing cooked food", "Cooling rice") and a record of who was trained on each, when and by whom [src: app/controllers/kitchen.php:125]. Admins write the topics; recording training needs the verify permission, which only an admin holds (a manager signs training off, as they sign days off). Everyone who can read the diary can read both.

Documents

Documents stores certificates, policies, pest-control and inspection reports as PDF, PNG or JPEG, up to 10 MB. The file's content decides its type, not its name, so an executable renamed .pdf is refused [src: app/controllers/kitchen.php:159]. Give a document an expiry date and the dashboard lists it 30 days before and after it expires [src: app/controllers/kitchen.php:503]; the morning digest lists it 14 days before. Removing a document deletes the stored file as well as the entry, and is audited.

The HACCP plan

HACCP plan holds your plan as a table, one row per step: hazard, control, critical limit, monitoring, corrective action, verification. An empty plan offers a sample of six common steps whose critical limits come from your own safe ranges in Settings [src: app/controllers/kitchen.php:239]; it is a starting point, not an approved plan. Edit the plan opens every step as a form; saving replaces the plan in one transaction, so a save that fails halfway leaves the old plan whole [src: app/controllers/kitchen.php:290], and is written to the audit trail. Print prints the table.

Allergens

Allergens is a matrix of your dishes against the 14 allergens food businesses in the UK and the EU declare (Regulation (EU) No 1169/2011, Annex II) [src: app/controllers/kitchen.php:307]: what each dish contains and what it may contain through cross-contact. A dish is one row: saving a dish under a name already on the list (in any case) updates it [src: app/controllers/kitchen.php:333]. Printable matrix gives a one-page sheet for the pass or the counter, with the date the matrix last changed. Check it against your recipes and labels whenever they change; Safora records what you enter.

Traceability and recalls

The delivery check now also takes the product, the batch or lot code and the use-by date (all optional) [src: app/controllers/app.php:528]. Traceability searches deliveries by supplier, product or batch, or part of one, in any case; % and _ are searched as characters [src: app/controllers/kitchen.php:354]. Deliveries recorded before 3.2.0 still show their supplier.

The recall log records a recall: product, batch, supplier, the reason, the quantity affected and what was done. An open recall shows on the dashboard until it is closed; closing records the day and the action, once [src: app/controllers/kitchen.php:441]. A recall's batch code links to the deliveries that carried it.

In the audit pack

The audit PDF ends with the inspection folder: the HACCP plan and the allergen matrix as they stand on the day you export, and for the chosen period the deliveries that carry a product, batch or use-by, the recalls raised (and any still open), the training on record and the documents on file [src: app/controllers/kitchen.php:461]. A section with nothing in it is left out.

Over the API and to an agent

Two read-only endpoints: GET /api/trace?q= (deliveries matching the search, with the recall log) and GET /api/allergens (the matrix; ?allergen=milk lists only the dishes that contain or may contain milk) [src: app/index.php:199]. The same two are agent tools, trace_deliveries and allergen_matrix; a read-only key may use them. Nothing in the inspection folder is written over the API.

The import's dry run applies the same checks the import does, row by row, and writes nothing: a row the import would skip (a fridge whose minimum is above its maximum) is listed as skipped in the dry-run report, not counted ready.

What Safora does not do

It does not certify compliance [src: app/src/FoodSafety.php:14], and it is not food-safety advice. It documents what you recorded.

It does not decide your thresholds. The defaults are UK-derived and every one is configurable [src: app/src/FoodSafety.php:24]; several say so in the source and tell you to verify them locally [src: app/src/FoodSafety.php:29].

It does not chase you in real time. Nothing fires at the moment a check becomes late — the absence shows on the day's record and stays visible afterward [src: app/src/Checks.php:10]. The morning digest (3.2.0, off by default) is one email a day listing what is due; it is not an alarm.

It does not write or approve your HACCP plan. The sample plan is a starting point built from your own safe ranges [src: app/controllers/kitchen.php:239]; the plan, the allergen matrix and the training instructions are yours, and Safora keeps them as you wrote them.

It does not know your country's holidays. England and Wales only, and only to offer a "were you shut?" prompt [src: app/src/FoodSafety.php:371].

It does not let a bad reading through quietly. Out of range without a corrective action is refused, for people and agents alike [src: app/src/FoodSafety.php:179].

Looking after your install (from 4.0)

Updates from inside Safora

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; Safora 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. Safora is unavailable for about a minute while this runs. The update replaces Safora'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 Safora 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 Safora 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: two weeks of food-safety records across fridges and freezers to try things on. You can remove it in one click. [src: app/install/index.php:131]. 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 records, checklists and equipment? 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 Backups 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 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 Safora 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 Safora 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/safora/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)

Safora 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 Safora 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 Safora'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 Safora from the console starts a fresh session [src: app/index.php:56].

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:28].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Safora'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 Safora 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:12]. If you had Safora 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 Safora'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.

← Back to Safora · Quickstart · API · Test run