Membora Manual
Membora — User Manual
A membership register for a club or association: members, subscriptions, meetings, attendance, motions and money. Version 4.1.3 [src: app/controllers/api.php:16].
About this manual
Every statement here was written by reading Membora'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 voting guard, and where it is applied
Nearly every club constitution says some version of "a member whose subscription is in arrears may not vote". The source names that sentence as the reason the subscription record and the vote record belong in one product: kept apart, the secretary is reconciling a spreadsheet against a minute book at the moment a decision is being taken [src: app/src/Membership.php:172].
The design decision that follows is the one to understand:
The rule is applied at the ballot, never at the door. A member in arrears is still recorded as present when they were present — attendance is a record of fact, and quorum is counted from it. Only the right to vote is withheld [src: app/src/Membership.php:177].
Executed, with today at 2026-09-05 — the guard and the exact reason it gives [src: app/src/Membership.php:184]:
no subscription on file no "No current subscription on file, so no vote is recorded.
Members in arrears may attend and be counted toward quorum."
active, ends 2026-12-31 YES
frozen no "Subscription is suspended, so no vote is recorded."
canceled no "Membership has been resigned, so no vote is recorded."
active, ended 2026-08-01 no "Subscription lapsed on 2026-08-01, so no vote is recorded."
Note the last line: the reason names the date it lapsed, which is what a member at a meeting will ask for.
⭐ Status is what is true today, not what was typed
An active membership whose end date has passed is really expired, and the register says so without anybody having to run anything [src: app/src/Membership.php:154]. Frozen and canceled are manual holds and are never auto-flipped — a suspension does not quietly become an expiry.
Executed:
stored active ends 2026-09-06 -> active
stored active ends 2026-09-05 -> active ← the end date itself is still inside the term
stored active ends 2026-09-04 -> expired
stored frozen ended 2026-01-01 -> frozen (never auto-flipped)
stored canceled ended 2026-01-01 -> canceled
no membership -> none
Terms, and the month-end case
Four billing periods [src: app/src/Membership.php:27]. Life and honorary memberships are open-ended — they have no end date at all rather than a distant one [src: app/src/Membership.php:240]. Executed from an awkward start date:
annual start 2026-01-31 -> ends 2027-01-31
monthly start 2026-01-31 -> ends 2026-02-28 ← clamped, not rolled into March
life start 2026-01-31 -> open-ended
honorary start 2026-01-31 -> open-ended
The meeting record: minutes, the roll, the tallies
Each meeting's roll page carries a Meeting record card. Minutes can be typed or corrected there after the meeting until it is closed [src: app/controllers/v3.php], and each change is written to the audit trail as a change, without the text. The same card downloads the record as PDF and CSV [src: app/controllers/v3.php:751]: the meeting, the quorum on the day (present against required, and whether it was quorate), the roll with each member's recorded state, each motion with its mover, seconder, votes for, against and abstaining, and outcome, and the minutes. The quorum and the tallies come from the same functions the screens use, so the record cannot disagree with them.
Closing a meeting seals its record (1.1)
Close the meeting and seal the record (meeting editors only) is offered once the meeting has taken place and every motion on it is decided or withdrawn [src: app/controllers/v3.php meeting_close_apply]. Closing stamps the record with the SHA-256 of its CSV (the meeting, quorum, roll, motions with tallies, and minutes), and from then on the minutes, the roll and the motions of that meeting are refused: saving minutes, recording attendance through any door (form, roll call, API, AI assistant), and proposing, voting on or deciding a motion. The PDF and CSV print when and by whom it was closed, the hash, and whether the record still matches it; if someone changed the database directly afterward, they say it has changed since it was sealed [src: app/controllers/v3.php meeting_record].
The renewals list only contains people you should actually chase
"About to lapse" means an active, dated term whose end date falls between today and the window you set [src: app/src/Membership.php:266]. Already-lapsed, suspended, resigned and open-ended subscriptions never appear — the source names the error it is avoiding: chasing a life member for a renewal [src: app/src/Membership.php:264].
Executed with a thirty-day window:
active ends 2026-09-20 -> on the list
active ends 2026-10-20 -> not listed (outside the window)
active ended 2026-09-04 -> not listed (already lapsed — chase it as arrears, not a renewal)
frozen ends 2026-09-20 -> not listed
active open-ended -> not listed
That third line is worth knowing: a membership that has already lapsed falls off the renewals list entirely, because it is no longer a renewal question.
Member fields, events and cards (1.1)
Member fields. Settings → Member fields takes one field per line, Label | type | required (type text, date, yesno or choice: A, B, C) [src: app/src/MemberFields.php]. The fields appear on the member form (required ones must be filled), on the member page, and as columns in the members CSV (formula-safe). Values are kept by the field's label, so reordering the list changes nothing, and a value whose field was removed is kept and shown as an earlier field.
Events. Tick Members can register online on a meeting and turn on Settings → Events: /events lists those meetings with places left, and a member registers with their member number and email — both must match, so the form cannot be used to look people up; wrong attempts are rate-limited [src: app/controllers/events.php]. Registration goes through the same roll path as the desk, so capacity applies and a closed meeting takes no more. The member gets a confirmation with a cancel link; canceling frees the place.
Membership cards. Each member has a card link with a QR code that opens it (member page → Membership card: Open card, Email the card, New card) [src: app/src/MemberCard.php]. The card shows the club, the member's name and number, their rate, and In good standing or Not currently in good standing, worked out when the card is opened, so it can never show a status that has lapsed since. The link is left out of the shareable JSON backup; a new card stops the old link.
Receipts and payment requests (1.1)
Every payment on the payments list and on a member's record has a PDF link [src: app/controllers/v3.php payment_receipt_pdf]. A paid charge gives a receipt (number R-00012, the member and member number, what it was for, the amount, the date paid and the method). A charge still due gives a payment request (number P-00012) with your payment link and bank details from Settings, so the secretary can send it on. Anyone who can see payments can download them. Membora takes no money itself.
Organization memberships and the filtered export (1.2)
A member can be a person or an organization (member form, Member type). An organization holds the subscription, and its people are added to its household exactly like a family: they are covered by its term, and its record lists them as "This organization's membership covers" [src: app/controllers/app.php member_kind_from_post]. The members list marks organizations, and the members CSV has a Type column.
Export members (bottom of the members list) downloads the members CSV for exactly who you choose. You can filter by status, rate, people or organizations, "lapsing within" a number of days (an active term ending that soon), members with an overdue charge, and one of your own member fields matching a value. Leave everything blank for every member [src: app/controllers/app.php members_export_rows].
Joint and family memberships (1.1)
On a member's record, the Household box adds people to that member's household [src: app/src/Household.php]. They are covered by the lead's subscription: with no active or frozen term of their own, their standing, card, place on the roll and vote follow the lead's term, and renewing the lead renews them all (current_membership() in app/controllers/app.php answers with the lead's term and says whose it is). A covered member's page names the lead and shows no Renew or Freeze buttons, because those act on the lead's term; renew it from the lead's record. The members CSV has a Covered by column, and the API's membership carries covered_by.
Households are one level: a lead is never covered by someone else, a member in one household cannot lead another, and a member with a subscription of their own is not added. Leave the household deletes nothing (every field stays); if the lead's term is paid beyond today, the member keeps it as a term of their own on the same rate, ending on the same date, with no charge. Erasing a lead takes each covered member out of the household the same way first.
Renewal notices and reminders (1.1)
The renewal email (Settings → Notices, sent by cron/expiring.php) now says what renewing costs, from the member's plan, and how to pay: your own payment link (https) and/or the bank details you type in Settings → Renewals [src: app/controllers/api.php renewal_how_to_pay]. With neither set it asks the member to reply or speak to the membership secretary, as before. The same cron run sends one follow-up, renewal_followup_days after the notice (14 by default, 0 = never), to a member whose membership still has the same end date, so nobody who renewed is chased [src: app/controllers/api.php renewal_followup_sweep]. On the arrears list, Send reminder emails one member the amount due and how to pay; the list shows how many reminders a charge has had, and a reminder that fails to send is not counted [src: app/controllers/api.php dues_remind]. Membora never takes the money.
Dues paid by card, through your own Stripe account
Card payment is off until you switch it on in Settings → Card payments for dues and save your own Stripe secret key, your webhook signing secret and a currency [src: app/controllers/dues.php pay_settings_save, app/src/Pay.php ready]. Membora sells nothing and takes no cut: the member pays on Stripe's own page, the money goes to your Stripe account, and no card number ever reaches your server [src: app/src/Pay.php].
- What a member can pay. Their oldest unpaid charge comes first. With none, their renewal, at the plan's price, once the membership is inside the renewal notice window (Settings → Notices) or has lapsed. A frozen or canceled membership, a free plan, a life membership and a person covered by someone else's household membership have nothing to pay here [src: app/controllers/dues.php
dues_due]. - Where they pay. The member's card page (
/card/<token>) shows the amount and a Pay by card button. The renewal email, the follow-up, the arrears reminder and the payment-request PDF add a "Pay by card" line linking to that page; the page never expires, while a Stripe checkout page expires within a day, so the checkout is created only when the member presses the button [src: app/controllers/api.phprenewal_how_to_pay,dues_remind; app/controllers/v3.php; app/views/card_public.php]. - When it counts. Only when Stripe's signed webhook says the money is in. Membora checks the signature over the request body exactly as received, refuses an event more than five minutes old, and records only a paid checkout in your currency for the amount the member was asked for [src: app/src/Pay.php
verifySignature; app/controllers/dues.phpdues_handle]. Coming back from Stripe's page changes nothing on its own. - What it records. A charge is marked paid with the method "Stripe". A renewal renews the membership exactly as the Renew button does, from the later of today and the current end date, and records a paid payment: that is what moves the member's standing [src: app/controllers/dues.php
dues_apply]. The payment.recorded webhook and your "payment recorded" email follow, as for a payment marked paid by hand. - Only once. Each Stripe event is claimed by its id before anything is recorded, and each checkout session pays at most once, so Stripe's retries cannot record a payment twice [src: app/src/Database.php
pay_events,idx_payments_provider_ref]. If the secretary renewed the membership first, the card payment pays that renewal's open charge instead of renewing it a second time. Money that arrives for something already settled is kept as its own paid payment, and the detail says so, so you can refund it. - Refunds are made in Stripe, then recorded in Membora by hand. Membora does not move money back.
- Set it up. In Stripe, add a webhook endpoint at
https://<your Membora address>/hooks/stripeforcheckout.session.completedandcheckout.session.async_payment_succeeded, then paste its signing secret in Settings. Settings shows that exact address and the latest events Stripe sent, with what Membora did with each. A test key (sk_test_…) works with Stripe's test cards. - The keys are write-only in Settings and are left out of the shareable JSON backup [src: app/controllers/app.php
membora_backup_redact].
Renewal reminders by text, through your own SMS account
Text reminders are off until you switch them on in Settings → Text reminders for renewals and give the address of an SMS gateway the club already pays for [src: app/src/Sms.php]. Your gateway bills the club at its own rate; Membora sells no credits and adds nothing.
- Any HTTPS gateway. Membora sends one HTTPS POST per text, as JSON or form-encoded, with your three field names for the number, the sender and the message (
to,from,textby default; Twilio's areTo,From,Body), and your key as a Bearer token, as Basic (user:password, for TwilioAccountSid:AuthToken) or not at all. Send test sends one text and shows what your gateway answered [src: app/src/Sms.phprequest,send; app/controllers/dues.phpsms_settings_test]. - Only members who agreed. A member gets texts only when their record has Texts OK ticked. The box is unticked until someone ticks it, the date it was ticked is kept and shown on the member's record, and every change is in the audit log [src: app/controllers/app.php
_member_sms_consent]. Having a phone number is not enough: the query that picks who to text only selects members with Texts OK ticked [src: app/src/Sms.phprunRenewalReminders]. - Every text names the club, gives the end date and links to the member's card page, where Stop text reminders stops them with one tap [src: app/src/Sms.php
renewalText; app/controllers/dues.phpcard_texts_off]. - Two reminders per renewal, 14 and 3 days before the membership ends by default; 0 switches either off. A membership found inside both windows gets only the nearer one [src: app/src/Sms.php].
- Once per renewal. Each reminder is marked on the membership for the end date it was sent for, and claimed before the gateway is called, so it never goes twice. Renewing moves the end date, so next year's renewal is reminded again [src: app/src/Database.php
sms1_period,sms2_period]. - Quiet hours. Texts go only inside your send window, 09:00 to 20:00 by default, in the club's time zone (Settings → Organization). A text that falls due outside it waits.
- When they go. A few at a time (three) each time someone opens the dashboard during the window, and the rest whenever
cron/expiring.phpruns inside it, so a club whose daily cron runs outside the window still sends its texts. Neither runner sends a text twice [src: app/src/Sms.phplazy,LAZY_BATCH; app/controllers/app.phpdashboard; app/cron/expiring.php]. - When the gateway refuses, nothing is marked as sent, the failure is written to the audit log, and the next run inside the window tries again. It never stops the run.
- The gateway key is write-only in Settings and is left out of the shareable JSON backup.
Arrears
A payment is overdue when it is unpaid and its due date is strictly before today — a payment due today is not yet overdue [src: app/src/Membership.php:277]. Executed:
due 2026-09-04 unpaid -> overdue
due 2026-09-05 unpaid -> not overdue ← due today
due 2026-09-06 unpaid -> not overdue
due 2026-09-01 paid -> not overdue
arrears total of the unpaid rows above: £74.99
Money is read the way people actually type it
Amounts are integer cents throughout [src: app/src/Membership.php:16], and the parser accepts both conventions [src: app/src/Membership.php:37]. Executed:
'$49.99' -> 4,999 cents 49 -> 4,900 cents
'1.234,56 €' -> 123,456 cents 49.9 -> 4,990 cents
'1,234.56' -> 123,456 cents '-12,50' -> −1,250 cents
'free' -> null — no number, and it says so rather than guessing zero
Motions cannot be decided in an inquorate meeting
Carrying or rejecting a motion is refused when the meeting was not quorate, and the refusal shows the arithmetic — how many were present against how many were required [src: app/controllers/v3.php:1024].
The event that follows is fired after the write, and the source explains why: so a subscriber can never learn of an outcome that was rolled back [src: app/controllers/v3.php:1026].
Users and roles
Roles are held per permission [src: app/controllers/app.php:25], with the audit page requiring audit.view [src: app/controllers/v3.php:16].
⚠️ A viewer can read the audit trail
The viewer role holds the wildcard *.view [src: app/controllers/app.php:25], which matches audit.view. If your read-only accounts are meant not to see who recorded which vote or which payment, that is not the behavior you have. This has been raised against the product.
Backups are gated on settings.edit [src: app/controllers/api.php:293], which no role below admin holds [src: app/controllers/api.php:299].
Webhooks
Four events are offered for subscription [src: app/src/Webhook.php:21], and each is sent:
| event | sent when |
|---|---|
meeting.attendance | attendance is recorded — from the roll call, the meeting page, the REST API or the agent, which all go through one function [src: app/controllers/app.php:133] |
attendance.refused | attendance could not be recorded (a canceled or full meeting), with the reason |
payment.recorded | a payment lands: added in the browser or through the REST API, or a due payment marked paid |
motion.decided | a motion is decided |
An endpoint saved before 1.0.3 under the old name meeting.attended receives meeting.attendance.
The API and agent access
Eight tools [src: app/controllers/mcp.php:61]: six read — the member list, one member, the meeting list, one meeting, the lapsing report and the subscription report — and two write, recording attendance [src: app/controllers/mcp.php:177] and recording a vote [src: app/controllers/mcp.php:208].
Recording attendance through an agent requires the same meeting.attend permission the screens do [src: app/controllers/mcp.php:191], so an automation cannot do what its key's role could not do by hand.
What Membora does not do
It does not stop a member in arrears at the door [src: app/src/Membership.php:177] — attendance is a record of fact and quorum counts it.
It does not auto-flip a suspension into an expiry [src: app/src/Membership.php:154].
It does not put a life member on the renewals list [src: app/src/Membership.php:264].
It does not treat a payment due today as overdue [src: app/src/Membership.php:277].
It does not guess zero when it cannot read an amount [src: app/src/Membership.php:37].
It does not let an inquorate meeting decide a motion [src: app/controllers/v3.php:1024].
It does not refund a card payment — refunds are made in Stripe and recorded in Membora by hand [src: app/controllers/dues.php].
It does not text a member who has not agreed to it — only members with Texts OK ticked are selected [src: app/src/Sms.php].
Looking after your install (from 4.0)
Updates from inside Membora
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; Membora 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. Membora is unavailable for about a minute while this runs. The update replaces Membora'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 Membora 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 Membora 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 club's members, subscriptions and meetings to try things on. You can remove it in one click. [src: app/install/index.php:126]. 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 members, payments and meetings? 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 Restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:71]. 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 Membora 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 Membora 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/membora/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)
Membora 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 Membora 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 Membora'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 Membora from the console starts a fresh session [src: app/index.php:59].
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:22].
New in 4.1.3: security update
New in 4.1.3 (security update): no other site can put Membora'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 Membora 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 Membora 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 Membora'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.