Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions eval/fixtures/laravel-pms/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copy to .env and fill in. The .env file is gitignored — never commit real
# secrets. Generate APP_KEY with `php artisan key:generate`.
APP_NAME="Laravel PMS"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost

# SQLite is used so the app runs from a fresh clone with no database server.
# The file lives at database/database.sqlite (created by `php artisan migrate`).
DB_CONNECTION=sqlite

# Server-side only. Fill in with your Seam API key.
SEAM_API_KEY=
29 changes: 29 additions & 0 deletions eval/fixtures/laravel-pms/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Dependencies
/vendor

# Local SQLite dev database
*.sqlite
*.sqlite-shm
*.sqlite-wal
database/*.sqlite

# Environment (keep the example, ignore the real thing)
.env
.env.*
!.env.example

# Laravel runtime
/storage/*.key
/storage/framework/cache/*
/storage/framework/sessions/*
/storage/framework/views/*
/storage/logs/*
/bootstrap/cache/*

# Tooling
/.phpunit.cache
/public/hot
/public/storage

# macOS
.DS_Store
37 changes: 37 additions & 0 deletions eval/fixtures/laravel-pms/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Laravel PMS

A tiny property-management app built with [Laravel](https://laravel.com/),
Eloquent, and Blade templates. It manages **spaces** (bookable rooms, suites,
cabins…), takes **reservations** against them, and lists the **guests** who have
booked. It is the Laravel/PHP counterpart of the FastAPI `fastapi-pms` and
Next.js `nextjs-pms` samples, with the same domain so the three exercise the
same integration.

## Getting started

```bash
composer install
cp .env.example .env # then fill in SEAM_API_KEY
php artisan key:generate # sets APP_KEY
php artisan migrate --seed # creates database/database.sqlite and seeds a few spaces
php artisan serve
```

Open [http://localhost:8000](http://localhost:8000). The SQLite database
(`database/database.sqlite`) is created by `php artisan migrate`. Running with
`--seed` adds a few example spaces so the booking form's picker isn't empty; use
plain `php artisan migrate` if you'd rather start with none.

## Layout

- `routes/web.php` — the routes for every page and form post.
- `app/Models/` — the `Space` and `Reservation` Eloquent models.
- `app/Http/Controllers/` — `BookingController` (public form), `ReservationController`
(front desk), `SpaceController` (inventory), `GuestController` (derived list).
- `app/Services/AvailabilityService.php` — overlap/capacity checks shared by
booking and the front desk.
- `app/Exceptions/BookingException.php` — the guest-readable "can't book" error.
- `app/SpaceKinds.php` — the kinds of space and their display labels.
- `database/migrations/` — the `spaces` and `reservations` schema.
- `database/seeders/DatabaseSeeder.php` — a few starter spaces.
- `resources/views/` — the Blade pages: booking form, reservations, spaces, guests.
14 changes: 14 additions & 0 deletions eval/fixtures/laravel-pms/app/Exceptions/BookingException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace App\Exceptions;

use Exception;

/**
* A guest-readable reason a space can't take a stay (missing, archived, too
* small, or already booked). Thrown by AvailabilityService and surfaced back to
* the form as a friendly message.
*/
class BookingException extends Exception
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace App\Http\Controllers;

use App\Exceptions\BookingException;
use App\Models\Reservation;
use App\Models\Space;
use App\Services\AvailabilityService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

/**
* The public booking flow: the landing page and the form that creates a stay.
*/
class BookingController extends Controller
{
public function __construct(private readonly AvailabilityService $availability)
{
}

/** The landing page with the booking form and the list of spaces. */
public function home(): View
{
$spaces = Space::query()
->where('status', 'active')
->orderBy('name')
->get();

return view('book', ['spaces' => $spaces]);
}

/** Create a reservation from the public booking form. */
public function book(Request $request): RedirectResponse
{
$data = $request->validate([
'guest_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email'],
'phone' => ['required', 'string', 'min:5'],
'check_in' => ['required', 'date'],
'check_out' => ['required', 'date', 'after:check_in'],
'party_size' => ['integer', 'min:1', 'max:20'],
'notes' => ['nullable', 'string', 'max:1000'],
'space_id' => ['nullable', 'integer', 'exists:spaces,id'],
]);

$spaceId = isset($data['space_id']) ? (int) $data['space_id'] : null;
$partySize = (int) ($data['party_size'] ?? 1);

if ($spaceId !== null) {
try {
$this->availability->assertSpaceBookable(
spaceId: $spaceId,
checkIn: $data['check_in'],
checkOut: $data['check_out'],
partySize: $partySize,
);
} catch (BookingException $error) {
return back()->withInput()->with('error', $error->getMessage());
}
}

Reservation::create([
'guest_name' => $data['guest_name'],
'email' => $data['email'],
'phone' => $data['phone'],
'check_in' => $data['check_in'],
'check_out' => $data['check_out'],
'party_size' => $partySize,
'notes' => $data['notes'] ?? null,
'space_id' => $spaceId,
]);

return redirect('/reservations');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace App\Http\Controllers;

abstract class Controller
{
//
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Http\Controllers;

use App\Models\Reservation;
use Illuminate\View\View;

/**
* The derived guests page: unique guests (deduped by email), with how many
* reservations each has. There is no guests table — this view is computed from
* reservations on the fly.
*/
class GuestController extends Controller
{
public function index(): View
{
$reservations = Reservation::query()
->orderByDesc('created_at')
->get();

$byEmail = [];
foreach ($reservations as $reservation) {
$key = strtolower(trim($reservation->email));

if (isset($byEmail[$key])) {
$byEmail[$key]['reservation_count']++;
} else {
// rows are newest-first, so the first hit is the guest's latest details
$byEmail[$key] = [
'name' => $reservation->guest_name,
'email' => $reservation->email,
'phone' => $reservation->phone,
'reservation_count' => 1,
];
}
}

return view('guests', ['guests' => array_values($byEmail)]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

namespace App\Http\Controllers;

use App\Exceptions\BookingException;
use App\Models\Reservation;
use App\Models\Space;
use App\Services\AvailabilityService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;

/**
* The front desk: list reservations, change status, assign a space, delete.
*/
class ReservationController extends Controller
{
public function __construct(private readonly AvailabilityService $availability)
{
}

/** The front-desk list, with the spaces available for reassignment. */
public function index(): View
{
$reservations = Reservation::query()
->with('space')
->orderByDesc('created_at')
->get();

$spaces = Space::query()
->orderBy('status')
->orderBy('name')
->get();

return view('reservations', [
'reservations' => $reservations,
'spaces' => $spaces,
]);
}

/** Update a reservation's status (front desk). */
public function updateStatus(Request $request, Reservation $reservation): RedirectResponse
{
$data = $request->validate([
'status' => ['required', 'in:pending,confirmed,cancelled'],
]);

// Cancelling releases the space, so reviving a cancelled stay has to win
// its space back — someone else may have taken it in the meantime.
if (
$reservation->status === 'cancelled'
&& $data['status'] !== 'cancelled'
&& $reservation->space_id !== null
) {
try {
$this->availability->assertSpaceBookable(
spaceId: $reservation->space_id,
checkIn: $reservation->check_in,
checkOut: $reservation->check_out,
partySize: $reservation->party_size,
excludeReservationId: $reservation->id,
);
} catch (BookingException) {
return redirect('/reservations');
}
}

$reservation->update(['status' => $data['status']]);

return redirect('/reservations');
}

/** Assign, move, or clear a reservation's space (front desk). */
public function assign(Request $request, Reservation $reservation): RedirectResponse
{
$data = $request->validate([
'space_id' => ['nullable', 'integer', 'exists:spaces,id'],
]);

$spaceId = isset($data['space_id']) ? (int) $data['space_id'] : null;

if ($spaceId !== null) {
try {
$this->availability->assertSpaceBookable(
spaceId: $spaceId,
checkIn: $reservation->check_in,
checkOut: $reservation->check_out,
partySize: $reservation->party_size,
excludeReservationId: $reservation->id,
);
} catch (BookingException) {
return redirect('/reservations');
}
}

$reservation->update(['space_id' => $spaceId]);

return redirect('/reservations');
}

/** Delete a reservation (front desk). */
public function destroy(Reservation $reservation): RedirectResponse
{
$reservation->delete();

return redirect('/reservations');
}
}
Loading
Loading