🔰

【Laravel】LaravelとJSライブラリを連携させる方法 - ApexChartsで学ぶデータの流れ

に公開

はじめに

支出記録アプリを例にApexCharts.jsを使用してグラフ表示機能を実装・解説していきます。

今回紹介する方法は以下にも応用できると考えてます。

  • 家計簿アプリ
  • プロジェクト管理ツール
  • ダッシュボード
  • データ分析ツール

他のチャートライブラリ(Chart.js、Highchartsなど)でも同じ手法で使えます!

この記事から学べること

✅ ApexChartsの基本的な使い方
✅ 型の概念と重要性
✅ データ整形のテクニック
✅ JSONの理解
✅ 基礎知識(Alpine.js / Vite)
✅ Viteの理解

想定してる読者

  • CRUD操作はできるけど、次のステップに悩んでいる人
  • 「なんとなく動いてる」状態から抜け出したい人
  • データ変換(型・JSON)をあまり意識してない人
  • LaravelでJSがどう動くのかわからない人
  • Viteが何をしているのか知りたい人

ApexChartsとは?

グラフを簡単に作れるJavaScriptライブラリです。

円グラフ、棒グラフ、折れ線グラフなど、様々なグラフを数行のコードで実装できます。

以下が公式Documentになります。
https://apexcharts.com/

今回、全体的なデータの流れは以下になります。

データの流れ: Controller → Blade → JavaScript → ApexCharts

ApexChartsインストール

npm install apexcharts --save

環境構築

使用技術

  • Laravel 12 (Laravel Sail)
  • MySQL 8.4
  • phpMyAdmin
  • Laravel Breeze (Blade + Alpine.js)
  • Tailwind CSS
  • ApexCharts.js

フォルダ構成

expense/
├── app/
│   ├── Http/
│   │   └── Controllers/
│   │       └── ExpenseController.php # 支出のCRUD処理
│   └── Models/
│       └── Expense.php  # 支出モデル(item, expense)
├── database/
│   └── migrations/
│       └── xxxx_xx_xx_xxxxxx_create_expenses_table.php # テーブル定義
├── resources/
│   ├── css/
│   │   └── app.css # Tailwind CSS
│   ├── js/
│   │   ├── app.js # Alpine.js + ApexCharts.js
│   │   └── expense.js(ApexCharts.js使用)
│   │
│   └── views/
│       ├── expenses/
│       │   ├── index.blade.php  # 一覧画面・グラフ表示
│       │   └── create.blade.php # 登録画面
│       │ 
│       └── layouts/
│           ├── app.blade.php
│           └── navigation.blade.php
├── routes/
│   └── web.php # ルーティング
├── docker-compose.yml
├── package.json
├── tailwind.config.js
└── vite.config.js

構成

database/migrations/xxxx_create_expenses_table.php

public function up(): void
{
    Schema::create('expenses', function (Blueprint $table) {
        $table->id();
        $table->string('item');      // 項目名
        $table->integer('expense');  // 金額
        $table->timestamps();
    });
}

モデル設定

app/Models/Expense.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

//`$fillable`: 一括代入可能なカラムを指定
class Expense extends Model
{
    protected $fillable = [
        'item',
        'expense',
    ];
}

ルーティング設定

routes/web.php

use App\Http\Controllers\ExpenseController;

Route::middleware(['auth', 'verified'])->group(function () {
    Route::get('/dashboard', function () {
        return view('dashboard');
    })->name('dashboard');

    Route::resource('expenses', ExpenseController::class);
});

コントローラー実装

app/Http/Controllers/ExpenseController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\Expense;

class ExpenseController extends Controller
{
    public function index()
    {
        // 1. データベースから全支出データを取得(Eloquentコレクション型で返される)
        $expenses = Expense::all();

        // 2. ApexCharts用にデータを配列形式で整形
        // pluck(): コレクションから特定カラムの値だけを取り出す
        // toArray(): コレクションを配列に変換
        $expenseChart = [
            'labels' => $expenses->pluck('item')->toArray(),
            'series' => $expenses->pluck('expense')->toArray(),
        ];
        
        return view('expenses.index', compact('expenseChart'));
    }

    public function create()
    {
        return view('expenses.create');
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'item' => 'required|string|max:30',
            'expense' => 'required|integer|min:0',
        ]);

        Expense::create($validated);

        return redirect()->route('expenses.index');
    }
}
なぜtoArray()が必要?

Eloquentとコレクション型

  • Expense::all()はCollectionオブジェクトを返す
  • ApexCharts.jsは純粋な配列しか受け付けない
  • コレクションのままだと正しくJSON化されない
  • 今回はinteger型を使っているので数値はそのまま使えますが、もしdecimal型を使う場合は文字列として評価されてしまいます。そのため、modelでキャストもしくは関数を使ってint型かfloat型に型変換してください。

ナビゲーションメニューへの追加

resources/views/layouts/navigation.blade.php

<!-- Navigation Links -->
<x-nav-link :href="route('expenses.index')" :active="request()->routeIs('expenses.index')">
  支出表
</x-nav-link>

<!-- Responsive Navigation Menu -->
 <x-responsive-nav-link :href="route('expenses.index')" :active="request()->routeIs('expenses.index')">
   支出表
</x-responsive-nav-link>

一覧画面

resources/views/expenses/index.blade.php

<x-app-layout>
    <x-slot name="header">
        <div class="flex justify-between items-center">
            <h2 class="font-semibold text-xl text-gray-800 leading-tight">
              支出表
            </h2>
        <div>
            <a href="{{ route('expenses.create') }}"
                   class="inline-flex items-center px-4 py-2 bg-blue-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-blue-700 focus:bg-blue-700 active:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition ease-in-out duration-150">
                    登録する
            </a>
        </div>
        </div>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
            <!-- data属性と@json()ディレクティブを使い、LaravelのデータをJavaScriptに渡す -->
            <!-- データが0件の場合、空のグラフが表示されるのを防ぐため、count()でチェックしています。 -->
               @if(count($expenseChart['series']) > 0)
                 <div
                  id="expenseChart"{{-- idは必ず設定してください。 --}}
                  data-series='@json($expenseChart["series"])'
                  data-labels='@json($expenseChart["labels"])'
                 ></div>
                @else
                  <p>データがありません</p>
                @endif
            </div>
        </div>
    </div>
</x-app-layout>
なぜ、JSONディレクティブとデータ属性を使う?

サーバーサイドとクライアントサイドの違い
Controllerで取得したPHPの配列データはサーバーサイドで処理されます。しかし、ApexCharts.jsはクライアントサイドで動作するため、そのままでは使えません。

データの橋渡し

  1. PHP配列@json()でJSON文字列に変換
  2. JSON文字列data-*属性としてHTMLに埋め込み
  3. HTML → ブラウザに送信
  4. JavaScriptdata-*属性から取得して使用

この方法により、安全かつ確実にサーバーからクライアントへデータを渡すことができます!

登録画面

resources/views/expenses/create.blade.php

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            支出登録
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6 text-gray-900">
                    <form method="POST" action="{{ route('expenses.store') }}">
                        @csrf

                        <div class="mb-4">
                            <label for="item" class="block text-sm font-medium text-gray-700">
                                項目名
                            </label>
                            <input
                                type="text"
                                name="item"
                                id="item"
                                value="{{ old('item') }}"
                                class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
                                required
                            >
                        </div>

                        <div class="mb-4">
                            <label for="expense" class="block text-sm font-medium text-gray-700">
                                金額
                            </label>
                            <input
                                type="number"
                                name="expense"
                                id="expense"
                                value="{{ old('expense') }}"
                                class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
                                min="0"
                                required
                            >
                        </div>

                        <div class="flex items-center justify-end gap-4">
                            <a
                                href="{{ route('expenses.index') }}"
                                class="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400"
                            >
                                キャンセル
                            </a>
                            <button
                                type="submit"
                                class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
                            >
                                登録
                            </button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

グラフ描画関数

resources/js/expense.js

import ApexCharts from 'apexcharts'

export function expenseChart() {
    // 1. グラフを表示する要素を取得
    const chartElement = document.getElementById("expenseChart");
    if (!chartElement) return;
    
    // 2. data属性からデータを取得し、JSON形式から配列に変換
    const series = JSON.parse(chartElement.dataset.series);
    const labels = JSON.parse(chartElement.dataset.labels);
    
    // 3. ApexChartsのオプション設定
    const options = {
        series: series,        // データの値
        chart: {
            width: 380,
            type: 'pie',       // 円グラフ
        },
        labels: labels,        // ラベル
        responsive: [{
            breakpoint: 480,   // 画面幅480px以下の場合
            options: {
                chart: { width: 200 },
                legend: { position: 'bottom' }
            }
        }]
    };
    
    // 4. グラフを生成して描画
    const chart = new ApexCharts(chartElement, options);
    chart.render();
}

データの中身

const options = {
    series: [3000, 1500],        // 実際の数値データ
    labels: ['食費', '交通費'],   // ラベル
    chart: {
        type: 'pie'               // グラフの種類
    }
}

resources/js/app.js

import './bootstrap';
import Alpine from 'alpinejs';
import { expenseChart } from './expense'

window.Alpine = Alpine;

// ページ読み込み完了後にグラフを描画
document.addEventListener('DOMContentLoaded', () => {
    expenseChart()
})

Alpine.start();

データの流れまとめ

ApexCharts.jsでグラフを表示するまでの全体の流れ:

1. Database
   ↓ データ取得
2. Controller (ExpenseController::index)
   ↓ データ整形
   $expenseChart = [
       'labels' => ['食費', '交通費'],
       'series' => [3000, 1500]
   ]

3. Blade (expenses/index.blade.php)
   <div data-series='@json($expenseChart["series"])' ...>
   ↓ HTML出力
   <div data-series='[3000,1500]' ...>

4. JavaScript (expense.js)
   const series = JSON.parse(el.dataset.series);
   // series = [3000, 1500]

5. ApexCharts
   new ApexCharts(el, options).render();

6. 画面に円グラフが表示

重要なポイント:

段階 役割 重要な処理
Controller データ取得・整形 pluck()->toArray()
Blade データ埋め込み @json(), data-*
JavaScript データ取得 JSON.parse()
ApexCharts グラフ描画 render()

備考: 開発環境について

基礎知識 - LaravelとJavaScript(Alpine.js)

今回の環境ではAlpine.jsが搭載されています。Alpine.jsはJSのフレームワークで一言で言えば、HTMLに直接書けるJSです。

Alpine.jsはLaravelのBladeテンプレートでレンダリングされたHTMLに対して、直接DOMを操作し、かつ必要最低限のJSで実装できます。Reactのようなライブラリを使用してないため、軽量と言われてます。

<!-- こんな感じでHTMLに直接書ける -->
<div x-data="{ open: false }">
    <button @click="open = !open">トグル</button>
    <div x-show="open">表示される内容</div>
</div>

Alpine.js、Tailwind CSS、Viteの役割

ブラウザは生のJavaScriptとCSSしか理解できません

しかし、開発の効率を上げるため、Alpine.jsやTailwind CSSといった便利なツールを使います。問題は、これらのツールで書いたコードはそのままではブラウザで動かないということです。

そこでViteの出番です!!
Viteはバンドラー・次世代ビルドツールと言われてます。

Viteの仕事

  • Alpine.jsやApexCharts.jsなどのコードを、ブラウザが読める生のJavaScriptに変換
  • Tailwind CSSを、実際に使われている部分だけを抽出した生のCSSに変換
  • 複数のファイルを1つにまとめて、読み込みを高速化(ファイルを一つにまとめることをバンドルと言います。)
私たちが書くコード(Alpine.js、Tailwind CSS)
         ↓ Viteが変換
ブラウザが読めるコード(生のJS、CSS)

これにより、開発のしやすさブラウザでの実行の両立ができます。

なぜVite?

同じ役割を持つWebpackというバンドラーもありますが、LaravelではViteが採用されています。
理由は圧倒的な速さです。
なぜ処理が速いのかは以下が理由です。

Webpack

  • すべてのJavaScriptファイルを毎回読み込んで処理
  • ファイルが多ければ多いほど、ビルド時間が長くなる。

Vite

  • 必要なファイルだけを読み込む
  • 使われていないコードは除外(Tree Shaking)
  • 変更したファイルだけを再処理

まとめ

ApexCharts.jsを扱う上で重要なポイント

  1. EloquentコレクションtoArray()で配列に変換が必要
  2. @json()ディレクティブでPHPデータをJavaScriptに渡す
  3. data-*属性はサーバーとクライアント間のデータ橋渡し

おわりに

ApexCharts.jsなど他のJSライブラリを使う際も、上記で解説した手法を元にデータを処理してます。ぜひ、また躓いたところがあれば今回の記事を参考にしていただけたら幸いです!

Discussion