aaronfrancis / reservable
Eloquent model reservation/locking through Laravel's cache lock system
Requires
- php: ^8.2
- illuminate/cache: ^11.0|^12.0
- illuminate/database: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- nesbot/carbon: ^3.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.8
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
README
Eloquent model reservation/locking through Laravel's cache lock system.
This package allows you to temporarily "reserve" Eloquent models using Laravel's atomic cache locks. This is useful when you need to ensure exclusive access to a model for a period of time, such as during background processing.
Installation
composer require aaronfrancis/reservable
Publish the migration:
php artisan vendor:publish --tag=reservable-migrations php artisan migrate
Optionally publish the config:
php artisan vendor:publish --tag=reservable-config
Requirements
This package requires Laravel's database cache driver. Make sure your cache_locks table exists (created by Laravel's default cache migration).
The published migration adds generated columns to parse reservation keys into queryable columns. This allows efficient querying of reserved/unreserved models.
Supported databases: PostgreSQL, MySQL/MariaDB, SQLite
Usage
Add the Reservable trait to your model:
use AaronFrancis\Reservable\Concerns\Reservable; class Video extends Model { use Reservable; }
Reserve a model
// Reserve for 60 seconds (default) $video->reserve('processing'); // Reserve for a specific duration in seconds $video->reserve('processing', 300); // 5 minutes // Reserve until a specific time $video->reserve('processing', now()->addHour()); // Reserve with CarbonInterval $video->reserve('processing', CarbonInterval::minutes(5)); $video->reserve('processing', CarbonInterval::hours(2));
The reserve() method returns true if the lock was acquired, or false if the model is already reserved.
Check if reserved
if ($video->isReserved('processing')) { // Model is currently reserved }
Release a reservation
$video->releaseReservation('processing');
Blocking reserve
Wait for a lock to become available instead of failing immediately:
// Wait up to 10 seconds (default) for the lock $video->blockingReserve('processing', duration: 60); // Wait up to 30 seconds $video->blockingReserve('processing', duration: 60, wait: 30); // With CarbonInterval $video->blockingReserve('processing', CarbonInterval::minutes(5), wait: 30);
Returns true if the lock was acquired, false if the wait time expired.
Reserve with callback
Automatically release the lock when your work is done:
$result = $video->reserveWhile('processing', 300, function ($video) { // Do work while holding the lock... return $video->transcode(); }); // Lock is automatically released
The lock is released even if the callback throws an exception. Returns false if the lock couldn't be acquired.
Extend a reservation
Add more time to an existing reservation without releasing it:
$video->reserve('processing', 60); // Later, if you need more time: $video->extendReservation('processing', 60); // Add 60 more seconds
Returns true if the reservation was extended, false if no active reservation exists.
Query scopes
Find unreserved models:
$available = Video::unreserved('processing')->get();
Find reserved models:
$reserved = Video::reserved('processing')->get();
Note: These scopes return point-in-time results. By the time you try to reserve a model from the results, another process may have already reserved it. Use
reserveForfor atomic find-and-reserve operations.
Find and reserve in one query:
// Get unreserved models and reserve them atomically $videos = Video::reserveFor('processing', 60)->limit(5)->get();
The reserveFor scope filters to unreserved models, then atomically reserves each one that's returned. Models that can't be reserved (race condition) are filtered out.
Note: Because of race conditions,
reserveFor()->limit(5)may return fewer than 5 results. If another process reserves a model between the query and the lock attempt, that model is excluded from the results.
Key types
Reservation keys can be strings, enums, or objects:
// String key $video->reserve('processing'); // Enum key $video->reserve(JobType::Transcoding); // Object key (uses class name) $video->reserve($someService);
Multiple reservation types
A model can have multiple different reservation types simultaneously:
$video->reserve('transcoding'); $video->reserve('thumbnail-generation'); $video->isReserved('transcoding'); // true $video->isReserved('thumbnail-generation'); // true $video->isReserved('uploading'); // false
How it works
Reservable builds on Laravel's atomic cache locks, which use the cache_locks table to provide database-level mutual exclusion.
Lock key format
When you call $video->reserve('processing'), Reservable creates a cache lock with a specially formatted key:
reservation:{morph_class}:{model_id}:{reservation_key}
For example: reservation:App\Models\Video:42:processing
Generated columns
The challenge with cache locks is that they're not inherently queryable by model. You can't efficiently ask "give me all Videos that aren't locked" because the lock table doesn't know about your models.
Reservable solves this by adding generated columns to the cache_locks table that parse the key format:
| Column | Extracted From | Example Value |
|---|---|---|
is_reservation |
Key contains reservation: |
true |
model_type |
Second segment | App\Models\Video |
model_id |
Third segment | 42 |
type |
Fourth segment | processing |
These columns are computed automatically by the database whenever a row is inserted or updated. The exact SQL varies by database engine (PostgreSQL uses split_part(), MySQL uses SUBSTRING_INDEX(), SQLite uses substr()).
Efficient queries
With generated columns in place, the reserved() and unreserved() scopes become simple JOIN queries:
-- Find unreserved videos SELECT * FROM videos WHERE NOT EXISTS ( SELECT 1 FROM cache_locks WHERE model_type = 'App\Models\Video' AND model_id = videos.id AND type = 'processing' AND expiration > UNIX_TIMESTAMP() )
This is much more efficient than fetching all videos and checking each lock individually in PHP.
Atomic reservations
The reserve() method uses Laravel's Lock::get() which performs an atomic database operation—either the lock is acquired or it isn't. There's no window where two processes can both think they have the lock.
The reserveFor scope combines this with query filtering: it finds unreserved models, then attempts to reserve each one, filtering out any that fail due to race conditions.
The CacheLock model
Reservable uses an Eloquent model (AaronFrancis\Reservable\Models\CacheLock) to query the cache_locks table. This model provides the reservations() relationship on your reservable models, allowing you to access active locks:
$video->reservations; // Collection of active CacheLock models
You can swap this for a custom model in the config if you need to add functionality.
Configuration
// config/reservable.php return [ // The model representing cache locks 'model' => AaronFrancis\Reservable\Models\CacheLock::class, ];
Testing
composer test
License
MIT