🚜

ミニカーコレクションSNSサービス開発10▶️開発188日目開発 - 技術スタックの変更。TALLスタックへの移行 -

に公開

(※これまで開発記事はnoteに投稿していましたが、開発記事のみzennに移行中です。@2025/5/28)


ユーザー同士がモデルカーのコレクションを共有し、皆で一つのガレージを作れるサービス

https://thegaragejp.com/news

使用技術の変更。Laravel + jQuery から TALL スタックへ変更。

これまでLaravelでの開発を続ける中で、これまで僕はBladeファイルやjQueryを用いたAjax処理やUIの実装を積み重ねてきました。実績のある手法ではありましたが、モダンな技術ではなくコードの複雑化や状態管理の煩雑さ、コンポーネントの再利用性の低さに限界を感じる場面が徐々に増えてきました。

そこでThe Garageの開発のできるだけ序盤に、Laravelと親和性の高い「TALLスタック」──Tailwind CSS、Alpine.js、Laravel、そしてLivewireを使ったプロダクトに改修したいと思っていました。元々の計画ではバックエンド側をLaravelとし、フロントエンドをReactやVueにして、API連携させる方法にプロダクトを改修していきたいと考えていましたが、中でもLivewireは、VueやReactのようなフロントエンドフレームワークを使わずに、動的でインタラクティブなUIをLaravel側だけで実現できる強力なツールなので、Livewireを使用したTALLスタックへ変更することにしました。(ちょうど副業でLivewireの先駆者の方とお仕事させて頂いているので、Livewireが一番使いやすいという理由もありました。)

本記事では、従来のLaravel + jQuery 開発から、TALLスタックへ移行することで得られた体験や利点、実際のコードの変化などについて綴っていきます。特に私と同じように、jQueryベースでの開発にモヤモヤを感じている方には、ぜひ参考にしていただきたい内容です。

TALLスタックとはどんな技術スタックか?

###Laravelと最高の相性を持つ、軽量かつ柔軟なUI開発スタック

「TALLスタック」は、以下の4つのモダンな技術で構成された、Laravelと極めて親和性の高い開発スタックです。

・Tailwind CSS
・Alpine.js
・Laravel
・Livewire

それぞれの技術がどんな役割を担い、どのように組み合わさって動的なUIを構築できるのかを見ていきます。

Tailwind CSS –CSSフレームワーク

Tailwind CSSは、クラス名だけで直感的かつ高速にUIを組み立てることができるCSSフレームワークで、従来のようにCSSファイルを別で書く必要がなく、HTMLの構造と見た目を1つの場所で完結できるため、開発効率を向上させることができます。

<button class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
  Submit
</button>

Alpine.js – Vueのような書き心地で、もっと軽いJavaScript

Alpine.jsは、「小さなVue」とも言われており、軽量なJavaScriptフレームワークです。ちょっとしたUIのインタラクション(モーダルの開閉、タブの切り替えなど)を、HTML上にシンプルな構文で書くことができます。

<div x-data="{ open: false }">
  <button @click="open = !open">Toggle</button>
  <div x-show="open">Hello!</div>
</div>

これまで非同期処理(いいね機能)などjQueryで書いていたDOM操作を、もっと直感的かつコンポーネント的に書けるのが魅力です。

Laravel – 強力なバックエンドの基盤

言わずと知れたLaravelは、PHP製の人気フレームワークで、TALLスタックの中核を担っています。ルーティング、認証、バリデーション、データベース操作など、堅牢なバックエンド機能を提供しています。

TALLスタックでは、LaravelがAPIとして振る舞う必要はなく、BladeテンプレートとLivewireを通じて、サーバーサイドとクライアントサイドを自然に連携することができます。

Livewire – JavaScriptを書かずに、動的なUIを

Livewireは、LaravelのBladeテンプレート内でフロントエンドの状態管理やDOM更新を可能にする革新的なライブラリです。Ajax処理やフォームバリデーションなど、これまでjQueryで行っていた処理を、PHPとBladeだけで完結できます。

<!-- Likeボタン -->
<button wire:click="toggleLike">いいね!</button>****

JavaScriptを書かずにリアクティブなUIを実現できるのが最大の強みの技術です。

なぜTALLスタックなのか?

従来のLaravel + jQueryでは、HTML、CSS、JavaScript、PHPと複数の層での管理が必要でした。TALLスタックでは、Laravel中心の一貫した構成で、状態管理、UI、スタイルまで一気通貫で開発することができる様になります。

詳細画面

詳細画面はこれまで別のBladeファイルへ画面遷移させる様にしていました。しかし、今回からTALLスタックを使いモーダル表示へ変更させました。


任意のモデルカーをクリックすると詳細画面がモーダルが表示される

以下はモーダルのコードです。モデルカーの一覧が表示されているBladeコード内から任意のモデルカーの画像カードをクリックすると、以下のモーダルが発火する様になっています。

authを通っているモデルカーカードのみ(自分が投稿したモデルカーカード)、詳細画面にて”Deleteボタン”と”Editボタン”が現れます。

↓モーダルLivewireのBladeファイル

<div x-show="openModal" x-cloak
    class="fixed inset-0 z-50 overflow-y-auto bg-black/50 flex items-center justify-center px-4 sm:px-6"
    x-transition:enter="transition ease-out duration-300"
    x-transition:enter-start="opacity-0 scale-95"
    x-transition:enter-end="opacity-100 scale-100"
    x-transition:leave="transition ease-in duration-200"
    x-transition:leave-start="opacity-100 scale-100"
    x-transition:leave-end="opacity-0 scale-95">

    <!-- モーダルの枠 (モバイル・PC両対応) -->
    <div class="relative bg-white rounded-xl shadow-xl overflow-hidden w-full max-w-[85%] sm:max-w-lg lg:max-w-4xl mx-auto my-12 sm:my-16 max-h-[90vh] overflow-y-auto"
        x-transition:enter="transition ease-out duration-300 transform"
        x-transition:enter-start="translate-y-8 opacity-0"
        x-transition:enter-end="translate-y-0 opacity-100"
        x-transition:leave="transition ease-in duration-200 transform"
        x-transition:leave-start="translate-y-0 opacity-100"
        x-transition:leave-end="translate-y-8 opacity-0"
    >

        <!-- ❌ バツマークボタン(右上) -->
        <button @click="openModal = false"
            class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center bg-white/80 border border-gray-300 rounded-full shadow-lg hover:bg-gray-300 active:bg-gray-400 transition duration-200 z-50">
            <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-gray-700" fill="none" viewBox="0 0 24 24"
                stroke="currentColor" stroke-width="2">
                <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
            </svg>
        </button>

        <!-- モーダルコンテンツ -->
        <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 p-6">

            <!-- 左側: 画像ギャラリー -->
            <div class="col-span-1 space-y-4">

                <!-- メイン画像 (画像A) -->
                @if($add->CarImageA && file_exists(public_path('storage/CarImages/' . $add->CarImageA)))
                    <div class="w-full aspect-w-16 aspect-h-9 rounded-xl overflow-hidden shadow-md">
                        <img src="{{ asset('storage/CarImages/' . $add->CarImageA) }}" alt="CarImageA"
                            class="w-full h-full object-cover transition-transform duration-300 hover:scale-105">
                    </div>
                @endif

                <!-- サムネイル画像 (画像BD) -->
                <div class="grid grid-cols-3 gap-3">
                    @if($add->CarImageB && file_exists(public_path('storage/CarImages/' . $add->CarImageB)))
                        <div class="cursor-pointer hover:scale-105 transition-transform duration-300 rounded-lg overflow-hidden shadow">
                            <img src="{{ asset('storage/CarImages/' . $add->CarImageB) }}" alt="CarImageB"
                                class="w-full h-auto object-cover rounded-lg">
                        </div>
                    @endif
                    @if($add->CarImageC && file_exists(public_path('storage/CarImages/' . $add->CarImageC)))
                        <div class="cursor-pointer hover:scale-105 transition-transform duration-300 rounded-lg overflow-hidden shadow">
                            <img src="{{ asset('storage/CarImages/' . $add->CarImageC) }}" alt="CarImageC"
                                class="w-full h-auto object-cover rounded-lg">
                        </div>
                    @endif
                    @if($add->CarImageD && file_exists(public_path('storage/CarImages/' . $add->CarImageD)))
                        <div class="cursor-pointer hover:scale-105 transition-transform duration-300 rounded-lg overflow-hidden shadow">
                            <img src="{{ asset('storage/CarImages/' . $add->CarImageD) }}" alt="CarImageD"
                                class="w-full h-auto object-cover rounded-lg">
                        </div>
                    @endif
                </div>

            </div>

            <!-- 右側: 詳細情報エリア -->
            <div class="col-span-1 bg-white rounded-2xl shadow-xl p-6 space-y-6 flex flex-col justify-between h-full">

                <!-- スペック情報 -->
                <div>
                    <h3 class="text-xl font-semibold text-neutral-800 border-b border-neutral-200 pb-3">Specs</h3>

                    <div class="grid grid-cols-2 gap-y-4 gap-x-4 text-sm text-neutral-700 mt-4">
                        @foreach ([
                            'CarMaker' => 'Car Maker',
                            'CarName' => 'Car Name',
                            'MinicarMaker' => 'Minicar Maker',
                            'Scale' => 'Scale',
                            'PurchaseDate' => 'Purchase Date',
                            'Descriptions' => 'Descriptions'
                        ] as $key => $label)
                            <div class="font-medium text-neutral-500">{{ $label }}</div>
                            <div class="text-neutral-900 text-right">{{ $add->$key }}</div>
                        @endforeach
                    </div>
                </div>

                <!-- 操作ボタンエリア(PCは横並び/スマホは縦並び) -->
                <div class="flex flex-col md:flex-row justify-between items-end pt-6 mt-8 border-t border-neutral-200 gap-4">

                    <!-- 左側: Delete + Edit(投稿者のみ) -->
                    @if (Auth::check() && Auth::id() === $add->user_id)
                        <div class="flex flex-col md:flex-row w-full md:w-auto gap-3">
                            <!-- Delete -->
                            <form action="{{ route('add.destroy', $add->id) }}" method="POST"
                                onsubmit="return confirm('Are you sure you want to delete this post?');"
                                class="w-full md:w-auto">
                                @csrf
                                @method('DELETE')
                                <button type="submit"
                                    class="w-full inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-lg bg-red-100 text-red-800 text-sm font-medium shadow-sm hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-300 transition-all duration-200">
                                    <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
                                        <path d="M6 18L18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
                                    </svg>
                                    Delete
                                </button>
                            </form>

                            <!-- Edit -->
                            <form action="{{ route('add.edit', $add->id) }}" method="GET" class="w-full md:w-auto">
                                @csrf
                                <button type="submit"
                                    class="w-full inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-lg bg-blue-100 text-blue-800 text-sm font-medium shadow-sm hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-300 transition-all duration-200">
                                    <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
                                        <path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5M15 3l6 6M9 13l6-6"
                                            stroke-linecap="round" stroke-linejoin="round"/>
                                    </svg>
                                    Edit
                                </button>
                            </form>
                        </div>
                    @else
                        <!-- 投稿者でない場合でも左側スペースを維持するダミーdiv -->
                        <div class="hidden md:block w-[1px] h-[1px]"></div>
                    @endif

                    <!-- 右側: Close(常に表示) -->
                    <div class="w-full md:w-auto">
                        <button @click="openModal = false"
                            class="w-full inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-lg bg-neutral-700 text-white text-sm font-medium shadow-md hover:bg-neutral-800 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-neutral-500 transition-all duration-200">
                            <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
                                <path d="M6 18L18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round"/>
                            </svg>
                            Close
                        </button>
                    </div>
                </div>

            </div>
        </div>
    </div>
</div>

↓モーダルLivewireのClassファイル

<?php

namespace App\Livewire;

use Livewire\Component;
use App\Models\Add;


class MyGarageModal extends Component
{
    public $addId;
    public $add; // 取得したデータを格納する変数

    public function mount($addId)
    {
        $this->add = Add::find($addId); // ID で投稿情報を取得
    }

    public function render()
    {
        return view('livewire.my-garage-modal');
    }
}

モーダルとすることにより、画面遷移するタイムラグやストレスがなくなり、よりシームレスに詳細を表示できる様になったと思います。
今後はDeleteボタンの押し間違え防止や、ボタンレイアウトの修正などを行っていきたいと思います。


画面の大きいPCなどでサービスを開いた場合は、モーダルの形状が大きく表示される仕様にしています。

いいね機能

これまでいいね機能はjQUeryを使い、Bladeファイルの末尾に<script>タグをつけてjQueryで非同期処理のコードを書いていました。今回は<script>タグを削除し、いいねのLivewireコンポーネントを元のBladeファイルに挿入する形にしています。

↓いいね機能を挿入させたい箇所にLikeコンポーネントを挿入

{{-- likeコンポーネントの挿入 --}}
<div class="flex h-full items-center justify-center">
  <livewire:likefunction :add="$add" />
</div>

↓いいね機能のLivewireコンポーネントBladeファイル

<div>
    @auth
        <button wire:click="toggleLike"
            class="flex flex-col items-center group ml-3 transition-transform duration-200 active:scale-90">
            <div class="relative">
                <svg class="h-7 w-7 transition-all duration-300 ease-in-out transform group-hover:scale-110
                            {{ $liked ? 'text-customYellow fill-customYellow' : 'text-zinc-400 hover:text-customYellow' }}"
                    fill="{{ $liked ? 'currentColor' : 'none' }}"
                    viewBox="0 0 24 24"
                    stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
                        d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
                </svg>
            </div>
            <span class="text-[11px] text-zinc-600 group-hover:text-customYellow transition-colors duration-200">
                {{ $likesCount }}
            </span>
        </button>
    @else
        <a href="{{ route('login') }}"
            title="ログインしていいね"
            class="flex flex-col items-center group ml-3">
            <svg class="h-7 w-7 text-zinc-300 hover:text-customYellow transition-all duration-300 ease-in-out transform group-hover:scale-110"
                fill="none"
                viewBox="0 0 24 24"
                stroke="currentColor"
                stroke-width="1.5">
                <path stroke-linecap="round" stroke-linejoin="round"
                    d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
            </svg>
            <span class="text-[11px] text-zinc-500 group-hover:text-customYellow transition-colors duration-200">
                {{ $likesCount }}
            </span>
        </a>
    @endauth
</div>

↓いいね機能のLivewireコンポーネントClassファイル

<?php

namespace App\Livewire;

use Livewire\Component;
use App\Models\Add;
use Illuminate\Support\Facades\Auth;

class Likefunction extends Component
{
    public Add $add;
    public bool $liked = false;
    public int $likesCount = 0;

    public function mount(Add $add)
    {
        $this->add = $add;
        $this->liked = $add->users()->where('user_id', Auth::id())->exists();
        $this->likesCount = $add->users()->count();
    }

    public function toggleLike()
    {
        $user = Auth::user();

        if ($this->liked) {
            $this->add->users()->detach($user->id);
            $this->liked = false;
        } else {
            $this->add->users()->attach($user->id);
            $this->liked = true;
        }

        $this->add->refresh();
        $this->likesCount = $this->add->users()->count();
    }

    public function render()
    {
        return view('livewire.likefunction');
    }
}

TALLスタックとLivewireについて

TALLスタックをはじめとしたLivewireはLaravel技術を使って簡単にReactやVueチックな処理を簡単に実装することができます。実際に使ってみて、こんなに簡単に実装できるとは思いませんでした。

TALLスタック自体はまだまだ日本では取り入れられているプロダクトは少ないですが、今後広まっていく可能性が大いにある技術スタックです。その理由は以下です。

1. フルスタック開発の壁を下げる

TALLスタック最大の魅力は、「バックエンドエンジニアでもフロントエンドが書ける」という点です。

従来のフルスタック開発では、VueやReactなどのJavaScriptフレームワークに精通しなければ、動的なUIを構築するのは困難でした。しかしLivewire + Alpine.jsの登場により、BladeテンプレートとPHPの知識があれば、リアクティブなWebアプリを作れる時代になりました。

これは、エンジニアの裾野を広げるだけでなく、開発のスピードと保守性も高める重要な進化と言えます。

2. Laravelシステムとの深い統合

TALLスタックは、Laravelの生態系と非常に深く結びついています。Livewireの開発者(Caleb Porzio)やTailwindの作者(Adam Wathan)は、Laravelコミュニティの中核的存在であり、公式イベントやツールとの統合も進んでいます。

この流れは今後も強化され、TALLスタックに最適化されたツールや拡張機能が増えていくことが見込まれます。

・Laravel BreezeやJetstreamがLivewire対応
・Laravel ForgeやVaporとの親和性
・Laravel Herdによるローカル開発環境の強化

当サービスのThe GarageはLaravelのBreezeを使ってauth機能を実装したのちにTALLスタックに移行しているので、すでにBreezeは織り込み済みです。

3. 単一ページアプリケーション(SPA)への柔軟な対応

LivewireはSPAのような体験を実現しながらも、ルーティングや状態管理をPHPで完結させられる設計になっています。そのため、VueやReactのような本格的なSPAフレームワークに頼らなくても、「ちょうどいい動的さ」を持ったWebアプリを作ることが可能です。

さらに、最近ではLivewire v3.x によるアニメーション、ファイルアップロード、モーダル処理の強化など、Vueに引けを取らない機能性が次々と実装されています。

4. 小規模〜中規模アプリ開発で特に有利

大規模なフロントエンド開発にはVueやReactが向いているケースもありますが、中小規模の業務アプリやダッシュボード、CMS、社内ツールなどにおいては、TALLスタックのシンプルさと柔軟さが非常に有効です。

特にスタートアップや1人開発体制では、「少ない学習コストで、最短距離で、結果が出せる」TALLスタックは圧倒的な武器になります。

ただし、LivewireのバージョンとLaravelのバージョン同士の互換性がうまく噛み合わない組み合わせも存在しています。僕が最初にLivewireを織り込んだ際はLivewireのバージョンが理由でエラー表示になりハマってしまいそうになりましたが、Livewireのバージョンを変更することにより、TALLスタックを織り込むことができました。
(2025/4/16時点で、Laravel : 11.44.2 Livewire : v3.6.2 の組み合わせでうまく連携できることを確認しています。)

今後の開発

約3ヶ月ほどThe Garageの開発が止まってしまっていましたが、今回TALLスタックに載せ替えて、ベースの改修が完了したので、次からは他の機能もTALLスタックらしいモダンなUIを実装していけたらと思います。

具体的には、新たな投稿を行う際には”Garage Inタブ”から投稿する必要がありましたが、ここもモーダルで表示させたり、他のユーザーをフォローする際にも非同期処理を埋め込んだり、今回実装した詳細画面のモーダル内のボタンレイアウトをさらに使いやすい様に変更したりなど、もっと使いやすく楽しく使える様なサービスへと開発を進めていければと思っています。

ぜひみなさんにも使ってもらいサービスを盛り上げていただければ幸いです。

⬇️The GarageはこちらのURL先よりご利用いただけます。
https://thegaragejp.com/news

Discussion