データベースを使う

前回終了時のソースコード

Laravelを使ったアプリケーション開発のソースコードは以下のようにしてダウンロードすることができます。

$ git clone https://kiku3.tsbio.info/git/study-laravel.git study_laravel

この章開始時点のソースコードは chapt5 ブランチにあります。

$ git switch chapt5

ソースコードを自分で書いていく場合は、自分用のブランチをつくるとよいです。

$ git switch -c my5 chapt5

Laravelのコードはsrcディレクトリからの相対パスになっています。

データベースを使う

PostgreSQLを使えるようにして、データベースと連携したアプリケーションにします。

PHPのモジュールを入れる

Dockerのイメージを更新します。

php/Dockerfile
FROM php:8.5-fpm

RUN apt-get update && apt-get install -y unzip \
    libpq-dev \
    libonig-dev \
    && docker-php-ext-install pdo pdo_pgsql

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html
$ sudo docker compose build

dockerでPostgreSQLを使う

すでにデータベースがあるのなら改めてつくる必要はありません。 docker-compose.yamlにpostgresqlの設定を加えて起動します。

docker-compose.yaml
services:
  php:
    build: ./php
    container_name: laravel_php
    volumes:
      - ./src:/var/www/html
      - vendor:/var/www/html/vendor
    environment:
      - TZ=Asia/Tokyo
 
  nginx:
    image: nginx:1.27-alpine
    container_name: laravel_nginx
    ports:
      - "8002:80"
    volumes:
      - ./src/public:/var/www/html/public:ro
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
 
  db:
    image: postgres:18.4-bookworm    # 本稿執筆時の最新版。特段、このバージョンでないといけないということはない
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - db_data:/var/lib/postgresql
    ports:
      - "5432:5432"
 
volumes:
  vendor:
  db_data:
$ sudo docker compose up -d

.env を編集する

データベース関連の設定をします。

.env
DB_CONNECTION=pgsql
DB_HOST=db
DB_PORT=5432
DB_DATABASE=appdb
DB_USERNAME=appuser
DB_PASSWORD=secret

接続を確認する

artisantinker サブコマンドを使うと、アプリケーションを起動した状態で、PHPのコマンドを実行できます。 dockerを使っている場合はsudo docker compose exec -it php bash してから実行します。

# ./artisan tinker 
> DB::select('select 1')

= [ {#7411 +"?column?": 1, }, ]

テーブルをつくる

laravelでテーブルをつくるのはマイグレーション(migration)と言います。 マイグレーションは元々移住とか移動とかという意味ですが、コンピュータ関係では別の環境への乗り換えという意味で使われます。 データベースのスキーマ(テーブルの構造に現れる枠組み)を変更することをマイグレーションと呼ぶようになりました。

テーブルの定義もPHPのコードで書くことができ、その変更をartisanコマンドで管理することができます。

# ./artisan make:migration create_oligos_table

これにより database/migrations 以下に 2026_07_30_082653_create_oligos_table.php が作られます。 ファイル名の数字の部分は、作成した日時などからなる重複しない番号になっています。

このファイルを下記のように編集します。

database/migrations/2026_07_30_082653_create_oligos_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('oligos', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('sequence');
            $table->float('concentration')->nullable(); // 濃度。nullableをつけると、値がなくてもよいことになる
            $table->string('box')->nullable();      // 保管場所
            $table->string('owner')->nullable();    // 所有者
            $table->string('supplier')->nullable();    // 販売元
            $table->string('note')->nullable();
            $table->timestamps();       // created_at, updated_at が作られる
            $table->softDeletes();       // deleted_at が作られる
        });
    }
 
    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('oligos');
    }
};

Blueprintオブジェクトの$tableにメソッドを適用することで、データの型を指定していきます。列をつくるメソッドをいくつか下に示します。

メソッド 作成される型
id 自動採番で主キーになる。引数を省略するとidという列名が使われる。
integer 整数
float 浮動小数点数
double floatより精度の高い浮動小数点数
string 文字数を指定した文字列。デフォルトは255文字。
text 長い文字列。
date 日付のみ
timestamp 日時
timestamps created_at と updated_atがつくられ、レコードの作成日時と更新日時が自動的に記録される。
softDeletes deleted_atがつくられ、nullでないときに見えないようになる。

レコードに対する制約をつけるには、次のメソッドを使います。

メソッド 制約
nullable null値を許容する。デフォルトはNOT NULLなので、つけなければNOT NULLになる。
default デフォルト値を設定する。

共通ID

デフォルトではテーブルごとにid列がつくられ、シリアル型が設定されます。 しかし、この方式ではリソースが増えてきたときに同じ番号のものがつけられるようになります(例えばオリゴDNAと種子に同じidがふられる)。 基本的にこのidをユーザーが意識することはないので、このままでもよいのですが、リソースとして重複しない番号をつけるようにすることができます。

その場合、リソースにつける番号をlab_idとして管理します。 そのためにマイグレーションを次のように修正します。 このシリアル値を定義するスクリプトのファイル名は、できるだけ先頭のほうにくるようにします。

# ./artisan make:migration create_lab_id

database/migrations/2026_07_30_000001_create_lab_id.php
<?php
 
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
 
return new class extends Migration
{
    private const SEQUENCE = 'lab_id';
 
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        DB::statement("CREATE SEQUENCE IF NOT EXISTS " . self::SEQUENCE);
    }
 
    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        DB::statement("DROP SEQUENCE IF EXISTS " . self::SEQUENCE);
    }
};
database/migrations/2026_07_30_082653_create_oligos_table.php
<?php
 
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
 
return new class extends Migration
{
    private const SEQUENCE = 'lab_id';
 
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('oligos', function (Blueprint $table) {
            $table->integer('id')
                ->primary()
                ->default(DB::raw("nextval('" . self::SEQUENCE . "')"));
            $table->string('name');
            $table->string('sequence');
            $table->float('concentration')->nullable(); // 濃度。nullableをつけると、値がなくてもよいことになる
            $table->string('box')->nullable();      // 保管場所
            $table->string('owner')->nullable();    // 所有者
            $table->string('supplier')->nullable();    // 販売元
            $table->string('note')->nullable();
            $table->timestamps();       // created_at, updated_at が作られる
            $table->softDeletes();       // deleted_at が作られる
        });
    }
 
    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('oligos');
    }
};

テーブルの作成

artisanのmigrationサブコマンドを実行すると、migrationsフォルダにあるスクリプトが一度だけ実行されます。

# ./artisan migrate

laravelインストール時にあるusers, cache, jobsテーブルもつくられる。またマイグレーション管理用のテーブルもできる。

$ sudo docker compose exec -it db psql -U appuser appdb
appdb=# \d
                  List of relations
 Schema |         Name          |   Type   |  Owner
--------+-----------------------+----------+---------
 public | cache                 | table    | appuser
 public | cache_locks           | table    | appuser
 public | failed_jobs           | table    | appuser
 public | failed_jobs_id_seq    | sequence | appuser
 public | job_batches           | table    | appuser
 public | jobs                  | table    | appuser
 public | jobs_id_seq           | sequence | appuser
 public | lab_id                | sequence | appuser
 public | migrations            | table    | appuser
 public | migrations_id_seq     | sequence | appuser
 public | oligos                | table    | appuser
 public | password_reset_tokens | table    | appuser
 public | sessions              | table    | appuser
 public | users                 | table    | appuser
 public | users_id_seq          | sequence | appuser
(15 rows) 

初期データを投入する

csvで保存してあったオリゴDNAのデータをいれます。

コンソールで動かすコマンドの作成

artisanコマンドを使って、コンソールで動かすスクリプトのテンプレートをつります。

# ./artisan make:command ImportOligo

これによって app/Console/Commands/ImportOligo.php が作られる。 これを以下のように修正する。

app/Console/Commands/ImportOligo.php
<?php
 
namespace App\Console\Commands;
 
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
 
#[Signature('app:import-oligo')]
#[Description('Import oligo data from a CSV file')]
class ImportOligo extends Command
{
    public function handle()
    {
        $csv = storage_path('app/private/oligo.csv');
        $table = 'oligos';
 
        $fp = fopen($csv, 'r');
        $items = [];
        while($row = fgetcsv($fp)){
            $item = array_combine(['name', 'sequence', 'created_at', 'owner'], $row);
            try{
                DB::table($table)->insert($item);
            } catch (\Throwable $e) {
                var_dump($item);
                $this->error("Failed to insert row into table: ${table}");
                return self::FAILURE;
            }
        }
        fclose($fp);
        return self::SUCCESS;
    }
}

class名の上の部分にある#[Signature('app:import-oligo')] はアトリビュート(属性)で、続くclassのメタデータとなります。 Illuminate\Console\Command クラスはこれをリフレクションによって読み込み、クラス変数のsignatureを設定します。 この値は artisanのサブコマンドとしてはたらき、handleメソッドが呼ばれることになります。

#[Description('Import oligo data from a CSV file')] も同じです。

実行

できたスクリプトを下記のように実行します。

# ./artisan app:import-oligo 

テーブルをCSVで出力する

データベースのテーブルをCSV形式で出力できるようにします。

コマンドをつくる

# ./artisan make:command ExportTable
app/Console/Commands/ExportTable.php
<?php
 
namespace App\Console\Commands;
 
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
 
#[Signature('app:export-table {table}')]
#[Description('Export table data to a CSV file')]
class ExportTable extends Command
{
    public function handle()
    {
        $table = $this->argument('table');
 
        $path_template = join('-', [$table, now()->format('ymd'), '%02d.csv']);
        $n = 1;
 
        // ファイルが存在する間は番号を増やしてループを続ける
        do {
            $csv = storage_path("app/private/" . sprintf($path_template, $n++));
        } while (file_exists($csv));
 
        try{
            $rows = DB::table($table)->get();
        } catch (\Throwable $e) {
            $this->error("Failed to fetch rows from table: ${table}");
            return self::FAILURE;
        }
 
        if ($rows->isEmpty()) {
            $this->warn("No rows found in table: {$table}");
            return self::SUCCESS;
        }
 
        $out = fopen($csv, 'w');
 
        // ヘッダー行
        fputcsv($out, array_keys((array) $rows->first()));
 
        // データ行
        foreach ($rows as $row) {
            fputcsv($out, (array) $row);
            $this->warn("Exported row: " . json_encode($row));
        }
 
        fclose($out);
        $this->info("Exported to $csv.");
 
        return self::SUCCESS;
    }
}

実行する

# ./artisan app:export-table oligos 

Eloquentを使う

Eloquentは、Laravel標準の、Object Relational Mapper (ORM)です。 データベースのテーブルと、PHPのクラスを対応づけて、SQLを使わずに入出力などができるようにします。

モデルを作成する

データベースのテーブルやそのレコードを表すものをモデルと呼びます。 表をPHPのオブジェクトにすることで、レコードの取り出しをSQLではなく、メソッドで行うことができるようになります。 また、レコードの作成、更新や削除もメソッドを通じて行うことができます。

app/Models/Oligo.php
<?php
 
namespace App\Models;
 
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
 
class Oligo extends Model
{
    use SoftDeletes; // deleteしたときに、実際に削除せず、deleted_atに時刻を設定する
 
    //  表示しない列を指定する (toArray, jsonなどで出力されない)
    protected $hidden = ['deleted_at'];
}

一覧表示に使用する

データの取得元をCSVファイルからデータベースに変更します。

app/Http/Controllers/OligoController.php
<?php
 
namespace App\Http\Controllers;
 
use App\Http\Requests\OligoRequest;
use App\Models\Oligo; // Oligoモデルを使う
 
class OligoController
{
    public function index(){
        $items = Oligo::all();  // すべてのレコードを取得する
        return view('oligo.index', compact('items'))
            ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-index")
        ;
    }
 
    public function create(){
        return view('oligo.create')
	    ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-create")
        ;
    }
 
    public function store(OligoRequest $request){
        $validated = $request->validated();
        $data = [];
        foreach(['name', 'sequence', 'created_at', 'owner'] as $key){
            $data[] = sprintf('"%s"', $validated[$key] ?? '');
        }
        $csv = storage_path('app/private/oligo.csv');
        file_put_contents($csv, implode(',', $data).PHP_EOL, FILE_APPEND);
 
        return redirect()->route('web.oligo.index')->with('status', '保存しました');
    }
}

ビューも少し修正して、IDも表示するようにしました。 フィールドの表示は $item['name'] のままですが、 $item->name のようにオブジェクトとしても書けます。

resources/views/oligo/index.blade.php
@extends('oligo.module')
 
@section('feature')
<x-feature id='oligo-index'>
  <table>
    <thead>
      <tr><th>id</th><th>名前</th><th>配列</th><th>作成日</th><th>所有者</th></tr>
    </thead>
    <tbody>
      @foreach($items as $item) {{-- コントローラーから渡された$itemsを表示する --}}
        <tr>
          <td>{{ $item['id'] }}</td>  {{-- $item->id とすることもできる --}}
          <td>{{ $item['name'] }}</td>
          <td>{{ $item['sequence'] }}</td>
          <td>{{ $item['created_at'] }}</td>
          <td>{{ $item['owner'] }}</td>
        </tr>
      @endforeach
    </tbody>
  </table>
</x-feature>
@endsection

データの登録に使用する

連想配列でデータを渡しますが、そのときにフィールドとして有効なキーをfillableとして設定する。

app/Models/Oligo.php
<?php
 
namespace App\Models;
 
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Attributes\Fillable;
 
/*
 * Fillableで指定した列はcreateとかupdateで新しい値を配列として受け取ることができる。
 * ここにない名前は与えられたとしても無視される。['id' => 123] というのを渡されたとしてもidは更新されない。
 */
#[Fillable(['name', 'sequence', 'concentration', 'box', 'owner', 'note', 'created_at', 'updated_at'])]
class Oligo extends Model
{
    use SoftDeletes;
 
    protected $hidden = ['deleted_at'];
}

コントローラーでデータをモデルに渡して、テーブルに追加します。

app/Http/Controllers/OligoController.php
<?php
 
namespace App\Http\Controllers;
 
use App\Http\Requests\OligoRequest;
use App\Models\Oligo; // Oligoモデルを使う
 
class OligoController
{
    public function index(){
        $items = Oligo::all();  // すべてのレコードを取得する
        return view('oligo.index', compact('items'))
            ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-index")
        ;
    }
 
    public function create(){
        return view('oligo.create')
	    ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-create")
        ;
    }
 
    public function store(OligoRequest $request){
        $validated = $request->validated();
        Oligo::create($validated);
 
        return redirect()->route('web.oligo.index')->with('status', '保存しました');
    }
}

日付の表示方法を変更する

研究室では日付をよく6桁の数字で表します。date('ymd') という感じです。 この形式を取り扱えるようにします。

複数のテーブルで使用するので、Traitとしてつくります。

app/Traits/ConvertsYmdDate.php
<?php
 
namespace App\Traits;
 
use Carbon\Carbon;
 
trait ConvertsYmdDate
{
    /**
     * 6桁の数字 (YYMMDD) → 日付 (YYYY-MM-DD)
     */
    protected function ymdToDate(string $ymd): string
    {
        if(preg_match('/^\d{6}$/', $ymd)){
            return Carbon::createFromFormat('ymd', $ymd)->startOfDay();
        }
        return $ymd;
    }
 
    /**
     * 日付 (YYYY-MM-DD) → 6桁の数字 (YYMMDD)
     */
    protected function dateToYmd(string $date): string
    {
        // Carbon を使って安全に変換
        return Carbon::parse($date)->format('ymd');
    }
}

モデルを修正して、created_atとupdated_atにアクセスするときに、Traitで定義したメソッドを使うようにします。

app/Models/Oligo.php
<?php
 
namespace App\Models;
 
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Casts\Attribute;  # 
use App\Traits\ConvertsYmdDate;
 
#[Fillable(['name', 'sequence', 'concentration', 'box', 'owner', 'note', 'created_at', 'updated_at'])]
class Oligo extends Model
{
    use SoftDeletes;
    use ConvertsYmdDate;
 
    //  表示しない(toArray, jsonなどで出力されない)
    protected $hidden = ['deleted_at'];
    protected function createdAt(): Attribute
    {
        return Attribute::make(
            get: function ($value, $attributes) {
                return isset($attributes['created_at'])
                    ? $this->dateToYmd($attributes['created_at'])
                    : null;
            },
            set: function ($value) {
                return ['created_at' => $value ? $this->ymdToDate($value) : null];
            },
        );
    }
 
    protected function updatedAt(): Attribute
    {
        return Attribute::make(
            get: function ($value, $attributes) {
                return isset($attributes['updated_at'])
                    ? $this->dateToYmd($attributes['updated_at'])
                    : null;
            },
 
            set: function ($value) {
                return ['updated_at' => $value ? $this->ymdToDate($value) : null];
            }
        );
    }
}

検索

テーブルを検索できるようにします。必要なものは以下のとおりです。

  1. 検索用のフォーム -> index.blade.phpにつくる。
  2. 検索クエリの送信 -> Javascriptのfetchで/oligo/searchに送信する。
  3. クエリの受信 -> web.phpでsearchルートを定義。OligoControllerへ送る。
  4. 検索クエリの検証 -> OligoSearchRequestで行う。
  5. SQLの実行 -> OligoServiceにつくる。
  6. 検索結果の送信 -> JSONで送る。index.blade.phpで表にする。

検索フォームをつくる

フォーム(inputタグなど)を resources/views/oligo/index.blade.php につくる。 フォームの要素にはx-modelを設定し、Javascriptで送信できるようにする。

resources/views/oligo/index.blade.php
@extends('oligo.module')
 
@section('feature')
<x-feature id='oligo-index'>
  <table
    class='border-2 border-cyan-600'
 
    {{-- x-initはAlpine.jsが処理し、itemsを設定する --}}
    x-init="items = @js($items)"  {{-- @jsはBladeのディレクティブ。$itemsをJSONにする。 --}}
  >
    <thead>
      {{-- thead 1行目 見出し --}}
      <tr class='bg-cyan-400 border-cyan-600 border-t-2'>
        <th></th>
        <th>{{ __('名前') }}</th>
        <th>{{ __('配列') }}</th>
        <th>{{ __('作成日') }}</th>
        <th>{{ __('更新日') }}</th>
        <th>{{ __('作成者') }}</th>
        <th></th>
      </tr>
 
      {{-- thead 2行目 フォーム --}}
      {{-- @inputはAlpine.jsのイベントリスナー。入力値が変更になったときに実行するJavascriptを設定する
        -- .debounce.500を後につけることで、500ミリ秒遅延させる。
        --}}
      <tr @input.debounce.500="search()" class="border-cyan-600 border-b-2 align-text-top">
        <th>検索</th>
        <td class="name">
          {{-- x-modelはAlpine.jsが処理する。フォームの入力値が変更されると変数の値を変え、変数の値が変わるとフォームの値(見た目)が変わる --}}
          <input x-model="query.name" placeholder="DROL1">
          <label>
              <input type="checkbox" x-model="query.use_regex" value=1 title="正規表現">
              正規表現
          </label>
        </td>
        <td class='sequence'>
          <input x-model="query.sequence" placeholder="ATCG...">
        </td>
        <td class='date'>
          <input x-model="query.created_at" placeholder="260101"
            title="6桁の数字で入力してください。-で範囲を指定できます。"
          >
        </td>
        <td class='date'>
          <input x-model="query.updated_at" placeholder="260201"
            title="6桁の数字で入力してください。-で範囲を指定できます。"
          >
        </td>
        <td class='owner'>
          <input x-model="query.owner">
        </td>
        <td></td>
      </tr>
 
      {{-- thead 3行目 ボタン --}}
      <tr>
        <td colspan="9" class='px-2 py-2'>
          <button @click="search()">  {{-- @inputで自動実行されるが、ボタンも加えておく --}}
            検索
            <x-ajax-state var='query' />
          </button>
 
          {{-- チェックボタンで選択・非選択をする --}}
          <button @click.prevent="
            $el.closest('table')
              .querySelector('tbody')
              .querySelectorAll('input[type=checkbox]')
              .forEach(cb => cb.click())
            "
          >
            チェックを反転
          </button>
 
          <button @click="query = {}">条件クリア</button>
        </td>
      </tr>
 
      {{-- thead 4行目 検索結果 --}}
      <tr class='border-cyan-600 border-b-2'>
        <td x-show="items.length == 0" colspan="9">
          条件に合うものは見つかりませんでした。
        </td>
        <td x-show="items.length > 0" colspan="9">
          検索結果: <span x-text="items.length"></span>件
          query:
          <template x-for="key in Object.keys(query)" :key="key">
            <template x-if="query[key] !== undefined && query[key] !== ''">
              <span x-text="key"></span>=<span x-text="query[key]"></span>
            </template>
          </template>
        </td>
      </tr>
    </thead>
 
    <tbody>
      {{-- 検索結果 --}}
      {{-- Alpine.js (x-for)によって、items配列のそれぞれの要素をitemに取り出し、template要素を増やす --}}
      <template x-for="item in items" :key="item.id">
        <tr>
          <td><input x-model="item.checked" type='checkbox'></td>
          <td x-text="item.name" class="name"></td>
          <td x-text="item.sequence" class="sequence"></td>
          <td x-text="item.created_at" class="date"></td>
          <td x-text="item.updated_at" class="date"></td>
          <td x-text="item.owner" class="owner"></td>
          <td x-text="item.id"></td>
        </tr>
      </template>
    </tbody>
  </table>
</x-feature>
@endsection

resources/views/components/ajax-state.blade.php
@props(['var' => 'item'])
<template x-if="{{ $var }}.status === 'loading'">
  <span style="color: gray;">⏳</span>
</template>
 
<template x-if="{{ $var }}.status === 'success'">
  <span style="color: green;">✔</span>
</template>
 
<template x-if="{{ $var }}.status === 'error'">
  <span style="color: red;">✖</span>
</template>

表やボタンの外観 (CSS) をモジュール内でそろえるために、resources/css/oligo.css をつくる。 これは resources/css/app.css で@importする。

resources/css/oligo.css
#oligo {
    table {
        thead th,thead td {
            @apply border-x border-cyan-600;
        }
 
        td {
            @apply border-x border-cyan-600 p-1;
        }
        td input {
            &[type=text], &:not([type]) {
                @apply border border-cyan-600 w-full min-w-0 px-1 h-[24px];
            }  
        }
 
        .name {
            width: 10em;
        }
        .owner {
            width: 5em;
        }
        .date, .created_at, .updated_at {
            width: 8em;
        }
        .note {
            width: 15em;
        }
        .sequence {
            @apply font-mono text-sm w-[30em] h-[24px];
        }
    }
 
    button {
        @apply border-cyan-600 bg-cyan-200;
        &:hover {
            @apply bg-cyan-600 text-white;
        }
    }
}
app.cssに@import “oligo.css"; を加える。

クエリの送信

検索フォームの値(query)をAjaxで送信するコードを #features に定義する。

resources/views/oligo/module.blade.php
@extends('layouts.app')
 
@section('module')
  <x-module id='oligo'>
    <!-- モジュールのメニュー -->
    <nav>
      <ul class='feature-tabs' role='tablist'>
        @foreach([
          'index' => '一覧',
          'create' => '追加',
        ] as $page => $label)
          <li class='feature-tab'
            :class="{
              'bg-white': activeFeature === 'oligo-{{ $page }}',
              'bg-gray-400 hover:bg-gray-200': activeFeature !== 'oligo-{{ $page }}'
            }"
          >
            <a href='{{ route("web.oligo.{$page}") }}' id='oligo-{{ $page }}-tab' role='tab'
              @click.prevent="selectTab(event)"
            >
              {{ $label }}
            </a>
          </li>
        @endforeach
        <li class='feature-tab bg-gray-400' x-text="activeFeature">
        </li>
      </ul>
    </nav>
    
    <div class='features'
      {{-- oligoモジュール全体で使用できるようにここにx-dataを定義する --}}
      x-data="{
        items: [], // テーブルから抽出されたデータ。今後更新や削除の機能でも使用できるように、ここ(#features)に定義する。
        query: {}, // 検索の条件。x-modelとして設定することで入力フォームの値がそのままJavascriptで使用できる
 
        // 検索 fetchは非同期(async)に実行されるので、それにそろえる
        async search(){
          const parameter = new URLSearchParams(this.query);  // 同じx-data内なので this.queryとなる。これをGETパラメーター(?key1=value1&key2=value2)に変換する
          console.log('parameter:', parameter.toString());  // 動作確認
          const options = {
            method: 'GET',
            headers: {
              'Accept': 'application/json',  // 返り値の設定。Laravelが判断するのに使う。
              'Content-Type': 'application/json',
            },
          };
 
          return fetch('{{ route('web.oligo.search') }}?' + parameter.toString(), options)
            .then(response => response.json())
            .then(response => {
              console.log('search done', response);  // 動作確認
              return this.append(response.data.items);  // itemsを置き換えるappendを呼ぶ
            })
            .catch(error => {debug(error)})
          ;
        },
 
        // 検索結果をitemsに反映させる。今後、新しい検索結果で置き換えるのではなく、追加するように修正していくので、appendとしてある。
        append(items){
          this.items = items;
        }
      }"
    >
      @yield('feature')
    </div>
  </x-module>
@endsection

クエリの受信

routes/web.php にOligoControllerへのルートを設定する。

routes/web.php
<?php
 
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\OligoController;
 
Route::get('/', function () {
    return view('index');
});
 
/*
 *	コントローラーを経由するルート
 */
Route::prefix('/oligo')->as('web.oligo.')->group(function (){
    Route::get('/', [OligoController::class, 'index'])->name('index');
    Route::get('/search', [OligoController::class, 'search'])->name('search');
    Route::get('/create', [OligoController::class, 'create'])->name('create');
    Route::post('/', [OligoController::class, 'store'])->name('store');
});

OligoSearchRequestを受け取るようにする。

app/Http/Controllers/OligoController.php
<?php
 
namespace App\Http\Controllers;
 
use App\Http\Requests\OligoRequest;
use App\Models\Oligo;
use App\Http\Requests\OligoSearchRequest;  // 検索フォーム
use App\Services\OligoService;  // 検索を担うサービス
 
class OligoController
{
    /* LaravelのDI (Dependency Injection)によってOligoServiceのオブジェクトが渡される。
     * privateとすることで、自動的に $this->service = $service が行われる。
     */
    public function __construct(
        private OligoService $service
    ) {
    }
 
    public function index(){
        $items = Oligo::all();
        return view('oligo.index', compact('items'))
            ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-index")
        ;
    }
 
    public function create(){
        return view('oligo.create')
	    ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-create")
        ;
    }
 
    public function store(OligoRequest $request){
        $validated = $request->validated();
        Oligo::create($validated);
 
        return redirect()->route('web.oligo.index')->with('status', '保存しました');
    }
 
    // 検索
    public function search(OligoSearchRequest $request)
    {
        try{
            $query = $request->validated();
            $query['use_regex'] = $query['use_regex'] ?? false;  // たぶんOligoSearchRequestのほうに移動したほうが美しい
            return response()->json([
                'status' => 'success',
                'data' => $this->service->search($query),
            ], 200);
 
        }catch(\Throwable $e){
            return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400);
        }
    }
}

入力値(Request)のバリデーション

app/Http/Requests/OligoSearchRequest.php
<?php
 
namespace App\Http\Requests;
 
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
 
class OligoSearchRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => 'nullable|string|max:255',
            'sequence' => 'nullable|string|max:255',
            'box' => 'nullable|string|max:255',
            'owner' => 'nullable|string|max:255',
            'note' => 'nullable|string|max:255',
            'updated_at' => 'nullable|regex:/^[0-9]*-?[0-9]*$/',
            'created_at' => 'nullable|regex:/^[0-9]*-?[0-9]*$/',
            'use_regex' => 'nullable|boolean',
        ];
    }
 
    public function messages(): array
    {
        return [
            'name.max' => 'サンプル名は255文字以内で入力してください。',
            'sequence.max' => '塩基配列は255文字以内で入力してください。',
            'box.max' => '場所は255文字以内で入力してください。',
            'owner.max' => '所有者は255文字以内で入力してください。',
            'note.max' => 'メモは255文字以内で入力してください。',
            'updated_at.regex' => '日付の書式が異なります。',
            'created_at.regex' => '日付の書式が異なります。',
        ];
    }
 
    protected function prepareForValidation()
    {
        $this->merge([
            'use_regex' => filter_var($this->use_regex, FILTER_VALIDATE_BOOLEAN),
        ]);
    }
}

検索サービスをつくる

コントローラーにつくるにはコードが長すぎるので、サービスをつくる。

app/Services/OligoService.php
<?php
 
namespace App\Services;
 
use App\Models\Oligo as Oligo;
use App\Traits\ParsesSearchQuery;
use App\Traits\ConvertsYmdDate;
 
class OligoService 
{
    use ConvertsYmdDate;
    use ParsesSearchQuery;
 
    public function __construct(
        protected Oligo $model
    ) {
    }
 
    public function search(array $query){
        $sql = $this->model->query()
            ->when(
                $query['use_regex'], 
                fn($q, $v) => $this->applyRegexCondition($q, 'name', $query['name'] ?? '')
            )
            ->when(
                ! $query['use_regex'], 
                fn($q, $v) => $this->applyTokenCondition($q, 'name', $query['name'] ?? '')
            );
 
        foreach(['sequence'] as $field){
            $sql->when(
                $query[$field] ?? null, 
                fn($q, $v) => $q->where($field, 'ilike', "%{$v}%")
            );
        }
        foreach(['box', 'owner', 'note'] as $field){
            $sql->when(
                $query[$field] ?? null, 
                fn($q, $v) => $q->where($field, 'like', "%{$v}%")
            );
        }
        foreach(['updated_at', 'created_at'] as $field){
            $sql->when(
                $query[$field] ?? null, 
                fn($q, $v) => $this->applyDateCondition($q, $field, $v)
            );
        }
        return [
            'items' => $sql->orderBy('created_at', 'desc')->orderBy('id')->limit(201)->get(),
            'sql' => $sql->toSql(), 
        ];
    }
}

app/Traits/ParsesSearchQuery.php
<?php
 
namespace App\Traits;
 
trait ParsesSearchQuery
{
    /**
     * 空白で単語を区切って、like検索をする
     */
    public function applyTokenCondition($sql, string $field, string $value)
    {
        $pattern = '/"([^"]*)"|\'([^\']*)\'|([^\s]+)/u';
 
        if (preg_match_all($pattern, $value, $matches, PREG_SET_ORDER)) {
            foreach ($matches as $m) {
                $sql->when(
                    $token = $m[1] ?? null, 
                    fn($q, $v) => $q->where($field, 'like', "%{$token}%")
                )->when(
                    $token = $m[2] ?? null, 
                    fn($q, $v) => $q->where($field, 'like', "%{$token}%")
                )->when(
                    $token = $m[3] ?? null, 
                    fn($q, $v) => $q->where($field, 'like', "%{$token}%")
                );
            }
        }
 
        return $sql;
    }
 
    public function applyRegexCondition($sql, string $field, string $value)
    {
        return $sql->whereRaw("{$field} ~ ?", [$value]);
    }
    
    public function applyDateCondition($sql, string $field, string $value)
    {
        if (!$value) return $sql;
 
        // 例: "210101-221231", "210101-", "-221231", "210101"
        $parts = explode('-', $value);
 
        // 210304
        if (count($parts) === 1) {
            return $sql->where($field, '=', $this->ymdToDate($parts[0]));
        }
 
        [$start, $end] = $parts;
 
        // 210304-
        if ($start !== '') {
            $sql->where($field, '>=', $this->ymdToDate($start));
        }
 
        //  -250607
        if ($end !== '') {
            $sql->where($field, '<=', $this->ymdToDate($end));
        }
 
        return $sql;
    }
 
}

データ登録

データ登録のフォームも更新します。タブ区切りテキストを受け取るようにして、一度にたくさんのデータを登録できるようにします。 必要なものは以下のとおりです。

  1. 登録用のフォーム -> create.blade.phpにつくる。
  2. 新規データの送信 -> Javascriptのfetchで/oligo/storeに送信する。
  3. クエリの受信 -> OligoControllerを複数のデータを受け取れるようにOligosRequestに変更。
  4. 送信データの検証 -> OligosRequestで行う。
  5. SQLの実行 -> OligoServiceにメソッド追加。
  6. 検索結果の送信 -> JSONで送る。成功したデータをcreateビューから削除し、itemsに追加してindexビューを更新する。

登録用フォームをつくる

入力用フォームを resources/views/oligo/create.blade.php につくる。 タブ区切りのテキストをtextareaに入力し、これを分割してテーブル型のフォームに埋めるようにする。 データはJavascriptで送信する。

resources/views/oligo/create.blade.php
@extends('oligo.module')
 
@section('feature')
<x-feature id='oligo-create'
  x-data="{
    fields: ['name', 'sequence', 'created_at', 'owner'],  // 空判定をする項目
    isRowEmpty(row) {
      return this.fields.every(field => !row[field] || row[field] === '');
    },
 
    id: 1,  // このidはrowsの中でのみ有効。template x-forでキーがあったほうが便利なので、重複しない値をつくる。
    rows:[],
    addRow(row) {
      if (this.rows.length > 50) return; // バグによって行数が増えすぎないための安全装置
      this.rows.push({...row, id: this.id++});
    },
    mode: 'paste',
  }"
  x-init="
    addRow({});  // 最初の行を追加
 
    // rowsの中身を監視する
    $watch('rows', (_rows) => {
      const hasEmpty = _rows.some(row => isRowEmpty(row));
 
      // 最後の行が空でないなら追加
      if (! hasEmpty) {
        addRow({});
      }
    }, { deep: true }); /* オブジェクトの内部の変更を検知するために deep: true が必須 */
  "
>
  <div class="flex gap-2 my-2">
    入力方法 (現在値: <span x-text="mode"></span>)
    <input type='radio' name='mode' id='mode-paste' value='paste' checked x-model='mode'><label for='mode-paste'>貼り付け</label>
    <input type='radio' name='mode' id='mode-form'  value='form' x-model='mode'><label for='mode-form'>フォーム</label>
  </div>
 
  {{-- タブまたはカンマ区切りのテキストを貼り付ける場所 --}}
  <div x-show="mode == 'paste'"
    x-data="{
      raw: '',
      parse() {
        if (!this.raw.trim()) return;
 
        const lines = this.raw.trim().split('\n').map(l => l.split(/[\t,]/));
 
        // 直接rowsを編集しないで、addRowを使うようにしたほうがよいが、今のところ新しい入力のための空行を最後にもってくる方法がわからない(複雑になる)ので、このようにしている。
        rows = lines.map(cols => {
          const row = {
            id: this.id++,
            name: cols[0],
            sequence: cols[1],
            created_at: '260816'  // 6桁の当日の数字をつくる関数に置き換える
          };
 
          console.log('row:', row);
          return row;
        });
 
        if(rows.length > 0){
          mode = 'form';  // フォーム表示に切り替える
        }
      }
    }"
    x-init="
      if(0){ // デバッグ用にデータを入力した状態にする
        raw = 'Oligo1\tACTGG\nOligo2\tAGGCCCT';
        parse();
      }
    "
  >
    <p>名前と配列を、タブまたはカンマで区切って、一行に一つずつ入力してください。</p>
    <textarea class="border border-cyan-600 w-full min-w-0 my-2 p-1 h-[10rem] font-mono text-sm"
      placeholder="{{ "name\tsequence" }}"
      x-model="raw"
      @change="parse()"
    ></textarea>
 
    <button :disabled="!raw.trim()"
      @click.prevent="parse()"
    >
      貼り付けたデータをフォームに反映
    </button>
  </div>
 
  {{-- フォーム --}}
  <div x-show="mode == 'form'">
    <table class='material-list border-cyan-600 border-b-2 border-t-2 w-full my-2' 
      x-show="mode === 'form'"
    >
      {{-- thead 1行目 見出し --}}
      <thead>
        <tr class='bg-cyan-400'>
          <th>{{ __('名前') }}</th>
          <th>{{ __('配列') }}</th>
          <th>{{ __('作成日') }}</th>
          <th>{{ __('作成者') }}</th>
        </tr>
      </thead>
 
      <tbody>
        <!-- 対象サンプル -->
        <template x-for="row in rows" :key="row.id">
          <tr>
            <td class='name'>
              <input x-model="row.name" placeholder="名前">
            </td>
            <td class='sequence'>
              <input x-model="row.sequence" placeholder="ATCG...">
            </td>
            <td class='date'>
              <input x-model="row.created_at" placeholder="260101"
                title="6桁の数字で入力してください。-で範囲を指定できます。"
              >
            </td>
            <td class='owner'>
              <input x-model="row.owner" placeholder="作成者">
            </td>
          </tr>
        </template>
      </tbody>
    </table>
 
    <button :disabled="rows.length <= 1"
      @click.prevent="
        fetch('{{ route('web.oligo.store') }}', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'X-CSRF-TOKEN': '{{ csrf_token() }}'
          },
          body: JSON.stringify({
            oligos: rows.filter(row => ! isRowEmpty(row))
          })
        })
          .then(response => response.json())
          .then(data => {
            console.log('data:', data);
            console.log('data.created:', data.created);
            rows = [];
            append(data.created);
          });
      "
    >
      追加
    </button>
 
  </div>
</x-feature>
@endsection

新規データの受信と登録

OligoControllerのstoreメソッドで、OligosRequestを受け取るようにする。 受け取ったデータはOligoServiceでテーブルに追加する。

app/Http/Requests/OligosRequest.php
<?php
 
namespace App\Http\Requests;
 
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
 
class OligosRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'oligos' => ['required', 'array'],
            'oligos.*' => ['array'],
        ];
    }
 
    public function messages(): array
    {
        return [
            'oligos.required' => '入力値がありません。',
            'oligos.array' => '送信されたデータは配列でありません。',
        ];
    }
 
    public array $validOligos = [];
    public array $invalidOligos = [];
 
    protected function passedValidation()
    {
        $single = new OligoRequest();
        $rules = $single->rules();
        $messages = $single->messages();
 
        foreach ($this->oligos as $index => $oligo) {
            // OligoRequest をインスタンス化して、そのルールだけ借用
            $validator = \Validator::make($oligo, $rules, $messages);
 
            if ($validator->fails()) {
                $this->invalidOligos[$index] = [
                    'data' => $oligo,
                    'errors' => $validator->errors()->toArray(),
                ];
            } else {
                $this->validOligos[] = $validator->validated();
            }
        }
    }
}
app/Http/Controllers/OligoController.php
<?php
 
namespace App\Http\Controllers;
 
use App\Models\Oligo;
use App\Services\OligoService;
use App\Http\Requests\OligoRequest;
use App\Http\Requests\OligoSearchRequest;
use App\Http\Requests\OligosRequest; // 複数のデータを受け取る
 
class OligoController
{
    /* LaravelのDI (Dependency Injection)によってOligoServiceのオブジェクトが渡される。
     * privateとすることで、自動的に $this->service = $service が行われる。
     */
    public function __construct(
        private OligoService $service
    ) {
    }
 
    public function index(){
        $items = Oligo::all();
        return view('oligo.index', compact('items'))
            ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-index")
        ;
    }
 
    public function create(){
        return view('oligo.create')
        ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-create")
        ;
    }
 
    public function store(OligosRequest $request){
        try{
            $oligos = $request->validOligos;
            $data = $this->service->create($oligos);
 
            return response()->json(array_merge($data, [
                'status' => 'success', 
                'input' => $oligos,
            ]), 200);
        }catch(\Throwable $e){
            return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400);
        }
    }
 
    // 検索
    public function search(OligoSearchRequest $request)
    {
        try{
            $query = $request->validated();
            $query['use_regex'] = $query['use_regex'] ?? false;  // たぶんOligoSearchRequestのほうに移動したほうが美しい
            return response()->json([
                'status' => 'success',
                'data' => $this->service->search($query),
            ], 200);
 
        }catch(\Throwable $e){
            return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400);
        }
    }
}
app/Services/OligoService.php
<?php
 
namespace App\Services;
 
use App\Models\Oligo as Oligo;
use App\Traits\ParsesSearchQuery;
use App\Traits\ConvertsYmdDate;
 
class OligoService 
{
    use ConvertsYmdDate;
    use ParsesSearchQuery;
 
    public function __construct(
        protected Oligo $model
    ) {
    }
 
    public function search(array $query){
        $sql = $this->model->query()
            ->when(
                $query['use_regex'], 
                fn($q, $v) => $this->applyRegexCondition($q, 'name', $query['name'] ?? '')
            )
            ->when(
                ! $query['use_regex'], 
                fn($q, $v) => $this->applyTokenCondition($q, 'name', $query['name'] ?? '')
            );
 
        foreach(['sequence'] as $field){
            $sql->when(
                $query[$field] ?? null, 
                fn($q, $v) => $q->where($field, 'ilike', "%{$v}%")
            );
        }
        foreach(['box', 'owner', 'note'] as $field){
            $sql->when(
                $query[$field] ?? null, 
                fn($q, $v) => $q->where($field, 'like', "%{$v}%")
            );
        }
        foreach(['updated_at', 'created_at'] as $field){
            $sql->when(
                $query[$field] ?? null, 
                fn($q, $v) => $this->applyDateCondition($q, $field, $v)
            );
        }
        return [
            'items' => $sql->orderBy('created_at', 'desc')->orderBy('id')->limit(201)->get(),
            'sql' => $sql->toSql(), 
        ];
    }
 
    public function create(array $items)
    {
        $results = [
            'created' => [], 
            'succeeded' => [],
            'failed' => [], 
        ];
 
        foreach($items as $index => $item){
            try{
                $created = $this->model->create($item);
                $results['created'][] = $created;
                $results['succeeded'][$index] = $created;
            }catch(\Throwable $e){
                $results['failed'][$index] = array_merge($item, ['error' => $e->getMessage()]);
            }
        }
        return $results;
    }
}

データ修正

データの修正ができるようにします。 検索で修正したいものを探し、チェックをいれたものについて、修正フォームをつくるようにします。 必要なものは以下のとおりです。

  1. 修正対象の選択 -> oligo/module.blade.phpのx-dataにget checkedを追加。
  2. フォームを取得するedit、更新するupdate ルートの作成 -> web.phpとOligoController
  3. フォームの作成 -> oligo/edit.blade.php
  4. データ検証は既存のOligoRequest、データ更新は コントローラーで$model->update
  5. 更新に失敗したものを表示するようにするのは後回し。

修正用フォームをつくる

resources/views/oligo/module.blade.php を修正して、チェックの入ったものだけを取得できるようにする。

resources/views/oligo/module.blade.php
@extends('layouts.app')
 
@section('module')
  <x-module id='oligo'>
    <!-- モジュールのメニュー -->
    <nav>
      <ul class='feature-tabs' role='tablist'>
        @foreach([
          'index' => '一覧',
          'create' => '追加',
          'edit' => '編集',
        ] as $page => $label)
          <li class='feature-tab'
            :class="{
              'bg-white': activeFeature === 'oligo-{{ $page }}',
              'bg-gray-400 hover:bg-gray-200': activeFeature !== 'oligo-{{ $page }}'
            }"
          >
            <a href='{{ route("web.oligo.{$page}") }}' id='oligo-{{ $page }}-tab' role='tab'
              @click.prevent="selectTab(event)"
            >
              {{ $label }}
            </a>
          </li>
        @endforeach
        <li class='feature-tab bg-gray-400' x-text="activeFeature">
        </li>
      </ul>
    </nav>
    
    <div class='features'
      {{-- oligoモジュール全体で使用できるようにここにx-dataを定義する --}}
      x-data="{
        items: [], // テーブルから抽出されたデータ。今後更新や削除の機能でも使用できるように、ここ(#features)に定義する。
        query: {}, // 検索の条件。x-modelとして設定することで入力フォームの値がそのままJavascriptで使用できる
 
        // 検索 fetchは非同期(async)に実行されるので、それにそろえる
        async search(){
          const parameter = new URLSearchParams(this.query);  // 同じx-data内なので this.queryとなる。これをGETパラメーター(?key1=value1&key2=value2)に変換する
          console.log('parameter:', parameter.toString());  // 動作確認
          const options = {
            method: 'GET',
            headers: {
              'Accept': 'application/json',  // 返り値の設定。Laravelが判断するのに使う。
              'Content-Type': 'application/json',
            },
          };
 
          return fetch('{{ route('web.oligo.search') }}?' + parameter.toString(), options)
            .then(response => response.json())
            .then(response => {
              console.log('search done', response);  // 動作確認
              return this.append(response.data.items);  // itemsを置き換えるappendを呼ぶ
            })
            .catch(error => {debug(error)})
          ;
        },
 
        // 検索結果をitemsに反映させる。今後、新しい検索結果で置き換えるのではなく、追加するように修正していくので、appendとしてある。
        append(items){
          console.log('append:', items);
          this.items = items;
        },
 
        // チェックがいれられたものだけを取得する。
        get checked(){
          return this.items.filter(item => item.checked);
        },
        get hasChecked(){
          return this.checked.length > 0;
        }
      }"
    >
      @yield('feature')
    </div>
  </x-module>
@endsection

resources/views/oligo/edit.blade.php に修正フォームをつくる。

resources/views/oligo/edit.blade.php
@extends('oligo.module')
 
@section('feature')
<x-feature id='oligo-edit'>
 
  <p x-show="!hasChecked" class="pb-10 pl-4">
    <a
      href="{{ route('web.oligo.index') }}"
      id="oligo-edit-abc"
      @click.prevent="selectTab(event)" // これが動作しない。後で考える。
    >
      先に編集対象のサンプルにチェックをいれてください。
    </a>
  </p>
 
  <table class='material-list border-cyan-600 border-b-2 border-t-2 w-full my-2'
    x-show="hasChecked"
  >
    <thead>
        <tr class='bg-cyan-400'>
          <th></th>
          <th>{{ __('名前') }}</th>
          <th>{{ __('配列') }}</th>
          <th>{{ __('作成日') }}</th>
          <th>{{ __('作成者') }}</th>
        </tr>
    </thead>
 
    <tbody>
      <!-- 検索結果 -->
      <template x-for="item in checked" :key="item.id">
        <tr
          @input.debounce.500="
            item.status = 'loading';
            try{
              fetch(
                url = `{{ route('web.oligo.index') }}/${item.id}`,
                {
                  method: 'PUT',
                  headers: {
                    'Content-Type': 'application/json',
                    'Accept': 'application/json',
                    'X-CSRF-TOKEN': '{{ csrf_token() }}',
                  },
                  body: JSON.stringify(item)
                }
              )
              .then(response => response.json())
              .then(data => {
                console.log('data:', data);
                item.status = data.status;
              });
            }catch(e){
              item.status = 'error';
            }
            setTimeout(() => item.status = null, 2000);
          "
        >
          <td class="status-cell min-w-10">
            <x-ajax-state var='item' />
          </td>
 
          <td class='name'>
            <input x-model="item.name" placeholder="名前">
          </td>
          <td class='sequence'>
            <input x-model="item.sequence" placeholder="ATCG...">
          </td>
          <td class='date'>
            <input x-model="item.created_at" placeholder="260101"
              title="6桁の数字で入力してください。-で範囲を指定できます。"
            >
          </td>
          <td class='owner'>
            <input x-model="item.owner" placeholder="作成者">
          </td>
        </tr>
      </template>
    </tbody>
  </table>
</x-feature>
@endsection

新規データの受信と登録

edit と update のルートをつくる。

routes/web.php
<?php
 
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\OligoController;
 
Route::get('/', function () {
    return view('index');
});
 
/*
 *	コントローラーを経由するルート
 */
Route::prefix('/oligo')->as('web.oligo.')->group(function (){
    Route::get('/', [OligoController::class, 'index'])->name('index');
    Route::get('/search', [OligoController::class, 'search'])->name('search');
    Route::get('/create', [OligoController::class, 'create'])->name('create');
    Route::post('/', [OligoController::class, 'store'])->name('store');
 
    // 編集フォームとデータ更新
    Route::get('/edit', [OligoController::class, 'edit'])->name('edit');
    Route::put('/{item}', [OligoController::class, 'update'])->name('update');
});
app/Http/Controllers/OligoController.php
<?php
 
namespace App\Http\Controllers;
 
use App\Models\Oligo;
use App\Services\OligoService;
use App\Http\Requests\OligoRequest;
use App\Http\Requests\OligoSearchRequest;
use App\Http\Requests\OligosRequest; // 複数のデータを受け取る
 
class OligoController
{
    /* LaravelのDI (Dependency Injection)によってOligoServiceのオブジェクトが渡される。
     * privateとすることで、自動的に $this->service = $service が行われる。
     */
    public function __construct(
        private OligoService $service
    ) {
    }
 
    public function index(){
        $items = Oligo::all();
        return view('oligo.index', compact('items'))
            ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-index")
        ;
    }
 
    public function create(){
        return view('oligo.create')
        ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-create")
        ;
    }
 
    public function store(OligosRequest $request){
        try{
            $oligos = $request->validOligos;
            $data = $this->service->create($oligos);
 
            return response()->json(array_merge($data, [
                'status' => 'success', 
                'input' => $oligos,
            ]), 200);
        }catch(\Throwable $e){
            return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400);
        }
    }
 
    // 検索
    public function search(OligoSearchRequest $request)
    {
        try{
            $query = $request->validated();
            $query['use_regex'] = $query['use_regex'] ?? false;  // たぶんOligoSearchRequestのほうに移動したほうが美しい
            return response()->json([
                'status' => 'success',
                'data' => $this->service->search($query),
            ], 200);
 
        }catch(\Throwable $e){
            return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400);
        }
    }
 
    public function edit(){
        return view('oligo.edit')
        ->fragmentIf(request()->hasHeader('HX-Request'), "feature-oligo-edit")
        ;
    }
 
    public function update(OligoRequest $request, Oligo $item){
        try{
            $item->update($request->validated());
 
            return response()->json([
                'status' => 'success', 
                'item' => $item,
            ], 200);
        }catch(\Throwable $e){
            return response()->json(['status' => 'error', 'message' => $e->getMessage()], 400);
        }
    }
}