Skip to content

Latest commit

 

History

94 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Doba — direct-booking engine and website for independent hotels

CI License: MIT Open in GitHub Codespaces

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.


Demo imagery

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.

Screenshots

The demo hotel below is what migrate --seed produces — real rooms, rates, events and four languages, rendered by the default theme.

Home
Home — hero, rooms with "from" prices, upcoming events
Rooms
Rooms — the room list with occupancy and rates
Room detail
Room detail — grouped inclusions, optional extras, HotelRoom + Offer schema
Events
Events — dated happenings with Event schema
Contact
Contact — enquiry form, plus the click-to-load map
Search
Booking search — live availability and stay totals
Front desk
Front desk — arrivals with stated times, in house, leaving, checked out
Apartment
Apartment — bedrooms, minimum stay, cleaning fee per stay, Apartment schema
Availability
Availability grid — allotment, prices and restrictions per night
Reports
Reports — occupancy, ADR, RevPAR, channel mix, pace
Checkout
Checkout — guest details, extras, consent, live summary
Calendar
Availability calendar — two months, per-night prices, restrictions

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.

Why this exists

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)

What is built today

SEO layer

Everything below is implemented and covered by tests.

  • Per-locale URLs with translated path segments and translated slugs — /de/zimmer/doppelzimmer and /en/rooms/double-room resolve 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, Offer with 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 as 12500.
  • Editable, translated FAQs rendered on the home page with matching FAQPage markup — 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 LocationFeatureSpecification entries 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.booked and held are caches of the booking tables, and availability:reconcile is the job that proves it — recomputing both from ground truth (one row per unit per night for bookings whose status declares the booked side, 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:link alternates, written nightly by php artisan doba:sitemap and generated live as a fallback.
  • robots.txt from the same flag as the meta robots tag — a staging install cannot be noindex in HTML and crawlable in robots.txt at 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/height on every image to prevent layout shift, WebP srcset/sizes capped at the source's real width, eager + fetchpriority=high on 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. hasMap and GeoCoordinates go into the Hotel structured data either way.
  • Events with per-locale slugs (/de/veranstaltungen/weinverkostung), an upcoming-events section on the front page, and schema.org/Event markup 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.

Booking engine

  • Availability as one row per room type per night, with a raw-DDL CHECK constraint (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 lockForUpdate inside the booking transaction — and on SQLite via a mandatory BEGIN IMMEDIATE connection so concurrent bookings genuinely serialise rather than throwing SQLITE_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 noindex because 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.

Payments

  • A PaymentGateway contract 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_due bookkeeping; refunds are their own rows so the ledger reconstructs a dispute.
  • Restaurant, bar & the menu — one venues table 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.org Restaurant/BarOrPub → Menu → MenuSection → MenuItem with offers, diets and openingHoursSpecification — this is the one hotel page a search engine will render as structured content, and a market-price dish carries no Offer rather 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, so net + tax equals 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.

Gift vouchers (§8)

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-XXXX from 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).

Channel sync (§9)

Tier-1 two-way iCal, which is what an independent hotel actually runs.

  • Export: GET /ical/{room_type}/{token}.ics publishes every night the room cannot be sold, merged into as few VEVENTs as possible and built from availability rather 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:sync runs every 15 minutes, matches events on their UID so a re-import is a no-op, and increments the same booked counter 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. DTEND is 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.

Security (§14)

  • No unsafe-eval, and therefore no expression-evaluating front-end framework. Alpine compiles its bindings with new 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).

Platform

  • 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 *_translations tables edited by the hotelier.
  • Theme layer — resources/views/themes/<slug> overrides default file 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 bigInteger minor units, no ENUM, no stored procedures.

Requirements

  • PHP 8.4+ with pdo_mysql, pdo_sqlite, mbstring, intl, gd, zip, curl, openssl — 8.4 is a hard floor: the SQLite BEGIN IMMEDIATE transaction 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 (see docs/architecture.md §15)

Try it without installing anything

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_URL is set to the forwarded Codespaces host, not localhost. URLs built inside a request already come out right — the app trusts the proxy, so canonical tags and hreflang hrefs follow the forwarded host. APP_URL is what everything built outside one uses: the sitemap that doba:sitemap writes to disk, the iCal feed URLs the admin hands to Booking.com, and the links in queued confirmation mail. Left at localhost, an SEO demo ships a sitemap advertising localhost.
  • HSTS is switched off there (DOBA_HSTS=false). A Codespace answers on *.app.github.dev, a domain this install does not own, and Strict-Transport-Security with includeSubDomains would 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 account

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).

Returning-guest discount

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.

Verified reviews

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.

Languages

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.

Installing

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 | bash

Same 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/doba

or 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.txt

As 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.txt and 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 intl is 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, /install returns 404 — not a redirect, not an "already installed" page. A scanner should learn nothing.
  • Two independent markers record the install: storage/installed.lock and a database row. They fail differently — a deploy that rsyncs storage/ 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.

Quick start (developers)

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 serve

Then 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

Updating

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:

  1. 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.
  2. close the site — a guest must not meet a half-migrated schema
  3. migrate
  4. rebuild the config, route and view caches, signal queue workers
  5. 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.

Backups

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_KEEP counts 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.

Getting the new code onto the server

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.

Commands

php artisan doba:sitemap    # regenerate public/sitemap.xml (nightly in production)
php artisan doba:images     # generate WebP srcset derivatives + backfill image dimensions

Tests, style, static analysis

php artisan test
vendor/bin/pint --test && vendor/bin/phpstan analyse --memory-limit=1G

CI 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.

Partner API

/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:write without bookings:cancel is 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; a check_in is never a timestamp.
  • RFC 9457 application/problem+json with stable type URIs. Partners branch on type, so those strings are contract and the human title is 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-Key is required on POST /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 a 409, 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 /availability and PUT /rates, PUT meant 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 answers 200 listing 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 an event_id and the resource's updated_at, and a receiver that ignores them will eventually resurrect a cancelled booking. Endpoints must be https: 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.

The spec is hand-written, and the code is tested against it

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 free booking links

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.

Reports

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".

Mail

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=quarantine rather than p=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 front desk

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 destroyed DOBA_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.

Admin & editing

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.

Theming & custom styles

Three layers, deliberately separate:

  1. Presets are complete looks — and still not a fork. /admin/styles offers 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 #fff panel is what makes dark impossible, and a test now fails if one reappears.
  2. Styles are settings. /admin/styles edits 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.
  3. Themes are structure. Set DOBA_THEME=<name> and create resources/views/themes/<name>/; any Blade file placed there overrides the same path in themes/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.

Configuration

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 | manual

Payment providers

Set 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

Architecture

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.

Roadmap

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

For web agencies

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.

Contributing

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.

Licence

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.

About

Open-source direct-booking engine and multilingual website for independent hotels. Laravel 12, MySQL or SQLite, first-class SEO: hreflang, schema.org Hotel/Room/Offer, sitemap, canonicals.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages