Tour operator website development using 1C-Bitrix

Our company is engaged in the development, support and maintenance of Bitrix and Bitrix24 solutions of any complexity. From simple one-page sites to complex online stores, CRM systems with 1C and telephony integration. The experience of developers is confirmed by certificates from the vendor.
Our competencies:
Development stages

Tour Operator Website Development on 1C-Bitrix

A tour operator's website is not a brochure with pretty destination photos. It is a system where data changes every day. Prices shift with early booking discounts, last-minute deals, and seasonal coefficients. Departure dates appear and disappear. Seat availability depends on external booking systems that respond differently — one returns JSON in 200 ms, another takes 8 seconds with XML. Design the catalog as a static info block with manual updates, and within a month managers stop maintaining prices while customers book tours at stale rates.

The two problems that define complexity and timeline are tour search with faceted filtering across multiple axes, and integration with external booking APIs. Everything else — templates, galleries, reviews — is standard work.

Tour Catalog: Info Blocks and Relationships

The catalog is built on two info blocks and one Highload block.

Destinations are sections of the tours info block. Hierarchy: "Europe" → "Italy" → "Tuscany". Section properties via UF_* fields: UF_COUNTRY_CODE (ISO 3166-1), UF_CLIMATE_INFO (text), UF_VISA_REQUIRED (boolean), UF_GALLERY (multiple file). Sections double as SEO landing pages: /tours/italy/, /tours/italy/tuscany/.

"Tours" info block (type tours) — elements inside destination sections. Properties:

  • DURATION — integer, number of days. Range filtering
  • DEPARTURE_DATES — multiple Date property. Each tour element holds 5-20 departure dates. An index on b_iblock_element_property by IBLOCK_PROPERTY_ID + VALUE_DATE is mandatory — without it, date filtering on a catalog of 2000+ tours degrades to seconds
  • TOUR_TYPE — list: "Sightseeing", "Beach", "Adventure", "Cruise", "Combined"
  • DIFFICULTY — list 1-5, for hiking and mountain routes
  • GROUP_SIZE_MIN, GROUP_SIZE_MAX — integers
  • INCLUDED_SERVICES — multiple string: flight, transfer, insurance, excursions, meals. Used in the detail card and the "What's Included" filter
  • BASE_PRICE — number, base price in primary currency. Actual price is computed dynamically
  • HOTEL_STARS — list: 2-5 stars, "No accommodation"
  • BOOKING_SYSTEM_ID — string, external tour identifier in the booking system (Samo, Sletat)
  • GALLERY — multiple file
  • VIDEO_URL — string, YouTube/Vimeo embed

The trade catalog connection via CCatalog::Add() is only needed if booking flows entirely through the sale module. If payment goes through an external system (Samo.Tourvisor), the Bitrix catalog is not attached — the info block acts as a storefront.

Highload block "PriceCoefficients" stores dynamic pricing rules. Fields: TOUR_ID, DATE_FROM, DATE_TO, COEFFICIENT (float), RULE_TYPE (list: "Early Booking", "Last Minute", "Seasonal", "Group Discount"), PRIORITY (integer, application order). Highload is chosen because there will be thousands of records — every tour × every season × every rule type. Queried via \Bitrix\Highloadblock\HighloadBlockTable::getList() filtered by TOUR_ID and current date.

Search and Faceted Filtering — the Core of the Project

Tour search is not text search. A customer thinks in categories: "Italy, June, 7-10 days, under $2000, sightseeing." Five filters simultaneously, and results must appear without a page reload.

The standard catalog.smart.filter does not work here. It is designed for products with fixed properties. A tour has multiple departure dates, a computed price, and a hierarchical destination. The facet index (\Bitrix\Iblock\PropertyIndex\Manager::buildIndex()) covers only part of the scenario.

The solution is a custom component project:tour.filter with its own logic.

Destination filtering. Hierarchical selection: country → region → resort. Selecting a country triggers an AJAX call to /api/tours/regions/?country=IT, which runs CIBlockSection::GetList() filtered by the parent SECTION_ID. Cached via CPHPCache with an iblock_id_N tag.

Departure date filtering. The customer selects a range — say, June 1 through June 30. Dates are stored as multiple property values. The straightforward filter:

$filter = [
    '>=PROPERTY_DEPARTURE_DATES' => '2025-06-01',
    '<=PROPERTY_DEPARTURE_DATES' => '2025-06-30',
];

The problem: CIBlockElement::GetList() with this filter on a multiple property is slow at scale. The solution is an intermediate table b_tour_departure_index, populated by an agent whenever a tour is updated. Schema: TOUR_ID, DEPARTURE_DATE (DATE, indexed). Filtering joins this table first, producing an array of TOUR_ID values that feed into CIBlockElement::GetList(['ID' => $tourIds]).

Price filtering. Price is computed at query time: BASE_PRICE × season coefficient × early booking coefficient. Direct filtering on a computed value is impossible. Two approaches:

  1. Materialized price — an agent recalculates each tour's current price hourly and writes it to a CURRENT_PRICE property. Filtering is then standard. Downside: up to one hour of lag between a coefficient change and the price update.
  2. Two-stage filtering — first select tours matching all other filters (destination, dates, duration), then compute the price for each and discard those outside the range. Accurate, but on a catalog of 5000 tours the second stage can take 200-500 ms. Mitigated by caching computed prices in Redis with a 15-minute TTL.

In practice, option one wins. The customer sees a price accurate to within an hour; the filter responds instantly. The exact price appears on the tour detail page and at checkout.

AJAX filtering. All filters are sent as a single GET request: /api/tours/search/?destination=IT&date_from=2025-06-01&date_to=2025-06-30&duration_min=7&duration_max=10&price_max=2000&type=sightseeing. The controller at local/modules/project.tours/lib/controller/search.php extends \Bitrix\Main\Engine\Controller, validates parameters, assembles a CIBlockElement::GetList() filter, and returns JSON with tour data and facet metadata.

Facets — the counts beside each filter value ("Sightseeing (23)", "Beach (45)") — are computed by separate COUNT(*) queries per property with all other filters applied. That is 4-6 lightweight queries given proper indexes. Results are cached for 5 minutes.

Integration with Booking Systems

A tour operator rarely sells only its own tours. The site aggregates offers from multiple sources: in-house tours (in the info block), package tours from Samo.Tourvisor, and offers from the Sletat.ru aggregator.

Samo.Tourvisor API is RESTful JSON. Key endpoints:

  • GET /api/search — search tours by parameters (country, resort, date, nights, adults/children). Returns an array of offers with price, hotel, departure date, operator
  • GET /api/hotel/{id} — hotel details: photos, description, coordinates
  • POST /api/order — create a booking request

Integration lives in local/modules/project.tourvisor/. The class \Project\Tourvisor\Client wraps HTTP calls via \Bitrix\Main\Web\HttpClient. The search() method takes the site's filter parameters, maps them to the API format, executes the request, and parses the response.

The critical issue is response time. Samo.Tourvisor takes 2-8 seconds depending on the number of operators queried. The user must not wait:

  1. The first page load shows results from the local info block (in-house tours) — instant
  2. Simultaneously, the frontend fires an AJAX request to /api/tourvisor/search/
  3. The backend queries the Tourvisor API and caches the result in Redis with a 30-minute TTL
  4. The response loads into the page, merges with local results, and sorts by price

Sletat.ru API uses XML/SOAP. An older protocol, but it covers a vast database of tours from hundreds of operators. The primary method is GetTours() with search parameters. The response is XML, parsed via SimpleXMLElement. Response time: 5-15 seconds.

The strategy is the same — asynchronous loading. But Sletat has a specificity: RequestId. The first request returns a RequestId. You then poll a second endpoint GetSearchResult() every 2-3 seconds until the status becomes Completed. This is implemented as frontend polling:

async function pollSletat(requestId) {
    const response = await fetch(`/api/sletat/result/?request_id=${requestId}`);
    const data = await response.json();
    if (data.status === 'completed') {
        renderResults(data.tours);
    } else {
        setTimeout(() => pollSletat(requestId), 3000);
    }
}

Merging results from different sources. On the frontend — a unified list with a source badge. Each result carries source (local / tourvisor / sletat), external_id, price, currency. Sorting by price requires currency conversion using exchange rates stored in a CurrencyRates Highload block, updated daily by an agent.

Dynamic Pricing

Three pricing layers:

  1. Seasonal coefficients — high season ×1.3, low season ×0.8. Stored in the PriceCoefficients Highload block with date ranges
  2. Early booking — 10-20% discount when booking 60+ days before departure. Rule: if DEPARTURE_DATE - TODAY > 60, apply coefficient 0.85
  3. Last-minute deals — 15-40% discount 3-7 days before departure when the group is underfilled. Coefficient depends on fill rate: GROUP_FILLED < 50% → 0.6

Calculation in \Project\Tours\PriceCalculator::calculate($tourId, $departureDate) fetches all matching coefficients ordered by PRIORITY and multiplies them sequentially. Priority determines order: seasonal (1) first, then early booking (2), then last-minute (3). Early booking and last-minute are mutually exclusive by definition.

Booking and Partial Payment

The checkout flow through the sale module:

  1. Customer selects a tour, departure date, number of participants
  2. Order creation: \Bitrix\Sale\Order::create(), basket with one item (the tour), order properties hold passenger data (full name, passport details, date of birth)
  3. Partial payment — first installment is 30-50% of the tour price. Second installment due 30 days before departure
  4. Implementation: two \Bitrix\Sale\Payment entities per order. The first has status "Pending Payment", the second — "Deferred". The OnSalePaymentEntitySaved handler checks whether both payments are complete. An agent switches the second payment to "Pending Payment" 30 days before departure and sends a reminder email

Travel Documents and Insurance

A "Documents" section — a separate info block or Highload block with visa requirements by country. Fields: COUNTRY_CODE, VISA_TYPE (list: "Not Required", "On Arrival", "Embassy", "E-Visa"), PROCESSING_DAYS, DOCUMENTS_LIST (text), NOTES. The tour detail page automatically displays visa information for the destination country, pulled by matching UF_COUNTRY_CODE from the section.

Insurance is a mandatory upsell. Implemented as a related product in the catalog or via a project:tour.insurance component with a calculator based on duration and destination.

B2B Agent Portal

A separate user group agents with access rights through the main module. Agents see:

  • Wholesale prices — calculated using a separate price type (WHOLESALE) in the catalog or a distinct coefficient in the Highload block
  • Commission per booking — an order property AGENT_COMMISSION, computed as a percentage of tour cost
  • Sales reports — a custom component querying b_sale_order by the agent's USER_ID

Authentication is standard via main.auth but redirects to /agents/ (a dedicated section with the agent_cabinet template). Agent registration is by application, approved by an administrator.

SEO: Schema.org and Geo Pages

The tour detail page outputs JSON-LD markup for TouristTrip:

{
  "@context": "https://schema.org",
  "@type": "TouristTrip",
  "name": "Tuscany Wine Routes",
  "touristType": "Cultural",
  "offers": {
    "@type": "AggregateOffer",
    "lowPrice": "1200",
    "highPrice": "1800",
    "priceCurrency": "USD"
  },
  "subjectOf": {
    "@type": "TravelAction",
    "fromLocation": {"@type": "City", "name": "Moscow"},
    "toLocation": {"@type": "City", "name": "Florence"}
  }
}

Generated in result_modifier.php, injected via $APPLICATION->AddHeadString(). AggregateOffer shows the price range across all departure dates — Google renders it in the snippet.

Geo pages at /tours/italy/, /tours/turkey/antalya/ are info block sections with unique UF_SEO_TITLE, UF_SEO_DESCRIPTION, UF_SEO_TEXT. Each page is an SEO-optimized catalog filtered to that destination.

Timeline

Project scope Estimated timeline
Storefront of in-house tours, no online booking 3-5 weeks
Catalog with filtering, booking, one API integration (Tourvisor) 6-10 weeks
Full platform: multiple APIs, B2B portal, dynamic pricing 10-14 weeks

Most of the time goes into the search engine and integrations. Catalog and templates are standard work — 2-3 weeks. The custom filter with facets takes about 2 weeks. Each external API integration is 1-2 weeks to connect plus a week of edge-case testing (timeouts, response format changes, service unavailability). The B2B portal is 2-3 weeks if there are no unusual reporting requirements.