Custom Route Files

Tutorials

October 7th, 2021

custom-routes-1633608850.jpg

One morning I woke up to Slack notifications. That's never a good sign.

Overnight, my Redis instance had filled completely up. We use Redis for two things:

  1. Session storage
  2. Caching a few bits of data, nothing substantial

I used TablePlus to see what I could in Redis. It's a bit hard to tell what's going on, since Laravel uses random hashes as part of the cache keys, and the payloads are encoded/encrypted.

However I could see that there were 2 Redis databases (db0 and db1). Checking the config/databases.php file, I found that 2 corresponding databases were indeed defined for Redis.

1# File config/databases.php
2return [
3 // Things ommitted here...
4 
5 
6 /*
7 |--------------------------------------------------------------------------
8 | Redis Databases
9 |--------------------------------------------------------------------------
10 |
11 | Redis is an open source, fast, and advanced key-value store that also
12 | provides a richer body of commands than a typical key-value system
13 | such as APC or Memcached. Laravel makes it easy to dig right in.
14 |
15 */
16 
17 'redis' => [
18 
19 'client' => env('REDIS_CLIENT', 'phpredis'),
20 
21 'options' => [
22 'cluster' => env('REDIS_CLUSTER', 'redis'),
23 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
24 ],
25 
26 'default' => [
27 'url' => env('REDIS_URL'),
28 'host' => env('REDIS_HOST', '127.0.0.1'),
29 'password' => env('REDIS_PASSWORD', null),
30 'port' => env('REDIS_PORT', '6379'),
31 'database' => env('REDIS_DB', '0'),
32 ],
33 
34 'cache' => [
35 'url' => env('REDIS_URL'),
36 'host' => env('REDIS_HOST', '127.0.0.1'),
37 'password' => env('REDIS_PASSWORD', null),
38 'port' => env('REDIS_PORT', '6379'),
39 'database' => env('REDIS_CACHE_DB', '1'),
40 ],
41 
42 ],
43];

The default connection uses db0 while the cache connection uses db1. It turns out that sessions are stored in db0, while thing we cache in code uses db1. The default database, db0, had MANY more keys than the cache database.

The application was creating too many sessions.

What Creates a Session?

Every web request (for routes defined in routes/web.php) creates a session (or uses an existing one). Web applications return a cookie when a session is created. Web browsers store these cookies, and send that cookie back when making additional web requests. This allows our web apps to know which session is valid for a given user.

If the browser didn't return a cookie on each request, then the user would not be able to stay logged in.

API-based sessions don't work like this. Each session is created and then destroyed within every web request - there are no cookies involved. Instead, the client needs to send it's authentication information on each web request (usually a token of some sort).

What Blew Up Redis?

So, what then caused our Redis instance to blow up with sessions?

Dynamically generated assets that others embed on their websites. We had two cases of this:

  1. Our application generated a .js file that others embedded on their web sites
  2. Our application also generated .svg images for the same purpose

These routes were defined in our routes/web.php file:

1Route::get('/embed.js');
2Route::get('/{project}/share.js');

Do you see the issue? Customers were putting these into their own websites. Everytime someone visited their website, an HTTP request was made to our application for the embed or SVG, and this created a session.

That means that our customers web traffic was also creating sessions in our web application!

How to Reduce Session Creation

The fix is that make sure that we don't create sessions for certain routes. Simple enough to say, but how to we accomplish that?

It turns out the creation of cookies and sessions are done in Laravel's middleware. This is good, as we control which middleware are applied to each routes.

To ensure some routes don't create sessions/return cookies, I like to create a separate routes file that has a different middleware stack.

To do that, we need to do a few things:

  1. Create a new routes/static.php file (the name is arbitrary)
  2. Add a middleware stack to app/Http/Kernel.php
  3. Update app/Providers/RouteServiceProvider.php to load our new route file, and apply our new middleware stack

The new routes file is simple - we make a new file and move our route definitions to them:

1# File routes/static.php`
2 
3# Move these from routes/web.php
4Route::get('/embed.js');
5Route::get('/{project}/share.js');

Then we can update the Kernel.php file to create a new middleware stack. We can copy the web middleware stack and remove the middleware that handle cookies and sessions:

1 # File app/Http/Kernel.php
2  
3 # Items omitted here
4  
5 /**
6 * The application's route middleware groups.
7 *
8 * @var array
9 */
10 protected $middlewareGroups = [
11 'web' => [
12 \App\Http\Middleware\EncryptCookies::class,
13 \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
14 \Illuminate\Session\Middleware\StartSession::class,
15 // \Illuminate\Session\Middleware\AuthenticateSession::class,
16 \Illuminate\View\Middleware\ShareErrorsFromSession::class,
17 \App\Http\Middleware\VerifyCsrfToken::class,
18 \Illuminate\Routing\Middleware\SubstituteBindings::class,
19 ],
20  
21 'api' => [
22 // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
23 'throttle:api',
24 \Illuminate\Routing\Middleware\SubstituteBindings::class,
25 ],
26+ 
27+ 'static' => [
28+ \Illuminate\Routing\Middleware\SubstituteBindings::class,
29+ ],
30 ];
31  
32 # Items omitted here

We created a new middleware group named static. It's similar to the API middleware, but we have no throttling.

Lastly, we need to register the new routes file, and apply our new static middleware group. We'll do that by updating the RouteServiceProvider.php:

1 # File app/Providers/RouteServiceProvider.php
2  
3 # Items omitted here
4 /**
5 * Define your route model bindings, pattern filters, etc.
6 *
7 * @return void
8 */
9 public function boot()
10 {
11 $this->configureRateLimiting();
12  
13 $this->routes(function () {
14 Route::prefix('api')
15 ->middleware('api')
16 ->namespace($this->namespace)
17 ->group(base_path('routes/api.php'));
18  
19 Route::middleware('web')
20 ->namespace($this->namespace)
21 ->group(base_path('routes/web.php'));
22+ 
23+ Route::middleware('static')
24+ ->namespace($this->namespace)
25+ ->group(base_path('routes/static.php'));
26+ });
27 }
28  
29 # Items omitted here

The RouteServiveProvider registers each route file, and determines their middleware. This is how everyting in routes/web.php gets the web middleware group assigned to it.

That's also why we create our own route file - we wanted to avoid the web middleware group, and be able to add routes to the new routes file whenever we needed to.

The Result

The result is that our two "static" routes (ones returning dynamically generated assets - a JS file, and an SVG) no longer create a session, nor return cookies.

This allowed our Redis instance to recover. As the sessions expired, they were deleted from Redis. Since our customers traffic no longer created sessions in our session store, the Redis instance never filled up again!

Filed in:

Chris Fidao

Teaching coding and servers at CloudCasts and Servers for Hackers. Co-founder of Chipper CI.