Introduction to Laravel caching
Holly Guevara
Holly Guevara
2/9/2022
Engineering16 min read

Introduction to Laravel caching

What is caching#

A common pain point in applications is optimizing and reducing the number of trips you have to take to the database. Say you have an e-commerce admin dashboard. Maybe you have a page that displays all inventory — every product, associated category, vendors, and more. A single page like this may perform dozens of calls to your database before the page can even display any data. If you don't think about how to handle this, your application can quickly become slow and costly.

One option to reduce the number of times you have to go to the database is through caching. Caching allows you to store specific data in application memory so that next time that query is hit, you already have the data on hand and won't have to go back to the database for it. Keep in mind, this is different from browser caching, which is user-based. This article covers application caching, which happens at the application level and cannot be cleared by the user.

Laravel has robust built-in functionality that makes caching a breeze.

Let's see it in action!

Set up a database#

For this demonstration, you will use a PlanetScale MySQL database to get a practice database up and running quickly. I promise this setup will be fast and painless!

  1. Create a free PlanetScale account.
  2. Create a new database either in the onboarding flow or by clicking "New database" > "Create new database".
  3. Give your database a name and select the region closest to you.
  4. Click "Create database".
PlanetScale modal to create a new database

Once it's finished initializing, you'll land on the Overview page for your database.

PlanetScale database overview page
  1. Click on the "Branches" tab and select the main branch. This is a development branch that you can use to modify your schema.

PlanetScale has a database workflow similar to the Git branching model. While developing, you can:

  • Create new branches off of your main branch
  • Modify your schema as needed
  • Create a deploy request (similar to a pull request)
  • Merge the deploy request into main
Diagram showing the PlanetScale workflow described above

Leave this page open, as you'll need to reference it soon.

Set up Laravel app#

Next, let's set up the pre-built Laravel 9 application. This comes with a simple CRUD API that displays random bogus sentences (we're going to think of them as robot quotes) along with the quote author's name. The data for both of these columns are auto-generated using Faker. There is currently no caching in the project, so you'll use this starter app to build on throughout the article.

For this tutorial, you'll use the default file-based cache driver, meaning the cached data will be stored in your application's file system. This is fine for this small application, but for a bigger production app, you may want to use a different driver. Fortunately, Laravel supports some popular ones, such as Redis and Memcached.

Before you begin, make sure you have PHP (this article is tested with v8.1) and Composer (at least v2.2) installed.

  1. Clone the sample application:
sh
git clone -b starter https://github.com/planetscale/laravel-caching
  1. Install the dependencies:
sh
composer install
  1. Copy the .env.example file to .env:
sh
mv .env.example .env
  1. Next, you need to connect to your PlanetScale database. Open up the .env file and find the database section. It should look like this:
env
DB_CONNECTION=mysql
DB_HOST=<ACCESS HOST URL>
DB_PORT=3306
DB_DATABASE=<DATABASE_NAME>
DB_USERNAME=<USERNAME>
DB_PASSWORD=<PASSWORD>
MYSQL_ATTR_SSL_CA=/etc/ssl/cert.pem
  1. Go back to your PlanetScale dashboard to the main branch page for your database.
  2. Click "Connect" in the top right corner.
  3. Click "Generate new password".
  4. Select "Laravel" from the dropdown (it's currently set to "General").
  5. Copy this and replace the .env content highlighted in Step 4 with this connection information. It'll look something like this:
env
DB_CONNECTION=mysql
DB_HOST=xxxxxxxx.xx-xxxx-x.psdb.cloud
DB_PORT=3306
DB_DATABASE=xxxxxxxx
DB_USERNAME=xxxxxxxxxxxxx
DB_PASSWORD=pscale_pw_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
MYSQL_ATTR_SSL_CA=/etc/ssl/cert.pem

Make sure you save the password before leaving the page, as you won't be able to see it again.

Note: Your value for MYSQL_ATTR_SSL_CA may differ depending on your system.

  1. Run the migrations and seeder:
sh
php artisan migrate
php artisan db:seed
  1. Start your application:
sh
php artisan serve
  1. You can now view your non-cached data from the PlanetScale database in the browser at http://localhost:8000/api/quotes.

Project structure overview#

Before diving in, let's explore the project and relevant files.

Quote controller

The sample application has a single controller, app/Http/Controllers/QuoteController.php, that has methods to display, update, create, and delete quotes. Since this is an API resource controller, you don't need to include the usual show and edit controllers that only return views.

You'll take a closer look at this soon once you add caching, but right now, nothing is cached.

Quote model

There's also a model, app/Models/Quote.php, where you can define how Eloquent interacts with the quotes table. Since you only have one table in this application, there are no relationships or interactions, so the model is pretty barebones right now:

php
class Quote extends Model
{
    use HasFactory;

    public $timestamps = FALSE;
    protected $fillable = [
        'text',
        'name'
    ];

}

You'll revisit it soon, though, once you implement caching.

Quote migration

Next up is the initial quotes migration file, database/migrations/2022_01_215158_create_quotes_table.php. When you ran the migrations in the previous step, this file created the quotes table with the specified schema:

php
public function up()
{
    Schema::create('quotes', function (Blueprint $table) {
        $table->id();
        $table->text('text');
        $table->string('name');
    });
}

Quote factory and seeder

Finally, there's a factory and seeder. The factory, database/factories/QuoteFactory.php, uses Faker to create mock sentence and author data. The seeder, database/seeders/DatabaseSeeder.php, then runs this factory 100 times to create 100 rows of this Faker-generated data in the quotes table.

php
public function definition()
{
    return [
        'text' => $this->faker->realText(100, 3),
        'name' => $this->faker->name()
    ];
}

When you ran php artisan migrate and php artisan db:seed in the previous steps, these are the files that were ran.

Queries without caching#

Before you add caching, it's important to see how the application currently perform so you know the effect that caching has. And how will you do that if you don't know what your query performance was before adding caching?

Let's run some queries and see how long they take to complete.

Get all data

Open up the app/Http/Controllers/QuoteController.php file and go to the index() method. Replace it with:

php
public function index()
{
    $startTime = microtime(true); // start timer
    $quotes = Quote::all(); // run query
    $totalTime = microtime(true) - $startTime; // end timer

    return response()->json([
        'totalTime' => $totalTime,
        'quotes' => $quotes
    ]);
}

The PHP function, microtime(true), provides an easy way to track the time before and after the query. You can also use an API testing tool like Postman to see the time it takes to complete.

Let's call the API endpoint to check how long it currently takes to pull all of this data from the database.

Open or refresh http://localhost:8000/api/quotes in your browser. You'll now see a totalTime value that displays the total time in seconds that it took to execute this query.

The total time will fluctuate, but I'm personally getting anywhere between 0.9 seconds and 2.3 seconds!

Of course, you'd want to paginate or chunk your data in most cases, so hopefully, it wouldn't take several seconds to grab in the first place. But caching can still greatly reduce the time it takes to get data from this endpoint after the initial hit.

Let's add caching now.

Add caching to your Laravel app#

Open up app/Http/Controllers/QuoteController.php, bring in the Cache facade at the top of the file, and replace the $quotes = Quote::all(); in index() with:

php
// ...
use Illuminate\Support\Facades\Cache;

// ...
public function index() {
    // ...
    $quotes = Cache::remember('allQuotes', 3600, function() {
        return Quote::all();
    });
    // ...
}
// ...

Now let's hit that API endpoint again. Refresh the page at http://localhost:8000/api/quotes. If this is your first time running the call, you'll have to refresh again for the caching to take effect.

Check out the new time I'm getting: 0.0006330013275146484 seconds!

Before caching, this exact same query took between 0.9 seconds and 2.3 seconds. Incredible, right?

Even though this seems to be a massive improvement on the surface, there are still some issues that you need to tackle. Let's first dissect the Cache::remember() method and then go over some gotchas with this addition.

Note — If at any time you need to clear the cache manually while testing, you can run the following in your terminal:

sh
php artisan cache:clear

Caching with remember()

The Cache::remember() method first tries to retrieve a value from the cache. If that value doesn't exist, it will go to the database to grab the value, and then store it in the cache for future lookups. You will specify the name of the value and how long it stores it, as shown below:

php
$quotes = Cache::remember('cache_item_name', $timeStoredInSeconds, function () {
    return DB::table('quotes')->get();
});

This method is super handy because it does several things at once: checks if the item exists in cache, grabs the data if not, and stores it in the cache once grabbed.

If you prefer just to grab the value from cache and do nothing if it doesn't exist, use:

php
$value = Cache::get('key');

If you want to grab the value from cache and pull it from the database if it doesn't exist, use:

php
$value = Cache::get('key', function () {
    return DB::table(...)->get();
});

This one is similar to remember(), except it doesn't store it in the cache.

Inconsistent data in the cache

So what are the problems that you need to deal with? Let's see one of them in action.

Refresh the [http://localhost:8000/api/quotes](http://localhost:8000/api/quotes) page in the browser one more time to make sure the cache hasn't expired. Now, add a new record to the quotes table by pasting the following in your terminal:

sh
curl -X POST -H 'Content-Type: application/json' -d '{
  "text": "If debugging is the process of removing software bugs, then programming must be the process of putting them in.",
  "name": "Edsger Dijkstra"
}' http://localhost:8000/api/quotes -i

You should get a HTTP/1.1 200 OK response along with the newly added record. Now go back to your Quotes page in the browser and refresh. The new data you added isn't there! That's because you just wrote this item to the database, but you're not actually going to the database to retrieve it. The cache has no idea it exists.

You can confirm it was added to the database by going back to your PlanetScale dashboard, select the database, click "Branches", and click "Console".

Run the following command and you should see 101 records:

sql
select * from quotes;
PlanetScale console select all query

Scroll to the bottom and you'll see the newly added quote. You can also query it directly by id:

sql
select * from quotes where id=101;
PlanetScale console select single quote query

Solving the write problem

If it's important for your application to always show the most up-to-date data, one quick way to fix this is using the Quote model's booted() method.

Open up app/Models/Quote.php and replace it with:

php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;

class Quote extends Model
{
    use HasFactory;

    public $timestamps = FALSE;
    protected $fillable = [
        'text',
        'name'
    ];

    protected static function booted()
    {
        static::saving(function() {
            Cache::forget('allQuotes');
        });
    }
}

The booted() method allows you to perform some action when a model event fires. In this case, when saving() is called, you're instructing Eloquent to forget the cache item named allQuotes.

Let's test it out. Refresh your quotes page in the browser one more time to make sure everything is cached, then add another quote with:

sh
curl -X POST -H 'Content-Type: application/json' -d '{
  "text": "Sometimes it pays to stay in bed on Monday, rather than spending the rest of the week debugging Mondays code.",
  "name": "Dan Salomon"
}' http://localhost:8000/api/quotes -i

Now refresh again, and you'll see the time to make this query has increased significantly, indicating that the database has been hit. You'll also now see the new entry in the output! With this, you can be confident that the displayed quotes are always up-to-date.

The saving() method works on both new and updated records. However, if you delete something, the cache won't be cleared. You can use the deleted() method to handle that.

First, try to delete something (replace the last number with the id of the item you want to delete):

sh
curl -X DELETE http://localhost:8000/api/quotes/1 -i

Refresh the quotes page, and the item should still be there. Now, go back to your Quote model and add this in the boot() method underneath saving():

php
static::deleted(function() {
    Cache::forget('allQuotes');
});

Run that cURL command one more time, but with a different id, to trigger a delete event. Refresh again and those records should now be gone!

More Laravel caching strategies#

As you've seen, when it comes to caching, you're going to have to think about a few things specific to your application before diving in.

Retrieving data

How long do you want to store the data in cache? The answer to this depends on the data. Is this data relatively static? Or does it change a lot? When a set of data is requested, how important is it that it's always up to date? These questions will help you decide how long to cache the data and if you should cache it at all.

Storing/updating/deleting data

This tutorial covered caching retrieved data. But you can also store new or updated data in the cache as well. This is called write caching. Instead of going to the database every time you need to add or update something, you store it in the cache and eventually update it all at once.

Deciding the time at which you update actually leads to more questions. What if you wait too long and there's a system failure? All of the cached data is gone. What if someone makes a request to see a product price, but that price has changed, and you've been storing the update in cache? The data between the cache and database is inconsistent, and the user won't see the latest price, which will cause problems.

The solution to write caching will depend on your application's needs. Write-back and write-through caching are some options. Check out this excellent primer for more information.

Conclusion#

As you've seen, caching can immensely speed up your application, but not without caveats. When implementing caching, it's important to think about how often your data will be accessed and how important immediate data consistency (from the user's perspective) is in your application.

Hopefully, this guide has shown you how to get started with Laravel caching. Make sure you check out the Laravel Cache Documentation for more information. And if you're interested in learning more about PlanetScale's next-level features like database branching and deploy requests, check out our documentation. You can find the final code complete with caching in this GitHub repo. Thanks for reading!