gumslone.github.io/doba — the short version for hoteliers, with a commission calculator · Deutsch
Doba is an open-source hotel booking system built on Laravel: a public, multilingual, server-rendered website with a direct-booking engine, an availability calendar, rate and restriction rules, online payments, OTA/channel sync and a full admin panel.
It exists for one reason. An independent hotel pays Booking.com, Expedia or Airbnb 15–18% commission on every reservation. A direct-booking site takes that back — but only if it ranks and converts, which is why the SEO layer here is a first-class subsystem rather than a meta tag bolted on at the end.
doba is the everyday word for one hotel night in Polish and Ukrainian.
Status: in progress. Built so far: the multilingual SEO layer, the availability & rate engine, the booking core (holds, locking, state machine), online payments (Stripe, PayPal, LiqPay, crypto, manual), events, extras & room inclusions, photo management with a maps block, invoices, two-way iCal channel sync, the guest booking funnel, and an interim admin with a WYSIWYG editor. Still to come: the full Filament panel — specified in
docs/architecture.md. See Roadmap. The correctness-critical paths are covered by 485+ tests on both database engines, but the platform has not yet run a real hotel — treat it as pre-release.
A public repository cannot carry real hotel photographs — they are someone's licensed work — but a hotel site with no images demonstrates nothing and hides every image bug. The demo ships illustrated scenes as SVG: a lake at dusk, a forest path, an interior in the evening. Eight of them cost 28 KB, stay crisp at any size, and are obviously illustrations rather than photographs — a placeholder that is plainly a placeholder is honest, where a blurred rectangle is merely useless.
Each slot names the scene it wants rather than taking the next file in the directory, because the alt text already tells a screen reader what the picture is; the picture has to actually be that, or the alt text is a lie told to the people who most depend on it.
The derivative generator skips SVG — a vector image is already every size,
and srcset is correctly omitted while the intrinsic dimensions are still
emitted so nothing shifts as the page loads. Uploads still refuse SVG
(jpg, png and webp only): serving our own scenes does not widen what a
hotelier can put on their site, because an uploaded SVG can carry script.
The demo hotel below is what migrate --seed produces — real rooms, rates,
events and four languages, rendered by the default theme.
The photographs are generated by the demo seeder, not stock imagery — a public repository can't ship licensed hotel photography, and a site with no images hides every image bug. Replace them from Admin → Photos.
| Booking.com | A generic website builder | Doba | |
|---|---|---|---|
| Commission per booking | 15–18% | 0% | 0% |
| Real availability & rates | ✅ | ❌ | ✅ |
| Ranks for "hotel in your town" | for them | maybe | that is the point |
| Guest data is yours | ❌ | ✅ | ✅ |
| Runs on €5/month hosting | — | — | ✅ (SQLite mode) |
Everything below is implemented and covered by tests.
- Per-locale URLs with translated path segments and translated slugs —
/de/zimmer/doppelzimmerand/en/rooms/double-roomresolve to the same room type. The slug lives in the translation table, never on the parent record. - Reciprocal
hreflang+x-default, emitted in the page head and in the sitemap. A record that is translated into three of four languages gets exactly three alternates — a fallback-rendered page is never advertised as a translation. - Self-referencing canonical URLs, query strings excluded, so
?utm_source=cannot fork a page into a second indexable URL. - JSON-LD structured data:
Hotel(address, geo, amenities, check-in times),HotelRoom,Offerwith price/currency/availability/validity,BreadcrumbList,ItemList,WebSite,FAQPage. Money is stored in integer minor units and converted at the boundary, so a €125 room is never published as12500. - Editable, translated FAQs rendered on the home page with matching
FAQPagemarkup — the structured data never describes questions the page doesn't visibly show. - Translated amenities per room type, rendered on the room page and
mirrored as
LocationFeatureSpecificationentries in the room's JSON-LD. - Room inclusions, grouped — WiFi, walk-in shower, bathtub, hairdryer, balcony and the rest arrive under Room / Bathroom / Comfort / View headings rather than as one flat wall of ticks, because a guest arrives with a specific question ("shower or bath?") and skims past a wall.
- The counters are audited nightly.
availability.bookedandheldare caches of the booking tables, andavailability:reconcileis the job that proves it — recomputing both from ground truth (one row per unit per night for bookings whose status declares thebookedside, plus every OTA block still holding, plus unreleased holds) and reporting every disagreement. Both directions are reported, and the quiet one is why this exists: overselling announces itself when a guest reaches the desk, but a counter stuck high silently stops the hotel selling a room it actually has, and nothing anywhere looks broken. It exits non-zero even after--fix, because drift means a bug already happened and a reconcile that repairs it and exits 0 is a bug nobody ever hears about. - Search does not slow down as the hotel grows. A twenty-room property
that lists its rooms individually — a villa, a boutique where every room
differs — used to issue 216 queries for one search, because each room
type asked the same questions and each re-read the same nights. The
nights and the rate plans are now loaded once per search: 12 queries,
flat, whether the hotel has three room types or thirty. The preload is
scoped to the single call and released in a
finally, never memoised for the request — a cache of availability that outlives the operation it was built for is one that can be read after somebody's booking changed it. - Bookable extras — breakfast, spa & sauna, airport transfer, parking, a cot, late checkout — each priced per stay, per night, per person or per person-night, which is the difference between a €45 transfer and a €108 breakfast on the same three-night booking. Extras carry their own VAT rate (accommodation is usually reduced, breakfast and parking are not), can be scoped to particular room types or offered house-wide, and can be marked included so the pool shows as a perk rather than a price. Prices are snapshotted onto the booking like every other amount.
- Contact form with enquiry storage on a translated route (
/de/kontakt,/en/contact): honeypot + timing check (spam is stored under its own status, never silently dropped, and the response never reveals detection), per-IP rate limiting, optional stay dates for quote requests, and a queued mail to the hotel inbox with reply-to set to the guest. - XML sitemap with per-URL
xhtml:linkalternates, written nightly byphp artisan doba:sitemapand generated live as a fallback. robots.txtfrom the same flag as the meta robots tag — a staging install cannot benoindexin HTML and crawlable inrobots.txtat the same time. Crawlers are kept out of the booking funnel, where crawling would manufacture inventory holds.- Legacy-URL redirects from a database table, resolved on 404 (no query on the happy path) and preserving the query string, so a hotel migrating from an old site keeps its rankings and its campaign attribution.
- Core Web Vitals defaults: intrinsic
width/heighton every image to prevent layout shift, WebPsrcset/sizescapped at the source's real width, eager +fetchpriority=highon the LCP image and lazy on everything else, no third-party origins in the critical path. - Photo management for every room type and the house gallery: uploads are stored under a random name, measured, and turned into WebP derivatives at upload time (never on the visitor's request); per-locale alt text; exactly one cover, always — deleting the cover promotes the next photo, and deleting a photo removes its derivatives with it.
- Google Maps, click-to-load. Nothing is requested from Google until the
visitor presses the button, so the map costs no third-party request on
arrival and the privacy policy stays short.
hasMapandGeoCoordinatesgo into theHotelstructured data either way. - Events with per-locale slugs (
/de/veranstaltungen/weinverkostung), an upcoming-events section on the front page, andschema.org/Eventmarkup with the hotel as the default venue — one of the few SERP features an independent hotel can win that an OTA listing cannot. - A visible breadcrumb trail that matches the structured one, a language
switcher that points at the current page in each language rather than the home
page, and one
<h1>per page.
- Availability as one row per room type per night, with a raw-DDL
CHECKconstraint (booked + held <= allotment) as the last line of defence against overselling on both MySQL and SQLite. - Correct restriction handling: min/max-stay and closed-to-arrival on the
arrival night only, closed-to-departure on the checkout night only,
min_stay_through, occupancy limits, multi-room composition. A missing row is unbookable, never "assume available". - Rate resolution: per-date override → season rate (priority + weekday bitmask) → default rate, then the chosen rate plan's adjustment — frozen per night into the booking so a confirmed price never moves.
- Rate plans: flexible, non-refundable saver, early bird, long stay. Each carries eligibility (nights, days-before-arrival, a validity window bounded by the stay), a percent (basis points) or fixed adjustment applied per night, and its own cancellation terms. A plan posted for a stay it is not eligible for is ignored rather than honoured — a discount cannot be bought by editing a form field.
- The cancellation policy is snapshotted onto the booking in the guest's own language at booking time, per room because a booking may mix plans. Refunds are computed from that snapshot, never from the live plan: a dispute is settled by the wording the guest agreed to, and editing the plan afterwards cannot change what a taken booking owes.
- Double-booking prevention via
lockForUpdateinside the booking transaction — and on SQLite via a mandatoryBEGIN IMMEDIATEconnection so concurrent bookings genuinely serialise rather than throwingSQLITE_BUSY. The concurrency test proves exactly one of two racers wins. - Holds & a state machine where inventory is released by the status being entered, so no path can leak a unit; expired holds are swept every minute.
- An admin availability grid (§12): room types as rows, the month's dates as columns, each cell showing the resolved nightly price — the one a guest is quoted, not just manual overrides — and how many rooms are still free. Drag across cells to fill the bulk-edit panel, which applies a price, min/max stay, allotment or stop-sell across a date range and a weekday filter: "Saturdays in July, min-stay 3" is one operation, not thirty-one. Fields left blank are left alone, and the allotment can never be cut below what is already sold — the night is named rather than the constraint throwing a driver error.
- A public two-month availability calendar on the home and room pages: per-night prices, hatched closed dates, closed-to-arrival marks, a last-room dot, and range selection that applies the same N-vs-B boundary and arrival-date min-stay rules the server enforces — so the guest is never offered a range the engine would refuse. It hands off to the real checkout, which re-validates.
- The complete guest funnel: search → checkout → hold → pay →
confirmation, plus a no-login manage link (40-character token,
constant-time compared) where the guest can view or cancel their own
booking. Availability is re-checked at checkout rather than trusted from
the results page, a room taken mid-typing sends the guest back to search
with an explanation instead of an error, and the whole funnel is
noindexbecause crawling it would manufacture holds against real inventory. Scarcity ("only 2 left") is counted from confirmed bookings only, and only once units have genuinely sold.
- A
PaymentGatewaycontract with Stripe, PayPal, LiqPay (Ukraine/PrivatBank), Coinbase Commerce (crypto) and a manual (bank-transfer / pay-on-arrival) implementation — all over Laravel's HTTP client, no SDK dependencies, fully faked in tests. - The webhook is the source of truth, never the browser redirect. Every handler verifies the provider's signature (and fails closed if the signing secret is unset), is idempotent on the gateway payment id, and re-acquires inventory under lock before confirming.
- The late-webhook trap is handled: a payment that lands after the hold expired and the room was resold is auto-refunded (or, for crypto that can't refund via API, released and flagged for a manual refund) — never confirmed into an overbooking.
- Partial and full refunds with correct
paid_amount/balance_duebookkeeping; refunds are their own rows so the ledger reconstructs a dispute. - Restaurant, bar & the menu — one
venuestable covering restaurant, bar, café and lounge, each with sections and dishes, all translated, on their own localised URLs (/en/dining/seehof,/de/gastronomie/…). Prices are minor units like every other amount and are nullable, because "market price" is a real menu entry and printing €0.00 for the day's catch is worse than printing nothing. Dishes carry the fourteen allergens EU Regulation 1169/2011 requires a food business to declare, as a fixed enum rather than free text — "nuts", "Nüsse" and "may contain traces of nuts" typed by three chefs are not searchable, translatable, or trustworthy to a guest whose reaction is measured in minutes. They print as the numbers a German or Austrian menu uses, ascending, with a key listing only the allergens that card actually uses. A dish with no allergen data is never presented as free of anything: an empty list may mean "contains none" or "nobody filled this in", and only one of those is safe to tell a guest with an allergy. Opening hours are two periods a day (a kitchen closes between lunch and dinner) and wrap past midnight, so a bar open 16:00–01:00 shows as open at half past midnight. The whole menu is published as schema.orgRestaurant/BarOrPub→Menu→MenuSection→MenuItemwith offers, diets andopeningHoursSpecification— this is the one hotel page a search engine will render as structured content, and a market-price dish carries noOfferrather than a zero a search engine would repeat. - Promo codes — percentage (stored in basis points, so no float ever touches the money path), fixed amount, or free nights with the cheapest nights discounted first, which is both what a guest reads into "your third night is free" and what costs the hotel least to honour. Codes carry minimum nights and total, separate usable and arrival date windows, total and per-guest usage limits, and a room-type restriction. Two rules keep them honest: a code discounts the stay, never the extras (the transfer is bought at its listed price), and the discount can never exceed the subtotal — a €200 code on a €150 stay is €150 off, not a refund the hotel now owes. The limit is re-checked under lock inside the booking transaction, because "50 uses" means 50 and two checkouts finishing in the same second must not both be the fiftieth. Cancelling a booking gives the use back while keeping the redemption row: a hundred abandoned holds must not retire a campaign, and a code that ran out has to stay explainable afterwards. A rejected code sends the guest back with the actual reason — "this code needs a longer stay" changes their dates, "invalid code" sends them to a competitor.
- Invoices, with the VAT arithmetic done the way tax authorities expect.
A confirmed booking issues a sequentially numbered invoice (
2026-0001, restarting each year, claimed under a locking read so two simultaneous confirmations cannot share a number) and the PDF is attached to the confirmation mail. Two rules make the figures trustworthy: VAT is extracted from the gross price rather than added to it — every amount the guest was ever shown already included it, so adding it would quietly bill them more than they agreed to — and net is rounded while tax is the remainder, sonet + taxequals the gross exactly on every line and therefore on every total. The document prints the per-rate breakdown a German, Polish or Ukrainian invoice is required to show, because accommodation is reduced-rate while breakfast, parking and the spa are not. PDFs are rendered with Dompdf (not Browsershot: no headless Chromium on a hotel's server) onto the private disk and served only through the admin session or the guest's own manage-link token — an invoice carries a name and a home address, and sequential numbers make a public URL trivially enumerable. The schema refuses to delete a booking that has been invoiced.
FEATURE_VOUCHERS=true adds a voucher page to the site, in every language.
A voucher is money received in advance — a means of payment, never a
discount — so it never touches a price or an invoice line; redeeming one
records a payment through the same path a card takes.
- Ordered online, paid to you. The buyer chooses an amount, a name and a message, and is mailed your payment instructions (bank details, a payment link, "pay at the desk" — free text under Admin → Gift vouchers). The order is worth nothing until you press Payment received; then the buyer gets the voucher as a PDF to print or forward. No card data, no second payment flow, no chargebacks — which is how small houses sell vouchers by phone today.
- Or sold at the desk, active at once, printable from the list.
- Redeemed by code on the guest's booking page. It takes only what is still owed and keeps the rest for next time; a pending booking is confirmed once the voucher covers its deposit. Every movement happens under a row lock, so a balance is never spent twice, and a refund goes back onto the voucher rather than to a bank.
- Codes are
GV-XXXX-XXXXfrom an alphabet without look-alikes, accepted however a person types them, and redemption is rate-limited because a code is a bearer instrument. Valid to the end of the year, three years on (DOBA_VOUCHER_VALID_YEARS).
Tier-1 two-way iCal, which is what an independent hotel actually runs.
- Export:
GET /ical/{room_type}/{token}.icspublishes every night the room cannot be sold, merged into as fewVEVENTs as possible and built fromavailabilityrather than from bookings — a night closed by the hotelier, sold direct or held by another channel all export identically, because the OTA needs "not for sale", not why. The feed carries no guest data at all: it is a subscription URL, fetched by whoever ends up with it. - Import:
channels:syncruns every 15 minutes, matches events on theirUIDso a re-import is a no-op, and increments the samebookedcounter a direct booking uses — under the same lock and the same CHECK constraint, because an OTA guest occupies the room exactly as a direct guest does.DTENDis read as exclusive, so the checkout night stays sellable; reading it as the last night silently burns a night on every imported booking. - Removals get a guard, because they are the dangerous direction.
Adding a spurious block costs one unsold night. Releasing a block that
was never cancelled sells a room an OTA has already promised, and the
hotel finds out when the guest arrives. So a removal must clear three
hurdles: the feed must have parsed completely (a truncated response
returns
null, never an empty event list — a 200 carrying half a calendar must not look like a quiet week), its event count must be plausible against the last sync (40 → 38 is two cancellations, 40 → 3 is a truncated download), and the event must be absent from three consecutive good syncs. A stay starting within 7 days is never auto-released — it is flagged for staff, who release or keep it from the admin queue. - Staleness is itself an alert. A feed with three consecutive errors or no success in an hour is logged at error level and shown in red in the admin: a dead sync and a quiet week look identical until two guests arrive for one room.
- The import URL cannot be pointed at your own network. It comes from a
form, so the fetcher resolves the host and refuses private, loopback and
link-local addresses — an admin session is the first thing an attacker
gets, and a URL fetcher that will follow
http://169.254.169.254/on request is an SSRF hole regardless of who filled the form in. - The limitation is stated in the admin UI, not just here: iCal syncs availability only, with a 15–60 minute lag, and cannot push prices. Two guests can still book the last room on two channels inside that window.
- No
unsafe-eval, and therefore no expression-evaluating front-end framework. Alpine compiles its bindings withnew Function(), so a strict CSP disables it silently; the three interactive pieces (calendar, click-to-load map, cookie notice) are plain DOM instead. A test asserts the policy still forbids eval and that no template reintroduces such directives — the server-side suite cannot catch this by running. - Response headers on every route: CSP,
X-Content-Type-Options,X-Frame-Options,Referrer-Policy,Permissions-Policy, and HSTS over HTTPS. - HTTPS forced in production so canonical URLs and redirects never emit
http://; secure-cookie and CSRF handling; webhook routes are CSRF-exempt but signature-authenticated instead. - Public forms carry a honeypot + timing check and per-IP rate limits; admin login is throttled. Money is integer minor units end to end.
- The branch ships with an adversarial security review on record (fail-open webhook verification found and fixed before merge).
- Locale resolution: URL prefix → session →
Accept-Language→APP_LOCALE, with an optional prefix-less default locale (the prefixed form then 301s). - Two-layer translation: interface strings in
lang/, content in*_translationstables edited by the hotelier. - Theme layer —
resources/views/themes/<slug>overridesdefaultfile by file; branding is settings, not theme files. - Settings service cached across requests, exposed to every view as
$hotel. - Portable schema: identical migrations on MySQL 8 and SQLite 3.35+,
money as
bigIntegerminor units, noENUM, no stored procedures.
- PHP 8.4+ with
pdo_mysql,pdo_sqlite,mbstring,intl,gd,zip,curl,openssl— 8.4 is a hard floor: the SQLiteBEGIN IMMEDIATEtransaction mode the booking engine's locking depends on requires it - Composer 2, Node 20 (build-time only)
- MySQL 8 or SQLite 3.35+
- Apache 2.4 with
mod_rewrite+ php-fpm in production (seedocs/architecture.md§15)
Open in GitHub Codespaces — the whole thing boots in a browser tab: PHP 8.4, SQLite, migrations, the demo hotel, the front-end build, and the site on port 8000. Roughly two minutes cold, seconds from a prebuild. It is the real application, not a mock-up — the booking funnel, the admin, the availability grid, the PDF invoices and the iCal feeds all work.
Two details the dev container gets right, because both would otherwise make the demo lie about the thing it is demonstrating:
APP_URLis set to the forwarded Codespaces host, notlocalhost. URLs built inside a request already come out right — the app trusts the proxy, so canonical tags andhreflanghrefs follow the forwarded host.APP_URLis what everything built outside one uses: the sitemap thatdoba:sitemapwrites to disk, the iCal feed URLs the admin hands to Booking.com, and the links in queued confirmation mail. Left atlocalhost, an SEO demo ships a sitemap advertisinglocalhost.- HSTS is switched off there (
DOBA_HSTS=false). A Codespace answers on*.app.github.dev, a domain this install does not own, andStrict-Transport-SecuritywithincludeSubDomainswould assert a year of policy over somebody else's wildcard host. Every other security header stays exactly as it is in production.
The seeded admin is admin@example.com / password at /admin. Set
DOBA_ADMIN_EMAIL and DOBA_ADMIN_PASSWORD before seeding anything you
intend to make public.
Hosting a public demo of your own is one switch, DOBA_DEMO=true: the
install builds a living demo hotel, prints the admin login on every page,
makes the dangerous half of the admin read-only and rebuilds itself every
night. See docs/demo-hosting.md.
GitHub Pages cannot host this: Pages serves static files, and Doba needs PHP, a database and writable storage. Codespaces runs the real thing.
The admin session edits what the public site prints, so it is the crown
jewels. Two-factor sign-in (standard TOTP — any authenticator app) is
switched on under Your account, with eight single-use recovery codes
shown once; turning it off or reissuing codes needs the password, so a
session left open on a desk cannot weaken the account it is in. Remember-me
is a checkbox that defaults to off — on a shared front-desk PC a long-lived
cookie is everyone's session. There is deliberately no "forgot password"
link: whoever can run php artisan doba:admin:reset-password on the server
is the owner, and that is their reset (--clear-2fa for the phone that
fell in the lake).
The loyalty scheme a small hotel can actually run: no points, no tiers, no
card. Set DOBA_LOYALTY_DISCOUNT_BPS (500 is 5%) and a guest who has stayed
before and books direct again with the same email gets it off automatically,
named on the invoice as what earned it. Three rules keep it honest: it
applies only on the hotel's own site, never to a channel manager's
booking; it never stacks on a promo code — a guest who typed one chose
that offer; and it counts stays confirmed before this one, so a first-timer
whose booking is still pending has not become a regular by sitting on the
checkout page.
Switched on with FEATURE_REVIEWS=true. The post-stay mail invites a
review; the guest writes it from their booking page — only a real,
departed stay can, because the manage token is the proof, so there is no
account system, no anonymous form and no imported stars. The hotel
moderates and replies in the admin; what it cannot do is edit a guest's
words, because a review the hotel can rewrite is worth exactly as much as
one it wrote itself. Published reviews appear on the home page with a
schema.org AggregateRating, computed from published reviews and nothing
else — the stars in a search result are an average, never an assertion.
The guest site ships in English, German, French, Dutch, Ukrainian and
Polish — interface, mails, invoices and translated URL segments
(/de/zimmer, /pl/pokoje, /uk/номери). The install wizard offers every
shipped language; the one chosen becomes the site's default. A test pins
key-parity across all six, so a string added in one language and forgotten
in another fails CI rather than rendering as a raw translation key in a
guest's inbox.
Room descriptions, pages and menus are the hotel's own content, translated per locale in the admin — a language with no translation for a page simply does not serve that page, rather than serving it half-English.
The admin speaks them too. Which languages guests are served is a
business decision; which language a receptionist reads is theirs alone, so
each member of staff picks their own under Your account (the login screen
offers them as well), and it may be one the hotel does not publish in. The
install wizard is translated as well. tests/Feature/AdminLanguageTest.php
pins key parity and placeholder survival across all six.
Four ways onto a server, all ending at the same wizard:
No shell at all — download doba-installer.php from the
latest release, upload that
one file by FTP, open it in a browser. It checks the server, downloads
the newest release, verifies the checksum, unpacks it around itself,
writes a bootable .env and deletes itself. It proves you own the server
the same way the wizard does: by asking for the contents of a token file
it just wrote next to itself.
With a shell —
curl -fsSL https://raw.githubusercontent.com/gumslone/doba/main/scripts/install.sh | bashSame steps, one command; DOBA_DIR and DOBA_URL override the
questions. Both installers configure only what Laravel needs to boot —
everything a hotelier actually decides is asked once, in the wizard,
by the same code either way. And both verify the tarball's SHA-256
before extracting a byte: the release workflow smoke-tests each
installer against the exact tarball it publishes, so a release whose
installers cannot install it never ships.
With Docker — one container, one volume:
docker run -d -p 8080:80 -v doba-data:/data -e DOBA_URL=http://localhost:8080 ghcr.io/gumslone/dobaor docker compose up -d with the docker-compose.yml
in this repository. Everything the hotel owns lives under /data — .env,
photos, invoices, backups, logs and the SQLite database — so backing up
that volume is backing up Doba, and updating is pulling a newer image:
the container runs the same health-checked updater on boot, snapshotting
the database first. The scheduler and the queue worker run inside the
container, because a container has no crontab. Put any reverse proxy in
front for a domain and https and set DOBA_URL to the public address. The
image is built for amd64 and arm64, so a Raspberry Pi or a Synology works.
The wizard's install token is one command away:
docker exec <container> cat /data/storage/install-token.txtAs a developer — clone and build; see Quick start.
Then open the site in a browser. An uninstalled copy sends every URL to
/install and walks through it: language, a blocking server check,
the database, the hotel, your account, your rooms, done. Nothing needs a
shell — which is the point, because most people running this do not have
one.
- The wizard is unauthenticated by definition, because there is nobody
to authenticate against yet. So it prints the path of
storage/install-token.txtand asks for what is inside: whoever can read a file on the server is exactly the person entitled to install onto it. The token is written on first load, so a fresh clone is never even briefly open, and it is deleted when the install completes. - The server check is blocking — no "continue anyway". Each failure
carries the fix, not just the fact. The hotelier who clicks past a
missing
intlis not the one who can diagnose the site that half-works a month later. - Every step is resumable. A browser crash at step 5 resumes at step 5. Progress lives in the session until the database step creates somewhere durable to keep it.
- The rooms step makes the calendar live immediately — from a template or your own list. A hotelier who finishes, opens the dashboard and sees an empty calendar concludes the software is broken.
- Once installed,
/installreturns 404 — not a redirect, not an "already installed" page. A scanner should learn nothing. - Two independent markers record the install:
storage/installed.lockand a database row. They fail differently — a deploy that rsyncsstorage/wipes the file, a restored backup carries a row for a filesystem nobody set up — so installed means both. When they disagree the wizard opens in repair mode and says what is missing, rather than offering a fresh install that would migrate over live reservations.
php artisan doba:update and Admin → Update handle everything after
that. See Updating.
git clone https://github.com/gumslone/doba.git && cd doba
composer install
cp .env.example .env && php artisan key:generate
touch database/database.sqlite
php artisan migrate --seed
npm install && npm run build
php artisan serveThen open http://127.0.0.1:8000/ — it content-negotiates to
/en, /de, /fr or /nl. The seeder creates a demo hotel whose junior suite
is deliberately not translated into Dutch, so the hreflang set, the language
switcher and the sitemap all have a real partial translation to show.
Useful URLs on the running site:
| URL | What it shows |
|---|---|
/de/zimmer/doppelzimmer |
room page: hreflang, canonical, HotelRoom + Offer JSON-LD |
/nl/kamers/junior-suite |
404 — untranslated, and deliberately not served under a fallback |
/sitemap.xml |
every translated URL with reciprocal alternates |
/robots.txt |
funnel disallows + sitemap reference |
Two ways in, one code path — because the people running this are often on shared hosting with no shell, and an update path that assumes SSH is one most of them cannot take. They stop updating, including past the security fixes.
From the browser: Admin → Update shows the version, what is waiting
to be applied, and a button. Typing UPDATE is required, because it
migrates a live hotel's reservations.
From a shell: php artisan doba:update (or --check to see what it
would do and change nothing).
Both run the same sequence, in this order:
- snapshot the database — before anything is touched. If the snapshot fails, nothing else happens. Migrating a hotel's reservations with no way back is the outcome this whole path exists to prevent.
- close the site — a guest must not meet a half-migrated schema
- migrate
- rebuild the config, route and view caches, signal queue workers
- reopen
If a migration fails, SQLite is restored automatically — the snapshot
is a whole-file copy and the file it replaces is the one the failed
migration just left half-written. MySQL is not: restoring over a partially
migrated database is a decision with consequences, and an automatic
restore that itself fails leaves nobody able to say what state the data is
in. There the site stays closed and you get the exact mysql < command. A
hotel that is down is recoverable; a hotel taking bookings against a
broken schema is not.
Snapshots use VACUUM INTO on SQLite and mysqldump --single-transaction
on MySQL — both consistent against a live database with writers
mid-transaction, which a cp of a WAL database is emphatically not.
A backup is the database and the uploaded photos, kept as one set under a single timestamp. Restoring the database alone gives a hotel back every booking and a website of broken images, so the two are pruned, deleted and restored together — half a restore is not a restore.
- Nightly by default (
DOBA_BACKUP_AT, 03:15). A hotel that has never once thought about backups is exactly the hotel that needs them, and "the hotelier will set this up" is not a plan. It logs at error level when it cannot run: a backup that silently never happens is worse than one nobody configured, because the hotel believes it has copies. - Before every update, automatically.
- On demand, from Admin → Update or
php artisan doba:backup. DOBA_BACKUP_KEEPcounts sets, not files, so pruning can never leave a database snapshot whose photos have been deleted.- Download either half from the admin. A backup that only exists on the same disk as the thing it protects is half a backup.
- Restore from the admin, confirmed by typing the timestamp. It takes a fresh backup of the current state first — a restore chosen by mistake has to be undoable too — and refuses to proceed at all if that safety copy fails. Photos are extracted over the current ones rather than instead of them, so a picture uploaded since the backup survives.
Each release ships a tarball carrying vendor/ and the built assets, so
no Composer and no Node are needed on the server. Extract it over the
install — your .env, database and uploads are untouched — then run the
update. The workflow smoke-tests every tarball by extracting it, migrating
and booting it before attaching it to the release, because a release that
cannot boot is worse than no release.
With a git checkout and a toolchain, the usual git pull && composer install --no-dev && npm ci && npm run build && php artisan doba:update.
php artisan doba:sitemap # regenerate public/sitemap.xml (nightly in production)php artisan doba:images # generate WebP srcset derivatives + backfill image dimensionsphp artisan testvendor/bin/pint --test && vendor/bin/phpstan analyse --memory-limit=1GCI runs the suite against both SQLite and MySQL on every push — that matrix is the only thing that keeps the "portable" promise honest.
A third job boots the demo hotel and runs an accessibility audit (WCAG 2.1
AA, HTML CodeSniffer) and Lighthouse over the pages a guest lands on,
failing below the thresholds in lighthouserc.json. SEO is the pitch, and
this is what proves the pages stay fast and readable. The theme derives
text-safe colours from any preset or brand colour (StylePreset::derived()),
so a hotel's gold can be its gold and still read at 4.5:1.
/api/v1, versioned in the path, for anything that calls the hotel: a
channel manager, an agency, a group website.
The controllers are translation layers over AvailabilityService,
RateResolver and BookingService — the same objects the website's own
funnel uses. That is the rule the whole surface rests on: the day the
API grows its own booking logic is the day it sells a room the website
thinks is free, and a guest finds that bug rather than we do.
- Key pairs (
X-Api-Key-Id+X-Api-Secret), the secret hashed and shown exactly once. Every rejection looks identical whether the key id is unknown, the secret is wrong, or the key was revoked an hour ago — telling a caller which half they got right is an oracle for enumerating the other. - Scopes per route, declared on the route rather than checked in a
controller, so a route's permission cannot drift away from it.
bookings:writewithoutbookings:cancelis a normal grant. - Optional IP allowlist, expiry, instant revocation — and a sandbox whose bookings are marked as tests, because without one a partner's first integration test runs against the hotel's live calendar.
- Money is always
{"amount": 12500, "currency": "EUR"}. Dates are dates; acheck_inis never a timestamp. - RFC 9457
application/problem+jsonwith stabletypeURIs. Partners branch ontype, so those strings are contract and the humantitleis not. Running out of rooms has its own type: "it went while you were deciding" is the one failure a booking partner must handle specifically. Idempotency-Keyis required onPOST /bookings. A partner whose request times out will retry, and an endpoint that cannot tell a retry from a second booking sells the room twice. A replay returns the stored response byte for byte — it is kept as raw text, not a JSON column, because MySQL's JSON type reorders keys and "identical" is the entire promise. The same key with a different body is a409, because that is a bug in the caller and replaying would hide it.- Cursor pagination on the pull endpoint. Offset pagination over a table being actively written to skips rows and repeats others, and a partner paging through it loses bookings without ever seeing an error.
- ARI push —
PUT /availabilityandPUT /rates,PUTmeant literally: these are idempotent range writes, not increments, so a channel manager whose push timed out can send the identical body again and change nothing. Both take a weekday mask, so "Saturdays in July, minimum stay three" is one call rather than thirty-one. The writes go through the same code the admin grid uses, which refuses to set an allotment below what is already booked and held — and answers200listing exactly which nights it refused, because a six-month push should not be thrown away over one oversold night. - Outbound webhooks, signed
X-Signature: t=…,v1=…where the timestamp is inside the signed string, so a captured delivery replayed an hour later fails verification even though the body is byte-identical. Queued with backoff (1m, 5m, 30m, 2h, 6h, 24h); every attempt is logged; twenty consecutive failures disables the endpoint. Delivery is at-least-once and can arrive out of order — every payload carries anevent_idand the resource'supdated_at, and a receiver that ignores them will eventually resurrect a cancelled booking. Endpoints must behttps: signing a payload does not stop anyone reading it. - Every response carries
X-Request-Id, logged with the request, so a partner's bug report is one lookup from an answer rather than a request to reproduce.
The full integration guide is docs/api.md.
resources/api/openapi.yaml is the source of truth, generated into
public/openapi.json — so every installation serves its own machine-readable
contract at https://<hotel>/openapi.json.
Generating that file from the controllers would guarantee it always matched the code, and would therefore never once tell us the code had changed in a way a partner cares about. A generated spec cannot be violated, so it cannot warn. So the spec is written by hand and the code is checked against it: every documented response is closed — every field required, no extras allowed — which means adding, renaming or retyping a field in a controller turns CI red until somebody writes down what changed.
The same tests assert that every route is documented, that no documented
route is imaginary, and that each operation's declared scope is the one its
middleware actually enforces — documentation about permissions that nothing
checks is worse than none, because a partner requests the scope the docs name
and then gets a 403 they cannot explain.
Writing the spec found three bugs on its own, which is the argument for
writing it: a hotel with no address filled in sent "address": [] instead of
{}, an untranslated room type did the same with its names, and
hold_expires_at was whatever string the database driver happened to store —
ISO 8601 on one engine, 2026-09-07 12:00:00 on another.
Google shows a free link to the hotel's own site, with a price, next to the
portals — if the hotel enters its rates in its Google Business Profile.
Admin → Google rates is the column to copy from: per night for the next 90
days, the lowest final price a guest could actually book on the website
(visitor's tax included, one-night-bookable rooms only), plus the booking
URL to paste in. What a real automated feed would take, and why it belongs
in the directory hub rather than in each install, is in
docs/google-free-booking-links.md.
Occupancy, ADR, RevPAR, channel mix and pace — with a CSV export, because a report that can only be read on screen gets retyped by hand, and that is where transcription errors come from.
Four decisions make these numbers mean something, and every one of them is easy to get quietly wrong:
- What counts as sold is read from the same enum the booking engine uses. A cancelled booking never occupied a room. A no-show did, in the sense that mattered — nobody else could have it.
- Revenue means the room, not the bill. ADR and RevPAR are room metrics; folding breakfast and the spa into them inflates both and makes them incomparable with every benchmark a hotelier reads. The frozen per-night prices exclude extras by construction.
- Discounts come off the rate. A promo code is apportioned across that booking's own nights in proportion to what each cost — so the months still sum to the year, and ADR reconciles with the bank instead of flattering the hotel by exactly what it gave away.
- OTA blocks occupy rooms and carry no rate. An iCal sync knows the room is gone and nothing about the money. Those nights count towards occupancy — the room really was occupied — but averaging them into ADR at zero would report a rate the hotel never charged, so they are kept out of it and the page says so. They are in the channel mix: leaving them out would make direct look like a larger share of the hotel than it is, which is the exact number the commission argument turns on.
Pace compares what is on the books against the same point in last year's booking curve, not against last year's finished total — that would always look catastrophic in March. Growth from a base of zero reports as "—" rather than "up 100%", which reads like growth and means "we had none".
Configured from the admin, not from .env — the person who needs to
change it has a browser, not a shell. But the part that matters is not
the form:
Mail is treated as broken until a human confirms a test message arrived. It is the one subsystem that fails completely silently. SMTP accepts the message, the queue reports success, every page looks right, and the guest simply never receives their confirmation — nobody finds out until somebody arrives at the desk holding nothing. A green tick meaning "the server accepted it" is worse than no tick, because it stops anyone looking.
So the test message carries a code, and confirming means typing that code back. A checkbox would be a thing people tick to make a warning go away; a code can only come from a message that actually arrived. Until that happens, every admin page carries a warning that cannot be dismissed. Changing any setting clears the confirmation, because a tick describing a configuration that no longer exists is worse than none.
- The SMTP password is encrypted at rest — the settings table is in every backup a hotelier downloads and emails to themselves.
- Leaving the password blank keeps the stored one, so editing the sender name does not silently empty the credential.
- A half-configured transport is not applied: switching to SMTP with no host would turn working output into a connection error.
- The SPF, DMARC and DKIM records for the sending domain are printed
on the page. Without them a hotel's confirmations are quietly filed as
spam and nobody tells them. DMARC starts at
p=quarantinerather thanp=reject, because publishing reject on day one with SPF slightly wrong stops a hotel delivering its own confirmations.
The guest who did not finish can be reminded — once, about an hour
after their unpaid hold expired, only while the room can still be had, with
a link that reopens checkout on the same room and dates and a sentence
saying nothing was charged. It follows only a booking the guest started on
the website and only a hold that ran out by itself; it stays silent if they
came back and booked, if they were erased, or if the room has gone. Off by
default (DOBA_MAIL_RECOVERY=true switches it on): in several countries a
reminder like this counts as advertising and needs consent, and that is the
hotel's call to make, not a default's.
The words are the hotel's. Subject, heading, opening and closing
line of the confirmation, the pre-arrival and the thank-you mail are
editable under Admin → Mail → Mail wording, one box per language,
with the shipped text greyed in as the starting point. Placeholders
(:name, :hotel, :date, :reference) keep working; a language left
empty keeps the shipped text, so switching the site's languages can never
send a guest an empty line.
The screen a hotel stands in front of all morning, and where the admin now opens: arriving, in the house, leaving today, checked out today — the four questions actually asked at a desk, rather than a booking list to work them out from.
- Arrivals carry the time the guest gave. Checkout asks for an estimated arrival, and the desk sorts by it — so a 22:00 guest is a room held rather than a room somebody wonders about at nine. Anyone who gave no time sorts last: an unknown arrival is not an early one.
- In-house includes stays that began days ago. An occupied room is occupied whether or not the guest arrived this morning.
- Checked out today shows the rooms free to clean and resell, with the time each guest actually left — and links to Housekeeping, a phone-sized list with one big button per door: dirty doors with a guest arriving today first (earliest stated arrival on top), then the rest, then doors still occupied by somebody leaving today, without a button. No guest names on it; housekeeping needs the door, not the person.
- Online check-in (
FEATURE_ONLINE_CHECKIN=true). From three days before arrival the guest fills in the registration form for everybody in the party on their booking page; the arrival row then says checked in online and links to the form as a PDF with a signature line — print, sign, hand over the key. Whose ID document is asked for is one setting (DOBA_CHECKIN_DOCUMENT=none|foreign|all), because registration law differs by country. The party's details sit in one encrypted column: nobody needs to search by passport number, and a leaked dump should not contain any. They are part of a GDPR export, go with an erasure, and are destroyedDOBA_CHECKIN_RETAIN_DAYS(365) after departure — a far shorter clock than the guest book's. Arrival instructions written under Hotel settings (a key-box code, which entrance) show only to a guest who has checked in, from the day before arrival, and optionally only once paid. - Late checkout is a request, never an answer. A guest asks from their
manage link; the desk grants a time or declines, because the room may be
sold to somebody arriving at three. The two are separate columns
(
requested_checkout_time,checkout_time) precisely so a form cannot promise a room on the hotel's behalf, and the request surfaces in the in-house list too — a guest asks the day before as often as the morning of, and a request that only appears on the departure morning is one the desk answers with the guest already standing there. - An impossible move — checking in a booking that is still pending — returns the reason from the §6 state machine rather than a 500.
Clock times are stored as plain strings, not datetimes: they are
wall-clock at the property. A hotel that writes "14:00" on a booking means
two in the afternoon there, whatever the server thinks its timezone is.
The check_in / check_out dates stay dates, because inventory is
counted in nights.
An interim admin area lives at /admin (the full Filament panel replaces it
later in phase 1), behind a sidebar grouping sections by what somebody is
trying to do — the front desk in the morning, the website in the afternoon,
the machinery rarely. It edits availability, rate plans, CMS pages, events,
extras and photos, and lists invoices for download, with
per-language tabs and a Trix WYSIWYG editor — clearing a
language's title unpublishes that language: its URL, hreflang entry and
sitemap line all disappear together. The demo seeder creates
admin@example.com / password (override with DOBA_ADMIN_EMAIL /
DOBA_ADMIN_PASSWORD before seeding anything public-facing).
- Enquiries is the contact form's inbox: unread, answered, spam and all, with a sidebar badge for what nobody has read. The answer is written in the panel and goes out as the hotel's own mail, Reply-To the hotel inbox, subject offered in the guest's language, kept on the enquiry with who sent it and when. The spam filter's catches are kept where a false positive can be rescued in one click.
- Invoices lists every invoice and credit note and exports a period as CSV — one row per document, decimal amounts, the VAT split into a column pair per rate, a credit note as a negative row naming what it reverses.
Three layers, deliberately separate:
- Presets are complete looks — and still not a fork.
/admin/stylesoffers eight: Alpenhof (alpine, warm paper, gold, near-square corners), Kontor (urban monochrome, hard edges, heavy tight display type, no shadows), Marisol (coastal, sand and teal, deep radii, pill buttons), Kalyna (spa greens, restrained type), Grand (dark and gilded, uppercase, the old grand hotel), Nest (hostel: flat colour, black outlines, hard offset shadows), Yamabuki (ryokan: hairlines, wide-tracked serif, a great deal of air) and Residence (aparthotel: cool blues, built around a rate table). Each one is a bundle of design tokens — colour, type scale, corner radii, section rhythm, shadows — and every preset renders the same markup. That is the entire point: the moment a look becomes a directory of Blade files, it stops receiving the accessibility fix, the schema.org addition and the security patch. A preset changes how the site looks and nothing about what it is. Surfaces, brand-on-brand text colours, corner radii, type scale and section rhythm are all tokens precisely so a dark preset is possible without a second stylesheet — a hard-coded#fffpanel is what makes dark impossible, and a test now fails if one reappears. - Styles are settings.
/admin/stylesedits brand colours, heading/body fonts and free-form custom CSS, stored in the database and emitted as CSS variables (--doba-primary,--doba-accent,--doba-font-*) on every public page. Fonts are a curated list of system stacks, so no webfont ever enters the critical path. Custom CSS is applied after the theme stylesheet;<is escaped on output so it cannot break out of its style block. These are emitted after the preset, so a hotelier's own brand colour always out-ranks the preset it started from — choosing a look is a starting point, not a cage. - Themes are structure. Set
DOBA_THEME=<name>and createresources/views/themes/<name>/; any Blade file placed there overrides the same path inthemes/default, file by file, everything else falls through. A theme override is for layout changes — the moment a theme exists only to change a colour, it should have been a setting.
Per-install settings live in .env (see .env.example for the full DOBA_*
block); feature flags live in config/doba.php. Anything a
hotelier should be able to change themselves — address, policies, texts,
colours, images, room descriptions — belongs in the database, not here.
DOBA_LOCALES=en,de,fr,nl # first entry is the default locale
DOBA_HIDE_DEFAULT_PREFIX=false # serve the default locale at / instead of /en
DOBA_NOINDEX=false # true on staging: noindex + Disallow: / together
DOBA_SCHEMA_TYPE=Hotel # or Resort, BedAndBreakfast, …
PAYMENT_GATEWAY=manual # stripe | paypal | liqpay | coinbase | manualSet PAYMENT_GATEWAY to the default and fill the matching keys in .env
(STRIPE_*, PAYPAL_*, LIQPAY_*, COINBASE_COMMERCE_*). manual takes
bookings without online payment — a legitimate starting configuration. Every
configured provider's POST /webhooks/<name> endpoint stays live regardless of
the default, so switching gateways never orphans events for payments taken
under the old one. Point each provider's dashboard at:
https://<your-domain>/webhooks/stripe
https://<your-domain>/webhooks/paypal
https://<your-domain>/webhooks/liqpay
https://<your-domain>/webhooks/coinbase
One shared codebase, one install per hotel — its own domain, .env,
database and uploads directory. Maximum isolation and per-hotel backup/restore,
at the cost of per-install maintenance, which is absorbed by three rules: nothing
hotel-specific is ever hard-coded, all installs share one release artifact, and
per-hotel customisation is data and theme — never a code fork.
The full design, including the availability engine, the double-booking locking
strategy on both database engines, payments, channel sync, the installation
wizard and the public API, is in
docs/architecture.md.
| Phase | Scope | State |
|---|---|---|
| 1 | Foundation: config/theme layer, content model, i18n routing, SEO layer, CI | done |
| 2 | Availability service, rate engine, holds + locking + reconciliation, calendar API, checkout funnel, admin availability grid | done |
| 3 | Payments (Stripe/PayPal/LiqPay/crypto/manual, webhook-driven, refunds), balance payment, city tax, lifecycle mail | done |
| — | Events, WYSIWYG admin, customizable styles, security headers | done |
| — | Invoices, iCal channel sync, promo codes, eight style presets, restaurant & menu | done |
| — | Install wizard + installers, safe updater with health checks, backups, reports, front desk, guest book with GDPR export/erasure, physical rooms & housekeeping, directory listing, six languages | done |
| — | Desk bookings and stay changes, settings and room-type editors, credit notes, offsite backups + failure alerts, multi-room bookings, 2FA and remember-me, apartments, enquiries inbox, editable mail wording, housekeeping list, invoice CSV | done |
| 4 | First hotel live | planned |
| 5 | Multi-install deploy (iCal sync and reports landed early) | planned |
| 6 | Public REST API + ARI push + webhooks + OpenAPI 3.1 contract | done |
| 7 | Full Filament panel (verified reviews and returning-guest discount landed early) | as needed |
Installing Doba for clients, re-theming it, and keeping it updated without a
support contract: docs/for-agencies.md. It is
MIT-licensed; nothing phones home.
Issues and pull requests are welcome — and so is a note from anybody who ran it for a real property and found something confusing. Start with CONTRIBUTING.md: how to set up, the house rules a reviewer checks, and the three commands to run before a PR. What changed in each release is in CHANGELOG.md.
Found a security problem? Please report it privately — see SECURITY.md.
MIT.
Keywords: hotel booking system, direct booking engine, Laravel hotel software, PMS, channel manager, iCal sync, multilingual hotel website, availability calendar, rate management, hotel SEO, schema.org Hotel, hreflang, open source booking engine.











