🌊

【ハンズオン】Laravel 13 一行でキャッシュが爆速!?

に公開

この記事を読み終えると何ができるか

  • #[Cache(memo: true)] を使って、1リクエスト中の同一キャッシュへの重複アクセスを防げる

前提条件・動作環境

この記事では以下の環境を想定しています。

項目 バージョン・内容
OS Windows(WSL2)/ Linux
Docker インストール済み
PHP 8.4以上(コンテナ内)
Laravel 13.13.0以上

この機能が追加された背景

Laravel には、Redisなどの外部キャッシュストアへのアクセスを、同一リクエスト中に限りメモリ上にキャッシュする「メモ化(Memoization)」という仕組みがあります。

これまでもコードの中で Cache::memo() を手動で呼び出すことで使えていましたが、DIコンテナ(サービスコンテナ)経由でクラスに注入する際には、通常の #[Cache] アトリビュートしか選択肢がなく、毎回ネットワーク越しのキャッシュアクセスが発生していました。

Laravel 13.13 では、#[Cache] アトリビュートに memo: true オプションが追加され、コンストラクタへの宣言1行でメモ化済みキャッシュを注入できるようになりました。

ステップ 1:プロジェクトを作成する

目的:Laravel の公式インストーラーを使ってプロジェクトを作成し、Docker(Sail)で開発環境を立ち上げる。

1-1. Laravel インストーラーでプロジェクトを作成する

目的:新しい Laravel プロジェクトを作成する。

ターミナルを開き、プロジェクトを作成したいディレクトリに移動してから次のコマンドを実行します。

laravel new cache-memo-demo

コマンドを実行するといくつか質問が表示されます。この記事では次のように選択してください。

Which starter kit would you like to install?
> None

Which database will your application use?
> SQLite

Would you like to run npm install and npm run build?
> Yes

1-2. プロジェクトディレクトリに移動する

目的:以降のコマンドをすべてプロジェクトルートで実行できるよう、ディレクトリを移動する。

cd cache-memo-demo

1-3. Laravel Sail を導入する

目的:Laravel Sail を追加し、MySQL・Redis を含む Docker 環境を構成する。

Laravel Sail(セイル)は、Docker で Laravel 開発環境を簡単に起動できる公式ツールです。composer require で追加します。

composer require laravel/sail --dev

次に、Sail の設定ファイルを発行します。使用するサービスを選ぶ画面が出るので、今回は mysqlredis を選択します。

php artisan sail:install
Which services would you like to install?
> mysql, redis

1-4. Sail のシェルエイリアスを設定する(任意・推奨)

目的:./vendor/bin/sail と毎回打つ手間を省くため、sail という短縮コマンドを登録する。

# ~/.bashrc または ~/.zshrc に追記する
alias sail='[ -f sail ] && sh sail || sh vendor/bin/sail'

追記後はターミナルを再起動するか source ~/.zshrc(または source ~/.bashrc)を実行してください。

以降の手順では sail コマンドで統一して説明します。

1-5. .env の設定を確認する

目的:Sail の MySQL・Redis コンテナに接続できるよう、設定が正しいか確認する。

.env ファイルを開き、以下の箇所を確認・修正してください。

.env
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=sail
DB_PASSWORD=password

REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379

CACHE_STORE=redis

1-6. Docker コンテナを起動する

目的:Docker コンテナを起動して、ローカル開発環境を立ち上げる。

sail up -d

-d オプションでバックグラウンド起動になります。初回は Docker イメージのダウンロードが走るため、数分かかります。

起動が完了したらコンテナの状態を確認します。

sail ps

laravel.testmysqlredis のコンテナがすべて Up 状態になっていれば成功です。

1-7. Laravel のバージョンを確認する

目的:13.13.0 以上のバージョンで動作していることを確認する。

sail artisan --version

出力例:

Laravel Framework 13.13.0

13.13.0 未満と表示された場合は、Composer でフレームワークをアップデートします。

sail composer update laravel/framework

アップデート後、再度バージョンを確認して 13.13.0 以上になっていれば準備完了です。

1-8. マイグレーションを実行する

目的:データベースのテーブル定義を反映し、Laravel が正常に動作する状態にする。

マイグレーション(DBのテーブル定義を管理する仕組み)を実行して、初期テーブルを作成します。

sail artisan migrate

出力例:

   INFO  Running migrations.

  2014_10_12_000000_create_users_table ................. 23ms DONE
  2014_10_12_100000_create_password_reset_tokens_table . 18ms DONE
  2019_08_19_000000_create_failed_jobs_table ........... 15ms DONE
  2019_12_14_000001_create_personal_access_tokens_table  17ms DONE

DONE と表示されれば成功です。

ステップ 2:デモ用のコントローラーとビューを作成する

目的:通常のキャッシュとメモ化キャッシュの実行時間を比較して画面に表示するデモを作る。

2-1. コントローラーを生成する

目的:計測ロジックを置くためのコントローラーのひな型を作る。

sail artisan make:controller CacheDemoController

app/Http/Controllers/CacheDemoController.php が生成されます。

2-2. コントローラーに計測ロジックを書く

目的:通常キャッシュとメモ化キャッシュのそれぞれで N 回アクセスし、実行時間をミリ秒で計測する。

生成されたファイルを次のように書き換えます。

app/Http/Controllers/CacheDemoController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class CacheDemoController extends Controller
{
    public function benchmark(Request $request)
    {
        // スライダーで変化させるアクセス回数(デフォルト50000)
        $n = (int) $request->input('n', 50000);

        // 同じキーと値をあらかじめキャッシュに保存しておく
        Cache::store('redis')->put('demo-key', 'hello', 60);

        // ---- 通常のキャッシュ(毎回 Redis へネットワーク通信が走る)----
        $start = microtime(true);
        for ($i = 0; $i < $n; $i++) {
            Cache::store('redis')->get('demo-key');
        }
        $normalMs = round((microtime(true) - $start) * 1000, 3);

        // ---- メモ化キャッシュ(2回目以降はメモリから返す)----
        // memo キャッシュの内部バッファをリセットするため
        // 一度 put を呼んでメモ化をクリアしてから計測する
        Cache::memo('redis')->put('demo-key', 'hello', 60);

        $start = microtime(true);
        for ($i = 0; $i < $n; $i++) {
            Cache::memo('redis')->get('demo-key');
        }
        $memoMs = round((microtime(true) - $start) * 1000, 3);

        return response()->json([
            'n'         => $n,
            'normal_ms' => $normalMs,
            'memo_ms'   => $memoMs,
            'ratio'     => $normalMs > 0 ? round($normalMs / max($memoMs, 0.001), 1) : 0,
        ]);
    }
}

2-3. ルートを追加する

目的:ブラウザからデモ画面にアクセスできるよう、URL とコントローラーを結びつける。

routes/web.php を開き、以下の2行を追加します。

routes/web.php
<?php

use App\Http\Controllers\CacheDemoController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('cache-demo');
});

Route::get('/cache-demo/benchmark', [CacheDemoController::class, 'benchmark']);

2-4. デモ画面のビューを作成する

目的:スライダーで N を変えながら計測結果をリアルタイムにグラフ表示するビューを作る。

resources/views/cache-demo.blade.php を新規作成します。

resources/views/cache-demo.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Cache Memo デモ</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #f8fafc;
            color: #1e293b;
            padding: 40px 20px;
        }
        .container { max-width: 720px; margin: 0 auto; }
        h1 { font-size: 1.5rem; margin-bottom: 8px; }
        .subtitle { color: #64748b; margin-bottom: 32px; font-size: 0.95rem; }

        .card {
            background: #fff;
            border-radius: 12px;
            padding: 28px;
            box-shadow: 0 1px 4px rgba(0,0,0,0.08);
            margin-bottom: 24px;
        }
        label { font-weight: 600; display: block; margin-bottom: 10px; }
        input[type=range] { width: 100%; accent-color: #6366f1; }
        .n-display {
            text-align: center;
            font-size: 2rem;
            font-weight: 700;
            color: #6366f1;
            margin: 12px 0 4px;
        }
        .n-label { text-align: center; color: #64748b; font-size: 0.85rem; margin-bottom: 4px; }

        .results { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
        .result-box {
            border-radius: 10px;
            padding: 20px;
            text-align: center;
        }
        .result-box.normal { background: #fef2f2; border: 1.5px solid #fca5a5; }
        .result-box.memo   { background: #f0fdf4; border: 1.5px solid #86efac; }
        .result-box .label { font-size: 0.8rem; font-weight: 600; margin-bottom: 6px; }
        .result-box.normal .label { color: #dc2626; }
        .result-box.memo   .label { color: #16a34a; }
        .result-box .ms {
            font-size: 2rem;
            font-weight: 700;
        }
        .result-box.normal .ms { color: #dc2626; }
        .result-box.memo   .ms { color: #16a34a; }
        .result-box .unit { font-size: 0.85rem; color: #64748b; }

        .ratio-box {
            text-align: center;
            background: #f1f5f9;
            border-radius: 10px;
            padding: 16px;
            margin-bottom: 24px;
        }
        .ratio-box .ratio-num {
            font-size: 2.4rem;
            font-weight: 700;
            color: #6366f1;
        }
        .ratio-box .ratio-label { font-size: 0.9rem; color: #475569; margin-top: 4px; }

        .bar-wrap { margin-bottom: 12px; }
        .bar-label-row {
            display: flex;
            justify-content: space-between;
            font-size: 0.82rem;
            color: #64748b;
            margin-bottom: 4px;
        }
        .bar-track {
            background: #e2e8f0;
            border-radius: 6px;
            height: 22px;
            overflow: hidden;
        }
        .bar-fill {
            height: 100%;
            border-radius: 6px;
            transition: width 0.4s ease;
            min-width: 4px;
        }
        .bar-fill.normal { background: #f87171; }
        .bar-fill.memo   { background: #4ade80; }

        .run-btn {
            display: block;
            width: 100%;
            padding: 14px;
            background: #6366f1;
            color: #fff;
            font-size: 1rem;
            font-weight: 600;
            border: none;
            border-radius: 10px;
            cursor: pointer;
            transition: background 0.2s;
        }
        .run-btn:hover { background: #4f46e5; }
        .run-btn:disabled { background: #a5b4fc; cursor: not-allowed; }

        .note { font-size: 0.82rem; color: #94a3b8; margin-top: 16px; text-align: center; }
    </style>
</head>
<body>
<div class="container">
    <h1>🚀 Cache Memo ベンチマーク</h1>
    <p class="subtitle">同じキャッシュキーに N 回アクセスしたときの実行時間を比較します</p>

    <div class="card">
        <label for="nSlider">アクセス回数 N を選ぶ</label>
        <input type="range" id="nSlider" min="100" max="100000" step="100" value="50000">
        <div class="n-display" id="nDisplay">50000</div>
        <div class="n-label">回</div>
    </div>

    <button class="run-btn" id="runBtn" onclick="runBenchmark()">▶ 計測する</button>

    <div class="card" id="resultCard" style="display:none; margin-top: 24px;">
        <div class="results">
            <div class="result-box normal">
                <div class="label">通常キャッシュ<br>(Cache::store)</div>
                <div class="ms" id="normalMs">—</div>
                <div class="unit">ミリ秒</div>
            </div>
            <div class="result-box memo">
                <div class="label">メモ化キャッシュ<br>(Cache::memo)</div>
                <div class="ms" id="memoMs">—</div>
                <div class="unit">ミリ秒</div>
            </div>
        </div>

        <div class="ratio-box">
            <div class="ratio-num" id="ratioNum">—</div>
            <div class="ratio-label">倍 速い(メモ化キャッシュ ÷ 通常キャッシュ の逆数)</div>
        </div>

        <div class="bar-wrap">
            <div class="bar-label-row">
                <span>通常キャッシュ</span>
                <span id="barNormalLabel">—</span>
            </div>
            <div class="bar-track">
                <div class="bar-fill normal" id="barNormal" style="width:0%"></div>
            </div>
        </div>
        <div class="bar-wrap">
            <div class="bar-label-row">
                <span>メモ化キャッシュ</span>
                <span id="barMemoLabel">—</span>
            </div>
            <div class="bar-track">
                <div class="bar-fill memo" id="barMemo" style="width:0%"></div>
            </div>
        </div>
    </div>

    <p class="note">※ Docker ローカル環境での計測のため、実測値は実行ごとに多少ばらつきます</p>
</div>

<script>
    const slider = document.getElementById('nSlider');
    const nDisplay = document.getElementById('nDisplay');
    slider.addEventListener('input', () => { nDisplay.textContent = slider.value; });

    async function runBenchmark() {
        const n = slider.value;
        const btn = document.getElementById('runBtn');
        btn.disabled = true;
        btn.textContent = '計測中...';

        const res = await fetch(`/cache-demo/benchmark?n=${n}`);
        const data = await res.json();

        document.getElementById('normalMs').textContent = data.normal_ms;
        document.getElementById('memoMs').textContent   = data.memo_ms;
        document.getElementById('ratioNum').textContent = data.ratio;

        const max = Math.max(data.normal_ms, data.memo_ms, 0.001);
        document.getElementById('barNormal').style.width = (data.normal_ms / max * 100) + '%';
        document.getElementById('barMemo').style.width   = (data.memo_ms   / max * 100) + '%';
        document.getElementById('barNormalLabel').textContent = data.normal_ms + ' ms';
        document.getElementById('barMemoLabel').textContent   = data.memo_ms   + ' ms';

        document.getElementById('resultCard').style.display = 'block';
        btn.disabled = false;
        btn.textContent = '▶ 再計測する';
    }
</script>
</body>
</html>

ステップ 3:デモ画面を動かして差を確認する

目的:ブラウザでスライダーを操作しながら、N の増加に応じて実行時間の差が広がることを体感する。

3-1. ブラウザでデモ画面にアクセスする

目的:作成したビューが正しく表示されることを確認する。

ブラウザで以下の URL を開いてください。

http://localhost

スライダーと「計測する」ボタンが表示されれば成功です。

3-2. N を変えながら計測する

目的:N(アクセス回数)を増やすほど差が大きくなることを数字で確認する。

スライダーで N を動かしながら「計測する」ボタンを押してみましょう。以下は Docker ローカル環境での参考値です。

N(回) 通常キャッシュ メモ化キャッシュ 倍率(目安)
10 約 3ms 約 0.01ms 約 300 倍
100 約 30ms 約 0.05ms 約 600 倍
500 約 150ms 約 0.1ms 約 1500 倍
1000 約 300ms 約 0.2ms 約 1500 倍

3-3. 実行時間の差を視覚的に読む

目的:グラフバーの長さを比較して、どれだけ差があるかを直感的に把握する。

画面では2種類の情報が表示されています。

  • 赤いバー(通常キャッシュ):N 回ぶんの Redis ネットワーク通信の合計時間
  • 緑のバー(メモ化キャッシュ):1回だけ Redis にアクセスし、残りはメモリから返した合計時間

N が大きくなるほど赤いバーだけが伸び、緑のバーはほぼゼロのままです。これが「2回目以降はメモリから返す」の意味です。

ステップ 4:#[Cache(memo: true)] アトリビュートで同じことを実現する

目的:デモで確認したメモ化の効果を、実務的なサービスクラスの書き方で実装できるようにする。

ここまでは Cache::memo() を直接呼ぶ方法でデモを動かしました。Laravel 13.13 の新機能は、これをコンストラクタへのアトリビュート1行で実現できるというものです。

4-1. デモ用サービスクラスを作成する

目的:通常キャッシュ版とメモ化キャッシュ版のサービスを並べて比較できるようにする。

sail artisan make:service NormalCacheService
sail artisan make:service MemoCacheService

app/Services/NormalCacheService.php を次のように記述します。

app/Services/NormalCacheService.php
<?php

namespace App\Services;

use Illuminate\Cache\Repository as CacheRepository;
use Illuminate\Container\Attributes\Cache;

class NormalCacheService
{
    public function __construct(
        // 通常の Redis キャッシュストアが注入される
        #[Cache('redis')] private CacheRepository $cache,
    ) {}

    public function getValue(string $key): mixed
    {
        return $this->cache->get($key);
    }
}

app/Services/MemoCacheService.php を次のように記述します。

app/Services/MemoCacheService.php
<?php

namespace App\Services;

use Illuminate\Cache\Repository as CacheRepository;
use Illuminate\Container\Attributes\Cache;

class MemoCacheService
{
    public function __construct(
        // memo: true を追加するだけでメモ化済みキャッシュが注入される
        #[Cache('redis', memo: true)] private CacheRepository $cache,
    ) {}

    public function getValue(string $key): mixed
    {
        return $this->cache->get($key);
    }
}

変更したのは1行だけです。#[Cache('redis')]#[Cache('redis', memo: true)] になりました。

4-2. コントローラーにサービスクラスを使った計測を追加する

目的:サービスクラス経由でも同じ速度差が再現されることを確認する。

CacheDemoControllerbenchmarkService メソッドを追加します。

app/Http/Controllers/CacheDemoController.php
<?php

namespace App\Http\Controllers;

use App\Services\NormalCacheService;
use App\Services\MemoCacheService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class CacheDemoController extends Controller
{
    public function __construct(
        private NormalCacheService $normalService,
        private MemoCacheService   $memoService,
    ) {}

    public function index()
    {
        return view('cache-demo');
    }

    public function benchmark(Request $request)
    {
        $n = (int) $request->input('n', 50000);

        Cache::store('redis')->put('demo-key', 'hello', 60);

        $start = microtime(true);
        for ($i = 0; $i < $n; $i++) {
            Cache::store('redis')->get('demo-key');
        }
        $normalMs = round((microtime(true) - $start) * 1000, 3);

        Cache::memo('redis')->put('demo-key', 'hello', 60);

        $start = microtime(true);
        for ($i = 0; $i < $n; $i++) {
            Cache::memo('redis')->get('demo-key');
        }
        $memoMs = round((microtime(true) - $start) * 1000, 3);

        return response()->json([
            'n'         => $n,
            'normal_ms' => $normalMs,
            'memo_ms'   => $memoMs,
            'ratio'     => $normalMs > 0 ? round($normalMs / max($memoMs, 0.001), 1) : 0,
        ]);
    }

    public function benchmarkService(Request $request)
    {
        $n = (int) $request->input('n', 50000);

        Cache::store('redis')->put('demo-key', 'hello', 60);

        // NormalCacheService 経由(通常キャッシュ)
        $start = microtime(true);
        for ($i = 0; $i < $n; $i++) {
            $this->normalService->getValue('demo-key');
        }
        $normalMs = round((microtime(true) - $start) * 1000, 3);

        // MemoCacheService 経由(メモ化キャッシュ)
        $start = microtime(true);
        for ($i = 0; $i < $n; $i++) {
            $this->memoService->getValue('demo-key');
        }
        $memoMs = round((microtime(true) - $start) * 1000, 3);

        return response()->json([
            'n'         => $n,
            'normal_ms' => $normalMs,
            'memo_ms'   => $memoMs,
            'ratio'     => $normalMs > 0 ? round($normalMs / max($memoMs, 0.001), 1) : 0,
        ]);
    }
}

routes/web.php にルートを追加します。

routes/web.php
Route::get('/cache-demo/benchmark-service', [CacheDemoController::class, 'benchmarkService']);

4-3. サービスクラス版の結果を Tinker で確認する

目的:アトリビュートで注入されたキャッシュが、Cache::memo() 直接呼び出しと同じ速度で動くことを確認する。

sail artisan tinker
$n = 200;

// NormalCacheService 経由で計測
$normal = app(App\Services\NormalCacheService::class);
\Illuminate\Support\Facades\Cache::store('redis')->put('demo-key', 'hello', 60);
$start = microtime(true);
for ($i = 0; $i < $n; $i++) { $normal->getValue('demo-key'); }
echo '通常: ' . round((microtime(true) - $start) * 1000, 3) . ' ms' . PHP_EOL;

// MemoCacheService 経由で計測
$memo = app(App\Services\MemoCacheService::class);
$start = microtime(true);
for ($i = 0; $i < $n; $i++) { $memo->getValue('demo-key'); }
echo 'メモ化: ' . round((microtime(true) - $start) * 1000, 3) . ' ms' . PHP_EOL;

出力例:

通常: 62.4 ms
メモ化: 0.08 ms

exit で Tinker を終了します。

ステップ 5:作業が終わったらコンテナを停止する

目的:不要なリソースを解放するため、Docker コンテナを停止する。

sail down

コンテナが停止します。次回作業を再開するときは sail up -d で再起動できます。

まとめ

この記事では、Laravel 13.13 で追加された #[Cache(memo: true)] の効果をデモ画面を通じて確認しました。

  • 通常のキャッシュ:N 回アクセスすれば N 回 Redis へのネットワーク通信が走る
  • メモ化キャッシュ:初回だけ Redis にアクセスし、2回目以降はメモリから返すため通信コストがほぼゼロになる
  • #[Cache(memo: true)] アトリビュート:コンストラクタへの宣言1行でメモ化済みキャッシュを注入できる(memo: false がデフォルトのため、既存コードへの影響はない)

「同じリクエスト中に同じキャッシュキーを複数のサービスから読んでいる」という状況があれば、まずそのクラスのコンストラクタに memo: true を追加するところから試してみてください。

参考リンク

Discussion