Looking to hire Laravel developers? Try LaraJobs

laravel-roomdoo maintained by tbi-software

Description
Laravel client for the Roomdoo PMS internal API (folios, reservations, services, charges, invoices, contacts). Published by TBI Consulting Group SL under PolyForm Noncommercial 1.0.0 — see COMMERCIAL.md for commercial use.
Last update
2026/05/25 12:15 (dev-main)
Downloads
0
Tags

Comments
comments powered by Disqus

tbi-software/laravel-roomdoo

tests PHP 8.2+ Laravel 11/12 License: PolyForm NC 1.0.0 Commercial license available Repo

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.

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 @beta were inferred from UI behavior and may break without notice. See docs/roomdoo-api.md for the full endpoint reference and known quirks.

Table of contents

Requirements

  • PHP 8.2+
  • Laravel 11 or 12
  • A Roomdoo JWT token obtained from a logged-in session (cookie name jwt in 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: /charge and /refund use YYYY-M-D (no zero-padding), while /transactions/p/{id} uses YYYY-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; pendingAmount goes negative (overpayment).
  • Registered refunds (Roomdoo::folios()->refund(...)).
  • Voided transactions (Roomdoo::payments()->voidTransaction(...)).
  • Down-payment invoices in state="posted" (isDownPayment move 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 200 responses on internal errors. If you observe surprising behaviour, log the ->raw payload 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-changesRoomdoo::folios()->batchChanges()
  • PATCH/DELETE /api/reservations/{id}/services/{id}Roomdoo::services()->update()/delete()
  • GET /api/products/{id}/default-pricesRoomdoo::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}/pdfRoomdoo::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

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:

  1. Confirming a @beta endpoint. Capture the request from the browser's network panel and update the corresponding Resource + add a test.
  2. Promoting ->raw fields to typed DTO properties. If your project keeps reading a specific field via ->raw, open a PR.
  3. 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-safe fromArray() cast.
  • Beta endpoints must be marked @beta in 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:

  1. PolyForm Noncommercial License 1.0.0 — default. Free for personal, research, educational, charitable, public-safety, environmental and government use.
  2. 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.