Setting Up Online Appointment Booking on 1C-Bitrix
A medical clinic website with only a "Leave a request and we'll call you back" form loses a significant share of conversions — users want to choose a specific physician, a specific time, and receive immediate confirmation. Online appointment booking with slot selection is the standard in healthcare. Implementation on 1C-Bitrix involves connecting to a MIS or managing the schedule within 1C-Bitrix itself when no MIS is present.
Schedule Source
The first design question: where does the schedule come from?
Option A: Schedule in 1C-Bitrix. The clinic administrator manages physician schedules through the 1C-Bitrix interface. Appointments are stored in 1C-Bitrix and forwarded to the MIS (or not forwarded — for clinics without a MIS). Suitable for small clinics without a complex MIS.
Option B: Schedule from MIS. 1C-Bitrix synchronises the schedule from the MIS every N minutes. Appointments are created via the MIS API. The website is only the interface; master data resides in the MIS.
The following describes Option A — a self-contained schedule managed within 1C-Bitrix.
Schedule Tables
-- Physician working hours template
CREATE TABLE local_doctor_schedule_template (
ID INT AUTO_INCREMENT PRIMARY KEY,
DOCTOR_ID INT NOT NULL, -- ID of the element in the "Doctors" infoblock
DAY_OF_WEEK TINYINT NOT NULL, -- 1=Mon, 7=Sun
TIME_FROM TIME NOT NULL,
TIME_TO TIME NOT NULL,
SLOT_DURATION INT DEFAULT 30, -- minutes per appointment
ACTIVE CHAR(1) DEFAULT 'Y'
);
-- Specific slots (generated from the template)
CREATE TABLE local_doctor_slots (
ID BIGINT AUTO_INCREMENT PRIMARY KEY,
DOCTOR_ID INT NOT NULL,
SLOT_DATE DATE NOT NULL,
SLOT_TIME TIME NOT NULL,
STATUS ENUM('free','reserved','booked','blocked') DEFAULT 'free',
APPOINTMENT_ID BIGINT,
INDEX idx_doctor_date (DOCTOR_ID, SLOT_DATE, STATUS)
);
-- Patient appointments
CREATE TABLE local_appointments (
ID BIGINT AUTO_INCREMENT PRIMARY KEY,
DOCTOR_ID INT NOT NULL,
SLOT_ID BIGINT NOT NULL,
USER_ID INT, -- NULL for unregistered users
PATIENT_NAME VARCHAR(200),
PATIENT_PHONE VARCHAR(20),
PATIENT_EMAIL VARCHAR(200),
SERVICE_ID INT, -- Service (services infoblock)
COMMENT TEXT,
STATUS ENUM('pending','confirmed','cancelled','completed') DEFAULT 'pending',
CREATED_AT DATETIME,
CONFIRMED_AT DATETIME,
CANCELLED_AT DATETIME
);
Slot Generation from Template
An agent running daily generates slots for the next 30 days:
function GenerateDoctorSlots(): string
{
$targetDate = (new \DateTime())->modify('+30 days');
$today = new \DateTime();
$templates = LocalDoctorScheduleTemplateTable::getList([
'filter' => ['ACTIVE' => 'Y'],
'select' => ['DOCTOR_ID', 'DAY_OF_WEEK', 'TIME_FROM', 'TIME_TO', 'SLOT_DURATION'],
]);
while ($tpl = $templates->fetch()) {
$date = clone $today;
while ($date <= $targetDate) {
if ((int)$date->format('N') === (int)$tpl['DAY_OF_WEEK']) {
generateSlotsForDay($tpl, $date);
}
$date->modify('+1 day');
}
}
return __FUNCTION__ . '();';
}
function generateSlotsForDay(array $tpl, \DateTime $date): void
{
$from = new \DateTime($date->format('Y-m-d') . ' ' . $tpl['TIME_FROM']);
$to = new \DateTime($date->format('Y-m-d') . ' ' . $tpl['TIME_TO']);
$interval = new \DateInterval('PT' . $tpl['SLOT_DURATION'] . 'M');
$current = clone $from;
while ($current < $to) {
// Do not create duplicates
$exists = LocalDoctorSlotsTable::getCount([
'DOCTOR_ID' => $tpl['DOCTOR_ID'],
'SLOT_DATE' => $date->format('Y-m-d'),
'SLOT_TIME' => $current->format('H:i:s'),
]);
if (!$exists) {
LocalDoctorSlotsTable::add([
'DOCTOR_ID' => $tpl['DOCTOR_ID'],
'SLOT_DATE' => $date->format('Y-m-d'),
'SLOT_TIME' => $current->format('H:i:s'),
'STATUS' => 'free',
]);
}
$current->add($interval);
}
}
Slot Selection Component
Component /local/components/local/appointment.booking/ with the following steps:
Step 1 — Physician/Specialisation Selection. Filter by specialisation from the physicians infoblock. AJAX update of the physician list.
Step 2 — Date and Time Selection. Calendar with highlighted available dates. On date selection — AJAX request for available slots:
// AJAX handler /local/ajax/get-slots.php
$doctorId = (int)$_POST['doctor_id'];
$date = $_POST['date']; // Y-m-d
$slots = LocalDoctorSlotsTable::getList([
'filter' => [
'DOCTOR_ID' => $doctorId,
'SLOT_DATE' => $date,
'STATUS' => 'free',
],
'order' => ['SLOT_TIME' => 'ASC'],
'select' => ['ID', 'SLOT_TIME'],
])->fetchAll();
header('Content-Type: application/json');
echo json_encode(['slots' => $slots]);
Step 3 — Patient Form. Name, phone, email, comment. For authenticated users — data is pre-populated from their profile. Phone number validation.
Step 4 — Confirmation and Booking.
public function bookSlot(int $slotId, array $patientData, int $serviceId = 0): int
{
$connection = \Bitrix\Main\Application::getConnection();
$connection->startTransaction();
try {
// Atomic slot reservation
$connection->queryExecute("
UPDATE local_doctor_slots
SET STATUS = 'reserved'
WHERE ID = ? AND STATUS = 'free'
", [$slotId]);
if ($connection->getAffectedRowsCount() === 0) {
throw new \RuntimeException('This slot is already taken');
}
$appointmentId = LocalAppointmentsTable::add([
'DOCTOR_ID' => $this->getSlotDoctorId($slotId),
'SLOT_ID' => $slotId,
'USER_ID' => $patientData['user_id'] ?? null,
'PATIENT_NAME' => $patientData['name'],
'PATIENT_PHONE' => $patientData['phone'],
'PATIENT_EMAIL' => $patientData['email'],
'SERVICE_ID' => $serviceId,
'COMMENT' => $patientData['comment'] ?? '',
'STATUS' => 'confirmed',
])->getId();
// Update slot — status and appointment link
LocalDoctorSlotsTable::update($slotId, [
'STATUS' => 'booked',
'APPOINTMENT_ID' => $appointmentId,
]);
$connection->commitTransaction();
// Notifications outside the transaction
$this->sendConfirmationSms($patientData['phone'], $appointmentId);
$this->sendConfirmationEmail($patientData['email'], $appointmentId);
return $appointmentId;
} catch (\Exception $e) {
$connection->rollbackTransaction();
throw $e;
}
}
The transaction with UPDATE ... WHERE STATUS = 'free' and affectedRows check protects against the race condition where two users simultaneously book the same slot.
Cancellation and Rescheduling from the Personal Account
A patient can cancel an appointment no later than N hours before the visit:
public function cancelAppointment(int $appointmentId, int $userId): void
{
$appointment = LocalAppointmentsTable::getById($appointmentId)->fetch();
if (!$appointment || (int)$appointment['USER_ID'] !== $userId) {
throw new \RuntimeException('Appointment not found');
}
$slot = LocalDoctorSlotsTable::getById($appointment['SLOT_ID'])->fetch();
$slotDateTime = new \DateTime($slot['SLOT_DATE'] . ' ' . $slot['SLOT_TIME']);
if ($slotDateTime <= (new \DateTime())->modify('+2 hours')) {
throw new \RuntimeException('Cancellation is only possible at least 2 hours in advance');
}
LocalAppointmentsTable::update($appointmentId, ['STATUS' => 'cancelled']);
LocalDoctorSlotsTable::update($appointment['SLOT_ID'], ['STATUS' => 'free', 'APPOINTMENT_ID' => null]);
}
Scope of Work
- Schedule template, slot, and appointment tables
- Slot generation agent
- Booking component: physician selection → date → slot → form → confirmation
- AJAX handlers for slots
- Race condition protection for concurrent bookings
- SMS/email notifications and reminders
- Personal account: appointment history, cancellation
Timeline: 3–5 weeks for a self-contained system without MIS. 6–10 weeks with MIS integration.







