#26 Laravel基礎

CSVインポート・エクスポート

CSVエクスポートの実装

一覧データをCSVファイルでダウンロードできる機能は、管理画面でよく求められます。


Responseで直接CSVを返す方法(シンプルな場合)

// 📁 app/Http/Controllers/PostController.php

use Symfony\Component\HttpFoundation\StreamedResponse;

public function exportCsv(): StreamedResponse
{
    $posts = Post::with('user')->published()->latest()->get();

    $headers = [
        'Content-Type'        => 'text/csv; charset=UTF-8',
        'Content-Disposition' => 'attachment; filename="posts_' . now()->format('Ymd') . '.csv"',
    ];

    $callback = function () use ($posts) {
        $handle = fopen('php://output', 'w');

        // BOM付きUTF-8(Excelで文字化けしない)
        fwrite($handle, "\xEF\xBB\xBF");

        // ヘッダー行
        fputcsv($handle, ['ID', 'タイトル', '著者', '作成日']);

        // データ行
        foreach ($posts as $post) {
            fputcsv($handle, [
                $post->id,
                $post->title,
                $post->user->name,
                $post->created_at->format('Y-m-d'),
            ]);
        }

        fclose($handle);
    };

    return response()->stream($callback, 200, $headers);
}

ルートとビューに追加します。

// 📁 routes/web.php

Route::get('/posts/export-csv', [PostController::class, 'exportCsv'])
     ->name('posts.export-csv')
     ->middleware('auth');
{{-- ダウンロードリンク --}}
<a href="{{ route('posts.export-csv') }}">CSVダウンロード</a>

CSVインポートの実装

フォームの作成

{{-- 📁 resources/views/posts/import.blade.php --}}

<form method="POST" action="{{ route('posts.import') }}" enctype="multipart/form-data">
    @csrf
    <input type="file" name="csv_file" accept=".csv">
    @error('csv_file')
        <p>{{ $message }}</p>
    @enderror
    <button type="submit">インポート</button>
</form>

コントローラー

// 📁 app/Http/Controllers/PostController.php

public function import(Request $request)
{
    $request->validate([
        'csv_file' => 'required|file|mimes:csv,txt|max:2048',
    ]);

    $file = $request->file('csv_file');
    $handle = fopen($file->getPathname(), 'r');

    // BOMを除去(Excelで作ったCSVに含まれることがある)
    $bom = fread($handle, 3);
    if ($bom !== "\xEF\xBB\xBF") {
        rewind($handle);
    }

    // ヘッダー行を読み飛ばす
    fgetcsv($handle);

    $imported = 0;
    $errors   = [];

    while (($row = fgetcsv($handle)) !== false) {
        // $row = ['タイトル', '本文', 'ステータス']
        [$title, $body, $status] = $row;

        if (empty($title) || empty($body)) {
            $errors[] = "タイトルまたは本文が空の行をスキップしました";
            continue;
        }

        Post::create([
            'title'   => trim($title),
            'body'    => trim($body),
            'status'  => in_array($status, ['draft', 'published']) ? $status : 'draft',
            'user_id' => auth()->id(),
        ]);

        $imported++;
    }

    fclose($handle);

    $message = "{$imported}件インポートしました。";
    if ($errors) {
        $message .= ' ' . count($errors) . '件スキップ。';
    }

    return redirect()->route('posts.index')->with('success', $message);
}

大量データのインポートはキューを使う

CSVが大きい場合は直接処理せずキューに投げます。

// 📁 app/Jobs/ImportPostsCsv.php

class ImportPostsCsv implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public string $filePath,  // ストレージに保存したファイルパス
        public int    $userId
    ) {}

    public function handle(): void
    {
        $handle = fopen(storage_path("app/{$this->filePath}"), 'r');
        // ...CSVを処理...
        fclose($handle);

        // 処理完了後にファイルを削除
        Storage::delete($this->filePath);
    }
}

コントローラーでファイルを一時保存してジョブをディスパッチします。

public function import(Request $request)
{
    $path = $request->file('csv_file')->store('imports');
    ImportPostsCsv::dispatch($path, auth()->id());
    return redirect()->back()->with('success', 'インポートをバックグラウンドで開始しました。');
}

まとめ

  • CSVエクスポートは response()->stream() でメモリ効率よく実装できる
  • fputcsv() でCSVを書き込む。BOM付きにするとExcelで文字化けしない
  • インポートは fgetcsv() で1行ずつ読み込み Post::create() でDBに保存する
  • 大量データのインポートはキューで非同期処理するのが安全

次回はフィーチャーテストの書き方を学びます。