PHP Laravel PostgreSQL event space booking
An event space site lets visitors compare rooms, reserve a future time, and avoid conflicting bookings. Tokay runs the Laravel page as a Web Service and checks upcoming reminders with a Scheduled Service.
Last updated
This project has the shape of a real app with public requests and scheduled work over one PostgreSQL database. Tokay recognizes both entry points and lets them share the database and encrypted Laravel key inside one Project.
- Runtime
PHP- Framework
- Laravel
- Service types
- Web Service, Scheduled Service
- Resources
- PostgreSQL
- Required secrets
APP_KEY
What it does
The public page introduces three rooms with their capacity, description, and hourly price.
Choose a room, start time, duration, and purpose, then press Confirm booking. The page saves valid requests and tells you when someone already holds an overlapping time.
The optional Upcoming bookings page gives staff a private list. The reminder Service records bookings due within 24 hours and can send email when you add SMTP settings.
Deploy it
Bring this folder to Tokay any way you like. Paste or upload it in the dashboard, push it with git, or hand it to your AI agent along with our agent instructions. Tokay figures out the rest.
Working in Claude, ChatGPT, VS Code, or another MCP client? Connect the Tokay MCP server and ask your agent to deploy this example. It signs in through your browser, so there is no token to paste.
New to Tokay? The getting started guide walks you through your first deploy.
Generate the required secret
Generate a value on your own computer.
php -r "echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;"
Keep the value ready for the setup step after Tokay creates both Services. Do not put it in this folder.
Finish setup in Tokay
On the Project page, choose Secrets. Find APP_KEY under Required setup, choose Set shared value, paste the value you generated, and save it. Both Services will use this same encrypted value.
Next, open each Service's Environment page. Under From your infrastructure, find DATABASE_URL, choose Confirm database, keep app, then choose Confirm. Do this for both Services so each one receives the same Project resource and logical database.
Check that APP_KEY and DATABASE_URL say Good to go on both Services. Follow both deployment trackers and choose Go Live anywhere that action appears. When the Web Service status says Live, click its live URL in the top section to open the booking page.
Try it
- Book the Workshop room, then try to reserve an overlapping time. The second request is rejected.
- Open the reminder Service and choose Run Now twice. The first run records a due reminder and the second skips the duplicate.
- Deploy again and return to the booking site. Your original reservation remains.
Turn on staff and email features
Set ADMIN_PASSWORD under From your code on the Web Service's Environment page, then deploy the Web Service again. Open /admin/bookings and enter the password to see upcoming names, rooms, and times.
The Scheduled Service records reminders in its run history by default. To send real email, set SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, and SMTP_FROM under From your code on its Environment page. Add a booking due within 24 hours, then choose Run Now.
How it works
Wondering how both Services agree on a booking? The Web Service owns the Laravel migrations for their shared database. Tokay tests php artisan migrate --force against a copy before the site receives traffic, while the reminder job connects without trying to own the schema.
A database lock lets only one overlapping request win for a room and time. The reminder command records each notification before optional email delivery, so the next hourly run can safely skip it.
Secrets
| Name | Required | Purpose |
|---|---|---|
APP_KEY |
Yes | Encrypts Laravel application values. Share one Project value with both Services. |
ADMIN_PASSWORD |
No | Opens the staff booking list on the Web Service. |
SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM |
No | Sends real reminder email from the Scheduled Service. |
Grow from here
Try the daily sales report when a scheduled job should create something people revisit.
Source code
reminders/artisan Raw
#!/usr/bin/env php
<?php
use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
require __DIR__.'/vendor/autoload.php';
$appKey = trim((string) getenv('APP_KEY'));
if ($appKey === '') {
fwrite(STDERR, "APP_KEY is required. Store the same Project Secret used by the Web Service.\n");
exit(1);
}
$databaseUrl = parse_url((string) getenv('DATABASE_URL'));
if (!$databaseUrl || empty($databaseUrl['host']) || empty($databaseUrl['path'])) {
fwrite(STDERR, "DATABASE_URL is required.\n");
exit(1);
}
$capsule = new Capsule();
$capsule->addConnection([
'driver' => 'pgsql',
'host' => $databaseUrl['host'],
'port' => $databaseUrl['port'] ?? 5432,
'database' => ltrim($databaseUrl['path'], '/'),
'username' => urldecode($databaseUrl['user'] ?? ''),
'password' => urldecode($databaseUrl['pass'] ?? ''),
'charset' => 'utf8',
'prefix' => '',
]);
$capsule->setAsGlobal();
$capsule->bootEloquent();
$application = new Application('Laravel reminder console', '13');
$application->addCommand(new class extends Command {
protected function configure(): void
{
$this
->setName('reminders:send')
->setDescription('Write reminders for bookings that begin in the next 24 hours');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$now = Carbon::now();
$bookings = Capsule::table('bookings')
->join('rooms', 'rooms.id', '=', 'bookings.room_id')
->leftJoin('notifications', function ($join): void {
$join->on('notifications.booking_id', '=', 'bookings.id')
->where('notifications.kind', '=', '24 hour reminder');
})
->whereNull('notifications.id')
->where('bookings.starts_at', '>=', $now)
->where('bookings.starts_at', '<', $now->copy()->addDay())
->select('bookings.*', 'rooms.name as room_name')
->orderBy('bookings.starts_at')
->get();
$smtpHost = trim((string) getenv('SMTP_HOST'));
$smtpPort = (int) (getenv('SMTP_PORT') ?: 587);
$smtpUsername = trim((string) getenv('SMTP_USERNAME'));
$smtpPassword = trim((string) getenv('SMTP_PASSWORD'));
$from = trim((string) (getenv('SMTP_FROM') ?: 'bookings@example.test'));
$mailer = null;
if ($smtpHost !== '') {
$credentials = $smtpUsername !== '' ? rawurlencode($smtpUsername).':'.rawurlencode($smtpPassword).'@' : '';
$mailer = new Mailer(Transport::fromDsn("smtp://{$credentials}{$smtpHost}:{$smtpPort}"));
}
foreach ($bookings as $booking) {
$delivery = 'log';
if ($mailer) {
$mailer->send(
(new Email())
->from($from)
->to($booking->email)
->subject('Your room booking is tomorrow')
->text("Hello {$booking->name},\n\nYour booking for {$booking->room_name} begins at {$booking->starts_at}.\n")
);
$delivery = 'smtp';
}
Capsule::table('notifications')->insert([
'id' => (string) Str::uuid(),
'booking_id' => $booking->id,
'kind' => '24 hour reminder',
'sent_at' => Carbon::now(),
'delivery' => $delivery,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
]);
$output->writeln(json_encode([
'booking' => $booking->id,
'room' => $booking->room_name,
'starts_at' => $booking->starts_at,
'delivery' => $delivery,
], JSON_UNESCAPED_SLASHES));
}
$output->writeln("<info>Reminder pass complete. {$bookings->count()} booking(s) handled.</info>");
return self::SUCCESS;
}
});
// Tokay invokes the scheduled entry point directly. Local runs can still name the command.
$application->setDefaultCommand('reminders:send');
exit($application->run());
reminders/composer.json Raw
{
"name": "tokay/event-space-reminders",
"type": "project",
"description": "The scheduled reminder command for the event space example",
"license": "MIT",
"require": {
"php": "^8.3",
"illuminate/console": "^13.8",
"illuminate/database": "^13.8",
"ramsey/uuid": "^4.9",
"symfony/mailer": "^8.0",
"symfony/mime": "^8.0"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
}
}
web/app/Models/Booking.php Raw
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Booking extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $casts = ['starts_at' => 'datetime', 'ends_at' => 'datetime'];
public function room(): BelongsTo
{
return $this->belongsTo(Room::class);
}
}
web/app/Models/Notification.php Raw
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Notification extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
protected $casts = ['sent_at' => 'datetime'];
}
web/app/Models/Room.php Raw
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Room extends Model
{
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
}
web/app/Providers/AppServiceProvider.php Raw
<?php
namespace App\Providers;
use App\Models\Booking;
use App\Models\Room;
use Illuminate\Database\QueryException;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
return;
}
try {
$rooms = [
['id' => '259678d7-1503-40d3-bbca-4fd5e5161fd1', 'name' => 'Workshop room', 'capacity' => 18, 'hourly_rate' => 8500, 'description' => 'North light, long worktables, a deep sink, and room to make a mess.'],
['id' => 'ff9eb54d-0da4-43b8-a810-c52c3b9acb32', 'name' => 'Gallery', 'capacity' => 45, 'hourly_rate' => 14000, 'description' => 'White walls, quiet floors, and a flexible open plan for gatherings or exhibitions.'],
['id' => '48a08887-00dc-48de-b144-5019de740d72', 'name' => 'Hall', 'capacity' => 110, 'hourly_rate' => 24000, 'description' => 'A generous room with a small stage, house chairs, and an adjoining kitchen.'],
];
foreach ($rooms as $room) {
Room::query()->updateOrCreate(['id' => $room['id']], $room);
}
if (!Booking::query()->where('id', '9aa2d30b-5596-4db6-847d-ed4c5fa50a14')->exists()) {
$start = now()->addHours(18)->startOfHour();
Booking::query()->create([
'id' => '9aa2d30b-5596-4db6-847d-ed4c5fa50a14',
'room_id' => $rooms[0]['id'],
'name' => 'Mira Bell',
'email' => 'mira@example.test',
'starts_at' => $start,
'ends_at' => $start->copy()->addHours(2),
'purpose' => 'Neighborhood printmaking circle',
'source' => 'seed',
]);
}
} catch (QueryException) {
// The first web process may begin while the migration is finishing.
}
}
}
web/bootstrap/app.php Raw
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
foreach ([
__DIR__.'/cache',
dirname(__DIR__).'/storage/framework/cache/data',
dirname(__DIR__).'/storage/framework/sessions',
dirname(__DIR__).'/storage/framework/views',
dirname(__DIR__).'/storage/logs',
] as $directory) {
if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}
}
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
})
->withExceptions(function (Exceptions $exceptions): void {
// Laravel's default exception rendering is enough for this small app.
})->create();
web/bootstrap/providers.php Raw
<?php
use App\Providers\AppServiceProvider;
return [AppServiceProvider::class];
web/config/app.php Raw
<?php
return [
'name' => env('APP_NAME', 'Open Door Rooms'),
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false),
'url' => env('APP_URL', 'http://localhost'),
'timezone' => 'America/New_York',
'locale' => 'en',
'fallback_locale' => 'en',
'faker_locale' => 'en_US',
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [],
'admin_password' => env('ADMIN_PASSWORD', ''),
'maintenance' => ['driver' => 'file', 'store' => 'array'],
];
web/config/auth.php Raw
<?php
return [
'defaults' => ['guard' => 'web', 'passwords' => 'users'],
'guards' => ['web' => ['driver' => 'session', 'provider' => 'users']],
'providers' => ['users' => ['driver' => 'database', 'table' => 'users']],
'passwords' => ['users' => ['provider' => 'users', 'table' => 'password_reset_tokens', 'expire' => 60, 'throttle' => 60]],
'password_timeout' => 10800,
];
web/config/cache.php Raw
<?php
return [
'default' => 'array',
'stores' => ['array' => ['driver' => 'array', 'serialize' => false]],
'prefix' => 'open-door-rooms',
];
web/config/database.php Raw
<?php
return [
'default' => 'pgsql',
'connections' => [
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DATABASE_URL'),
'host' => '127.0.0.1',
'port' => '5432',
'database' => 'app',
'username' => '',
'password' => '',
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
],
'migrations' => ['table' => 'migrations', 'update_date_on_publish' => true],
];
web/config/filesystems.php Raw
<?php
return [
'default' => 'local',
'disks' => [
'local' => ['driver' => 'local', 'root' => storage_path('app/private'), 'throw' => false],
],
];
web/config/logging.php Raw
<?php
use Monolog\Handler\StreamHandler;
return [
'default' => 'stderr',
'deprecations' => ['channel' => 'null', 'trace' => false],
'channels' => [
'stderr' => [
'driver' => 'monolog',
'level' => 'info',
'handler' => StreamHandler::class,
'handler_with' => ['stream' => 'php://stderr'],
],
'null' => ['driver' => 'monolog', 'handler' => Monolog\Handler\NullHandler::class],
'emergency' => ['path' => storage_path('logs/laravel.log')],
],
];
web/config/mail.php Raw
<?php
return [
'default' => 'log',
'mailers' => ['log' => ['transport' => 'log', 'channel' => 'stderr']],
'from' => ['address' => 'bookings@example.test', 'name' => 'Open Door Rooms'],
];
web/config/queue.php Raw
<?php
return [
'default' => 'sync',
'connections' => ['sync' => ['driver' => 'sync']],
'batching' => ['database' => 'pgsql', 'table' => 'job_batches'],
'failed' => ['driver' => 'null', 'database' => 'pgsql', 'table' => 'failed_jobs'],
];
web/config/services.php Raw
<?php
return [];
web/config/session.php Raw
<?php
return [
'driver' => 'cookie',
'lifetime' => 120,
'expire_on_close' => false,
'encrypt' => true,
'files' => storage_path('framework/sessions'),
'connection' => null,
'table' => 'sessions',
'store' => null,
'lottery' => [2, 100],
'cookie' => 'open_door_rooms_session',
'path' => '/',
'domain' => null,
'secure' => null,
'http_only' => true,
'same_site' => 'lax',
'partitioned' => false,
'serialization' => 'json',
];
web/database/migrations/2026_07_18_000001_create_booking_tables.php Raw
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('rooms', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('name')->unique();
$table->unsignedInteger('capacity');
$table->unsignedInteger('hourly_rate');
$table->text('description');
$table->timestampsTz();
});
Schema::create('bookings', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('room_id')->constrained('rooms');
$table->string('name');
$table->string('email');
$table->timestampTz('starts_at');
$table->timestampTz('ends_at');
$table->string('purpose');
$table->string('source')->default('public form');
$table->timestampsTz();
$table->index(['room_id', 'starts_at', 'ends_at']);
});
Schema::create('notifications', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('booking_id')->constrained('bookings');
$table->string('kind');
$table->timestampTz('sent_at');
$table->string('delivery');
$table->timestampsTz();
$table->unique(['booking_id', 'kind']);
});
}
public function down(): void
{
Schema::dropIfExists('notifications');
Schema::dropIfExists('bookings');
Schema::dropIfExists('rooms');
}
};
web/public/.htaccess Raw
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
web/public/index.php Raw
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
require __DIR__.'/../vendor/autoload.php';
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
web/public/site.css Raw
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; background: #f2eee6; color: #282b2a; }
* { box-sizing: border-box; }
body { margin: 0; }
header { width: min(1160px, calc(100% - 36px)); margin: 0 auto; padding: 22px 0; display: flex; justify-content: space-between; border-bottom: 1px solid #bdb8ac; }
header a { color: inherit; font-weight: 800; }
main { width: min(1160px, calc(100% - 36px)); margin: 56px auto 90px; }
.hero { display: grid; grid-template-columns: 1fr 0.7fr; gap: 24px 60px; align-items: end; }
.hero > p { grid-column: 1 / -1; color: #a14a35; font-size: 0.76rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
h1 { margin: 0; font: 500 clamp(4rem, 10vw, 9rem)/0.84 Georgia, serif; letter-spacing: -0.065em; }
.hero > div { color: #68645c; font: 1.2rem/1.65 Georgia, serif; }
.notice { margin-top: 30px; padding: 15px 18px; border-radius: 10px; background: #dce9db; color: #275035; font-weight: 700; }
.rooms { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 55px; }
.rooms article { min-height: 255px; padding: 23px; display: flex; flex-direction: column; border: 1px solid #cbc5b9; border-radius: 17px; background: #faf7f0; }
.rooms span, .kicker { color: #9a4f3a; font-size: 0.75rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; }
.rooms h2 { margin: auto 0 8px; font: 500 2.2rem Georgia, serif; }
.rooms p { color: #69645b; line-height: 1.55; }
.rooms strong { margin-top: 12px; }
.booking { display: grid; grid-template-columns: 0.7fr 1fr; gap: 55px; margin-top: 80px; padding-top: 45px; border-top: 1px solid #bdb8ac; }
.booking h2, .admin h2 { margin: 10px 0; font: 500 3rem Georgia, serif; }
.booking p, .admin p { color: #68645c; line-height: 1.6; }
form { padding: 24px; border-radius: 18px; background: #fffdf8; box-shadow: 0 15px 45px #50493f16; }
label { display: block; margin-bottom: 13px; font-size: 0.82rem; font-weight: 800; }
input, select { width: 100%; margin-top: 6px; padding: 12px; border: 1px solid #bcb4a6; border-radius: 8px; background: white; font: inherit; }
button { width: 100%; padding: 13px 18px; border: 0; border-radius: 9px; background: #31594d; color: white; font: inherit; font-weight: 800; cursor: pointer; }
.errors { margin-bottom: 14px; padding: 10px 14px; border-radius: 8px; background: #f5dfda; color: #8b3328; }
.errors p { margin: 4px 0; color: inherit; }
.admin { max-width: 780px; }
.admin form { max-width: 430px; margin: 30px 0; }
.booking-list { display: grid; gap: 14px; margin: 35px 0; }
.booking-list article { padding: 20px; border: 1px solid #cbc5b9; border-radius: 14px; background: #faf7f0; }
code { padding: 2px 5px; border-radius: 4px; background: #e5dfd3; }
a { color: #31594d; }
@media (max-width: 760px) { .hero, .booking { grid-template-columns: 1fr; } .rooms { grid-template-columns: 1fr; } .rooms article { min-height: 210px; } }
web/resources/views/admin.blade.php Raw
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Upcoming bookings</title><link rel="stylesheet" href="/site.css"></head>
<body>
<main class="admin">
<p class="kicker">Staff view</p><h1>Upcoming bookings</h1>
@if (!$enabled)
<p>Admin access is off. Add <code>ADMIN_PASSWORD</code> as a Project Secret and deploy again.</p>
@elseif (!$authorized)
<form method="post">@csrf<label>Admin password<input name="password" type="password" required></label><button>Open bookings</button></form>
@else
<div class="booking-list">@foreach ($bookings as $booking)<article><strong>{{ $booking->room->name }}</strong><h2>{{ $booking->purpose }}</h2><p>{{ $booking->starts_at->format('D, M j · g:i A') }} to {{ $booking->ends_at->format('g:i A') }}</p><p>{{ $booking->name }} · {{ $booking->email }}</p></article>@endforeach</div>
@endif
<a href="/">Return to the public page</a>
</main>
</body>
</html>
web/resources/views/home.blade.php Raw
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Open Door Rooms</title>
<link rel="stylesheet" href="/site.css">
</head>
<body>
<header><a href="/">Open Door Rooms</a><a href="#book">Book a room</a></header>
<main>
<section class="hero"><p>Three rooms in one old civic building</p><h1>Make room for the thing.</h1><div>Workshops, rehearsals, dinners, meetings, and the ideas that need a real table.</div></section>
@if ($confirmed)<div class="notice">Your booking is confirmed. We saved the time and room.</div>@endif
<section class="rooms">
@foreach ($rooms as $room)
<article><span>{{ $room->capacity }} people</span><h2>{{ $room->name }}</h2><p>{{ $room->description }}</p><strong>${{ number_format($room->hourly_rate / 100) }} per hour</strong></article>
@endforeach
</section>
<section id="book" class="booking">
<div><p class="kicker">Public booking form</p><h2>Choose your time.</h2><p>We lock the room while checking availability, so two people cannot reserve the same slot.</p></div>
<form method="post" action="/bookings">
@csrf
<label>Room<select name="room_id" required>@foreach ($rooms as $room)<option value="{{ $room->id }}" @selected(old('room_id') === $room->id)>{{ $room->name }}</option>@endforeach</select></label>
<label>Your name<input name="name" value="{{ old('name') }}" required maxlength="100"></label>
<label>Email<input name="email" type="email" value="{{ old('email') }}" required maxlength="180"></label>
<label>Start time<input name="starts_at" type="datetime-local" value="{{ old('starts_at') }}" required></label>
<label>Hours<input name="hours" type="number" min="1" max="8" value="{{ old('hours', 2) }}" required></label>
<label>What are you planning?<input name="purpose" value="{{ old('purpose') }}" required maxlength="180"></label>
@if ($errors->any())<div class="errors">@foreach ($errors->all() as $error)<p>{{ $error }}</p>@endforeach</div>@endif
<button>Confirm booking</button>
</form>
</section>
</main>
</body>
</html>
web/routes/web.php Raw
<?php
use App\Models\Booking;
use App\Models\Room;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
Route::get('/health', fn () => response()->json(['ok' => true]));
Route::get('/', function () {
return view('home', [
'rooms' => Room::query()->orderBy('hourly_rate')->get(),
'confirmed' => request()->boolean('confirmed'),
]);
});
Route::post('/bookings', function (Request $request) {
$input = $request->validate([
'room_id' => ['required', 'uuid', 'exists:rooms,id'],
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'max:180'],
'starts_at' => ['required', 'date', 'after:now'],
'hours' => ['required', 'integer', 'min:1', 'max:8'],
'purpose' => ['required', 'string', 'min:5', 'max:180'],
]);
$start = now()->parse($input['starts_at']);
$end = $start->copy()->addHours((int) $input['hours']);
DB::transaction(function () use ($input, $start, $end): void {
Room::query()->whereKey($input['room_id'])->lockForUpdate()->firstOrFail();
$overlap = Booking::query()
->where('room_id', $input['room_id'])
->where('starts_at', '<', $end)
->where('ends_at', '>', $start)
->exists();
if ($overlap) {
throw ValidationException::withMessages(['starts_at' => 'That room is already booked during this time.']);
}
Booking::query()->create([
'id' => (string) Str::uuid(),
'room_id' => $input['room_id'],
'name' => $input['name'],
'email' => $input['email'],
'starts_at' => $start,
'ends_at' => $end,
'purpose' => $input['purpose'],
'source' => 'public form',
]);
});
return redirect('/?confirmed=1');
});
Route::match(['get', 'post'], '/admin/bookings', function (Request $request) {
$expected = (string) config('app.admin_password', '');
if ($expected === '') {
return view('admin', ['enabled' => false, 'authorized' => false, 'bookings' => collect()]);
}
$authorized = $request->isMethod('post') && hash_equals($expected, (string) $request->input('password'));
return response()->view('admin', [
'enabled' => true,
'authorized' => $authorized,
'bookings' => $authorized
? Booking::query()->with('room')->where('starts_at', '>=', now())->orderBy('starts_at')->get()
: collect(),
], $request->isMethod('post') && !$authorized ? 403 : 200);
});
web/artisan Raw
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
require __DIR__.'/vendor/autoload.php';
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
web/composer.json Raw
{
"name": "tokay/event-space-booking",
"type": "project",
"description": "A Laravel event space booking example",
"license": "MIT",
"require": {
"php": "^8.3",
"laravel/framework": "^13.8"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
]
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"minimum-stability": "stable",
"prefer-stable": true
}
Built from revision 52c7b7f