Prevent double requests — Laravel
Have you ever been in a situation where you needed to lock a route once a request is run? I.e. prevent the user from sending the same request twice. If that’s the case you are in the right place.
Please note that this is not throttling, if you are interested in that instead, please check here.
Requirements
Some basic understanding of Laravel and how it works.
Step 1: Creating custom locking mechanism
We need a way to persistently store data, there are many options for this, but I decided to use cache simply because it is so easy to implement.
Here we are going to create a custom class/trait and use it as a locking mechanism. I personally prefer to use the trait method since it saves on the need to instantiate the class.
To proceed in this manner first create a folder called Traits in app/Traits
and create a new file called LockEventTrait.php
Therefore, our new file structure will be like this
app/Traits/LockEventTrait.php
We now need to make the Traits folder visible to our Laravel application, we do so by editing composer.json
and adding the following configuration
"classmap": [
......."app/Traits",
]
After adding the above configuration run composer dumpautoload
or composer dump-autoload
to regenerate autoload files.
Step 2: Writing the code
I am going to save you time and instead paste the code that I use.
<?php
namespace App\Traits;use Illuminate\Cache\FileStore;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Cache;trait LockEventTrait
{
protected FileStore $file_cache; public function __construct(Filesystem $files)
{
// You can change the cache path for file store, this will be used if and only
// if the cache store is array since array is not persistent storage
$this->file_cache = new FileStore($files, storage_path('app/cache/data'));
} /**
* @param $key
* @return string
*/
protected function getKey($key) : string {
// If user is authenticated then use this mechanism to generate key
// You can override this method of generating unique key
if(auth()->check()){
return $key . config('app.name') . auth()->user()->id;
} // This is for unauthenticated user
return $key . implode( '|',[
config('app.name'),
request()->ip(),
request()->getRequestUri(),
request()->session()->getId()
]);
} /**
* Lock the event using the specified key
*
* @param string $key - Unique key to lock the event
* @param int $decay - The time in seconds before the key expires, 0 for never, default 1 minute
* @param mixed $value - The value to store
* @return bool
*/
public function lock(string $key, int $decay = 60, $value = '') : bool {
if($this->isLocked($key)){
return true;
} // The value to store
if($value === null){
$value = '';
} // Check if app uses array as Cache driver, since it's not persistent
if($decay === 0){
if(!$this->isUsingCache()){
$this->file_cache->forever($key, $value);
} else {
Cache::forever($key, $value);
}
} else {
if(!$this->isUsingCache()){
$this->file_cache->add($key, $value, $decay);
} else {
Cache::add($key, $value, $decay);
}
} // Lock success
return true;
} /**
* Unlock the event with the specified key
* @param $key - Unique key to identify event
* @return bool|mixed|null
*/
public function unlock(string $key): ?bool
{
if(!$this->isLocked($key)){
return true;
} $value = null; // Check if app uses array as Cache driver, since it's not persistent
if(!$this->isUsingCache()){
$value = $this->file_cache->get($key);
$this->file_cache->forget($key);
} else {
$value = Cache::get($key);
Cache::forget($key);
} return $value;
} /**
* Check if the given action is locked, returns false if key does not exist
* @param $key - Unique key to identify the event
* @return bool
*/
public function isLocked(string $key) : bool {
// Check if app uses array as Cache driver, since it's not persistent
if(!$this->isUsingCache()){
return !is_null($this->file_cache->get($key));
} else {
return Cache::has($key);
}
} /**
* Get the value stored in the cache associated with this event
* @param $key - Unique key to identify the event
* @return mixed|null
*/
public function getLockEventValue($key){
if(!$this->isUsingCache()){
return $this->file_cache->get($key);
} else {
return Cache::get($key);
}
} /**
* Checks if the current cache store is using array,
* array is not persistent
* @return bool
*/
public function isUsingCache() : bool {
return config('cache.default') !== config('cache.stores.array.driver');
}}
A bit of an explanation about the code above. Basically the code uses cache storage to indicate that a lock has been set. If the application is using array as the default cache store then we will instead use FileStore as the cache driver in that situation.
Step 3: Creating the middleware
We need to create a middleware that will utilize the lock class above, to create a middleware in Laravel, run the command php artisan make:middleware LockRouteMiddleware
We need to register the middleware alias in app\Http\Kernel.php
and add the following configuration.
protected $routeMiddleware = [....
'lock' => \App\Http\Middleware\LockRouteMiddleware::class,....
];
In your app\Http\Middleware
folder you should have the LockRouteMiddleware.php
file, replace it with the one below.
<?phpnamespace App\Http\Middleware;use App\Traits\LockEventTrait;
use Closure;
use Illuminate\Http\Request;class LockRouteMiddleware
{
use LockEventTrait; /**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param Closure $next
* @param string $key
* @param int $seconds
* @param bool $unlock_after_request
* @param bool $unlock_on_error
* @return mixed
* @throws \Throwable
*/
public function handle(Request $request, Closure $next, string $key, int $seconds = 200,
bool $unlock_after_request = true, bool $unlock_on_error = true)
{
// Get unique key
$key = $this->getKey($key); if($this->isLocked($key)) {
abort(419, 'Another request is being processed, please wait and try again in a few seconds.');
} // Lock before executing the request
$this->lock($key, $seconds); // Execute the request
try{
$response = $next($request); // Check if the route should be unlocked after response
if($unlock_after_request){
$this->unlock($key);
} // return response
return $response;
} catch (\Throwable $th){
// Unlock route on error
if($unlock_on_error){
$this->unlock($key);
} // Rethrow the error
throw $th;
} }
}
Explanation of the above code
Basically we can pass in parameters to the middleware that allows us to tweak the behavior of the lock mechanism.
The most important parameter here is $key
this is the unique string that identifies the event. We first call the getKey($key)
function which is found in theLockEventTrait
ensures that no two requests from different users have the same key.
The $seconds
parameter here specifies the TTL- time to live, in short how long the $key
is valid in the system, for example if $seconds
100 then the $key
is only valid for 100 seconds after that the route is unlocked.
The $unlock_after_request
parameter specifies whether you want the route to be unlocked immediately after the request. This parameter overrides the $unlock_on error
parameter.
The $unlock_on error
parameter specifies that whenever the status code is not that of success i.e. 200, and 300 series then unlock the route.
More about how the configurations can be set below.
Step 4: Using the middleware
This is the final step, we now want to use middleware with a specific route.
We can use the above middleware like this.
Route::group(['middleware' => 'lock:my_key,200'], function(){
// Locked route go here example
Route::get('/example_route', function(){.....
);});
In the above configuration we lock the route for 200 seconds provided an error does not occur. If an error does occur then the route is unlocked.
Please note that
my_key
can be anything.
Other configurations possible
Here is a list of other configurations possible
Route::group(['middleware' => 'lock:my_key,200,false'], function(){
// Locked route go here example
Route::get('/example_route', function(){.....
);});
In this configuration the route is not unlocked once the request has been processed and no error occurred.
Route::group(['middleware' => 'lock:my_key,200,false,false'], function(){
// Locked route go here example
Route::get('/example_route', function(){.....
);});
In this configuration the route is locked for 200 seconds irregardless of whether an error occurred or not.
Warning
Route::group(['middleware' => 'lock:my_key,0,false,false'], function(){
// Locked route go here example
Route::get('/example_route', function(){.....
);});
The above configuration must be used carefully, since it
- Locks the route forever i.e. when
$seconds
is 0. - It does not unlock the route after response.
- It does not unlock the route even if an error occurs.
Extending this idea
This is not limited to routes only, for example you can lock a route when a request is sent and unlock it after a job is processed.
This can also be used internally maybe to prevent race condition. For example if the same job is dispatched twice then the second job can be ignored.
I also believe with a bit of ingenuity you can unlock the routes if a certain response code is received e.g. if response code is 200 then unlock else continue locking, or vice versa. This is useful in a situation whereby you want to lock the route on success because it triggered an event that would last for several minutes. For example suppose you delayed deleting a certain record from DB. If response is 200 then we have started the delete process. But if it’s not then we need to unlock the route, or you simply want to prevent another request for a certain period if the response was successfully processed.
Use cases
- You wish to lock the route to prevent double requests, another request is rejected until the initial request is processed.
['middleware' =>'lock:my_key'
- You wish to lock a route for 300 seconds (5 minutes), for example you wish that the user sends only one sms per 300 seconds.
['middleware' =>'lock:my_key,500,false']
- You wish to lock a route until a job is processed
['middleware' =>'lock:my_key,0,false,false']
remember to create the unlock logic.
Recommendation
I recommend that you only lock a route forever if and only if you understand what you are doing. As I said the above locking idea can be extended to more than just locking routes.
Using a good cache driver will affect the overall speed of the locking. Moreover this must be used at a brae minimum since it slows down the request a bit. Therefore it should be used in situations where we really want to restrict double requests.
Final thoughts
I hope the above helps you.