Prečo riešenie na mieru, nie hotový nástroj
Calendly a Bookly riešia jednoduchý prípad — jeden poskytovateľ, jeden typ schôdzky. Akonáhle pribudne viac pobočiek, viazanosť rezervácie na konkrétny sklad, alebo prepojenie s fakturáciou, hotové nástroje sa obchádzajú cez Zapier integrácie a kompromisy.
| Riešenie | Cena | Vlastná logika | Dáta |
|---|---|---|---|
| Calendly | od 12 USD/mes. na používateľa | Obmedzená, cez Zapier | Cudzí server |
| Bookly (WP plugin) | 89 USD jednorazovo + add-ony | Len cez platené rozšírenia | Vaša WP databáza |
| Vlastný systém v Laraveli | Jednorazový vývoj | Plná kontrola | Vaša databáza |
Dátový model
Základ tvoria tri entity: Service (typ služby s dĺžkou trvania), Resource (zamestnanec, miestnosť alebo stroj, ktorý službu poskytuje) a Booking (samotná rezervácia). Oddelenie služby od zdroja umožňuje, aby jednu službu poskytovalo viacero zamestnancov súčasne.
// database/migrations/..._create_bookings_table.php
Schema::create('services', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->unsignedSmallInteger('duration_minutes');
$table->unsignedSmallInteger('buffer_minutes')->default(0);
$table->decimal('price', 8, 2);
$table->timestamps();
});
Schema::create('resources', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->json('working_hours'); // {"mon": ["09:00","17:00"], ...}
$table->timestamps();
});
Schema::create('bookings', function (Blueprint $table) {
$table->id();
$table->foreignId('service_id')->constrained();
$table->foreignId('resource_id')->constrained();
$table->string('customer_name');
$table->string('customer_email');
$table->dateTime('starts_at');
$table->dateTime('ends_at');
$table->enum('status', ['pending', 'confirmed', 'cancelled', 'no_show'])->default('pending');
$table->timestamps();
// Jeden zdroj nemôže mať dve rezervácie s rovnakým časom
$table->unique(['resource_id', 'starts_at']);
});
// app/Models/Booking.php
class Booking extends Model
{
protected $fillable = ['service_id', 'resource_id', 'customer_name', 'customer_email', 'starts_at', 'ends_at', 'status'];
protected $casts = ['starts_at' => 'datetime', 'ends_at' => 'datetime'];
public function service(): BelongsTo { return $this->belongsTo(Service::class); }
public function resource(): BelongsTo { return $this->belongsTo(Resource::class); }
}
Generovanie dostupných termínov
Dostupné sloty nie sú statické dáta v databáze — generujú sa na požiadanie z otváracích hodín zdroja, dĺžky služby a existujúcich rezervácií. Buffer medzi termínmi (napr. 10 minút na upratovanie miestnosti) sa pripočíta k dĺžke služby.
// app/Services/AvailabilityService.php
class AvailabilityService
{
public function getAvailableSlots(Resource $resource, Carbon $date, Service $service): array
{
$dayKey = strtolower($date->format('D')); // "mon", "tue", ...
[$openTime, $closeTime] = $resource->working_hours[$dayKey] ?? [null, null];
if (!$openTime) {
return []; // Zdroj v tento deň nepracuje
}
$slotLength = $service->duration_minutes + $service->buffer_minutes;
$cursor = $date->copy()->setTimeFromTimeString($openTime);
$closing = $date->copy()->setTimeFromTimeString($closeTime);
$taken = Booking::where('resource_id', $resource->id)
->whereDate('starts_at', $date)
->where('status', '!=', 'cancelled')
->get(['starts_at', 'ends_at']);
$slots = [];
while ($cursor->copy()->addMinutes($slotLength)->lte($closing)) {
$slotEnd = $cursor->copy()->addMinutes($service->duration_minutes);
$overlaps = $taken->contains(
fn ($booking) => $cursor->lt($booking->ends_at) && $slotEnd->gt($booking->starts_at)
);
if (!$overlaps) {
$slots[] = $cursor->format('H:i');
}
$cursor->addMinutes($slotLength);
}
return $slots;
}
}
Ochrana proti double-bookingu
Kontrola dostupnosti pri generovaní slotov nestačí — medzi zobrazením kalendára a odoslaním formulára môže iný klient obsadiť rovnaký termín. Bez uzamknutia riadku v databáze môžu dve súbežné požiadavky obe prejsť kontrolou a vytvoriť kolidujúce rezervácie.
// app/Http/Controllers/BookingController.php
public function store(StoreBookingRequest $request): RedirectResponse
{
$booking = DB::transaction(function () use ($request) {
$resource = Resource::lockForUpdate()->find($request->resource_id);
$overlap = Booking::where('resource_id', $resource->id)
->where('status', '!=', 'cancelled')
->where('starts_at', '<', $request->ends_at)
->where('ends_at', '>', $request->starts_at)
->exists();
if ($overlap) {
throw ValidationException::withMessages([
'starts_at' => 'Tento termín si práve rezervoval iný klient. Zvoľte iný čas.',
]);
}
return Booking::create($request->validated());
});
NotifyCustomerOfBooking::dispatch($booking);
return redirect()->route('bookings.confirmation', $booking);
}
lockForUpdate() funguje len v rámci DB transakcie a len ak databáza podporuje riadkové zámky (MySQL InnoDB, PostgreSQL — nie SQLite v default móde). Unique constraint na (resource_id, starts_at) z migrácie je druhá poistka — ak by zámok zlyhal, databáza odmietne duplicitný insert vlastnou integritou.
Notifikácie potvrdenia
Email s potvrdením by nemal blokovať response — zákazník čaká na presmerovanie, nie na odoslanie SMTP požiadavky. Notifikácia sa odošle asynchrónne cez queue.
// app/Notifications/BookingConfirmed.php
class BookingConfirmed extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public Booking $booking) {}
public function via($notifiable): array { return ['mail']; }
public function toMail($notifiable): MailMessage
{
return (new MailMessage)
->subject('Potvrdenie rezervácie — ' . $this->booking->service->name)
->line('Vaša rezervácia je potvrdená.')
->line('Termín: ' . $this->booking->starts_at->format('d.m.Y H:i'))
->action('Zobraziť rezerváciu', route('bookings.show', $this->booking));
}
}
Spracovanie queue jobov vrátane retry logiky a monitoringu rozoberám v článku Laravel Queues a Jobs.
Frontend kalendár bez frameworku
Pre výber termínu nepotrebujete React komponent — Alpine.js s jedným fetch volaním na API endpoint stačí na plynulý výber dňa a slotu.
<div x-data="{ slots: [], selected: null, loading: false }"
x-init="$watch('selectedDate', async (date) => {
loading = true;
const res = await fetch(`/api/availability?resource=${resourceId}&date=${date}`);
slots = await res.json();
loading = false;
})">
<template x-for="slot in slots" :key="slot">
<button @click="selected = slot"
:class="selected === slot ? 'bg-primary text-white' : 'bg-gray-800'"
class="px-4 py-2 rounded-lg text-sm">
<span x-text="slot"></span>
</button>
</template>
</div>
/api/availability volá presne tú istú AvailabilityService::getAvailableSlots() metódu, ktorú používa aj backend validácia pri odoslaní formulára. Jedna zdrojová pravda zabráni situácii, keď frontend zobrazí slot, ktorý backend pri uložení odmietne.
Admin prehľad rezervácií
Pre správu rezervácií — manuálne potvrdenie, zrušenie, presun termínu — nemusíte stavať vlastný admin od nuly. Filament Resource nad modelom Booking s filtrom podľa stavu a zdroja pokryje 90 % potrieb za pár hodín; postup je v článku Filament 3: plnohodnotný Laravel admin panel.
Edge cases, ktoré sa neoplatí podceniť
- Časové pásma —
starts_at/ends_atukladajte v UTC, na frontende konvertujte podľa časového pásma zákazníka (Intl.DateTimeFormatv JS aleboCarbon::setTimezone()na backende) - No-show tracking — stav
no_showumožňuje neskôr vyhodnotiť spoľahlivosť klienta a prípadne podmieniť ďalšiu rezerváciu zálohou - Zrušenie na poslednú chvíľu — biznis pravidlo typu „zrušenie možné len 24 h vopred" patrí do
BookingPolicy, nie do kontroléra — ľahšie sa testuje a opätovne použije - Súbežné platby — ak rezervácia vyžaduje záväznú platbu vopred, kombinujte s Stripe Checkout Session a aktivujte rezerváciu až vo webhooku, nie pri presmerovaní
Záver: checklist pred nasadením
- Unique constraint na
(resource_id, starts_at)existuje v databáze, nielen vo validácii - Vytvorenie rezervácie prebieha v transakcii s
lockForUpdate() - Generovanie slotov a validácia pri uložení používajú rovnakú službu
- Notifikácie idú cez queue, nie synchrónne v request cykle
- Časy sa ukladajú v UTC a konvertujú len pri zobrazení
- Pravidlá zrušenia/preplánovania sú v Policy triede, nie rozsypané v kontroléroch
Rezervačný systém je typický prípad, kde sa „jednoduchý CRUD" rýchlo zmení na sústavu race conditions a biznis výnimiek. Investícia do správneho dátového modelu a transakčnej ochrany na začiatku sa vráti pri každej ďalšej požiadavke klienta na novú pobočku alebo typ služby.