How to use Blink to improve complex tag performance in Statamic
Statamic uses Spatie’s Blink package under the hood to provide an easy-to-access cache for the life of a single request. Which means we have access to the Blink package within our Statamic Tag code too.
If you’re reading this, you’re probably well aware as to how swift Statamic can be: but there can be times when some code you write creates a bottleneck. Think of this like avoiding the n+1 situation with Eloquent in a Laravel app: it is remarkably easy to write a repetitive piece of code that can start to slow things down – and this is where Blink can help us out.
The problem
Let’s say we’re writing a custom Statamic tag that needs to load specific content with a rather tricky and time-consuming query. With our tag, we need to then use this loaded data in multiple places – or multiple methods within our Tag. Let’s say our Tag is working with our Collection of Session Times, and we need functions to get an array of times and an array of dates.
1<?php 2 3namespace App\Tags; 4 5use Statamic\Entries\Entry; 6use Statamic\Facades\Entry as EntryFacade; 7use Statamic\Tags\Tags; 8 9class SessionTimes extends Tags10{11 public function times()12 {13 return $this->loadSessions()14 ->map(fn(Entry $entry) => $entry->date)15 ->unique();16 }17 18 public function dates()19 {20 return $this->loadSessions()21 ->map(fn(Entry $entry) => $entry->date->format('Y-m-d'))22 ->unique();23 }24 25 protected function loadSessions()26 {27 return EntryFacade::query()28 ->where('collection', 'sessions')29 ->orderBy('date')30 ->get();31 }32}
This would give us tags such as and
to help us get simple arrays of just our unique session times and dates.
When you only have a handful of entries, this is mostly fine. But what if your collection expands to the thousands? What if your query is more complex and takes a long time to run? What if you need to process your loaded entries before you can use them?
The issue here is that our now expensive query is running on each Tag call.
When you want the times
, the query is run. When you want the dates
, the query is run. See the issue? N+1, hey?
Unfortunately we can’t even store the response of our expensive query as an internal variable: each call gets its own tag instance.
But we can use Blink to help us out.
Add Blink to your tag
In our sessions_times
code, we are calling the loadSessions
method twice: this is the expensive part – and this is where we can get Blink to help us out.
The basic gist is:
Check if we have run the query (
Blink::has
)If so, return the result (
Blink::get
)If not, run the query, store it (
Blink::put
) and then return the result
This means that the expensive part – the running of the query – is only executed once per request.
1<?php 2 3namespace App\Tags; 4 5use Statamic\Entries\Entry; 6use Statamic\Entries\EntryCollection; 7use Statamic\Facades\Blink; 8use Statamic\Facades\Entry as EntryFacade; 9use Statamic\Tags\Tags;10 11class SessionTimesBlink extends Tags12{13 public function times()14 {15 return $this->loadSessions()16 ->map(fn(Entry $entry) => $entry->date)17 ->unique();18 }19 20 public function dates()21 {22 return $this->loadSessions()23 ->map(fn(Entry $entry) => $entry->date->format('Y-m-d'))24 ->unique();25 }26 27 protected function loadSessions()28 {29 // do we have the events already? if so, return it30 if (Blink::has('events')) {31 return Blink::get('events', new EntryCollection([]));32 }33 34 // run the query35 $entries = EntryFacade::query()36 ->where('collection', 'sessions')37 ->orderBy('date')38 ->get();39 40 // store the result41 Blink::put('events', $entries);42 43 return $entries;44 }45}
Now when we run either of our tag’s methods, we’re only running the actual query once per request. Woo!
Separate multiple Blink caches using store
Blink is available anywhere within your site’s code: meaning you could have key collisions. We can do this by specifying a store
to use. We can easily create a protected variable (see the protected $store
variable) within our tag of our unique store name, and specify this in all of our Blink calls.
1<?php 2 3namespace App\Tags; 4 5use Statamic\Entries\Entry; 6use Statamic\Entries\EntryCollection; 7use Statamic\Facades\Blink; 8use Statamic\Facades\Entry as EntryFacade; 9use Statamic\Tags\Tags;10 11class SessionTimesBlinkStore extends Tags12{13 protected string $store = 'session_times';14 15 public function times()16 {17 return $this->loadSessions()18 ->map(fn(Entry $entry) => $entry->date)19 ->unique();20 }21 22 public function dates()23 {24 return $this->loadSessions()25 ->map(fn(Entry $entry) => $entry->date->format('Y-m-d'))26 ->unique();27 }28 29 protected function loadSessions()30 {31 // do we have the events already in this store? if so, return it32 if (Blink::store($this->store)->has('events')) {33 return Blink::store($this->store)->get('events', new EntryCollection([]));34 }35 36 // run the query37 $entries = EntryFacade::query()38 ->where('collection', 'sessions')39 ->orderBy('date')40 ->get();41 42 // store the result in the 'session_times' store43 Blink::store($this->store)->put('events', $entries);44 45 return $entries;46 }47}
What’s the benefit?
The session_times
example may be a really trivial example – but if you need to do more advanced filtering, sorting, grouping and processing, running that once and storing its manipulated form can help improve the overall performance.
What if your query takes 1 second to load and process? And you need to call three of your tag’s methods, each of which needs your data. What does that do to your page’s performance?
Without Blink | With Blink | |
---|---|---|
Call 1 | 1.00105s | 1.00122s |
Call 2 | 1.00056s | 0.00002s |
Call 3 | 1.00044s | 0.00001s |
Total | 3.00205s | 1.00125s |
If you want to play with the code used for these tests, check out this gist on Github.
I’ve worked on a complex tag recently that does some crazy processing on Entries to make it easier to work with for that site’s specific needs: and by using this technique, have seen a major improvement in performance given there are half a dozen different methods being utilised.
What about caching across multiple requests?
Blink can’t help you here – it is designed for a single request – but Laravel’s Cache can. This opens up a whole new level of possibilities – including:
Caching for a few minutes
Caching forever
Listening for the
EntrySaved
event to clear the remembered-forever Cache
But if you really need that level of caching, Statamic’s static caching could always be an option too. Laravel’s Cache could be superb for complex queries that may be user specific: so could definitely come in handy, but that’s enough content for another day.
I am really curious to see how Laravel 11’s new once function could come in to play with complex operations such as this too. With Laravel 11 not far away, and the Statamic team already at work on support, won’t be too long until I can have a play with that.