ReactとLaravel連携してPostgreSQLに登録するまでの流れ

# サンプルイメージ
今回作るサンプルは下記のとおりです。

イベント登録ボタンをクリックして...
![明日の一覧画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1169664/84ff0087-28c6-fd88-57df-9e3c623c9e27.png)

イベント登録画面を開きます。
![明日の登録画面.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1169664/2c3d9f3b-ae8a-5f98-914e-c71276c8a90e.png)

イベント情報を入力して登録ボタンをクリックします。
![明日の登録画面データ入力.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1169664/2ca7b4f5-b109-6cce-4039-477f526d799e.png)

登録出来たら、このように画像が表示されます。
![登録後明日の登録画面データ入力.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1169664/50092da6-fd26-2535-8d1e-018370defdc5.png)

データベースにもちゃんと値が入っていますね。
![postgre.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1169664/6536ec71-daa9-6cc6-f641-2f0f3362dea1.png)

プログラムのロジックは、下記の通りになります。
<b>①画像ファイルの送信</b>
現在、eventThumbnailにe.target.valueを設定していますが、画像ファイルはFileオブジェクトであるため、e.target.files[0]を使う必要があります。

<b>②handleSubmit関数の非同期処理</b>
非同期関数にはasyncをつける必要があります。また、レスポンスを正しく処理します。

<b>③APIへのデータ送信</b>
送信時、FormDataに各データを追加して送信します。

<b>④バックエンド側の処理（Laravel）</b>
Laravel側で受け取り、画像を保存し、データをPostgreSQLに登録するロジックを記載します。


# ディレクトリ構成
準備中...

# まずはフロント(React)側を実装する

## ナビゲーションバーを作成する
ナビゲーションバーには、①イベント登録と②ログアウトボタンを設置しています。
また、画面間を行き来するために<b>useNavigate</b>を追加しています。

```frontend/src/components/navbar/page.tsx
import { useNavigate } from "react-router-dom"

export default function Navbar(){
    //ルーティング設定
    const navigate = useNavigate();
    //イベント登録ボタンクリック時の処理
    const goToEventRagistration = ()=>{        
        navigate('/event/eventRegistration');
    }
    //ログアウトボタンをクリックしたときの処理
    const backToLogin = ()=>{
        navigate('/');
    }

    return (
        <nav className="shadow-md relative flex w-full">
            <div className="ml-4 mt-4 mb-4 mr-4">
                <div className="flex items-center">
                    <button 
                        type="button" 
                        className="bg-blue-500 text-white font-bold rounded-lg px-4 py-2 me-3 inline-block"
                        onClick={goToEventRagistration}
                        >
                        イベント登録
                    </button>
                    <button 
                        type="button" 
                        className="bg-pink-500 text-white font-bold rouded-lg px-4 py-2"
                        onClick={backToLogin}>
                        ログアウト
                    </button>
                </div>
            </div>
        </nav>
    )
}
```

## イベント登録画面を実装する
この画面で、サーバ通信をするためHTTP通信ライブライ<b>Axios</b>を使います。
また、<b>Axios</b>でPOST通信するにあたり、`Content-Typeをmultipart/form-data`に設定している場合、リクエストデータは<b><font color="red">FormDataオブジェクト</font></b>として送信する必要があります。

```frontend/src/event/eventRegistration/page.tsx

const formData = new FormData();
formData.append("eventName", eventName);
formData.append("eventDescription", eventDescription);
formData.append("eventThumbnail", eventThumbnail);

const response = await axios.post("http://localhost:8000/api/eventRegistration", formData, {
    headers: {
        "Content-Type": "multipart/form-data",
    },
});

//登録処理...

```

全体のコードはこちら↓
```frontend/src/event/eventRegistration/page.tsx
import { useNavigate } from "react-router-dom"
import { useState } from "react";
import axios from "axios";

export default function RegisterEvent(){
    //ルーティングの設定
    const navigate = useNavigate();

    const [eventName,setEventName] = useState("");
    const [eventDescription,setEventDescription] = useState("");
    const [eventThumbnail,setEventThumbnail] = useState<File | null>(null);
    //データベース登録後の画像パス
    const [uploadedImageUrl, setUploadedImageUrl] = useState<string>("");

    //登録ボタン押下時の処理
    const handleSubmit = async(e:React.FormEvent)=>{
        // POST送信するため、かつデフォルト動作を防止
        e.preventDefault(); 
        //イベント名、イベント内容が空の場合はデータベース登録しない
        if(!eventName || !eventDescription){
            alert("イベント名とイベント内容を入力してください。");
            return
        }
        //イベント画像が空文字列の場合はデータベース登録しない。
        if(!eventThumbnail){
            alert('画像を選択してください。');
            //リターンして処理終了
            return
        }

        const formData = new FormData();
        formData.append("eventName",eventName);
        formData.append("eventDescription",eventDescription);
        formData.append("eventThumbnail",eventThumbnail);
        console.log("フォームデータは、" + formData);

        formData.forEach((value, key) => {
            console.log(key, value);
          });

        try{
            const response = await axios.post("http://localhost:8000/api/eventRegistration",formData,{
                headers:{
                    "Content-Type":"multipart/form-data",
                }
            });
            if(response.request.status === 200){
                const { eventName,eventDescription,eventThumbnail } = response.data;
                alert(`登録成功：${eventName},${eventDescription},${eventThumbnail}`);
                //データベースに登録された画像をセットする
                setUploadedImageUrl(eventThumbnail);
                //navigate('/dashboard');　※アップローされた画像を画面で確認するためいったんコメントアウト
            }
        }catch(error){
            console.error(error);
            alert("登録に失敗しました");
        }
        
    }
    
   const backToDashboard = ()=>{
        navigate('/dashboard');
   }

    return (
        <div className="w-svw h-svh flex items-center justify-center min-h-screen border border-gray-300 px-4 py-4">
            <form onSubmit={handleSubmit} className="border border-gray-500 px-4 py-4 rounded-md w-full">
                <div>
                    <label className="font-bold">イベント名</label>
                    <input 
                        type="text" 
                        name="eventName" 
                        maxLength={10}
                        onChange={(e)=>setEventName(e.target.value)} 
                        className="w-full border border-blue-500 px-2 py-2 rounded-md hover:bg-blue-100"
                        placeholder="花見イベント"
                        required/>
                </div>
                <div>
                    <label className="font-bold">イベント詳細</label>
                    <textarea 
                        name="eventDescription" 
                        maxLength={30}
                        onChange={(e)=>setEventDescription(e.target.value)}
                        className="w-full border border-blue-500 px-2 py-2 rounded-md hover:bg-blue-100"
                        placeholder="花見のイベント詳細"
                        required
                        ></textarea>
                </div>
                <div>
                    <input 
                        type="file" 
                        name="eventThumbnail"
                        accept="image/*"
                        className="w-full border border-blue-500 px-2 py-2 rounded-md hover:bg-blue-100"
                        onChange={(e)=>setEventThumbnail(e.target.files?.[0] || null)}
                        />
                </div>
                <div className="flex items-center justify-center">
                    <button 
                        type="submit" 
                        name="eventRegistration"
                        className="bg-blue-500 text-white font-bold px-4 py-2 rounded-md mt-4 mr-4"
                        >
                            登録
                    </button>
                    <button 
                        type="button" 
                        className="bg-green-500 text-white font-bold rounded-md px-4 py-2 mt-4 mr-4">
                            クリア
                    </button>
                    <button 
                        type="button"
                        className="bg-rose-500 text-white font-bold rounded-md px-4 py-2 mt-4 mr-4"
                        onClick={backToDashboard}
                        >
                    一覧に戻る
                </button>
                </div>                                               
            </form>
            <div>
                {uploadedImageUrl && (
                    <div>
                        <h2>アップロードされた画像</h2>
                        <img src={`http://localhost:8000${uploadedImageUrl}`} alt="イベント画像"/>
                    </div>
                )}
            </div>
        </div>

    )
}
```

## <font color="red">【★重要★】React Routerを設定する</font>
新しく登録画面を作成したので、このパスを読み込ませる必要があります。
なので、`index.tsx`にルートパスを追加しておきましょう。

```frontend/src/index.tsx
import ReactDOM from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import App from "./App";
import Dashboard from "./dashboard/page";
import RegisterEvent from "./event/eventRegistration/page";
import "./index.css"; // Tailwind CSSをインポート

const Root = () => (
  <BrowserRouter>
    <Routes>
      {/* ルート設定 */}
      <Route path="/" element={<App />} />
      <Route path="/dashboard" element={<Dashboard />} />
      <Route path="/event/eventRegistration" element={<RegisterEvent/>}/>
    </Routes>
  </BrowserRouter>
);

const rootElement = document.getElementById("root");
if (rootElement) {
  const root = ReactDOM.createRoot(rootElement);
  root.render(<Root />);
} else {
  console.error("Root element not found");
}

```


# サーバ側(Laravel)を実装する。

つづいて、Laravel側を作成していきます。
## Controllerクラスの作成
`EventController.php`を作成するため、下記のコマンドを実行します。

```
php artisan make:controller EventController
```
`EventController.php`を下記のように修正します。

```backend/app/Http/Controllers/EventController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;//20250110追加
use App\Models\Event;//2025.01.10追加

class EventController extends Controller
{
    public function registerEvent(Request $request)
    {
        \Log::info('リクエストデータ:', $request->all());
        $request->validate([
            'eventName' => 'required|string|max:255',
            'eventDescription' => 'required|string|max:1000',
            'eventThumbnail' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048',
        ]);

        //元のファイル名を取得する
        $originalFileName = $request->file('eventThumbnail')->getClientOriginalName();
        //ファイル名が重複しないようにタイムスタンプを追加する。
        $fileName = time().'_'.$originalFileName;

        //カスタムファイル名で画像を保存する
        $imagePath = $request->file('eventThumbnail')->storeAs('public/event_thumbnails',$fileName);
        //公開URLを生成する
        $imageUrl = Storage::url($imagePath);

        //データベースに保存する
        $event = new Event();
        $event->name = $request->eventName;
        $event->description = $request->eventDescription;
        $event->thumbnail_path = $imageUrl;
        $event->save();

        return response()->json([
            'eventName' => $event->name,
            'eventDescription' => $event->description,
            'eventThumbnail' => $imageUrl,   
        ],200);
    }
}

```

## ルートの設定
フロントからのAPI連携するためにルートを設定します。

```backend/routes/api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\RegisterController;//2025.01.08追加
use App\Http\Controllers\EventController;//2025.01.10追加

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

Route::post('/login',[RegisterController::class,'login']);//2025.01.08追加
Route::post('/eventRegistration',[EventController::class, 'registerEvent']);//2025.01.10追加

```


## Modelクラスの作成
ブラウザから取得してきたデータを保持するための`Event.php`を作成します。
```
php artisan make:model Event
```
`Event.php`を下記のように修正します。

```backend/app/Models/Event.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Event extends Model
{
    use HasFactory;
    //20250110追加
    protected $fillable = ['name','description','thumbnail_path'];
}

```



## Migrationクラスの作成
LaravelとPostgreSQLでのデータマッピングをするために`create_events_table.php`を作成します。
```
php artisan make:migration create_events_table.php
```

`create_events_table.php`を下記のように修正します。

```backend/database/migrations/create_events_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('events', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->text('description');
            $table->string('thumbnail_path');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('events');
    }
};

```

マイグレーションを設定して、データ接続は完了です。

```
php artisan migrate
```



# <b><font color="red">【重要』シンボリックリンクを作成する</font></b>
サーバ側(Laravel)で保存した画像をブラウザで表示したいので<b><font color="red">シンボリックリンク</font></b>を設定する必要があります。
<b>storageディレクトリの公開設定は、忘れがちなので覚えておいてください</b>
そのため、下記のPHPコマンドを入力・実行しましょう。

```
php artisan storage:link
```

# CORS設定
フロントエンド（React）とバックエンド（Laravel）が異なるドメインやポートで動作している場合、CORS (Cross-Origin Resource Sharing) の設定が必要です。

Laravelの`app/Http/Middleware/VerifyCsrfToken.php`に以下を追加して、APIエンドポイントでCSRFトークンを無視させます。

```app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
    'api/eventRegistration',
];

```

# トラブルシューティング
作成にあたり、注意すべきポイントをまとめました。
## axios.postなのにネットワークはGETになっている場合
<b>1. onSubmitでリロードが発生している</b>
<form>のonSubmitイベントはデフォルトでページをリロードします。その結果、意図せずGETリクエストが送信されることがあります。

解決策: `e.preventDefault()`を追加 handleSubmit関数の最初に以下のコードを追加してください。

```sample.tsx
const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault(); // デフォルト動作を防止

```
<b>2. awaitとasyncが一致していない</b>
非同期処理の関数をasyncで宣言していない場合、関数が中断され、フォームのデフォルト動作（リロード）が発生します。

解決策: 確認 handleSubmitがasyncであることを確認してください

<b>3. action属性が指定されている場合</b>
<form>タグにaction属性が指定されていると、それが優先される可能性があります。

解決策: 確認して削除 <form>タグにactionが設定されていないことを確認してください。

```sample.tsx
<form onSubmit={handleSubmit}>
```

<b>4. buttonタグのtype属性が適切でない</b>
<button>タグのtype属性がbuttonでない場合、デフォルトでsubmitとして扱われます。これが原因で、意図しないリクエストが送信されることがあります。

解決策: 確認 送信ボタンにはtype="submit"を、他のボタンにはtype="button"を指定します。

```sample.tsx
<button type="submit" name="eventRegistration">登録</button>
```

<b>5. フォームのデータが正しく送信されていない</b>
axios.postのformDataが正しく設定されていない可能性があります。FormDataオブジェクトの中身を確認してください。

デバッグ方法: console.logで確認 以下のコードを追加して、送信データを確認します。
```sample.tsx
formData.forEach((value, key) => {
  console.log(key, value);
});

```

## HTTPステータスが500になっているとき
<b>Laravelのルート設定を確認</b>
ルートが正しく設定されていないと、リクエストがコントローラに到達しません。

APIルートの確認 (routes/api.php) 以下のようにregisterEventが登録されているか確認します。

<b><font color="red">Controllerクラスのインポートは忘れやすいので注意しましょう！</font></b>

```backend/routes/api.php
use App\Http\Controllers\EventController;　※この部分をわすれないこと！

Route::post('/eventRegistration', [EventController::class, 'registerEvent']);

```

## (役立ちメモ)Laravelのstoreで画像パスがハッシュ化される
Laravelで画像保存時に元のファイル名を保持するには、保存プロセスでファイル名をカスタマイズする必要があります。
デフォルトでは、Laravelのstoreメソッドはファイル名をハッシュ化して保存しますが、`storeAs`メソッドを使うと任意の名前で保存できます。

以下のようにコードを修正してください。

```sample.tsx
public function registerEvent(Request $request)
{
    \Log::info('リクエストデータ:', $request->all());

    $request->validate([
        'eventName' => 'required|string|max:255',
        'eventDescription' => 'required|string|max:1000',
        'eventThumbnail' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048',
    ]);

    // 元のファイル名を取得
    $originalFileName = $request->file('eventThumbnail')->getClientOriginalName();

    // ファイル名が重複しないようにタイムスタンプを追加
    $fileName = time() . '_' . $originalFileName;

    // カスタムファイル名で画像を保存する
    $imagePath = $request->file('eventThumbnail')->storeAs('public/event_thumbnails', $fileName);

    // 公開URLを生成
    $imageUrl = Storage::url($imagePath);

    // データベースに保存する
    $event = new Event();
    $event->name = $request->eventName;
    $event->description = $request->eventDescription;
    $event->thumbnail_path = $imageUrl; // カスタムファイル名のパス
    $event->save();

    return response()->json([
        'eventName' => $event->name,
        'eventDescription' => $event->description,
        'eventThumbnail' => $imageUrl,
    ], 200);
}

```

<b>ポイント</b>
元のファイル名を取得

$request->file('eventThumbnail')->getClientOriginalName()でアップロードされたファイルの元の名前を取得します。
重複回避のための工夫

元のファイル名が重複しないように、タイムスタンプ（time()）を追加しています。
storeAsメソッドを使用

storeAs('ディレクトリ名', 'カスタムファイル名')で、任意の名前でファイルを保存します。
データベースへの保存

元のファイル名を含むURLを保存します。

## (役立ちメモ)画像URLをReactに渡す
画像をアップロードした際に生成されるURL（例：/storage/event_thumbnails/<filename>）をLaravelのAPIレスポンスとしてReactに返します。
```sampleController.php

//何らかの処理...

return response()->json([
    'eventName' => $event->name,
    'eventDescription' => $event->description,
    'eventThumbnail' => $imageUrl, // 画像のURLを含む
], 200);


```

`$imageUrl`には、`Storage::url($imagePath)`で生成されたURLが含まれています。このURLは、Reactから画像を表示する際に使用します。

<b>Reactで画像を表示</b>
Laravelから返されたURLをReactで受け取り、そのURLを使って画像を表示します。

例：Reactコードの修正
以下は、登録後に画像を表示する例です。

```sample.tsx
import { useState } from "react";
import axios from "axios";

export default function RegisterEvent() {
    const [eventName, setEventName] = useState("");
    const [eventDescription, setEventDescription] = useState("");
    const [eventThumbnail, setEventThumbnail] = useState<File | null>(null);
    const [uploadedImageUrl, setUploadedImageUrl] = useState<string>("");

    const handleSubmit = async (e: React.FormEvent) => {
        e.preventDefault();

        if (!eventThumbnail) {
            alert("画像を選択してください。");
            return;
        }

        const formData = new FormData();
        formData.append("eventName", eventName);
        formData.append("eventDescription", eventDescription);
        formData.append("eventThumbnail", eventThumbnail);

        try {
            const response = await axios.post(
                "http://localhost:8000/api/eventRegistration",
                formData,
                {
                    headers: {
                        "Content-Type": "multipart/form-data",
                    },
                }
            );

            if (response.status === 200) {
                const { eventThumbnail } = response.data;
                setUploadedImageUrl(eventThumbnail); // 画像のURLを保存
                alert("登録成功！");
            }
        } catch (error) {
            console.error(error);
            alert("登録に失敗しました");
        }
    };

    return (
        <div>
            <form onSubmit={handleSubmit}>
                <div>
                    <label>イベント名</label>
                    <input
                        type="text"
                        name="eventName"
                        maxLength={10}
                        onChange={(e) => setEventName(e.target.value)}
                        required
                    />
                </div>
                <div>
                    <label>イベント内容</label>
                    <textarea
                        name="eventDescription"
                        maxLength={30}
                        onChange={(e) => setEventDescription(e.target.value)}
                    ></textarea>
                </div>
                <div>
                    <input
                        type="file"
                        name="eventThumbnail"
                        accept="image/*"
                        onChange={(e) => setEventThumbnail(e.target.files?.[0] || null)}
                    />
                </div>
                <button
                    type="submit"
                    name="eventRegistration"
                    className="bg-blue-500 text-white font-bold px-4 py-2 rounded-sm"
                >
                    登録
                </button>
            </form>

            {uploadedImageUrl && (
                <div>
                    <h3>アップロードされた画像:</h3>
                    <img src={`http://localhost:8000${uploadedImageUrl}`} alt="イベント画像" />
                </div>
            )}
        </div>
    );
}

```

<b>↑コード解説</b>
<b>APIレスポンスを使用</b>

Laravelから返されるeventThumbnail（画像のURL）をReact側でuploadedImageUrlとして保存します。
画像を表示

<img>タグのsrc属性に、http://localhost:8000（アプリのURL）と画像のURLを組み合わせて表示します。