Laravelチートシート(個人用)

 

DBに関する知見

 

なぜか忘れる

  • 4つのテーブルをいい感じに操作することを目指さない。中間テーブルを含めて3つまでが最大値だと考える。
  • 多対多を1対多的に扱うのなら、「リレーション名()->where('テーブル名.id', $id)」とするといい。
  • 多対多の関係で、片方のDBのカラムのみを参照したいのなら、mapなどを使って「$モデル->{任意のキー名} = 計算結果」とした方が無難。

 

リレーション

関係 table_a table_b table_c リレーション(table_aからtable_b)  
1対1 id table_a_id   hasOne  
1対1 table_b_id id   belongsTo  
1対多 id table_a_id  

hasMany(

    TableB::class,

    'table_a_id',

    'id'

)

 
多対多 id

table_a_id

table_c_id

id

belongsToMany(

    TableC::class

)

belongsToMany(

    TableC::class,

    'table_b',

    'table_b.table_a_id',

    'table_b.table_c_id'

)

 

〜経由で1つへ紐づく id table_a_id table_b_id

hasOneThrough(TableC, TableB)

hasOneThrough(

    TableC::class,

    TableB::class,

    'table_b.table_a_id',

    'table_c.table_b_id',

    'table_a.id',

    'table_b.id')

 

 

 

マイグレーションで「SQLSTATE[01000]: Warning: 1265 Data truncated for column」

public function up(): void
    {
        Schema::table('テーブル名', function (Blueprint $table) {
            $table->tinyInteger('カラム名')->nullable()->change();
        });
    }
 
    public function down(): void
    {
        Schema::table('テーブル名', function (Blueprint $table) {
            $table->tinyInteger('カラム名')->nullable(false)->change();
        });
    }

こういう状況でも発生する。

実データより小さい型にしようとすると出るエラーなので、レコードを削除すれば通る。

 

複数のモデルを使ってwhereしたい

リレーション先のデータだけを絞り込む
use App\Models\User;

$user = User::find(1);

$user->posts()->where('active', 1)->get();

括弧の有無で挙動が大きく変わる。

$user->posts->whereはPost::whereになると思う(ユーザIDで絞り込みしてくれない)

User::with('posts')している場合で、postsがCollectionインスタンスならユーザIDで絞り込みが可能

 

リレーション先のカラムでリレーション元を絞り込みをしたい

whereRelation()を使う。

リレーション先の存在の有無を確認したい場合はhas()

$posts = Post::whereRelation(
    'comments', 'created_at', '>=', now()->subHour()
)->get();

whereHas('リレーションのメソッド名')

use Illuminate\Database\Eloquent\Builder;

// code%と似ている単語を含むコメントが少なくとも1つある投稿を取得
$posts = Post::whereHas('comments', function (Builder $query) {
    $query->where('content', 'like', 'code%');
})->get();

// code%と似ている単語を含むコメントが10件以上ある投稿を取得
$posts = Post::whereHas('comments', function (Builder $query) {
    $query->where('content', 'like', 'code%');
}, '>=', 10)->get();

 

リレーション先のデータだけ絞り込みたい

with()で絞り込む

use App\Models\User;

$users = User::with(['posts' => function ($query) {
    $query->where('title', 'like', '%code%');
}])->get();

 

 

Eloquentでシリアライズする時、一時的にカラム名を変えたり非表示にしたりしたい

makeVisible()/makeHidden() vs select()

HogeModel->select('カラム', 'カラム as hoge')->get();

「SELECT カラム, カラム AS hoge FROM hoge_models」と同等。

Eloquentはクエリビルダのラッパーみたいなものっぽい。

内部的なデータの取得も減っているのでシリアライズ時もカラムの量が減る。

クエリビルダから見ると正しいが、Model側から見ると不可解な挙動になったような気がするが…思い出したら追記したい。

HogeModel::get()->makeVisible('カラム');

makeVisible()/makeHidden()はシリアライズ時の表示/非表示の制御なので好きに変更する。

内部的には「SELECT * FROM hoge_models」なのでレコードのデータはすべて持っている。

$hoge = HogeModel::get();

$hoge->makeVisible(['カラム名']);

//1対1の場合

$hoge->relation名->makeVisible(['カラム名']);

//1対多の場合

$hoge->relation名->each(function($m){

    $m->setVisible(['カラム名']);

});

makeVisibleは「リレーション.カラム名」のような書き方はできない。

リレーションで取得済みのモデルに対して、個別にmakeVisibleを行う。

(new HogeModel())->{'適当なメンバ変数名'} = '';

こういう時はシリアライズ時にどうなる…?

 

Eloquentでgroup byがしたい

やめておいた方がいい。

Model::select('COUNT(カラム)')->groupBy('カラム')->get()

これで取得できるが、Modelの型でgroupByの結果を格納したオブジェクトが出来る。

直感的ではなく、型が半端に同じなので事故の元。

DBファサードを使った方が無難だと思う。

DB::table('テーブル名')->select(DB::raw('COUNT(カラム)'))->groupBy('カラム')->get()

こうするとstdClassのようなオブジェクトに値を入れた状態でデータを返す。

 

updated_atのタイミングで他のカラムも更新したい

  • Modelの$dispatchesEventsを使ってイベント経由で更新する
    • メリット:Laravelの内部挙動を気にする必要がない
    • デメリット:クエリを2回発行する
  • ModelのupdateTimestampsメソッドをオーバーライドする
    • 正確にはHasTimestampsトレイトのメソッド
    • メリット:クエリ発行が1回で済む
    • デメリット:publicなメソッドとはいえドキュメントに明記されていないので、Laravelのバージョンアップで使えなくなるかもしれない

 

toArray()、toJson()の日付のフォーマットをどうにかしたい

DBの値をCarbonにするとISO 8601形式の日付になる。

モデルのメソッドのserializeDateをオーバーライドすると一括で好きなフォーマットに出来る。

/**
 * 配列/JSONシリアル化の日付の準備
 *
 * @param  \DateTimeInterface  $date
 * @return string
 */
protected function serializeDate(DateTimeInterface $date)
{
    return $date->format('Y-m-d');
}

 

LaravelがサポートしていないDBの型・関数を扱うには

static::addGlobalScope('addSelect', function (Builder $builder) {
    $builder
        ->addSelect('*')
        ->addSelect(DB::raw('1 AS one'))
        ->addSelect(DB::raw('2 AS two'));
});

Eloquentのグローバルスコープに書く場合。

MySQLのgeometryのような、SQL内で関数を使って値を取得する必要がある場合に使える。

 

Model::select('使うカラム名', 'カラム名', ...)->selectRaw('関数を伴う値 as カラム名')

この方法を使うと初めからテーブルのカラムが存在するかの如くデータを扱える。

ローカルスコープに設定しておくと楽が出来る。

 

下記はDBへ何度もSQLを発行しかねないデメリットが大きいので使わない方がいい

MySQLのgeometryのように、データをinsertする時にST_GeomFromTextの戻り値を指定しないといけないものを指す。

クエリ発行数が増えるが、ミューテタを使ってSELECT 関数名(?)でinsert時に入れるデータを求めると良い。

他に良い方法があれば教えて欲しい…。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

class
User extends Model
{ protected function firstName(): Attribute { return Attribute::make(
          //SELECTでnameカラムを持つレコードを1行作ったので
//[0]->nameで関数の結果を取り出す

          set: fn ($value) =>
DB::select('SELECT 関数名(?) as name',[$value])[0]->name
); } }

 

 

 

 

 

モデルとマイグレーションとリソースコントローラ生成

artisan make:model モデル名 -mcr

モデル名なので単数形。DBの名前はデフォルトでは複数形になる。

モデルとマイグレーションとリソースコントローラを生成。

テストをするならfを加えてファクトリもどうぞ。

モデル作成後のTODO

  • マイグレーションファイルの編集・実行
    • unsignedBigIntegerが外部キーと同じ型
  • マイグレーション時にsoftDeletesを指定した場合、Modelにuse SoftDeletesを追加する
  • protected $fillableカラム名を設定する
  • 必要があればグローバルスコープを設定
  • ファクトリを生成した場合、ファクトリ内のdefinition()でダミーデータを設定する
  • artisan make:test テスト名Test
  • テストにuse RefreshDatabaseを追加

 

Laravelにないバリデーションを作りたい

バリデーションを配列にして、関数を使う。

$request->validate([

    function($attribute, $value, $fail){

        //バリデーションエラーの時は以下を実行する

        return $fail('エラーメッセージ');

    }

]);

Laravelでクロージャを使った自作バリデーションの作成 - Qiita

 

URLのパラメータのバリデーション

Route::get('/user/{name}', function ($name) {
    //
})->where('name', '[A-Za-z]+');

Route::get('/user/{id}', function ($id) {
    //
})->where('id', '[0-9]+');

Route::get('/user/{id}/{name}', function ($id, $name) {
    //
})->where(['id' => '[0-9]+', 'name' => '[a-z]+']);

ただし、あくまで正規表現としてしかバリデーションが出来ない。

php artisan make:request StoreBlogPost

DBの内容と比較する場合はフォームリクエストバリデーションを使う。

 

authorize()でfalseの場合は403。

$this->route('comment')でURLのパラメータにもアクセス可能。

あくまでバリデーションと同じ位置づけなので、ルートモデル結合を解決した後の値が入る。

 

rules()で返す配列は通常のバリデーションと同じ。

 

データの整合性

データの整合性を維持する機構がいくつかある。

※設計指針

機能 特徴 使い時  
Scope

Modelの機能。

SQLの単位でデータを制限可能。

複数のテーブルを使おうとすると面倒になる。

事前にデータを求めてwhereInを使えばよいかもしれない。

全データに制限を設けたい時

使用者の人数が多いデータに対して使うと良いかも

middleware

複数のURLに使用可能。

リクエストに対してPHP的に可能な事ならなんでも制限出来る。

1つのリクエストに特化する処理はフォームリクエストバリデーションなどで行った方が良い。

 
フォームリクエストバリデーション

コントローラの1メソッドで使用可能。

バリデーションを汎用的にしたもの。

1リクエストに対してPHP的に可能な事ならなんでも制限出来る。

アクセス不可の場合403を返す。

rules()はコントローラのメソッド毎に変わりがちなので、authorize()のみ使用した方が再利用はしやすい。

リクエスト単位で制限を設けたい場合

SQL的にデータを制限できなかった時はこちらを使うと良いかも

 

Policy

コントローラの1メソッドか、リソースコントローラに対して使用可能。

 

1リクエストに対してPHP的に可能な事ならなんでも制限出来る。

アクセス不可の場合403を返す。

URLのパラメータにデータがない場合も考慮されている。

 

 

 

 

リソースコントローラ

動詞 URI アクション ルート名
GET /photos index photos.index
GET /photos/create create photos.create
POST /photos store photos.store
GET /photos/{photo} show photos.show
GET /photos/{photo}/edit edit photos.edit
PUT/PATCH /photos/{photo} update photos.update
DELETE /photos/{photo} destroy photos.destroy

PUT/PATCH/DELETEは@methodを追加

<form action="/foo/bar" method="POST"> @method('PUT') </form>

  • 暫定設計指針
    • edit, updateを先に作ってcreate, storeに反映出来ると楽
    • <input type="hidden">のvalueに更新対象のidを入れとくと楽
      • 新規の時だけidとは別に数字以外の何かを付与する必要があるが、required_ifとかを使えばバリデーションで弾ける
    • <option>のvalueもidにする

 

何故か覚えられないファイルストレージ

use Illuminate\Support\Facades\Storage;

//S3を使う場合
Storage::disk('s3')

//diskの引数はドライバ名で、ディレクトリ名ではない
//ドキュメントに下記のような記載があって紛らわしい
Storage::disk('avatars')

//テストで使う。これ以降のS3を指定するStorageの処理がS3を経由せずテスト出来る
Storage::fake('s3')

ドライバの設定はconfig/filesystems.phpのdisksを参照の事。

Storage::disk()やStorage::fake()は設定したドライバしか指定出来ない。

 

 

HTTPエラー一覧

ステータスコード どういう状態だと出るか
302
  • バリデーションをパス出来なかった
403
  • フォームリクエストのauthorizeでfalseを返した
  • ポリシーをパス出来なかった
404
  • ルートモデル結合で一致するデータがなかった
    • そもそも存在しない
    • ソフトデリートされた
    • グローバルスコープで制御されている
405 HTTPのmethodが間違っている
419
422 XHRでデータを取得した時、バリデーションをパス出来なかった
503
  • artisan downしている場合、artisan upを叩く
  • ./vendor/bin/sail downを間違えて./vendor/bin/sail artisan downを叩いた

 

 

Sail(とDocker)

Sail導入済みのプロジェクトを始める場合

# GitなどでプロジェクトをWSL2内に展開

cd 「プロジェクトのパス」

docker run --rm -v "$(pwd)":/opt -w /opt laravelsail/php82-composer:latest bash -c 'composer require laravel/sail --dev'

cp .env.example .env

./vendor/bin/sail up

これでsailが使えるようになった。

※docker runで使っているコンテナはSailで開発を開始する時に使うシェルを参考にしている

※何か足りてない気がする

 

動かない時に確認するリスト

  1. Sailで使われるDockerfileを独自で作った場合、最新版のSailのDockerfileから必要なコマンドを追加する
  2. コンテナが起動しない場合、Docker DesktopのVolumesを削除してsail build --no-cache
  3. .envで、他のコンテナと接続する場合はdocker-composeの名前を*_HOSTに設定する(Docker内部で使われるドメイン名を指定する)
  4. 「usr/bin/env: 'bash\r': No such file or directory」が出る場合、コンテナに送るシェルスクリプトの改行がCRLFになっている。LFにしてsail build --no-cache

 

 

Request

<form method="POST">
    @csrf

    <input type="text" name="text_input">
    <input type="password" name="password_input">
    <input type="submit" value="Submit">
    <textarea name="textarea_input"></textarea>
    <select name="select_input">
    </select>
    <input type="radio" name="radio_input" value="radio1"> Radio 1
    <input type="checkbox" name="checkbox_input" value="checkbox1"> Checkbox 1
    <input type="file" name="file_input">
    <input type="hidden" name="hidden_input">
    <input type="email" name="email_input">
    <input type="number" name="number_input">
    <input type="date" name="date_input">
    <input type="color" name="color_input">
    <input type="range" name="range_input">
    <input type="search" name="search_input">
    <input type="tel" name="tel_input">
    <input type="url" name="url_input">
    <input type="time" name="time_input">
    <input type="datetime-local" name="datetime_local_input">
    <input type="month" name="month_input">
    <input type="week" name="week_input">
    <button type="button" name="button_button">ボタン</button>
    <button type="submit" name="submit_button">送信</button>
    <button type="reset" name="reset_button">リセット</button>

    <input type="text" name="text_input_default" value="default text">
    <input type="password" name="password_input_default" value="defaultpassword">
    <input type="submit" value="Submit_default">
    <textarea name="textarea_input_default">default text area</textarea>
    <select name="select_input_default">
        <option value="option1_default">Option 1 Default</option>
        <option value="option2_default">Option 2 Default</option>
    </select>
    <input type="radio" name="radio_input_default" value="radio1_default"> Radio 1 Default
    <input type="checkbox" name="checkbox_input_default" value="checkbox1_default"> Checkbox 1 Default
    <input type="file" name="file_input_default">
    <input type="hidden" name="hidden_input_default">
    <input type="email" name="email_input_default" value="default@example.com">
    <input type="number" name="number_input_default" value="123">
    <input type="date" name="date_input_default" value="2023-01-01">
    <input type="color" name="color_input_default" value="#ff0000">
    <input type="range" name="range_input_default" value="5">
    <input type="search" name="search_input_default" value="default search">
    <input type="tel" name="tel_input_default" value="123-456-7890">
    <input type="url" name="url_input_default" value="http://example.com">
    <input type="time" name="time_input_default" value="12:00">
    <input type="datetime-local" name="datetime_local_input_default" value="2023-01-01T12:00">
    <input type="month" name="month_input_default" value="2023-01">
    <input type="week" name="week_input_default" value="2023-W01">
    <button type="button" name="button_button_default" value="default">ボタン入力</button>
    <button type="submit" name="submit_button_default" value="default">送信入力</button>
    <button type="reset" name="reset_button_default" value="default">リセット入力</button>
</form>

 

dd($request->all(), $_POST);

array:36 [ // app/Http/Controllers/SandBoxController.php:16
  "_token" => "DLS6wGkN7IkFh3MzFfMqJsqfRFq3TE9CXHrSW2Nm"
  "text_input" => null
  "password_input" => null
  "textarea_input" => null
  "file_input" => null
  "hidden_input" => null
  "email_input" => null
  "number_input" => null
  "date_input" => null
  "color_input" => "#000000"
  "range_input" => "50"
  "search_input" => null
  "tel_input" => null
  "url_input" => null
  "time_input" => null
  "datetime_local_input" => null
  "month_input" => null
  "week_input" => null
  "text_input_default" => "default text"
  "password_input_default" => "defaultpassword"
  "textarea_input_default" => "default text area"
  "select_input_default" => "option1_default"
  "file_input_default" => null
  "hidden_input_default" => null
  "email_input_default" => "default@example.com"
  "number_input_default" => "123"
  "date_input_default" => "2023-01-01"
  "color_input_default" => "#ff0000"
  "range_input_default" => "5"
  "search_input_default" => "default search"
  "tel_input_default" => "123-456-7890"
  "url_input_default" => "http://example.com"
  "time_input_default" => "12:00"
  "datetime_local_input_default" => "2023-01-01T12:00"
  "month_input_default" => "2023-01"
  "week_input_default" => "2023-W01"
]
array:36 [ // app/Http/Controllers/SandBoxController.php:16
  "_token" => "DLS6wGkN7IkFh3MzFfMqJsqfRFq3TE9CXHrSW2Nm"
  "text_input" => ""
  "password_input" => ""
  "textarea_input" => ""
  "file_input" => ""
  "hidden_input" => ""
  "email_input" => ""
  "number_input" => ""
  "date_input" => ""
  "color_input" => "#000000"
  "range_input" => "50"
  "search_input" => ""
  "tel_input" => ""
  "url_input" => ""
  "time_input" => ""
  "datetime_local_input" => ""
  "month_input" => ""
  "week_input" => ""
  "text_input_default" => "default text"
  "password_input_default" => "defaultpassword"
  "textarea_input_default" => "default text area"
  "select_input_default" => "option1_default"
  "file_input_default" => ""
  "hidden_input_default" => ""
  "email_input_default" => "default@example.com"
  "number_input_default" => "123"
  "date_input_default" => "2023-01-01"
  "color_input_default" => "#ff0000"
  "range_input_default" => "5"
  "search_input_default" => "default search"
  "tel_input_default" => "123-456-7890"
  "url_input_default" => "http://example.com"
  "time_input_default" => "12:00"
  "datetime_local_input_default" => "2023-01-01T12:00"
  "month_input_default" => "2023-01"
  "week_input_default" => "2023-W01"
]

select, type="radio", type="checkbox"は値がない場合はサーバに送られない

(同名でtype="hidden"を作ると送れる)

type="submit"はボタンを押した時のみ送れる

PHP的には""だが、Laravel的には空文字はnull扱いになる

$request->has()でパラメータの存在確認ができる

 

 

テスト

Viewで使われる特定の変数をテストしたい

assertViewHas('変数名', 値);

 

HTMLの特定の箇所をテストしたい

$response->assertSeeInOrder([]);

配列の1要素がHTMLの1行扱いになる

厳密に1行で判定するので、開始タグで改行している場合も影響を受ける

第二引数にfalseを入れる

 

時刻のテストに失敗する

assertJsonではCarbonの比較で失敗する。

assertExactJsonを使う。

 

response()->streamDownloadでレスポンスを返した結果をテストしたい

ヘッダ以外はテストできない?

getContent()で値が取れそうな気がするが、どうやら常にfalseを返すみたい。

exec('curl ~');など、HTTPで通信出来るコマンドからデータを取得する方法しか思いつかなかった。

symfony/src/Symfony/Component/HttpFoundation/StreamedResponse.php at 6.4 · symfony/symfony · GitHub

 

時刻を固定する

Carbon::setTestNow(Carbon::parse('2024/1/10 8:00'));

 

テストするとよさそうな項目

  • ユーザが違った場合
  • 複数登録可能な場合、複数登録した時
  • 存在しないIDを指定した場合
  • 不正な値が入った場合
  • 登録したデータが存在しなかった
  • パラメータが存在しなかった場合
  • リレーション先が削除 or ソフトデリートされた場合
  • コマンドやタスクスケジュールとして叩かれた時の動作(Auth::user()がnullのケース)

 

雑記:Laravelのここが古い

ルーティングが偶発的凝集

別のファイルに分割可能なので、分けるとハッピーかも。

リソースコントローラが偶発的凝集

リソースコントローラの形はそのままで、使用は避けた方がいいかもしれない。

もちろん、凝集が正しい場合は使ってもいい。

configがenum非対応

定数をとりあえずconfigに書く時代ではないのかもしれない。