zaber-dev/laravel-reservation
Application-level resource reservation for Laravel: temporarily hold seats, rooms, or inventory, then confirm, cancel, or let them expire to prevent double-bookings. Uses cache (Redis, Memcached) or database storage.
Install and publish
composer require zaber-dev/laravel-reservation
php artisan vendor:publish --tag="reservation-config"
php artisan vendor:publish --tag="reservation-migrations" # only if using the database store
php artisan migrate
Where to look first
config/reservation.php — this is the decision point for the whole package. It selects the storage driver (cache vs database), the cache store/key prefix, and the default TTL. Read this file before writing any code; most surprises later trace back to it.First use case: a 10-minute hold
The canonical day-one scenario is "hold this slot while the user finishes checkout." Roughly:
use ZaberDev\Reservation\Facades\Reservation; // confirm exact namespace in README
$hold = Reservation::make('seat:'.$seat->id)
->for($user) // optional owner
->for($seat) // optional Eloquent subject
->expiresIn(10) // minutes
->create();
if (! $hold->exists()) { // or ->failed() — check the fluent builder's terminal methods
return back()->withErrors('That seat was just taken.');
}
Method names are the one thing you should verify against the README/tests before committing — the fluent builder is the package's main surface and it has evolved across releases. Everything else (driver choice, expiry, Eloquent attachment, middleware) is stable in concept.
Pick the store per use case, not per app
The two drivers serve genuinely different needs:
A common production shape is a cache hold for the "in-flight" window and a database row written on confirmation, so the durable record and the hot path are decoupled.
Treat the reservation as a state machine, not a boolean
Model your own domain states explicitly (held → confirmed → cancelled/expired) and drive transitions from your application, using the package for the "is this still exclusively mine?" question. On a payment webhook, confirm the reservation inside a transaction and only then write your Booking record. On cancellation, release explicitly rather than waiting for TTL — it makes capacity available immediately and keeps cache and DB consistent.
Guard the double-booking race properly
Expiry is not mutual exclusion. Two concurrent requests can both observe "free" before either writes. For anything with real contention:
Cache::lock("reservation:seat:{$seat->id}", 5)->block(3, function () use ($seat, $user) {
// check-then-create here
});
…or a unique index on (resource_key, active) in your own table. The package reduces the problem; it doesn't remove the need for an atomic primitive at the boundary.
Eloquent integration is for display, not enforcement
Attaching reservations to models (->for($model)) is excellent for "show the current hold on this listing" and for relationship-scoped queries. Use it for reads and UI. Keep the authoritative availability check in the reservation call itself, not in a ->reservations()->count() comparison, which is racy and drifts once expired rows exist.
Middleware for route-level protection
Route middleware is the right tool for coarse guards — "only the holder of this reservation may hit the checkout step." Order it after auth/session so the owner is resolvable, and return a redirect rather than a 403 for expired holds; users hitting a stale tab should land somewhere useful.
Housekeeping is your job
Schedule a prune for expired database rows (daily is usually enough) and make sure your availability queries exclude expired records. Without pruning, the table grows monotonically and where('expires_at', '>', now()) scans degrade.
Testing
Carbon::setTestNow() plus the array cache driver gives you fast, deterministic expiry tests. For the database store, use RefreshDatabase and assert on the table directly — it's the fastest way to confirm the package does what you assume about release-on-expiry.
Cache eviction is silent data loss. Under Redis maxmemory-policy allkeys-lru, holds evaporate without any exception. If a lost hold means a lost sale or a support ticket, use the database store — or at minimum monitor evictions and treat holds as advisory.
Config caching bites during driver switches. After changing the store in config/reservation.php, run php artisan config:clear locally and config:cache in deploy. A stale cached config pointing at the old driver is a classic "why is this writing to the wrong place" bug.
Key composition needs a namespace. If you build keys by hand ("seat:{$id}"), collisions across resource types are easy. Prefix consistently (hold:flight:{id}) and keep the package's own key prefix in mind — don't assume the raw string you pass is the literal cache key.
Clock and timezone consistency. Expiry comparisons depend on now(). Make sure APP_TIMEZONE, your DB session timezone, and queue workers agree; a worker in UTC expiring rows written by a web node in a local timezone produces off-by-hours behaviour that looks like a package bug and isn't.
Never create holds on GET. Reservation creation is a write. Put it behind POST with an idempotency key so a double-submit or retry doesn't consume two holds or fail the second one confusingly.
Idempotency on webhooks. Payment callbacks retry. Confirming an already-confirmed reservation, or confirming one that expired between the provider's call and yours, needs an explicit branch — decide whether late payment confirms or refunds, and encode it.
Watch middleware ordering. Reservation middleware that resolves an owner before auth has run will see null and behave inconsistently between requests; the failure is intermittent and confusing.
Small ecosystem, so pin the version. With a handful of stars and no dependents, there's no crowd to catch regressions. Pin to a specific minor in composer.json, and after any upgrade re-run your own expiry and double-booking tests — those are the paths most likely to shift.
Debugging tactic. When a hold "isn't working," bypass the package once: read the raw cache key (or the database row) directly. That immediately tells you whether the problem is creation, lookup, or expiry, and it's faster than reasoning about the fluent builder.
Extension point. If you outgrow the bundled stores, the storage layer is contract-based — a custom driver (e.g., DynamoDB, or a sharded table) is the intended escape hatch. Implement the contract and point the config at your class rather than forking the package.
How can I help you explore Laravel packages today?