Home›Leavora›Manual
Leavora · Manual

Leavora Manual

Leavora — User Manual

Leave entitlement, requests, approvals and balances. Version 4.1.3 [src: app/src/App.php:8].

About this manual

Every statement here was written by reading Leavora'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.

What Leavora is

It tracks leave only — entitlement, requests, approvals and balances [src: app/src/Leave.php:17].

It is not payroll. It never computes wages, never touches money, cards or bank rails, and initiates no payments [src: app/src/Leave.php:18].

Leave is counted in hundredths of a day

Amounts are integer centidays, where one day is 100 and half a day is 50 [src: app/src/Leave.php:11].

That is not fussiness — it is what makes accrual exact. A monthly accrual of "20 days a year" is credited as intdiv(2000 × months, 12), so twelve months lands on exactly 2000 — twenty days — with zero floating-point drift, and every intermediate month is an exact hundredth of a day [src: app/src/Leave.php:12].

A typed amount like 20, 1.5, 0.5 or 20.25 is parsed with string arithmetic and no float round-trip [src: app/src/Leave.php:35].

Sums are computed in PHP with a final clamp, never with a SQL SUM() — which the source describes as "a strict-mode 500 waiting to happen" [src: app/src/Leave.php:14].

Working days

A request's length counts only working days: weekends and company holidays are both excluded [src: app/src/Leave.php:5].

Your weekend is configurable. It is a list of weekday numbers rather than an assumption — 0,6 is the Sunday-and-Saturday default, but a Friday-Saturday weekend is simply a different list [src: app/src/Leave.php:68]. Anything out of range is dropped, and an empty result falls back to the standard weekend rather than counting every day.

The count is DST-safe: it iterates UTC-anchored calendar days, so a span crossing a clock change counts the exact number of dates with no 23-hour or 25-hour drift [src: app/src/Leave.php:106].

Holidays may be supplied either as a list of dates or as a map keyed by date — both are accepted, and weekends are still excluded on top [src: app/src/Leave.php:102].

Accrual

Three methods [src: app/src/Leave.php:29], all prorated by the employee's start date so nobody accrues for months that began before they joined [src: app/src/Leave.php:198]:

  • Annual — the whole start-prorated entitlement is granted up front, the moment the leave year (or the employee's start, if later) begins [src: app/src/Leave.php:201].
  • Monthly — one twelfth per leave-month, credited at each month's start, with integer division keeping a full year landing on exactly the annual figure [src: app/src/Leave.php:203].
  • None — never accrues, for something like unpaid leave [src: app/src/Leave.php:205].

The leave year does not have to be the calendar year: it starts on a month and day you choose [src: app/src/Leave.php:180].

Carryover

Leftover balance carries into the next year, capped and floored at zero [src: app/src/Leave.php:240] — a negative closing balance never becomes a negative opening one.

The balance calculation walks every leave year from the employee's start year to the as-of year, carrying the capped leftover across each boundary [src: app/src/Leave.php:251].

The balance

Stated as an equation the source defines exactly [src: app/src/Leave.php:264]:

entitlement = carry_in + accrued
available   = entitlement − taken − pending

Pending requests are deducted. A request awaiting approval already reduces what an employee can book, which is what stops somebody booking the same days twice while the first request sits in a queue.

Holidays per location, and region packs (3.2)

A holiday belongs to everyone (no location) or to one location [src: app/src/Database.php, holidays.location]. A person's working days leave out the holidays for everyone plus those of their own location [src: app/controllers/app.php, _holiday_dates()].

Load a region's holidays adds a year of public holidays as ordinary rows [src: app/src/HolidayPacks.php]. The regions are England and Wales, Scotland, Northern Ireland, Ireland, the United States (federal), Germany (nationwide) and France. Dates come from each region's standing rules and the Gregorian Easter calculation; substitute days are added where the region has them (the UK regions, and the US observed dates). One-off days proclaimed for a single year, and holidays kept only in some states or regions, are not in any pack — add those by hand. Loading the same year twice adds nothing. A location's holidays are added to the holidays for everyone, so for offices in different countries load each region's pack for its own location and keep "everyone" for days the whole company closes.

Who can be off (3.2)

Departments carry a most off at once number (0 = no cap). Approving a request is refused when, on any working day it covers, that many of the department are already on approved leave [src: app/controllers/app.php, _dept_cap_refusal()]. Weekends and the person's holidays do not count. Approved sick leave counts as someone off.

Blackout dates stop planned leave for everyone or for one department: a request inside one is refused when filed [src: app/controllers/app.php, _blackout_refusal(), called from _request_from_input()], and a request filed before the blackout existed can no longer be approved.

A leave type marked unplanned (sick leave is, out of the box) is never stopped by either rule.

Both rules sit in the request rules and the one decide rule [src: app/controllers/app.php, _request_decision_refusal()], so every door — the form, the staff page, the REST API and the AI tools — meets them.

Time off in lieu, and who's off (3.2)

Time off in lieu. A leave type with the time off in lieu method accrues nothing by the calendar; each credit an administrator records on a person's page (a date and a number of days) is accrual for that leave-year, carried under the type's cap like anything else [src: app/src/Leave.php, balanceAsOf() $earnedByYear].

Who's off. /off?date= lists everyone on approved leave that day. cron/offtoday.php sends that list once a day to every webhook subscribed to leave.off_today and, if switched on, to the notification address [src: app/controllers/rules.php, lv_off_today_run()]. A webhook can be set to post a chat message — {"text": "…"}, which Slack incoming webhooks accept — instead of the signed JSON; chat lines name the person, the leave type and the dates, never the reason [src: app/controllers/app.php, lv_chat_line()].

Clashes

Overlapping requests are detected [src: app/src/Leave.php:156], and a clash check can exclude the request being edited so amending a request does not collide with itself [src: app/src/Leave.php:167].

Users and roles

Four roles [src: app/controllers/v3.php:23], and two of the decisions behind them are worth stating.

Permissionviewermemberemployeeadmin
leave.read, employee.read, report.readyesyes—yes
leave.write—yes—yes
self.read, self.write——yesyes
deciding, settings, team———yes

Submitting and approving are separate authorities. The source separates them on purpose: "submitting a request and approving one are different authorities, and a product about approvals should not blur them" [src: app/controllers/v3.php:17].

The self-service role cannot approve anything. An employee sees and books their own leave and nothing else — no colleague's balance, no register, and deliberately no decision. Approving stays admin-only, so, in the source's words, "opening the doors to staff cannot open the door to self-approval" [src: app/controllers/v3.php:26].

Backups are for administrators

Both backup downloads — the JSON export and the SQLite file — ask for the administrator role [src: app/controllers/api.php, backup_json() and backup_sqlite()]. Viewer, member and self-service employee accounts are refused, and the Security page shows the backup buttons to administrators only.

A backup contains every table, so the employee register, everyone's leave history and the audit trail come out together. Before 3.1.4 a sign-in alone was enough to download it.

One version, one name

Every surface that states a version reads the same constant, App::VERSION [src: app/src/App.php]: the REST ping, the OpenAPI document, the health check, the agent specification and its transport probe. The agent specification, its probe and the REST API all name the server leavora in lower case [src: app/controllers/v3.php]. Before 3.1.4 the probe said Leavora, so a client probing with GET and one calling with POST were told two different names.

The API and agent access

Tools cover listing requests, request detail, a team calendar, submitting a request and deciding one [src: app/controllers/v3.php:84].

An unauthenticated GET is answered rather than rejected, and the source explains why: a client probes with GET to find out whether a server speaks the protocol at all, and answering 401 first "gets 401 and concludes there is no MCP endpoint here, which is the opposite of the truth" [src: app/controllers/v3.php:222]. No tools are named to an unauthenticated caller [src: app/controllers/v3.php:224].

What Leavora does not do

It does not do payroll [src: app/src/Leave.php:18] — no wages, no money, no payments.

It does not let a self-service employee approve anything [src: app/controllers/v3.php:26].

It does not assume your weekend [src: app/src/Leave.php:68].

It does not drift on accrual [src: app/src/Leave.php:12], and it does not sum leave in SQL [src: app/src/Leave.php:14].

It does not carry a negative balance forward [src: app/src/Leave.php:240].

Privacy (GDPR): what an erase removes and what it keeps (4.1.1)

An employee's page carries Privacy (GDPR) for an admin: export their data as JSON, or anonymize them (refused while a request of theirs is still pending). Anonymizing removes their name and email, the reasons and change notes on their requests, the notes on their time off in lieu, and the attachments to their requests (sick notes), files included. The leave history and its day counts stay.

Kept: their own sign-in account, if they had one, with its name and email, and any personal calendar feed made for them, because the erase does not reach accounts or feeds; deactivate the account under Team & roles and revoke the feed under API & agents.

Looking after your install (from 4.0)

Updates from inside Leavora

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; Leavora 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. Leavora is unavailable for about a minute while this runs. The update replaces Leavora'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 Leavora 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 Leavora 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 small team with leave types, holidays and leave requests to try things on. You can remove it in one click. [src: app/install/index.php:132]. 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 employees and leave requests? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the backup page (open /restore; it has no menu link), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:74]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.

To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Leavora 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 Leavora 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/leavora/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)

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

The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:21].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Leavora'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 Leavora 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 Leavora 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 Leavora'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 Leavora · Quickstart · API · Test run