Laravel 演習入門

チーズは何枚消えた?

Eloquent groupBy の基礎

問題

あなたは、ハンバーガー屋さんの在庫管理システムを開発しています。
「最新の確定在庫数」と、「その後の注文履歴ログ」から、「現時点の推定在庫数」を計算する API を実装してください。

推定在庫数 API の仕様:

Ingredient モデル及び確定在庫数の仕様:

ここでの Ingredient とは、食材の意味です。

Order モデル及びログ集計の仕様:

メニューのマスタデータの仕様:

学習上の緩和事項:

入門用の学習のため、以下は実装する必要はありません。
(もちろん、実装できそうならチャレンジしてみてください。)

利用できるデータ:

ハンバーガー屋さんのデータ に、以下のデータを用意しています。


問題は、以下の手順で解いてください。

  1. Red:小さいテストを作成し、失敗を確認してください
  2. Green:テストを成功させてください
  3. Refactor:整理・整頓してください
  4. 必要に応じて、1から3を繰り返してください

ヒント

背景知識
便利なアサーションの例

アサーションの調べ方 も合わせてご覧ください。
今回は、以下を使うのではないかと思います。

解答例

続きを読む

実行環境:

Red1: 各モデルにデータが入力されているか

テストを作成します。
bash:

php artisan make:test HamburgerTest

tests/Feature/HamburgerTest.php:

<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\Ingredient;
use App\Models\Order;
use Database\Seeders\IngredientSeeder;
use Database\Seeders\OrderSeeder;

class HamburgerTest extends TestCase
{
    use RefreshDatabase;
    public function test_Ingredientモデルにデータが入力されているか(): void
    {
        $this->seed([IngredientSeeder::class]);
        $start = Ingredient::latest()->first();
        $this->assertSame($start->buns, 800);
    }
    public function test_Orderモデルにデータが入力されているか(): void
    {
        $this->seed([OrderSeeder::class]);
        $first = Order::oldest()->first();
        $this->assertSame($first->menu, 'double-cheeseburger');
    }
}

テストの失敗を確認します。

Green1

Ingredient, Order モデル、マイグレーション、シーダーを作成します。
bash:

php artisan make:model Ingredient -ms
php artisan make:model Order -ms

database/migrations/日時_create_ingredients_table.php:

    public function up(): void
    {
        Schema::create('ingredients', function (Blueprint $table) {
            $table->id();
            $table->integer('buns');
            $table->integer('cheese');
            $table->integer('patty');
            $table->integer('pickles');
            $table->integer('ketchup');
            $table->integer('teriyaki');
            $table->timestamps();
        });
    }

database/migrations/日時_create_orders_table.php:

    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->enum('menu', [
                'hamburger',
                'cheeseburger',
                'teriyakiburger',
                'double-burger',
                'double-cheeseburger',
            ]);
            $table->timestamps();
        });
    }

database/seeders/IngredientSeeder.php:

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;

    public function run(): void
    {
        $data = <<<END
            buns,cheese,patty,pickles,ketchup,teriyaki
            800,100,500,800,700,500
            END;
        [$keys, $vals] = explode(PHP_EOL, $data);
        $keys = str_getcsv($keys, escape: '\\');
        $vals = str_getcsv($vals, escape: '\\');
        $map = array_combine($keys, $vals);
        $map['created_at'] = '2026-05-29T08:00:00+09:00';
        $map['updated_at'] = '2026-05-29T08:00:00+09:00';
        DB::table('ingredients')->insert($map);
    }

database/seeders/OrderSeeder.php:

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;

    public function run(): void
    {
        $data = <<<END
            2026-05-29T09:03:53+09:00 double-cheeseburger
            (中略)
            2026-05-29T18:44:32+09:00 double-burger
            END;
        $data = new Collection(explode(PHP_EOL, $data))
            ->map(function(string $dat):array {
                [$datetime, $menu] = explode(' ', $dat);
                $result = [
                    'created_at' => $datetime,
                    'updated_at' => $datetime,
                    'menu' => $menu,
                ];
                return $result;
            });
        DB::table('orders')->insert($data->toArray());
    }

テストの成功を確認します。

Refactor1

特にありません。

Red2: データを集計し、チーズの枚数が合うか

テストを追加します。
tests/Feature/HamburgerTest.php:

use App\Service\IngredientCountService;

    public function test_営業終了後のチーズの枚数が合うか(): void
    {
        $this->seed([
            IngredientSeeder::class,
            OrderSeeder::class,
        ]);
        $ics = app(IngredientCountService::class);
        $this->assertSame(56, $ics->now()['cheese']);
    }

Green2

サービスを作成します。(名前はサービスでなくても構いませんが、慣例的にサービスを使うそうです)
bash:

mkdir ./app/Service/
touch ./app/Service/IngredientCountService.php

app/Service/IngredientCountService.php:

<?php

namespace App\Service;

use App\Models\Ingredient;
use App\Models\Order;

class IngredientCountService
{
    private array $master;

    public function __construct() {
        $json = <<<END
        [
            {
                "uri": "hamburger",
                "name": "ハンバーガー",
                "ingredients": {
                    "buns": 2,
                    "patty": 1,
                    "pickles": 2,
                    "ketchup": 1
                }
            },
            (中略。以下同様にデータを挿入)
        ]
        END;
        
        $this->master = [];
        $master_array = json_decode($json, true);
        foreach($master_array as $data) {
            $this->master[$data['uri']] = $data['ingredients'];
        }
    }

    public function now():array {
        $latest_ingredient = Ingredient::latest()->first();
        $result = $latest_ingredient->toArray();

        $latest_datetime = $latest_ingredient->created_at;
        $orders = Order::where('created_at', '>', $latest_datetime)
            ->get();
        foreach($orders as $order) {
            $menu = $order->menu;
            foreach($this->master[$menu] as $ingredient => $ing_count) {
                $result[$ingredient] -= $ing_count;
            }
        }
        return $result; 
    }
}

テストの成功を確認します。

Refactor2

すべてのデータをオブジェクトとして取ってきて、ループを回していますが、SQLでメニューごとの集計をすることが可能です。
app/Service/IngredientCountService.php:

    public function now():array {
        $latest_ingredient = Ingredient::latest()->first();
        $result = $latest_ingredient->toArray();

        $latest_datetime = $latest_ingredient->created_at;
        $order_counts = Order::where('created_at', '>', $latest_datetime)
            ->groupBy('menu')
            ->selectRaw('menu, COUNT(*) AS cnt')
            ->get();
        foreach($order_counts as $order_count) {
            $menu = $order_count['menu'];
            $menu_count = $order_count['cnt'];
            foreach($this->master[$menu] as $ingredient => $ing_count) {
                $result[$ingredient] -= $ing_count * $menu_count;
            }
        }
        return $result; 
    }

Red3: 適切に JSON データを返すか

テストを追加します。
tests/Feature/HamburgerTest.php:

    public function test_apiの確認(): void
    {
        $this->seed([
            IngredientSeeder::class,
            OrderSeeder::class,
        ]);
        $response = $this->get('/ingredient/now');
        $response->assertJsonPath('cheese', 56);
    }

Green3

ルーティング、コントローラーを作成します。
bash:

php artisan make:controller IngredientController

routes/web.php:

Route::get('/ingredient/now', [IngredientController::class, 'now']);

app/Http/Controllers/IngredientController.php:

<?php

namespace App\Http\Controllers;

use App\Service\IngredientCountService;

class IngredientController extends Controller
{
    public function now(IngredientCountService $ics):array {
        return $ics->now();
    }
}

Refactor3

特にありません。

解説

続きを読む

groupBy の使い方

SQL には、集計関数が用意されています。
今回のように、メニューごとのデータ数を数えることが可能です。
(他に、数値を合算したり、平均したりもできます)

        $order_counts = Order::where('created_at', '>', $latest_datetime)
            ->groupBy('menu')
            ->selectRaw('menu, COUNT(*) AS cnt')
            ->get();
        foreach($order_counts as $order_count) {...}

SQL は集合論なので、グループに分けるような処理の表現は得意です。
一方、 ORM はオブジェクト指向なので、メソッドのつながり(メソッドチェーン)で表現する必要があります。

groupBy に関しては、先に SQL の処理(GROUP BY 句)を調べてから、それを素直にメソッドで表現したものとして読むと、すっきり分かりやすくなる気がしました。

Eloquent ビルダーと DB ファサードのクエリビルダー

Eloquent ビルダーによる処理の結果は、戻り値がそのモデルのインスタンスのコレクション型になります。
たとえば今回の場合、戻り値は App\Models\Order (本来は個々の注文履歴)のコレクションになります。
欲しい情報は、メニューごとの集計結果なので、型としての意味合いは間違っていますし、一部の Eloquent メソッドは動きません。

集計結果を取得するという目的は達成しているので、これでも問題はないのですが、もし型が気持ち悪いと感じる方は、 use Illuminate\Support\Facades\DB から DBファサードのクエリビルダを使う方が、素直かもしれません。
クエリビルダの戻り値は、標準クラスなので、 Order 型ではなくなるからです。

        $order_counts = DB::table('orders')
            ->where('created_at', '>', $latest_datetime)
            ->groupBy('menu')
            ->selectRaw('menu, COUNT(*) AS cnt')
            ->get();
        foreach($order_counts as $order_count) {
            dump(get_class($order_count));  // stdClass
        }

できる限り Eloquent を使うべきか、それとも DB ファサードと混合しても良いかは、各実装チームのルールによると思います。


<= 問題を読んだ・解いた・理解したなどのチェックにご利用ください。クリックすると、チェックが変化します。
問題一覧に戻る