Creating a caching user provider for Laravel

Published by at 12th January 2018 1:16 pm

EDIT: I no longer recommend this approach - please refer here for an alternative approach to this.

If you have a Laravel application that requires users to log in and you use Clockwork or Laravel DebugBar to examine the queries that take place, you'll probably notice a query that fetches the user model occurs quite a lot. This is because the user's ID gets stored in the session, and is then used to retrieve the model.

This query is a good candidate for caching because not only is that query being made often, but it's also not something that changes all that often. If you're careful, it's quite easy to set your application up to cache the user without having to worry about invalidating the cache.

Laravel allows you to define your own user providers in order to fetch the user's details. These must implement Illuminate\Contracts\Auth\UserProvider and must return a user model from the identifier provided. Out of the box it comes with two implementations, Illuminate\Auth\EloquentUserProvider and Illuminate\Auth\DatabaseUserProvider, with the former being the default. Our caching user provider can extend the Eloquent one as follows:

1<?php
2
3namespace App\Auth;
4
5use Illuminate\Auth\EloquentUserProvider;
6use Illuminate\Contracts\Cache\Repository;
7use Illuminate\Contracts\Hashing\Hasher as HasherContract;
8
9class CachingUserProvider extends EloquentUserProvider
10{
11 /**
12 * The cache instance.
13 *
14 * @var Repository
15 */
16 protected $cache;
17
18 /**
19 * Create a new database user provider.
20 *
21 * @param \Illuminate\Contracts\Hashing\Hasher $hasher
22 * @param string $model
23 * @param Repository $cache
24 * @return void
25 */
26 public function __construct(HasherContract $hasher, $model, Repository $cache)
27 {
28 $this->model = $model;
29 $this->hasher = $hasher;
30 $this->cache = $cache;
31 }
32
33 /**
34 * Retrieve a user by their unique identifier.
35 *
36 * @param mixed $identifier
37 * @return \Illuminate\Contracts\Auth\Authenticatable|null
38 */
39 public function retrieveById($identifier)
40 {
41 return $this->cache->tags($this->getModel())->remember('user_by_id_'.$identifier, 60, function () use ($identifier) {
42 return parent::retrieveById($identifier);
43 });
44 }
45}

Note that we override the constructor to accept a cache instance as well as the other arguments. We also override the retrieveById() method to wrap a call to the parent's implementation inside a callback that caches the response. I usually tag anything I cache with the model name, but if you need to use a cache backend that doesn't support tagging this may not be an option. Our cache key also includes the identifier so that it's unique to that user.

We then need to add our user provider to the auth service provider:

1<?php
2
3namespace App\Providers;
4
5use Illuminate\Support\Facades\Gate;
6use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
7use App\Auth\CachingUserProvider;
8use Illuminate\Support\Facades\Auth;
9
10class AuthServiceProvider extends ServiceProvider
11{
12 /**
13 * Register any authentication / authorization services.
14 *
15 * @return void
16 */
17 public function boot()
18 {
19 $this->registerPolicies();
20
21 Auth::provider('caching', function ($app, array $config) {
22 return new CachingUserProvider(
23 $app->make('Illuminate\Contracts\Hashing\Hasher'),
24 $config['model'],
25 $app->make('Illuminate\Contracts\Cache\Repository')
26 );
27 });
28 }
29}

Note here that we call this provider caching, and we pass it the hasher, the model name, and an instance of the cache. Then, we need to update config/auth.php to use this provider:

1 'providers' => [
2 'users' => [
3 'driver' => 'caching',
4 'model' => App\Eloquent\Models\User::class,
5 ],
6 ],

The only issue now is that our user models will continue to be cached, even when they are updated. To be able to flush the cache, we can create a model event that fires whenever the user model is updated:

1<?php
2
3namespace App\Eloquent\Models;
4
5use Illuminate\Notifications\Notifiable;
6use Illuminate\Foundation\Auth\User as Authenticatable;
7use App\Events\UserAmended;
8
9class User extends Authenticatable
10{
11 use Notifiable;
12
13 protected $dispatchesEvents = [
14 'saved' => UserAmended::class,
15 'deleted' => UserAmended::class,
16 'restored' => UserAmended::class,
17 ];
18}

This will call the UserAmended event when a user model is created, updated, deleted or restored. Then we can define that event:

1<?php
2
3namespace App\Events;
4
5use Illuminate\Broadcasting\Channel;
6use Illuminate\Queue\SerializesModels;
7use Illuminate\Broadcasting\PrivateChannel;
8use Illuminate\Broadcasting\PresenceChannel;
9use Illuminate\Foundation\Events\Dispatchable;
10use Illuminate\Broadcasting\InteractsWithSockets;
11use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
12use App\Eloquent\Models\User;
13
14class UserAmended
15{
16 use Dispatchable, InteractsWithSockets, SerializesModels;
17
18 /**
19 * Create a new event instance.
20 *
21 * @return void
22 */
23 public function __construct(User $model)
24 {
25 $this->model = $model;
26 }
27}

Note our event contains an instance of the user model. Then we set up a listener to do the work of clearing the cache:

1<?php
2
3namespace App\Listeners;
4
5use Illuminate\Queue\InteractsWithQueue;
6use Illuminate\Contracts\Queue\ShouldQueue;
7use App\Events\UserAmended;
8use Illuminate\Contracts\Cache\Repository;
9
10class ClearUserId
11{
12 /**
13 * Create the event listener.
14 *
15 * @return void
16 */
17 public function __construct(Repository $cache)
18 {
19 $this->cache = $cache;
20 }
21
22 /**
23 * Handle the event.
24 *
25 * @param object $event
26 * @return void
27 */
28 public function handle(UserAmended $event)
29 {
30 $this->cache->tags(get_class($event->model))->forget('user_by_id_'.$event->model->id);
31 }
32}

Here, we get the user model's class again, and clear the cache entry for that user model.

Finally, we hook up the event and listener in the event service provider:

1<?php
2
3namespace App\Providers;
4
5use Illuminate\Support\Facades\Event;
6use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
7
8class EventServiceProvider extends ServiceProvider
9{
10 /**
11 * The event listener mappings for the application.
12 *
13 * @var array
14 */
15 protected $listen = [
16 'App\Events\UserAmended' => [
17 'App\Listeners\ClearUserId',
18 ],
19 ];
20
21 /**
22 * Register any events for your application.
23 *
24 * @return void
25 */
26 public function boot()
27 {
28 parent::boot();
29
30 //
31 }
32}

With that done, our user should be cached after the first load, and flushed when the model is amended.

Handling eager-loaded data

It may be that you're pulling in additional data from the user model in your application, such as roles, permissions, or a separate profile model. Under those circumstances it makes sense to treat that data in the same way by eager-loading it along with your user model.

1<?php
2
3namespace App\Auth;
4
5use Illuminate\Auth\EloquentUserProvider;
6use Illuminate\Contracts\Cache\Repository;
7use Illuminate\Contracts\Hashing\Hasher as HasherContract;
8
9class CachingUserProvider extends EloquentUserProvider
10{
11 /**
12 * The cache instance.
13 *
14 * @var Repository
15 */
16 protected $cache;
17
18 /**
19 * Create a new database user provider.
20 *
21 * @param \Illuminate\Contracts\Hashing\Hasher $hasher
22 * @param string $model
23 * @param Repository $cache
24 * @return void
25 */
26 public function __construct(HasherContract $hasher, $model, Repository $cache)
27 {
28 $this->model = $model;
29 $this->hasher = $hasher;
30 $this->cache = $cache;
31 }
32
33 /**
34 * Retrieve a user by their unique identifier.
35 *
36 * @param mixed $identifier
37 * @return \Illuminate\Contracts\Auth\Authenticatable|null
38 */
39 public function retrieveById($identifier)
40 {
41 return $this->cache->tags($this->getModel())->remember('user_by_id_'.$identifier, 60, function () use ($identifier) {
42 $model = $this->createModel();
43 return $model->newQuery()
44 ->with('roles', 'permissions', 'profile')
45 ->where($model->getAuthIdentifierName(), $identifier)
46 ->first();
47 });
48 }
49}

Because we need to amend the query itself, we can't just defer to the parent implementation like we did above and must instead copy it over and amend it to eager-load the data.

You'll also need to set up model events to clear the cache whenever one of the related fields is updated, but it should be fairly straightforward to do so.

Summary

Fetching a user model (and possibly some relations) on every page load while logged in can be a bit much, and it makes sense to cache as much as you can without risking serving stale data. Using this technique you can potentially cache a lot of repetitive, unnecessary queries and make your application faster.

This technique will also work in cases where you're using other methods of maintaining user state, such as JWT, as long as you're making use of a guard for authentication purposes, since all of these guards will still be using the same user provider. In fact, I first used this technique on a REST API that used JWT for authentication, and it's worked well in that case.