laravel-roomdoo maintained by tbi-software
tbi-software/laravel-roomdoo
A Laravel client for the Roomdoo PMS internal API (https://your-instance.host.roomdoo.com). Wraps folios, reservations, extra services, charges, refunds, invoices, contacts, calendar and catalogs behind typed DTOs and a fluent facade.
- Repository: https://github.com/Tu-buen-camino/laravel-roomdoo
- Issues: https://github.com/Tu-buen-camino/laravel-roomdoo/issues
- Publisher: TBI Consulting Group SL — https://tbi-software.com
- Commercial licensing: COMMERCIAL.md ·
licensing@tbi-software.com
Source-available under the PolyForm Noncommercial License 1.0.0 — free for personal, research, educational, charitable, and government use. A commercial license is required for use in paid SaaS, commercial products, and for-profit internal tooling.
Reverse-engineered. Roomdoo does not publish their internal API. This client was built by capturing SPA traffic against
your-instance.roomdoo.com. Endpoints marked@betawere inferred from UI behavior and may break without notice. Seedocs/roomdoo-api.mdfor the full endpoint reference and known quirks.
Table of contents
- Requirements
- Installation
- Configuration
- Quick start
- Resources
- Cancelling a folio
- Multi-property usage
- Custom token providers
- Error handling
- Raw responses
- Beta endpoints
- Testing
- Contributing
- License
Requirements
- PHP 8.2+
- Laravel 11 or 12
- A Roomdoo JWT token obtained from a logged-in session (cookie name
jwtin the browser)
Installation
Install via Composer from Packagist:
composer require tbi-software/laravel-roomdoo
Then publish the config (optional but recommended):
php artisan vendor:publish --tag=roomdoo-config
Configuration
Set the following in your .env:
ROOMDOO_BASE_URL=https://your-instance.host.roomdoo.com
ROOMDOO_JWT=eyJhbGciOi... # JWT captured from the browser cookie
ROOMDOO_PMS_PROPERTY_ID=2 # your default property id
# optional
ROOMDOO_AUTH_HEADER=cookie # "cookie" (default) or "bearer"
ROOMDOO_TIMEOUT=30
ROOMDOO_RETRY_TIMES=0
ROOMDOO_RETRY_SLEEP_MS=200
ROOMDOO_LOG_ENABLED=false
ROOMDOO_LOG_CHANNEL=stack
The config file (config/roomdoo.php) also lets you wire a custom token provider class — useful in multi-tenant apps where each authenticated user has their own Roomdoo JWT.
Quick start
use Tbi\Roomdoo\Facades\Roomdoo;
// look up an existing folio
$folio = Roomdoo::folios()->find(8293);
echo $folio->name; // "F/26050611"
echo $folio->amountTotal; // 832.0
// register a cash charge
$txId = Roomdoo::folios()->charge(
folioId: 8293,
amount: 832.0,
journalId: 11, // 11 = cash, from /api/account-journals
date: '2026-05-22',
);
// create an invoice from the folio's sale lines and validate it
$saleLines = Roomdoo::folios()->saleLines(8293);
$invoiceId = Roomdoo::folios()->createInvoice(
folioId: 8293,
partnerId: 1187,
saleLines: $saleLines->map->raw->all(),
);
$invoice = Roomdoo::invoices()->find($invoiceId);
Roomdoo::invoices()->validate(
invoiceId: $invoiceId,
date: '2026-05-22',
partnerId: 1187,
moveLines: $invoice->moveLines->map->raw->all(),
);
Resources
The facade exposes one resource per logical group. Every list-returning method returns an Illuminate\Support\Collection of typed DTOs. Every DTO carries ->raw (the original API payload) and ->toArray() for serialization.
Instance and properties
// instance metadata and per-module feature flags
$instance = Roomdoo::instance()->get();
$features = Roomdoo::instance()->extraFeatures('pms');
// list properties (the `id` you pass to forProperty / ROOMDOO_PMS_PROPERTY_ID)
$properties = Roomdoo::properties()->all(); // Collection<Property>
$pmsProps = Roomdoo::properties()->pms();
$links = Roomdoo::properties()->links(2);
Catalog
Read-only master data needed to build payloads for the rest of the API.
$rooms = Roomdoo::catalog()->rooms(); // Collection<Room>
$products = Roomdoo::catalog()->products(); // Collection<Product>
$journals = Roomdoo::catalog()->accountJournals(); // Collection<AccountJournal>
$boardSvcs = Roomdoo::catalog()->boardServices(); // Collection<BoardService>
$saleChannels = Roomdoo::catalog()->saleChannels();
$pricelists = Roomdoo::catalog()->pricelists();
$countries = Roomdoo::catalog()->countries();
$states = Roomdoo::catalog()->countryStates(68); // states of country 68
$zip = Roomdoo::catalog()->zipAutocomplete('15001');
$products = Roomdoo::catalog()->products();
Other methods: languages, categories, idCategories, fiscalDocTypes, agencies, availabilityPlans, roomTypes, roomTypeClasses, amenities, amenityTypes, roomClosureReasons, boardServiceLines, extraBeds, notificationsReservationsToAssign.
Calendar and availability
// daily prices for a room type over a date range
$prices = Roomdoo::calendar()->prices(
pricelistId: 1,
roomTypeId: 6,
dateFrom: '2026-12-10',
dateTo: '2026-12-12',
);
// availability for a room type
$avails = Roomdoo::calendar()->avails(
availabilityFrom: '2026-12-10',
availabilityTo: '2026-12-11',
roomTypeId: 3,
);
// full planning grid
$grid = Roomdoo::calendar()->between(
dateFrom: '2026-12-01',
dateTo: '2026-12-31',
availabilityPlanId: 1,
);
Rates (pricelists, availability plans, cancelation rules)
Rate management is split into three entities — see docs/roomdoo-api.md §15 for the full reference.
// list pricelists (with cancelationRuleId, defaultAvailabilityPlanId,
// pmsPropertyIds, saleChannelIds)
$pricelists = Roomdoo::pricelists()->all();
// only those active in the planning's daily-management dropdown
$active = Roomdoo::pricelists()->all(isActiveOnDailyManagement: true);
$pricelist = Roomdoo::pricelists()->find(1); // detail
$cancellation = Roomdoo::cancelationRules()->all();
$plans = Roomdoo::availabilityPlans()->all();
// read prices + restrictions in one call (preferred by the planning view)
$grid = Roomdoo::calendar()->pricesRules(
dateFrom: '2026-06-01',
dateTo: '2026-06-30',
availabilityPlanId: 1,
pricelistId: 1,
);
foreach ($grid as $roomTypeBlock) {
$roomTypeBlock->roomTypeId;
foreach ($roomTypeBlock->dates as $day) {
$day->price; $day->quota; $day->closed; $day->minStay; // …
}
}
// read prices only
$items = Roomdoo::pricelists()->items(
pricelistId: 1,
dateFrom: '2026-06-01',
dateTo: '2026-06-30',
);
// read restrictions only
$rules = Roomdoo::availabilityPlans()->rules(
availabilityPlanId: 1,
dateFrom: '2026-06-01',
dateTo: '2026-06-30',
);
Writing prices
// upsert one or more items (pricelistItemId = -1 to create, real id to update)
Roomdoo::pricelists()->upsertItems(1, [
['pricelistItemId' => -1, 'roomTypeId' => 3, 'date' => '2026-12-01', 'price' => 120, 'pmsPropertyId' => 2, 'pricelistId' => 1],
['pricelistItemId' => 3566, 'roomTypeId' => 3, 'date' => '2026-05-21', 'price' => 95, 'pmsPropertyId' => 2, 'pricelistId' => 1],
]);
// bulk across many days / room types in one request
Roomdoo::pricelists()->batchChanges([
['pricelistItemId' => -1, 'roomTypeId' => 3, 'date' => '2026-12-01', 'price' => 120, 'pmsPropertyId' => 2, 'pricelistId' => 1],
['pricelistItemId' => -1, 'roomTypeId' => 3, 'date' => '2026-12-02', 'price' => 120, 'pmsPropertyId' => 2, 'pricelistId' => 1],
]);
Writing restrictions
Field semantics: quota: -1 unlimited, 0 closed by quota, N allocated rooms. minStay/maxStay: 0 = no restriction. closed: true fully blocks the day for that room type.
Roomdoo::availabilityPlans()->upsertRules(1, [[
'availabilityRuleId' => -1,
'roomTypeId' => 3,
'date' => '2026-05-21',
'quota' => 5,
'maxAvailability' => -1,
'minStay' => 2,
'closed' => false,
'availabilityPlanId' => 1,
'pmsPropertyId' => 2,
]]);
// single-rule POST variant (no envelope)
Roomdoo::availabilityPlans()->createRule(1, [
'roomTypeId' => 3, 'date' => '2026-12-03', 'quota' => 10, 'pmsPropertyId' => 2,
]);
// bulk — unset fields MUST be sent as null (not 0) so the backend leaves them alone
Roomdoo::availabilityPlans()->batchChanges([
[
'availabilityRuleId' => -1, 'roomTypeId' => 3, 'date' => '2026-12-03',
'quota' => 10, 'maxAvailability' => null,
'minStay' => 2, 'maxStay' => null, 'minStayArrival' => null, 'maxStayArrival' => null,
'closed' => false, 'closedDeparture' => null, 'closedArrival' => null,
'pmsPropertyId' => 2, 'availabilityPlanId' => 1,
],
]);
Master entities (
pricelist,availability-plan,cancelation-rule) and the assignments between pricelists, channels and properties are managed in the Odoo backend — the SPA does not expose endpoints for those operations.
Folios
A folio groups one or more reservations and is the unit of billing.
// create a folio + reservations in one call
$folioId = Roomdoo::folios()->create([
'pmsPropertyId' => 2,
'checkin' => '2026-12-10',
'checkout' => '2026-12-12',
'saleChannelId' => 1,
'pricelistId' => 1,
'reservationType' => 'normal',
'partnerName' => 'Test API Roomdoo',
'partnerEmail' => 'testapi@example.com',
'partnerPhone' => '600000000',
'preferredCommunicationLang' => 'es_ES',
'sendConfirmationEmail' => false,
'reservations' => [[
'roomTypeId' => 3,
'preferredRoomId' => 13,
'adults' => 2,
'children' => 0,
'reservationLines' => [
['date' => '2026-12-10', 'price' => 400.0, 'roomId' => 13],
['date' => '2026-12-11', 'price' => 400.0, 'roomId' => 13],
],
]],
]);
// search, find and sub-resources
$folios = Roomdoo::folios()->search('Test API');
$folio = Roomdoo::folios()->find($folioId);
$reservations = Roomdoo::folios()->reservations($folioId);
$saleLines = Roomdoo::folios()->saleLines($folioId);
$transactions = Roomdoo::folios()->transactions($folioId);
$invoices = Roomdoo::folios()->invoices($folioId);
$messages = Roomdoo::folios()->messages($folioId);
Reservations
$reservation = Roomdoo::reservations()->find(9110);
$lines = Roomdoo::reservations()->lines(9110); // 1 per night
$services = Roomdoo::reservations()->services(9110);
$checkinPartners = Roomdoo::reservations()->checkinPartners(9110);
$wizardStates = Roomdoo::reservations()->wizardStates(9110);
// update reservation (e.g. room change) — @beta
Roomdoo::reservations()->update(9110, ['preferredRoomId' => 13]);
Extra services
// add breakfast (productId 7) on two dates
$serviceId = Roomdoo::services()->add(
reservationId: 9110,
productId: 7,
serviceLines: [
['date' => '2026-12-11', 'priceUnit' => 8, 'quantity' => 2],
['date' => '2026-12-12', 'priceUnit' => 8, 'quantity' => 2],
],
);
// @beta — confirm shape against live API
Roomdoo::services()->update(9110, $serviceId, [...]);
Roomdoo::services()->delete(9110, $serviceId);
// @beta — used by the SPA to suggest default prices before adding
$defaults = Roomdoo::services()->defaultPrices(
productId: 7, pricelistId: 1, dateFrom: '2026-12-11', dateTo: '2026-12-12',
);
Charges, refunds and transactions
Two different ways to "reverse" a charge depending on accounting policy:
// register a charge (returns the new transaction id)
$txId = Roomdoo::folios()->charge(
folioId: 8293,
amount: 832.0,
journalId: 11,
date: '2026-5-22', // YYYY-M-D for /charge and /refund
);
// register a refund (creates a new opposing transaction — audit-friendly)
Roomdoo::folios()->refund(
folioId: 8293,
amount: 832.0,
journalId: 11,
date: '2026-5-22',
partnerId: 1187,
reference: 'Cancelacion',
);
// void a transaction in place (sets amount=0 — original amount is lost)
Roomdoo::payments()->voidTransaction(
transactionId: $txId,
journalId: 11,
date: '2026-05-22', // YYYY-MM-DD for /transactions/p/
reference: 'F/26050611',
);
// edit a transaction (change method, amount, date, partner — all fields required)
Roomdoo::payments()->editTransaction(
transactionId: $txId,
journalId: 9, // switch cash → card
amount: 500.0,
date: '2026-05-22',
transactionType: 'customer_inbound',
reference: 'F/26050611',
partnerId: 1187,
);
// cash register / cashbook
$cashbook = Roomdoo::payments()->cashRegister(journalId: 11);
⚠️ Note the date format quirk:
/chargeand/refunduseYYYY-M-D(no zero-padding), while/transactions/p/{id}usesYYYY-MM-DD. The package passes dates through unmodified — pass them in the format each endpoint expects.
Invoices
// create a proforma from a folio's sale-lines
$invoiceId = Roomdoo::folios()->createInvoice(
folioId: 8293,
partnerId: 1187,
saleLines: Roomdoo::folios()->saleLines(8293)->map->raw->all(),
);
// fetch and validate (proforma -> posted)
$invoice = Roomdoo::invoices()->find($invoiceId);
Roomdoo::invoices()->validate(
invoiceId: $invoiceId,
date: '2026-05-22',
partnerId: 1187,
moveLines: $invoice->moveLines->map->raw->all(),
);
// list and filter
$invoices = Roomdoo::invoices()->list([
'limit' => 80,
'dateStart' => '2026-04-22',
'dateEnd' => '2026-05-22',
]);
// @beta — buttons in the UI but not directly captured
Roomdoo::invoices()->updateDraft($invoiceId, [...]);
Roomdoo::invoices()->cancel($invoiceId);
Roomdoo::invoices()->refund($invoiceId);
Roomdoo::invoices()->sendMail($invoiceId);
$pdf = Roomdoo::invoices()->pdf($invoiceId); // raw bytes
Contacts
// create a company
$contact = Roomdoo::contacts()->create([
'contactType' => 'company',
'name' => 'TEST API SL',
'street' => 'Calle Test 1',
'zipCode' => '15001',
'city' => 'A Coruña',
'country' => 68,
'fiscalIdNumber' => 'B12345674',
'fiscalIdNumberType' => 'vat',
]);
// create an individual
Roomdoo::contacts()->create([
'contactType' => 'individual',
'firstname' => 'Jane',
'lastname' => 'Doe',
'email' => 'jane@example.com',
'birthdate' => '1990-05-12',
'fiscalIdNumber' => '12345678Z',
'fiscalIdNumberType' => 'vat',
]);
// update
Roomdoo::contacts()->update($contact->id, ['name' => 'TEST']);
// paginated lists
$result = Roomdoo::contacts()->list(page: 1, pageSize: 50);
$result->items; // Collection<Contact>
$result->total; // total available
$result->hasMorePages(); // bool
// other lists
Roomdoo::contacts()->customers();
Roomdoo::contacts()->agencies();
Roomdoo::contacts()->guests();
Roomdoo::contacts()->suppliers();
// free-text search across all partners
$results = Roomdoo::contacts()->searchPartners('test', limit: 20);
Checkin partners
// listing is done via the reservation resource
$guests = Roomdoo::reservations()->checkinPartners(9110);
// @beta — REST pattern inferred
Roomdoo::checkinPartners()->create(9110, [...]);
Roomdoo::checkinPartners()->update(9110, $checkinPartnerId, [...]);
Roomdoo::checkinPartners()->delete(9110, $checkinPartnerId);
Files
$url = Roomdoo::files()->imageUrl(123, accessToken: 'abc');
$bytes = Roomdoo::files()->downloadImage(123, accessToken: 'abc');
Cancelling a folio
Roomdoo::folios()->cancel(8293);
What does NOT block cancellation
Confirmed empirically — these all let cancel() succeed:
- Positive charge balance. The folio still cancels;
pendingAmountgoes negative (overpayment). - Registered refunds (
Roomdoo::folios()->refund(...)). - Voided transactions (
Roomdoo::payments()->voidTransaction(...)). - Down-payment invoices in
state="posted"(isDownPaymentmove lines). They remain valid.
What DOES block cancellation
HTTP 400 — "You cannot delete a sale order line once a invoice has been created from it."
A full sales invoice attached to the folio in state="posted" (i.e. move_type="out_invoice" with isDownPayment=false) blocks cancellation. Cancel or refund the invoice first via Roomdoo::invoices()->cancel($id) / Roomdoo::invoices()->refund($id) (both currently @beta).
Recommended flow
// 1. inspect existing invoices to detect blockers
$blocking = Roomdoo::folios()->invoices($folioId)
->filter(fn ($inv) => $inv->state === 'posted'
&& collect($inv->moveLines ?? [])->every(fn ($l) => empty($l->isDownPayment)));
// 2. if there are any, cancel/refund them first (beta)
foreach ($blocking as $inv) {
Roomdoo::invoices()->cancel($inv->id); // or ->refund($inv->id)
}
// 3. (optional) clean up the charge balance for a 0-balance cancellation
// Otherwise cancellation succeeds with an overpayment.
foreach (Roomdoo::folios()->transactions($folioId) as $tx) {
Roomdoo::payments()->voidTransaction(
transactionId: $tx->id,
journalId: $tx->journalId,
date: now()->toDateString(),
transactionType: $tx->transactionType,
reference: $tx->reference,
partnerId: $tx->partnerId,
);
}
// 4. cancel
Roomdoo::folios()->cancel($folioId);
See docs/roomdoo-api.md §14 for the full diagnostic trail (test folios 8293, 8296, 8297, 8298).
Multi-property usage
The default property comes from ROOMDOO_PMS_PROPERTY_ID. To call against another property in the same request:
$folios = Roomdoo::forProperty(5)->folios()->search('Smith');
// all subsequent calls on the returned instance use property 5
$forBranch = Roomdoo::forProperty(5);
$forBranch->catalog()->rooms();
$forBranch->folios()->find($id);
pmsPropertyId is auto-injected on the property-scoped endpoints (catalog, calendar, folio search, invoice list, customer/guest/supplier lists).
Custom token providers
By default the JWT is read from ROOMDOO_JWT. For multi-tenant apps, where each user has their own Roomdoo session, implement Tbi\Roomdoo\Client\JwtTokenProvider:
namespace App\Roomdoo;
use Tbi\Roomdoo\Client\JwtTokenProvider;
class UserScopedTokenProvider implements JwtTokenProvider
{
public function token(): ?string
{
return auth()->user()?->roomdoo_jwt;
}
}
Then in config/roomdoo.php:
'token_provider' => \App\Roomdoo\UserScopedTokenProvider::class,
For closure-based use:
use Tbi\Roomdoo\Client\CallbackJwtTokenProvider;
app()->singleton(JwtTokenProvider::class, fn () =>
new CallbackJwtTokenProvider(fn () => session('roomdoo_jwt'))
);
Error handling
Non-2xx responses throw subclasses of Tbi\Roomdoo\Exceptions\RoomdooException:
| Exception | Triggers on |
|---|---|
AuthenticationException |
401, 403 |
NotFoundException |
404 |
ValidationException |
422 |
RateLimitException |
429 |
ServerException |
5xx |
RoomdooException (base) |
Anything else (incl. 400) |
Each exception exposes:
try {
Roomdoo::folios()->cancel($folioId);
} catch (\Tbi\Roomdoo\Exceptions\RoomdooException $e) {
$e->status; // int — HTTP status
$e->body; // string — raw response body
$e->errors; // ?array — decoded JSON if any
$e->response; // ?Illuminate\Http\Client\Response — original response
report($e);
}
The Roomdoo backend occasionally returns Odoo-style error strings inside
200responses on internal errors. If you observe surprising behaviour, log the->rawpayload of the returned DTO before reporting a bug.
Raw responses
Every DTO carries the original payload under ->raw:
$folio = Roomdoo::folios()->find(8293);
$folio->raw['externalReference'];
$folio->raw['createdBy'];
If a field is missing from the typed DTO, you can always read it from ->raw without subclassing. Open an issue or PR to promote it into the DTO when useful.
Beta endpoints
These endpoints are exposed by the client but inferred (not captured directly). Confirm shape against the live API before using them in production. They are documented with @beta in their PHP doc-blocks.
PATCH /api/reservations/{id}—Roomdoo::reservations()->update()PATCH /api/folios/{id}/batch-changes—Roomdoo::folios()->batchChanges()PATCH/DELETE /api/reservations/{id}/services/{id}—Roomdoo::services()->update()/delete()GET /api/products/{id}/default-prices—Roomdoo::services()->defaultPrices()POST/PATCH/DELETE /api/reservations/{id}/checkin-partners[...]—Roomdoo::checkinPartners()->*POST /api/invoices/{id}/{send-mail|cancel|refund}—Roomdoo::invoices()->sendMail()/cancel()/refund()GET /api/invoices/{id}/pdf—Roomdoo::invoices()->pdf()PATCH /api/invoices/{id}(edit draft) —Roomdoo::invoices()->updateDraft()POST/DELETE /pmsApi/contacts/{id}/id-numbers[...]—Roomdoo::contacts()->addIdNumber()/deleteIdNumber()
Testing
composer install
vendor/bin/pest
Tests use Laravel's Http::fake() together with JSON fixtures captured from a real Roomdoo session (tests/Fixtures/*.json). No network access is required.
To add your own integration tests:
use Illuminate\Support\Facades\Http;
use Tbi\Roomdoo\Facades\Roomdoo;
it('does the thing', function () {
Http::fake([
'your-instance.host.roomdoo.com/api/folios/*' => Http::response([/* ... */], 200),
]);
$result = Roomdoo::folios()->find(8293);
expect($result->id)->toBe(8293);
Http::assertSent(fn ($r) => $r->hasHeader('Cookie', 'jwt=test-jwt-token'));
});
Links
- Source code: https://github.com/Tu-buen-camino/laravel-roomdoo
- Issue tracker: https://github.com/Tu-buen-camino/laravel-roomdoo/issues
- API reference (reverse-engineered):
docs/roomdoo-api.md - Contributing:
CONTRIBUTING.md - Changelog:
CHANGELOG.md - Security policy:
SECURITY.md - Commercial licensing:
COMMERCIAL.md·licensing@tbi-software.com - Publisher: TBI Consulting Group SL
Contributing
PRs welcome at https://github.com/Tu-buen-camino/laravel-roomdoo. Since this
wraps a reverse-engineered API, the most valuable contributions confirm real
request/response shapes. See CONTRIBUTING.md for setup,
conventions, the DCO sign-off and how to anonymise fixtures.
In short:
- Confirming a
@betaendpoint. Capture the request from the browser's network panel and update the correspondingResource+ add a test. - Promoting
->rawfields to typed DTO properties. If your project keeps reading a specific field via->raw, open a PR. - Adding fixtures. Anonymise and add to
tests/Fixtures/then write a test.
Conventional style:
- Resources own URL paths; DTOs own response shapes.
- New fields go into the DTO constructor as nullable, with a
null-safefromArray()cast. - Beta endpoints must be marked
@betain the PHPDoc and listed in this README.
Security issues: please follow SECURITY.md — do not open a
public issue. Release history is in CHANGELOG.md.
License
Dual-licensed by TBI Consulting Group SL:
- PolyForm Noncommercial License 1.0.0 — default. Free for personal, research, educational, charitable, public-safety, environmental and government use.
- Commercial license — required for paid SaaS, commercial products, internal tooling of for-profit organisations, redistribution and OEM use.
If you are unsure which applies to your use case, see COMMERCIAL.md or contact licensing@tbi-software.com.
Contributions are accepted under the same dual-licensing scheme — see COMMERCIAL.md → Contributions.