Home›Rostera›Manual
Rostera · Manual

Rostera Manual

Rostera — User Manual

Shift scheduling: who is on, when, and for how long. Version 4.1.3 [src: app/src/App.php:8].

About this manual

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

It plans shifts only — who is scheduled, when, and for how long [src: app/src/Shift.php:22].

It is not payroll, and the source rules out four separate things: it holds no pay rates or wages, touches no money, cards or bank rails, has no time clock and no biometric capture, and initiates no payments [src: app/src/Shift.php:23]. It records planned shifts and their hours.

That third exclusion is worth noticing. Rostera records what was rostered, not what was worked. If you need attendance capture, this is not that product.

Everything is whole minutes

A clock time is minutes since midnight — 09:30 is 570 — and a duration is a whole number of minutes, so an eight-hour shift is 480 [src: app/src/Shift.php:10].

There is no float anywhere in the duration or total path, so weekly totals are exact and never drift [src: app/src/Shift.php:12].

⭐ The daylight-saving handling, and why there are two different answers

This is the most careful piece of engineering in the product, and it turns on a distinction most schedulers get wrong.

A shift's real duration is computed in your business timezone with real calendar instants [src: app/src/Shift.php:103], so a shift that spans a clock change counts the time actually elapsed rather than the time the clock appears to show.

Running the product's own arithmetic on a New York shift rostered 01:00 to 05:00:

DateWall clock saysReal duration
Ordinary day240 min240 min
Spring forward (the 02:00 hour does not exist)240 min180 min
Clocks back (the 01:00 hour happens twice)240 min300 min

The source states the spring case explicitly [src: app/src/Shift.php:15]; the autumn case follows from the same mechanism, and is arguably the one that matters more — somebody genuinely works an extra hour that night, and the total says so.

But overlap uses wall-clock time, deliberately

Double-booking is checked on a wall-clock day-ordinal timeline, and the source gives the reason in a parenthesis worth reading twice: double-booking is about the clock the roster is written in [src: app/src/Shift.php:18].

That timeline is anchored at UTC midnight, so it is DST-immune [src: app/src/Shift.php:19].

Two different questions, two different correct answers: "how long did they actually work?" is a question about elapsed time, and "are these two shifts the same shift?" is a question about the roster. Conflating them would break one or the other.

Overlap and clashes

A shift ending exactly when the next begins is not a clash. Intervals are half-open, so a clean hand-over or back-to-back shift passes, and only a genuinely shared minute counts [src: app/src/Shift.php:217].

Overnight shifts are handled properly: a midnight-crossing shift extends past its own day's boundary, so an overnight shift on Monday correctly meets an early Tuesday one [src: app/src/Shift.php:205].

An open shift belongs to nobody and never double-books [src: app/src/Shift.php:232].

Weeks

The week-start day is configurable rather than assumed [src: app/src/Shift.php:178], and the week's dates follow from it [src: app/src/Shift.php:187].

Copy last week

Copy last week on the rota copies the week before into the week on screen, as drafts [src: app/controllers/app.php, _ro_copy_week()]. Every copy goes through the same checks as a shift made by hand: the times are re-read, an overnight shift gets its true length for its new date (an hour longer or shorter across a clock change), and the double-booking guard runs. A copy that would double-book someone, or belongs to someone no longer active, is skipped and counted in the message; an identical shift already in the week is not copied again. Nothing is published until you publish the week. Members and administrators can use it; viewers cannot.

Draft and published

Two statuses [src: app/src/Shift.php:35], with anything unrecognized falling back to draft rather than to published [src: app/src/Shift.php:262] — the safe direction, since a draft rota shown as published is worse than the reverse.

Publishing is an administrator's act, not an editor's [src: app/controllers/v3.php:23].

Users and roles

Three roles, and the source summarizes them in one line: a viewer reads the rota, a member edits shifts, and an admin also publishes, decides swaps, and owns settings [src: app/controllers/v3.php:23].

Permissionviewermemberadmin
shift.read, employee.read, report.readyesyesyes
shift.write—yesyes
swap.offer—yesyes
publishing, deciding swaps, settings——yes

Note the pairing: a member may offer a swap but not decide one. Offering and approving are separated the same way they are in the leave product — the person affected is not the person who rules on it.

Staff logins — My rota (3.2)

A fourth role, employee, belongs to a login bound to ONE staff record [src: app/controllers/v3.php, _ro_perms() and _ro_my_employee()]. It holds only self.read and self.write: it reads that person's own published shifts and makes requests for them, and it cannot read the rota, the staff list or anyone else's shifts. One rule, run before any route, keeps such a login inside My rota [src: app/controllers/staff.php, ro_guard_employee_scope()]: a page outside it sends them back to their own, and a write outside it is refused.

An administrator creates the login from the person's page (Give them a login, a single-use invite, valid 7 days, for the email on the record), and can switch it off there. See what they see shows the same page read-only.

From My rota a person can:

  • ask to take an open shift — published open shifts only; a manager approves or declines under Swaps & requests, and approval re-runs the double-booking guard and the time-off check at that moment [src: app/controllers/staff.php, _ro_claim_refusal()];
  • offer one of their shifts, or pick up a colleague's — the same swap a manager decides once;
  • say when they can work — per weekday: any time, not available, or a window. This is a preference: the rota shows it and "who's free" lists people outside it last, but it never blocks a shift;
  • ask for time off — which a manager approves or declines;
  • make their own calendar link — their published shifts only.
  • ask for texts about their shifts, once you have switched texts on (see "Texts to staff, on your own SMS account").

Approved time off blocks rostering

Once time off is approved, that person cannot be put on a shift on those days by any route: the shift form, a drag on the rota, Copy last week (the copy is skipped and counted), the REST API (409), a CSV import, the AI tool, or a swap or open-shift approval [src: app/controllers/app.php, _ro_time_off_refusal()]. A shift that runs past midnight also counts the next day, unless it ends exactly at midnight. To roster them after all, cancel the time off first.

Templates, budget, lock and Clockora (3.2)

Templates. Save the week on the rota as a named template; apply it to any week as drafts [src: app/controllers/staff.php, _ro_apply_template()]. It places each shift through the same function as Copy last week (_ro_place_draft()), so an overnight shift gets its true length for its new date, and a shift that would double-book someone, belongs to someone who has left or falls on their approved time off is skipped and counted. Applying twice places nothing more.

Weekly hours budget. A number of hours per week (Settings). The rota shows the week's scheduled hours against it; it never involves money [src: app/controllers/staff.php, _ro_budget_for()].

Availability lock. While locked, staff see their availability on My rota but cannot change it; a manager still can, on the person's page [src: app/controllers/staff.php, portal_availability_save()].

Clockora. With a Clockora address and API key saved, the Hours report reads Clockora's GET /api/timesheet for the same dates when the page opens and shows each person's clocked hours beside their published hours, matched by name [src: app/controllers/staff.php, _ro_clockora_clocked()]. Nothing is stored; if Clockora does not answer, the page says so rather than showing zeros.

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 and member accounts are refused, and the Security page shows the backup buttons to administrators only. Before 3.1.4 a sign-in alone was enough.

One server name

The REST interface, the agent specification and its unauthenticated probe all name the server rostera [src: app/controllers/v3.php]. Before 3.1.4 the probe said Rostera, so a client probing with GET and one calling with POST were told two different names.

Texts to staff, on your own SMS account

Off until you connect an SMS gateway you already pay for, in Settings → Texts to staff [src: app/controllers/sms.php, sms_settings_save()]. Rostera sells no credits and adds nothing to the price: your gateway bills you, at its own rate, for every text it sends.

  • Any HTTPS gateway. Rostera 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,text by default; Twilio's are To,From,Body), and your key as a Bearer token, as HTTP Basic (user:password, for Twilio AccountSid:AuthToken) or not at all. Any 2xx answer counts as sent [src: app/src/Sms.php, request()].
  • Your business name first. Every text starts with the business name in Settings, so texts stay off until the business is named [src: app/src/Sms.php, enabled()].
  • Each person decides. On their own My rota page, a person ticks Text me when my shifts are published or change and gives their mobile number; a second tick, Also text me when open shifts are up for grabs, adds open shifts [src: app/controllers/sms.php, portal_texts_save()]. A manager can turn a person's texts off from their staff page when they ask, never on [src: app/controllers/sms.php, employee_texts_off()], so a person without a staff login is not texted. The ticks are read in the query that picks who is texted, at the moment the texts go [src: app/src/StaffSms.php, flush()].
  • What is texted. A shift that goes from draft to published: Publish week, a shift's own Publish, or an edit that publishes it. A change to a published shift: a new day or time, moved to someone else, made open, unpublished or deleted, through the shift form, a drag on the rota, a delete or an AI agent's shift tool [src: app/src/StaffSms.php, collectChange()]. A label or note edit is not news, and neither is anything on a day before today. A published shift that becomes open goes to the people who asked about open shifts, not to the person just taken off it. Not texted: a shift created already published (by the form, the API, an agent or an import), and the approval of a swap or an open-shift request; the person sees those on My rota.
  • One text per person per change. Publishing a week sends each person one text listing their shifts (three named, the rest counted), with any new open shifts in the same text; a change that touches two people sends each of them one [src: app/src/StaffSms.php, compose()]. Each text is recorded under its own key before it is sent, so it never goes twice [src: app/src/StaffSms.php, flush()].
  • How staff stop them. Every text ends with two links: My rota, and Stop texts, which opens a page with a Stop these texts button and needs no login; opening the link alone changes nothing [src: app/controllers/sms.php, texts_stop_page()]. Pressing the button, or unticking the box on My rota, stops the texts and forgets the number [src: app/src/StaffSms.php, stop()].
  • When the gateway says no. The publish or the change stands. The manager's message says what the gateway answered, the rest of that batch is not tried, and Texts sent in Settings and the person's staff page list each text with its result. A refused text is not tried again: by the next day a rota text can be wrong [src: app/src/StaffSms.php, flush()].
  • No sending hours. Texts go when a manager acts, so a change to today's shift reaches the person at once; a rota published late in the evening reaches staff late in the evening.
  • Test it. Send test sends one text through the saved gateway and shows the gateway's answer as it came; switch texts on and save first [src: app/controllers/sms.php, sms_settings_test()].
  • On the record. Every attempt is in the audit trail with the last four digits of the number only. The gateway key is write-only in Settings, and the key and each person's stop link are left out of the JSON backup [src: app/controllers/v3.php, _ro_backup_redact()]. On a demo install no text leaves: it is written to the list and the gateway is never called.

The API and agent access

Five tools are exposed to an agent [src: app/controllers/v3.php, _ro_mcp_spec()]: listing shifts, shift detail, open shifts and a roster period on the read side; assigning a shift on the write side, which refuses a double-booking and approved time off.

Webhooks announce shift.created, shift.swapped (a manager approved a swap: who gave the shift to whom), shift.claimed (a manager approved a request for an open shift) and rota.published [src: app/src/Webhook.php].

What Rostera does not do

It does not do payroll [src: app/src/Shift.php:23] — no rates, no wages, no payments.

It does not capture attendance [src: app/src/Shift.php:23]. There is no time clock and no biometric capture; it records what was rostered.

It does not count a clean hand-over as a clash [src: app/src/Shift.php:217].

It does not double-book an open shift [src: app/src/Shift.php:232].

It does not let a member publish a rota or decide a swap [src: app/controllers/v3.php:23].

It does not report wall-clock hours as worked hours across a clock change [src: app/src/Shift.php:103].

It does not text anyone who did not ask, and it sells no text credits: texts go through your own gateway, only to a person who ticked the box on their own My rota page [src: app/src/StaffSms.php, flush()].

Looking after your install (from 4.0)

Updates from inside Rostera

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; Rostera 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. Rostera is unavailable for about a minute while this runs. The update replaces Rostera'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 Rostera 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 Rostera 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 hospitality team with a published week of shifts to try things on. You can remove it in one click. [src: app/install/index.php:125]. 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 shifts? 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 Rostera 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 Rostera 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/rostera/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].

Switching from Deputy: your team members

On the team import page (/import/employees), Switch from another tool takes the file Deputy exports. In Deputy: People > Add People > Bulk import and update > Update team members > Download > CSV (Deputy prepares the file: the bell says when it is ready). Upload it and press Read the export (nothing is written yet): the switch report shows how many rows will import, which are already here from an earlier import, which repeat a row above, which have a problem (by line), and which columns do not come across. Then import them in one click. Importing the same file again adds nothing twice [src: app/views/_switch.php].

Switching from Deputy: your schedule

On the shifts import page (/import/shifts), Switch from another tool takes the file Deputy exports. In Deputy: Schedule > the week view > the "..." Options menu > Export schedule > Format CSV > Export. Upload it and press Read the export (nothing is written yet): the switch report shows how many rows will import, which are already here from an earlier import, which repeat a row above, which have a problem (by line), and which columns do not come across. Then import them in one click. Importing the same file again adds nothing twice [src: app/views/_switch.php].

Connect it to other tools (Zapier, Make, n8n)

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

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 Rostera'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 Rostera 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 Rostera 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 Rostera'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 Rostera · Quickstart · API · Test run