Skip to main content

Booking Slots System

Comprehensive guide to the slot generation engine that powers appointment availability in Salonnz.

Overview

The Booking Slots system is the core availability engine that determines when customers can book appointments. It operates in two fundamentally different modes and applies multiple filtering layers to ensure staff availability, resource allocation, and business hour compliance.

Dual-Mode Architecture


Configuration Settings

All slot behavior is controlled by 5 critical settings stored in the database.

1. Slot Mode (enable_dynamic_slot)

Database: frontend_settings.enable_dynamic_slot

Values:

  • 0 = Location-Based Slots (Business Hours)
  • 1 = Staff-Based Slots (Individual Staff Schedules) ✅ Recommended

Impact:

ModeWorking Hours SourceStaff SelectionBooking Conflicts
Location (0)business_hours tableNot applicableShared across all staff
Staff (1)staff_working_hours tableIntelligent load balancingPer-staff tracking

Example Scenario:

Location Mode:
- Salon opens 9 AM - 5 PM.
- ALL services must fit within this window.
- If one staff is booked, slots still appear (another staff may be available).

Staff Mode:
- Staff A works 9 AM - 3 PM (30 appointments today).
- Staff B works 10 AM - 6 PM (15 appointments today).
- System auto-selects Staff B for new bookings (load balancing).
- If Staff B is booked at 2 PM, that slot is removed.

2. Booking Interval (booking_interval)

Database: frontend_settings.booking_interval

Values: Integer (minutes)

  • Common: 10, 15, 30, 60

Impact: Determines slot granularity.

Example:

If interval = 15 minutes:
9:00 AM
9:15 AM
9:30 AM
9:45 AM
10:00 AM
...

If interval = 30 minutes:
9:00 AM
9:30 AM
10:00 AM
...

[!TIP] Smaller intervals (10-15 min) provide more flexibility but may create fragmentation. Larger intervals (30 min) reduce choice but simplify scheduling.


3. Resource Selection (resource_selection)

Database: frontend_settings.resource_selection

Values:

  • 0 = Disabled
  • 1 = Enabled (requires enable_dynamic_slot = 1)

Impact: Allocates rooms/equipment to appointments.

Use Case: Salons with limited treatment rooms, massage tables, or specialized equipment.

Logic:

// If enabled, system finds the resource with fewest appointments
if ($resource_selection == 1 && $enable_dynamic_slot == 1) {
$selectedResourceId = findLeastBusyResource($service);
// Slot is only offered if this resource is available
}

4. Staff Selection (staff_selection)

Database: frontend_settings.staff_selection

Values:

  • 0 = Auto-assign (system picks staff)
  • 1 = Customer choice (show staff list)

Impact: UI flow and staff assignment logic.

ValueCustomer ExperienceBackend Logic
0No staff selection shownAuto-selects least busy staff
1Dropdown to choose staffUses customer's selection

5. Time Zone (time_zone)

Database: calendar_settings.time_zone

Values: Standard timezone string (e.g., America/New_York, Asia/Kolkata)

Impact: Same-day booking cutoff calculation.

Logic:

const now = Carbon.now().timezone(calendar_settings.time_zone);
const buffer = now.addMinutes(10).roundToNext10Minutes();

// Slots before this buffer time are hidden
if (date === today) {
start_from = buffer;
}

Slot Generation Process

Step 1: Determine Working Hours

Location Mode (enable_dynamic_slot = 0):

  1. Check for BlockTimeForBusinessHours (date-specific overrides).
  2. If not found, check for recurring block times for that day of week.
  3. Fallback to BusinessHours for the location.
// Priority cascade
$working_hour = BlockTimeForBusinessHours::where('location_id', $location)
->whereDate('date', $date)
->first();

if (!$working_hour) {
$working_hour = BlockTimeForBusinessHours::where('location_id', $location)
->whereDate('date', '<=', $date)
->where('day', $day_number)
->where('is_recurring', 1)
->first();
}

if (!$working_hour) {
$working_hour = BusinessHours::where('location_id', $location)
->where('day_no', $day_number)
->first();
}

Staff Mode (enable_dynamic_slot = 1):

For each service, find the optimal staff:

// Auto-selection criteria (if staff not pre-selected)
$minAppt = 99;
$maxDuration = 0;
$selectedStaffId = null;

foreach ($staffList as $staff) {
// Skip staff with advanced pricing (special rates)
if (hasAdvancedPricing($staff, $service)) continue;

// Get working hours (priority: specific date > recurring > default)
$working_hour = getStaffWorkingHour($staff, $date, $day_number);

// Skip if closed
if ($working_hour->open == 0) continue;

// Count existing appointments
$appointments = countAppointments($staff, $date);

// Select staff with fewest appointments AND longest duration
if ($appointments <= $minAppt && $working_hour->duration >= $maxDuration) {
$minAppt = $appointments;
$maxDuration = $working_hour->duration;
$selectedStaffId = $staff->id;
}
}

Step 2: Calculate Time Range

Start Time:

$timezone = $calendar_settings->time_zone;
$now = Carbon::now()->timezone($timezone);

if (Carbon::parse($date)->isToday()) {
// Same-day booking: current time + 10 min buffer, rounded up
$start_from = $now->addMinutes(10 - ($now->minute % 10))->format('h:i A');
} else {
// Future date: working hour start time
$start_from = $working_hour->start_time;
}

// Ensure start_from is not before working hours
if (Carbon::parse($start_from)->lessThan(Carbon::parse($working_hour->start_time))) {
$start_from = $working_hour->start_time;
}

End Time:

$end_to = $working_hour->end_time;

Step 3: Generate Slot Candidates

Using the booking_interval, create time slots:

$timePeriod = new CarbonPeriod(
Carbon::parse($start_from),
$frontendSettings->booking_interval . ' minutes',
Carbon::parse($end_to)
);

foreach ($timePeriod as $time) {
$slots[] = $time->format('h:i A');
}

Example (10:00 AM - 2:00 PM, 30-min interval):

10:00 AM
10:30 AM
11:00 AM
11:30 AM
12:00 PM
12:30 PM
1:00 PM
1:30 PM

Step 4: Filter Unavailable Slots

Slots are removed if any of the following conditions are true:

4.1 Day is Closed

if ($working_hour->open == 0) {
// Remove ALL slots for this date
}

4.2 Overlaps with Break 1

if ($slotStart + $totalDuration >= $break1_start && $slotStart < $break1_end) {
// Remove slot
}

4.3 Overlaps with Break 2

if ($slotStart + $totalDuration >= $break2_start && $slotStart < $break2_end) {
// Remove slot
}

4.4 Staff Has Existing Appointment (Staff Mode Only)

if ($enable_dynamic_slot == 1) {
$existingAppointments = Appointment::whereHas('appointment_services', function($q) use ($date, $staffIds) {
$q->whereDate('date', $date)
->whereIn('staff_id', $staffIds);
})->where('status', 1)->get();

foreach ($existingAppointments as $appt) {
if ($slotTime >= $appt->start_time && $slotTime <= $appt->end_time) {
// Remove slot
}
}
}

4.5 Service Duration Doesn't Fit

$totalDuration = sum(all service durations);  // e.g., 90 minutes
$slotEndTime = $slotStart + $totalDuration;

if ($slotEndTime >= $end_to) {
// Remove slot (service would extend past working hours)
}

Example:

Working hours: 9:00 AM - 5:00 PM
Service duration: 90 minutes
Last valid slot: 3:30 PM (ends at 5:00 PM)
Slots removed: 3:40 PM, 3:50 PM, 4:00 PM, etc.

4.6 Staff Has Block Time

$blockTimes = BlockTimes::where('staff_id', $staffId)
->where('date', $date)
->get();

foreach ($blockTimes as $block) {
if ($slotTime >= $block->start_time && $slotTime <= $block->end_time) {
// Remove slot
}
}

Step 5: Resource Allocation (Optional)

If resource_selection = 1:

foreach ($services as $service) {
$resources = $service->resources;
$minAppt = 99;
$selectedResourceId = 0;

foreach ($resources as $resource) {
$apptCount = countResourceAppointments($resource, $date);
if ($apptCount <= $minAppt) {
$minAppt = $apptCount;
$selectedResourceId = $resource->id;
}
}

$finalResources[] = [
'service_id' => $service->id,
'resource_id' => $selectedResourceId
];
}

Special Conditions

Multi-Service Bookings

When a customer books multiple services (e.g., Haircut + Manicure):

$totalDuration = 0;
foreach ($services as $service) {
$totalDuration += $service->duration;
}

// Slot must accommodate ENTIRE duration
$slotIsValid = ($slotStart + $totalDuration) <= $workingHourEnd;

Example:

Service 1: Haircut (45 min)
Service 2: Color (60 min)
Total: 105 minutes

Slot at 3:00 PM is INVALID if working hours end at 4:30 PM.
(3:00 PM + 105 min = 4:45 PM > 4:30 PM end time)

Block Time Priority Cascade

When determining working hours, the system follows this priority:

  1. Date-Specific Block Time (highest priority)

    • BlockTimeForBusinessHours or StaffBlockTimeWorkingHour where date = $targetDate
  2. Recurring Block Time

    • Same tables where is_recurring = 1 and day = $dayOfWeek
  3. Default Working Hours (lowest priority)

    • BusinessHours or StaffWorkingHour

Use Case:

Default: Staff works Monday 9 AM - 5 PM.
Recurring Override: Every Monday, close at 2 PM (recurring block time).
Specific Override: On Dec 25, closed all day (date-specific block time).

Result for Dec 25: Closed (specific override wins).
Result for Dec 18 (Monday): 9 AM - 2 PM (recurring override).
Result for Dec 19 (Tuesday): 9 AM - 5 PM (default).

Same-Day Booking Buffer

Hardcoded Rule: Customers cannot book within the next 10 minutes.

Implementation:

const now = moment().tz(calendar_settings.time_zone);
const buffer = now.add(10, 'minutes');

// Round up to next booking interval
const roundedBuffer = buffer.minute(
Math.ceil(buffer.minute() / booking_interval) * booking_interval
);

if (date === today) {
start_from = max(start_from, roundedBuffer);
}

Example (10-min interval):

Current time: 2:17 PM
Buffer: 2:27 PM
Rounded: 2:30 PM
First available slot: 2:30 PM

Database Schema

Core Tables

frontend_settings

CREATE TABLE frontend_settings (
id BIGINT PRIMARY KEY,
enable_dynamic_slot INT DEFAULT 0, -- 0=Location, 1=Staff
booking_interval INT DEFAULT 10,
staff_selection INT DEFAULT 0,
resource_selection INT DEFAULT 0,
...
);

calendar_settings

CREATE TABLE calendar_settings (
id BIGINT PRIMARY KEY,
time_zone VARCHAR(255) DEFAULT 'UTC',
time_format VARCHAR(10),
...
);

business_hours

CREATE TABLE business_hours (
id BIGINT PRIMARY KEY,
location_id BIGINT,
day_no INT, -- 0=Sunday, 1=Monday, ...
day VARCHAR(20),
open INT DEFAULT 1, -- 0=Closed, 1=Open
start_time TIME,
end_time TIME,
break1_start TIME,
break1_end TIME,
break2_start TIME,
break2_end TIME,
duration INT, -- Total working minutes
...
);

staff_working_hours

CREATE TABLE staff_working_hours (
id BIGINT PRIMARY KEY,
staff_id BIGINT,
day_no INT,
day VARCHAR(20),
open INT,
start_time TIME,
end_time TIME,
break1_start TIME,
break1_end TIME,
break2_start TIME,
break2_end TIME,
duration INT,
...
);

block_time_for_business_hours

CREATE TABLE block_time_for_business_hours (
id BIGINT PRIMARY KEY,
location_id BIGINT,
date DATE,
day INT, -- Day of week
is_recurring INT DEFAULT 0, -- 0=One-time, 1=Every week
open INT,
start_time TIME,
end_time TIME,
-- Same break fields as business_hours
...
);

staff_block_time_working_hour

Same structure as block_time_for_business_hours, but for staff members.

block_times (Manual Staff Blocks)

CREATE TABLE block_times (
id BIGINT PRIMARY KEY,
staff_id BIGINT,
date DATE,
start_time TIME,
end_time TIME,
reason VARCHAR(255),
...
);

API Reference

Endpoint: Get Available Slots

POST /booking/get-slots

Request Body:

{
"start_date": "2024/12/25",
"end_date": "2024/12/27",
"location_id": 1,
"service_pricing_options": [5, 12], // Array of service IDs
"staff": [-1, 8] // -1 = auto-select, or specific staff ID
}

Response:

{
"status": true,
"data": {
"2024-12-25": ["09:00 AM", "09:30 AM", "10:00 AM", ...],
"2024-12-26": ["10:00 AM", "10:30 AM", ...],
"2024-12-27": [] // No slots (day closed or fully booked)
},
"staff": [
{ "staff": 3, "service_id": 5 },
{ "staff": 8, "service_id": 12 }
],
"resources": [
{ "service_id": 5, "resource_id": 2 }
]
}

Troubleshooting

Issue: "No slots available" on a workday

Check:

  1. business_hours or staff_working_hours: Is open = 1?
  2. block_time_for_business_hours: Any date-specific closures?
  3. ✅ Staff has no existing appointments filling the entire day?
  4. booking_interval is not too large (e.g., 60 min on a 2-hour shift = only 2 slots).

Solution: Review working hours and block times in admin panel.


Issue: Slots appearing during break times

Likely Cause: Caching issue.

Solution:

# Clear location cache
php artisan cache:forget locations_get

# Or clear all cache
php artisan cache:clear

Issue: Customer-selected staff not receiving slots

Check:

  1. ✅ Is staff_selection = 1 in frontend_settings?
  2. ✅ Does the staff have a staff_working_hours entry for that day?
  3. ✅ Staff is not marked with ServiceAdvancePricingOptions (special pricing excludes them from auto-assignment).

Issue: Same-day slots start 20 minutes from now, but interval is 10 min

Expected Behavior: 10-min buffer is hardcoded, then rounded to next interval.

Example:

Current time: 2:07 PM
Buffer: 2:17 PM
Rounded (10-min interval): 2:20 PM ✅ Correct

Best Practices

  1. Use Staff Mode for Multi-Location Businesses: Better control over individual schedules and prevents overbooking.

  2. Set Realistic booking_interval:

    • 10-15 min for quick services (haircut, nails).
    • 30 min for longer services (massage, color treatment).
  3. Always Test Block Times: After creating recurring or date-specific blocks, check slot availability on the frontend.

  4. Monitor Staff Load: If one staff is always fully booked, the auto-selection algorithm will route customers to less busy staff.

  5. Use Resources for Limited Capacity: Salons with 3 massage tables should enable resource_selection to prevent double-booking equipment.