Laravel活用術: エクセルを入力フォームへ変えて効率化する方法

こんにちは❗フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、これも「開発者あるある」なのかもしれませんが、「それいいですね❗やりましょう。」となるアイデアが出ても、最終的にこうなることってないでしょうか。

あれ、仕様的に使えなくなっちゃった…😭

そうです。

アイデア自体はすごくいいものだったのに、クライアントさんが求めることを考えると、それでは実装できないのでお蔵入りになるというパターンですね。

そして、先日私も同じことがあり先方さんに「アイデアはとても面白いので、お蔵入りなら、ぜひブログ記事にしてもいいでしょうか❓」と伺ったところ快くOKしていただいた機能があります。

それが・・・・・・

エクセルを入力フォームとして使う

というものです。

つまり、流れとしてはこうなります。

  1. エクセルをダウンロードする
  2. 各項目に入力をして保存
  3. そのエクセルをアップロードする

そして、これが実装できると以下のようなメリットがあります。

  • エクセルに慣れている人ならコピペが簡単
  • たくさんの入力や選択があるとフォームだとページ分割しないといけないけど、エクセルでは不要
  • 動的にエクセルに選択肢もつくれる
  • 入力が多い場合、途中で休憩できる(エクセルを保存するだけでOK)
  • (そして何より)我々開発者はフォームを作らずに済む(ヤッター✨😄👍)

そこで❗
今回は、Laravelを使ってエクセルをフォームの代わりとして使う機能を実装してみたいと思います。

ぜひ何かの参考になりましたら嬉しいです。😄

「最近、運の良さを実感しています
(スピリチュアルな話ではなくて😄)」

開発環境: Leaflet 1.9.4、Alpine.js 3.12、tailwindCSS 3.3.2

パッケージをインストールする

では、まずは必要なパッケージをインストールしておきましょう。
以下のコマンドを実行してください。

composer require phpoffice/phpspreadsheet

パッケージの準備は完了です❗

コントローラーをつくる

続いて、コントローラーをつくります。
以下のコマンドを実行してください。

php artisan make:controller SpreadsheetFormController

すると、ファイルが作成されるので、中身を以下のように変更してください。

app/Http/Controllers/SpreadsheetFormController.php

<?php

namespace App\Http\Controllers;

use App\Http\Requests\SpreadsheetFormRequest;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationRuleParser;
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;

class SpreadsheetFormController extends Controller
{
    public function create()
    {
        return view('spreadsheet_form.create');
    }

    public function store(SpreadsheetFormRequest $request)
    {
        // TODO: ここで DB へ保存(今回は省略します)

        return ['result' => true];
    }

    public function download()
    {
        $spreadsheet_data = config('spreadsheet_form.user_registration');

        $spreadsheet = new Spreadsheet();
        $sheet = $spreadsheet->getActiveSheet();

        $sheet->setCellValue('A1', '項目');
        $sheet->setCellValue('B1', '入力');
        $sheet->setCellValue('C1', '必須');

        $row_number = 2;

        foreach ($spreadsheet_data as $spreadsheet_values) {

            $label_location = 'A'. $row_number;
            $input_location = 'B'. $row_number;
            $required_location = 'C'. $row_number;

            $sheet->setCellValue($label_location, $spreadsheet_values['label']);

            // 選択肢
            $options = $spreadsheet_values['options'] ?? [];

            if(count($options) > 0) {

                $sheet->setCellValue($input_location, '選択してください');
                $validation = new DataValidation();
                $validation->setFormula1('"'. implode(',', $options) .'"');
                $validation->setType('list');
                $validation->setAllowBlank(true);
                $validation->setShowDropDown(true);
                $sheet->setDataValidation($input_location, $validation);

            }

            // 必須
            $rules = (! is_array($spreadsheet_values['rules']))
                ? explode('|', $spreadsheet_values['rules'])
                : $spreadsheet_values['rules'];
            $parser = new ValidationRuleParser($rules);

            if(in_array('required', $parser->data)) { // 必須の場合

                $sheet->setCellValue($required_location, '※');

            }

            $row_number++;

        }

        $path = storage_path('spreadsheet_form.xlsx');
        $writer = new Xlsx($spreadsheet);
        $writer->save($path);

        return response()->download($path)->deleteFileAfterSend();
    }
}

ちなみに、download()ではスプレッドシート用のデータを読み取り、それに合わせて動的にシートの中身を作っています。

つまり、データさえ変更すれば内容が自動的に変更できるということですね👍

データを共通化する

では、先ほどコントローラーの中で指定した「スプレッドシートのデータ情報」をつくりましょう。(共通化したいので、configフォルダの中に設置しました)

config/spreadsheet_form/user_registration.php

<?php

use Illuminate\Validation\Rule;

function user_registration()
{
    $favorite_foods = [
        'ラーメン',
        'カレー',
        '寿司',
        'パスタ',
        'ハンバーガー',
    ];

    return [
        [
            'column' => 'email',
            'label' => 'メールアドレス',
            'rules' => 'required|email|max:255',
        ],
        [
            'column' => 'password',
            'label' => 'パスワード',
            'rules' => [ // 配列バージョンでも OK!
                'required',
                'min:8',
                'max:255',
                'regex:/^[a-zA-Z0-9]+$/',
            ],
        ],
        [
            'column' => 'phone_number',
            'label' => '電話番号',
            'rules' => 'nullable|numeric|digits_between:10,11',
        ],
        [
            'column' => 'password',
            'label' => '好きな食べ物',
            'options' => $favorite_foods,
            'rules' => [
                'required',
                Rule::in($favorite_foods),
            ],
        ]
    ];
}

return user_registration();

※ なお、「好きな食べ物」データは重複する部分があるので、関数をつくりそこからデータを取得するようにしています。

バリデーション(FormRequest)をつくる

次に、これもコントローラーの中で指定した「SpreadsheetFormRequest」を作っていきます。この中ではバリデーション(入力チェック)を行うことになります。

以下のコマンドを実行してください。

php artisan make:request SpreadsheetFormRequest

すると、FormRequestファイルが作成されるので中身を以下のように変更します。

app/Http/Requests/SpreadsheetFormRequest.php

<?php

namespace App\Http\Requests;

use App\Rules\SpreadsheetForm;
use Illuminate\Foundation\Http\FormRequest;

class SpreadsheetFormRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
     */
    public function rules(): array
    {
        $spreadsheet_data = config('spreadsheet_form.user_registration');

        return [
            'excel_file' => [
                'required',
                'file',
                'mimes:xlsx',
                new SpreadsheetForm($spreadsheet_data)
            ]
        ];
    }

    public function attributes()
    {
        return [
            'excel_file' => 'Excel ファイル'
        ];
    }
}

そして、この中で指定した専用の独自バリデーション「SpreadsheetForm」をつくります。以下のコマンドを実行してください。

php artisan make:rule SpreadsheetForm

すると、Ruleファイルが作成されるので中身を次のように変更します。

app/Rules/SpreadsheetForm.php

<?php

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;

class SpreadsheetForm implements ValidationRule
{
    public function __construct(
        private array $spreadsheet_data
    ) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $request = request();
        $excel_path = $request->file($attribute)->path();
        $spreadsheet = null;

        try {

            $spreadsheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($excel_path);

        } catch (\Throwable $th) {

            $fail('Excel ファイルの読み込みに失敗しました。');
            return;

        }

        $sheet = $spreadsheet->getActiveSheet();
        $excel_data = $this->getExcelData($sheet);
        $this->validateExcelData($excel_data, $fail);
    }

    private function getExcelData($sheet)
    {
        $excel_data = [];

        foreach ($sheet->getRowIterator() as $i => $row) {

            if($i === 1) continue;

            $row_data = ['row_index' => $i];
            $keys = [
                'A' => 'name',
                'B' => 'value',
            ];
            $cellIterator = $row->getCellIterator();
            $cellIterator->setIterateOnlyExistingCells(false);

            foreach ($cellIterator as $j => $cell) {

                $key = $keys[$j] ?? null;

                if(! is_null($key)) {

                    $row_data[$key] = $cell->getValue();

                }

            }

            $excel_data[] = $row_data;

        }

        return $excel_data;
    }

    private function validateExcelData($excel_data, $fail)
    {
        $errors = [];

        foreach ($this->spreadsheet_data as $spreadsheet_values)
        {
            $checking_label = $spreadsheet_values['label'];
            $checking_rules = $spreadsheet_values['rules'];

            $excel_values = Arr::first(
                $excel_data,
                function($value) use ($checking_label) {

                    return $value['name'] === $checking_label;

                }
            );
            $excel_value = data_get($excel_values, 'value', null);
            $excel_row_index = data_get($excel_values, 'row_index', null);

            $random_str = Str::random(10);
            $validator = Validator::make(
                [$random_str => $excel_value],      // $data
                [$random_str => $checking_rules],   // $rules
                [],
                [$random_str => $checking_label]    // $attributes
            );

            if($validator->fails()) {

                $row_errors = Arr::flatten(
                    $validator->errors()->toArray()
                );

                foreach ($row_errors as $row_error) {

                    $errors[] = $excel_row_index .'行目: '. $row_error;

                }

            }

        }

        if(count($errors) > 0) {

            foreach ($errors as $error) {

                $fail($error);

            }

            return false;

        }

        return true;
    }
}

この中でやっていることは(ちょっと複雑に見えるかもですが)シンプルに以下のとおりです。

  1. Excel ファイルからデータを取得
  2. バリデーションで入力チェック
  3. エラーがひとつでもあったら、エラー処理をし、OKなら通過する

ビューをつくる

では、コントローラー内で指定したビューもつくりましょう。
以下のファイルを作成してください。

<html>
<head>
    <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1, maximum-scale=1, user-scalable=no">
    <title>Excel をフォームとして使うサンプル</title>
    <script src="https://cdn.tailwindcss.com/3.3.2"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.12.1/dist/cdn.min.js"></script>
</head>
<body>
<div x-data="spreadsheetForm">

    <div class="p-7">
        <h1 class="mb-8">&#x1F4CB; Excel をフォームとして使うサンプル</h1>

        <div class="mb-8">
            <h2 class="mb-4">
                <span class="text-sm px-2 py-1 mr-1 bg-gray-400 text-white rounded-full">1</span>
                Excel ファイルをダウンロードしてください。
            </h2>

            <form method="post" action="{{ route('spreadsheet_form.download') }}" class="m-0 p-0">
                @csrf
                <button type="submit" class="text-sm bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
                    ダウンロード
                </button>
            </form>
        </div>

        <div class="mb-8">
            <h2 class="mb-4">
                <span class="text-sm px-2 py-1 mr-1 bg-gray-400 text-white rounded-full">2</span>
                ファイルを開き、必要な項目を入力 & 選択して保存してください。
            </h2>
        </div>

        <div class="mb-8">
            <h2 class="mb-4">
                <span class="text-sm px-2 py-1 mr-1 bg-gray-400 text-white rounded-full">3</span>
                その Excel ファイルをアップロードしてください。
            </h2>
            <div class="py-3">
                <label class="text-sm bg-blue-500 hover:bg-blue-700 text-white font-bold py-2.5 px-4 rounded">
                    <input type="file" name="excel_file" class="hidden" accept=".xlsx" @change="onFileChange" />
                    アップロード
                </label>
            </div>
            <template x-if="errors.excel_file">
                <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 mt-4 rounded relative" role="alert">
                    <strong class="font-bold">&#x26A0; エラー!</strong>
                    <template x-for="error in errors.excel_file">
                        <div x-text="error"></div>
                    </template>
                </div>
            </template>
        </div>

    </div>
</div>
<script>

    const spreadsheetForm = {
        errors: [],
        onFileChange(e) {

            const files = e.target.files;
            const file = files[0];

            if(file instanceof File) {

                this.errors = [];

                const url = '{{ route('spreadsheet_form.store') }}';
                const formData = new FormData();
                formData.append('excel_file', file);

                axios.post(url, formData)
                    .then((response) => {

                        alert('アップロードが完了しました。');
                        location.reload();

                    })
                    .catch((error) => {

                        this.errors = error.response.data.errors;

                        // ファイル選択をクリア
                        const el = document.querySelector('input[type="file"][name="excel_file"]');
                        el.value = '';

                    });

            }

        }
    };

</script>
</body>
</html>

なお、ここでAlpine.jsaxiosTailwindCSSを使っています。

ルートをつくる

では、最後にルートです。
以下の部分をすでに存在しているファイルに追加してください。

routes/web.php

use App\Http\Controllers\SpreadsheetFormController;

// 省略
Route::prefix('spreadsheet_form')->controller(SpreadsheetFormController::class)->group(function () {
    Route::get('/create', 'create')->name('spreadsheet_form.create');
    Route::post('/', 'store')->name('spreadsheet_form.store');
    Route::post('/download', 'download')->name('spreadsheet_form.download');
});

これで作業は完了です。
お疲れ様でした。😄✨

テストしてみる

では、実際にテストしてみましょう❗

まず、ブラウザで「https://******/spreadsheet_form/create」へアクセスします。

ダウンロードとアップロードのボタンが表示されています。
では、まずは「ダウンロード」ボタンをクリックしてみましょう。

すると・・・・・・

はい❗
自動でExcelファイルがダウンロードされました。

では、中身を見てみましょう。

はい❗
各項目がセットされていて、「選択してください」のセルをクリックするとドロップダウンが表示されました(スクショできなかった…😅)

では、まずは何も変更しない(エラーが出るだろう)状態でアップロードしてみましょう。

すると・・・・・・

はい❗
間違っている行のエラーが表示されました。

まずは成功ですね。

では、次にちゃんとした入力をして保存してからアップロードしてみましょう。

どうなるでしょうか・・・・・・🤔

はい❗
バリデーションが通過したので、完了のポップアップがでました。

すべて成功です😄✨

企業様へのご提案

今回実装した内容は基本的な機能を使ったものでしたが、PhpSpreadsheetでは以下のようなものをセルに用意することもできます。

  • リンク
  • 枠線
  • オートフィルタ
  • 改ページ

そのため、ダウンロードするExcelファイルもより複雑な内容に対応することができるでしょう。

もしこういった機能をご用意になりたい場合は、お気軽にお問い合わせからご相談ください。

お待ちしております。😄✨

おわりに

ということで、今回は「Laravel + Excelファイル」でフォームの代わりになるような機能をつくってみました。

このアイデアを最初聞いたとき「おっ、それは面白そう❗」と思いました。
お蔵入りになったからとは言え、こうしてブログ記事にすることを承諾していただいたクライアントさん、感謝です。🙏✨

なお、今回は利用しやすいExcelファイルでしたが、仕様によってはWordや入力できるPDFなんかでも同じようなことができそうです。

ぜひ皆さんもそんなカンジで研究してみてくださいね。

ではでは〜❗

「1年経っても日焼け跡が
とれなかった…
…のにまた炎天下へ出かける❗」

開発のご依頼お待ちしております 😊✨
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly  

開発効率を上げるための機材・まとめ