20191023のPHPに関する記事は18件です。

【サルが書く】Laravel 基本情報編

Laravelのとくちょう

わきゃ
最近Laravelに触る機会ができたので、基本的なところをまとめたいと思います。

1. 学習コストが低い

Laravelの特徴として、学習コストが低いことが挙げられます。
その特徴として、 ファサード を使用することで、PHPのスタティックなクラスメソッドを呼び出すように各機能を使用することができます。
Laravel 5.5 ファサード

Laravelのファサードはすべて、Illuminate\Support\Facades 名前空間下で定義されているので。
使いたいときは use Illuminate\Support\Facades\Cache;のようにuseで指定して使うようですね。

ファサードを使用するときの注意点としては、ファサードを使用しすぎてクラスの肥大化しないように気をつけること。クラスの責任範囲を小さくするようにファサードを使用しすぎない。

2. symfonyベース

Laravelはsymfonyベースで作られています。
symfonyのコアはModel View Controller(MVC)フレームワークである Mojavi、オブジェクトリレーショナルマッピング (ORM) である Propel、そして Ruby on Rails のテンプレートヘルパーなどがベースとなっています。

3. 多機能

Laravelはフルスタックフレームワークであり、様々な機能を持っています。
ルーティングやコントローラー、ビュー、ORMなどの基本機能の他に、認証機能、UT (ユニットテスト)、などの応用的な機能も備えています。

4. 積極的なバージョンアップ

Laravelは積極的なバージョンアップを常に行っています。
現時点では半年ごとにマイナーリリースが行われています。PHPのフレームワークの中では早めのリリースサイクルを保っています。そのため、2年毎にLTS(長期サポート版)がリリースされます。

最近 (2019/09/11) ではLaravel6のリリースが発表されました。
https://laravel-news.com/laravel-6

5. 高い拡張性

Laravelではディレクトリ構成に高い拡張性を備えています。ディレクトリ構成は開発者が自由に決めることができます。そのためMVCパターン、ADR、レイヤードアーキテクチャ等の様々なアーキテクチャを採用することができます。

逆に言えば、自由なディレクトリ構造をできるので、ディレクトリの管理ができなくなりやすいのかなとも思います。

phpのフレームワークでCakePHPがありますが、そちらはLaravelとは逆にディレクトリ構成を規約で縛ることにより、崩れにくいディレクトリ管理をすることができます。

参考文献

PHPフレームワーク Laravel Webアプリケーション開発 バージョン5.5 LTS対応

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Codeigniter3.2.0でCannot call constructorが表示される

現象

セッションが原因でcodeigniterのバージョンを3.1.10から3.2.0に
日本語ドキュメントの手順通りにアップグレードしたら
An uncaught Exception was encountered
Type: Error
Message: Cannot call constructor

と表示されてしまいました・・・

解決策

英文のドキュメントには書いてありましたが
3.2.0に以降した場合はCI_Modelの
public function __construct()
{
parent::__construct();
}

は不要のようでした。

全てのmodelファイルで上記の記載を削除したら、無事ページが表示されるようになりました!

日本語ドキュメントだけ見てても抜けがあるかもなので、
英文のドキュメントも確認する必要がありますね:frowning2:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

配列のグループ分け

配列のグループ分け

Laravelを使って班分け(グループ分け)してみたというのを見たのですが、devide関数ややこしくない?

PHPには配列を分割するarray_chunkという関数が最初から用意されているのですが、参照先の参照先にもあるように末尾処理が微妙です。
10個の配列を4分割すると[3個, 3個, 3個, 1個]になってしまうのです。

そんなわけで元記事ではarray_sliceで切り出しているのですが、正直なところわかりにくいので、もっと簡単にやりましょう。

    /**
     * 配列を分割して返す
     * @param array 元配列
     * @param int 分割数
     * @return array 元配列を分割したやつ
     */
    function divide(array $arr, int $division):array{
        $ret = [];
        $cnt = 0;
        foreach ($arr as $k => $v) {
            $ret[$cnt++ % $division][$k] = $v;
        }
        return $ret;
    }

    $a = range(0, 9);
    shuffle($a);
    $ret = divide($a, 4); // [3個, 3個, 2個, 2個]

できた。

元記事ではしていなかったのですが、一応キーも保持するようにしています。

おわりに

作ってから気付いたんだけど動作違うわこれ。

array_chunkや元記事は、分割後の配列も並んでいる順に整列します。
ABCDEFGHIJが並んでいたら[ABC, DEF, GH, IJ]となります。

対してこちらは、レジに並ぶ客のように分かれます。
ABCDEFGHIJ[AEI, BFJ, CG, DH]になるということです。

そんなわけで元記事をそのまま差し替えるには至りませんでした。
残念。

ここまで全部間違い

以下2019/10/24追記

ここまでリリースしたあとで突っ込まれたわけですが、元記事を読み返してみたら設問が根本的に違う。

・1組あたりの人数は4人で、グループ数は可変。
・端数が出たら5人組にする。
・人数が少ない場合は特殊処理。

いったい何をキメていたら、この設問から上のdivide()みたいな関数を作れるんですかね??

ということで作りなおしましょう。
人数が少ないときの挙動がよくわからないので列挙しておきます。

・5人以下:[n]
・6人:[3, 3]
・7人:[4, 3]
・8人:[4, 4]
・9人:[5, 4]
・10人:[5, 5]
・11人:[4, 4, 3]
・12人:[4, 4, 4]
・13人以上:定義通り

グループあたり人数を可変にするとこのあたりの動作がよくわからなくなるので、以下では4人固定としておきます。

/**
 * 配列を分割して返す
 * @param array 元配列
 * @return array 元配列を分割したやつ
 */
function divide(array $arr): array {
    $cnt = count($arr);

    // 6未満
    if ($cnt < 6) {
        return [$arr];
    }

    // 11未満
    if ($cnt < 11) {
        return array_chunk($arr, ceil($cnt / 2));
    }

    // 4の倍数と11
    if (!($cnt % 4) || $cnt === 11) {
        return array_chunk($arr, 4);
    }

    // それ以外
    $ret = array_chunk($arr, 4);
    $extra = array_pop($ret);
    $cnt = 0;
    foreach ($extra as $v) {
        $ret[$cnt++][] = $v;
    }
    return $ret;
}

// 確認
for ($a = 1; $a < 20; $a++) {
    var_dump( divide(range(1, $a)) );
}

うーん、元記事とたいして変わってない。
これならわざわざ書き換える意味がないかんじですね。

先にグループ数を求めてから配分したら多少行数が減るかな?

function divide(array $arr): array {
    // 分割数
    $cnt = count($arr);
    if ($cnt < 6) {
        $division = 1;
    } elseif ($cnt < 11) {
        $division = 2;
    } elseif ($cnt < 12) {
        $division = 3;
    } else {
        $division = floor($cnt / 4);
    }

    // 分割する
    $cnt = 0;
    foreach ($arr as $k => $v) {
        $ret[$cnt++ % $division][$k] = $v;
    }
    return $ret;
}

行数は減りましたが、読みやすくなったかというとどうでしょうね。
というか11が厄介だな。

やろうと思えば$divisionの算出は1行でできますが、さらにわかりにくくなるだけなので辞めましょう。

やろうと思ったら読めなくなった
function divide(array $arr): array
{
    $cnt = 0;
    foreach ($arr as $k => $v) {
        $ret[$cnt++ % (($c = count($arr)) < 12 ? $c < 11 ? 1 + floor($c / 6) : 3 : floor($c / 4))][$k] = $v;
    }
    return $ret;
}

さて、今度こそ設問に間違いはないよな?よな?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

大きなファイルをJavaScriptで分割アップロードする

FileReader関数を利用して分割アップロードする

アップロード前に画像のプレビューを表示するくらいしか使い道が思いつかなかったFileReader関数ですが、実用的な使途がありました。

サーバーの制限を回避する

php.iniなど設定を変更できないサーバーを使っている場合、タイムアウトやメモリ-不足などでアップロードが失敗してしまいます。
<?php
ini_set('upload_max_filesize', '128M');
ini_set('max_execution_time', '120');
?>

などの記述で回避できる場合もありますが、これらを書いても効かない場合もあります。ならば分割して少しずつ送信すれば良いわけです。

アップロードがメインになるようなサービスを作ろうというわけではないので、極力シンプルに書いてみたのが、以下のコードです。

ブラウザーからの分割アップロード(html部分)

FileReader.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>dividing_upload</title>
<script src="./dividing_upload.js" type="text/javascript"></script>
</head>
<body>
    <form>
        <input id="file" name="file" type="file" />
    </form>
    <span id="msg"></span>
    <form>
        <input type="text" id="from" name="from" readonly  />
        <textarea id="data" name="data" readonly></textarea>
    </form>
</body>
</html>

実用上は、2つめのformは非表示にした方が良いでしょうね。

ブラウザーからの分割アップロード(js部分)

dividing_upload.js
var cnt=0;
var total=0;

function post(result,from,size)
{
    plus=0;
    data=result.substr(from,size);
    if(data.substr(size-1,1)=="\r"){    // \r\nを分断すると\nを補ってくれちゃうので
        plus=1;
        data=result.substr(from,size+plus);
    }
    document.querySelector('#data').value=data;
    document.querySelector('#from').value=from;
    postForm('FileReaderPOST.php',document.forms[1],function(responseText){
        if(responseText==''){
            cnt+=data.length;
            if(cnt<total){  // 2回め以降の送信
                document.querySelector('#msg').innerHTML =""+cnt+"/"+total;
                post(result,from+size+plus,size);
            }else{          // 完了時のリセット
                document.forms[0].reset();
                document.forms[1].reset();
                cnt=0;
                total=0;
                document.querySelector('#msg').innerHTML ='completed => <a target="_blank" href="FileReaderPOST.txt">download</a><br />';
            }
        }else{
            alert(responseText);
        }
    });
}
function postForm(uri,formNode,callback1)
{
    form = new FormData(formNode);
    var xhr = new XMLHttpRequest();
    xhr.open('POST', uri);
    xhr.onreadystatechange=function(){
        if(xhr.readyState == 4 && xhr.status == 200){
            callback1(xhr.responseText);
        }
    };
    xhr.send(form);
}
window.addEventListener('DOMContentLoaded', function() {
    document.querySelector("#file").addEventListener('change', function(e) {
        if (window.File) {
            var input = document.querySelector('#file').files[0];
            var reader = new FileReader();
            reader.addEventListener('load', function(e) {
                base64=btoa(reader.result); // 文字バケ防止のエンコード
                post(base64,0,32768);       // 初回送信、分割サイズ指定
                total=base64.length;
                document.querySelector('#msg').innerHTML ="送信中";
            }, true);
            reader.readAsBinaryString(input);
        }
    }, true);
});

FileReaderを使う部分はコチラをベースに使いました

分割をまとめるため、サーバー側で再結合する処理が必要です。

FileReaderPOST.php
<?php
    $filename='FileReaderPOST.txt'; // とりあえず固定値
    if(is_file($filename)){
        if($_POST['from']==0){      // 初回送信なら前回のファイルを削除
            $data='';
            unlink($filename);
        }else{
            $data=file_get_contents($filename);
        }
    }else{
        $data='';
    }
    file_put_contents(  // デコードしてから追加書き込み
        $filename,
        $data.base64_decode ($_POST['data']));
?>

file_put_contentsじゃなくてfopen("……","a")にしろ、ってところでしょうか。要するにデコードして追加書き込みをするだけなので、phpでなくても構いません。
"$filename"がハードコーディングで".txt"になっていますが、エンコードされるのでバイナリーデータも送信できます。その場合はform内にファイルタイプを追加して、拡張子を変更させれば良いでしょう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

関連テーブルの並び替えは

    /**
     * 関連アーティストリレーション
     */
    public function related_artist()
    {
        return $this->belongsToMany(Artist::class, 'artist_artist_assoc', 'artist_id', 'related_artist_id')
            ->orderBy('sort_order');
    }

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PHP標準入力(足し算)

PHP標準入力(足し算)

私はどうしても標準入力が苦手で…:sweat_smile:
まず簡単な足し算からと思い、検索したら
理解できました!:relaxed:
配列自体は基本的なことは理解してるので、そんなに難しくなかったですね!

入力値が横の場合

5 7 8
$i=trim(fgets(STDIN));
$o=explode(" ",$i);
$p=$o[0]+$o[1]+$o[2];
echo $p;

入力値が縦の場合

5
7
8
$i=trim(fgets(STDIN));
$o=trim(fgets(STDIN));
$p=trim(fgets(STDIN));
echo $i+$o+$p;

足したらどちらも合計20になります!
プログラミング始めて2ヶ月ぐらいなので簡単なことでも思ったことがあればアウトプットしていきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【PHP】自作ページネーションしてみた(しちゃった)

副業で、Codeigniterを使ってWEBサイトの開発をさせていただいています。

先日、ページネーションを生のPHPで実装しました。
※Codeigniterの機能でも実装出来るみたいです。

実装が人によってかなり差が出そうだなーと思ったので、アウトプット&他の人からの意見を期待して書いてみます。

前提

・PHP 7.2くらい
・Codeigniter 3.1

書いたコード

フロントで実装しちゃいました。
PHPもプログラミングも駆け出しです。すみません

以下、ページネーションの部分のコード

qiita.rb
<ul class="pagination">
    <?php
    //ページネーションの表示総数
    $page_max_num=(int)ceil($data_count/20);

    //現在の表示しているページ番号
    $page_num=(int)$page_num;

    //ページネーションのボタン表示数(最大5個)
    if($page_max_num-5<0){
        $display_num = $page_max_num;
    }else{
        $display_num = 5;
    }

    //表示するボタンの表示開始位置の計算(左から数えた場合)
    //whileで表示の個数を数えているので、マイナスから始まっても問題なし
    $btn_num = -2;
    if($page_num == $page_max_num){
        $btn_num=-4;
    }
    if($page_max_num-$page_num == 1){
        $btn_num = -3;
    }

    //ページ番号の表示
    $display_count = 0;

    while($display_count <$display_num){
        if($page_num+$btn_num<1){
            $btn_num++;
            continue;
        }
    ?>
    <li>
        <a href="javascript:setAndSubmit('
        <?php echo html_escape($page_num+$btn_num);?>');">
            <?php echo html_escape($page_num+$btn_num);?>
        </a> 
    </li>
    <?php 
        $count++;
        $btn_num++;
    }
</ul>



■ここで出てきていない変数説明
$data_count:DBから表示可能件数を取ってきています。
page_num:現在表示中のページ数(getパラメータ)


■仕様
・1ページに20件表示し、あふれた分はその分ページネーションする
・ページネーションのボタンは5つを表示最大数とする。
・1ページ目、2ページ目、最終ページ、最終ページの前ページ以外の場合、選択中のページが5つの真ん中に来るようにする
→例:5ページ目の場合「3・4・5・6・7」が表示される


■何を考えて作ったのか
1.if-elseを使いまくって表示、非表示を書いていくと後で解読が大変そう
2.→表示する分を先に計算すれば、ボタンはループで回せばいいんじゃね?
3.ボタンの表示位置と現在の表示しているページはどうしよう
4.→真ん中になることが多そうだから、左から数えて2番目ってことで「-2」ページ目から始めるかぁ
5.最終ページの時と、その前のページの時は表示がおかしくなるな
6.if文2つ追加して「-3」「-4」ページ目の場合分けしよう。
7.・・・

■改めて読んだ時の個人的な感想
・わからなくもないけど、読むのめんどくさい
・仕様変更になった時、変更が大変そう
・コメントの意図を拾いきれないところがある
・てかフロントに処理書くなごみカスが
※実際のコードは選択中ページボタンの色の変更、次のページ、前のページなども実装しました。読みにくさはこれより上です。

反省点

・フレームワークの機能として用意されているか、を確認しておく
・自分で書くとしても、もっといい方法を考えて書く(フロントであんまり処理書かないとか)

最後に

プロジェクトに時間的な余裕が無かったため、コードは採用になりましたがこれはゴミです。
他のやり方を知っている方、ご教授いただけると幸いです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravelで並列テストを導入するための道のり

こんにちはみなさん

テストって書くだけなら大したことないんですよね。やることって、条件作って動作させて結果見るだけって感じで、プリントデバッグとかでいつもやってることを、機械化するだけですんで。
ですが、テストの数が増えてくると顕在化するのがテストのスピードです。ある意味成長するプロダクトにおいては宿命とも言えますが、基本的にテストが減ることはなく、累積されていくので必ずいつかはぶち当たる壁になっています。

そんなわけで、テストが遅くなってきたら並列でやりたいなっていうのと、そいつをLaravelでやろうという試みをしたので、その記録をここに書いていきます。

TL;DR

  • Paratestっていうのがあるよ
  • Laravelには、テストにおいてはじめの一回だけ動く系の処理があって、これをそのままにするとParatestを素直に導入できないよ
  • RunnerとBootstrapを改造して対応できるよ
  • テストの数が多いほど、Paratestの効果が期待できるみたい

Laravelで普通にテストを書く

Laravel はテストを重要視しており、それはマニュアルのメインコンテンツの一角を testing が占めているところを見ても明らかです。
https://laravel.com/docs/6.x/testing

まずはテストを書いてみましょう。
テスト内容は適当なユーザデータを作ってそれをModelで取得できるかという、まるで意味のないテストです。

ParaTest.php
<?php

namespace Tests\Unit\User;

use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ParaTest extends TestCase
{

    use RefreshDatabase;

    /**
     * A basic unit test example.
     *
     * @return void
     */
    public function testExample()
    {
        $user = factory(User::class)->create();
        sleep(1);
        $this->assertEquals($user->name, User::find($user->id)->name);
    }
}

これと同じテストを20個くらい用意します。
当然テストを走らせると、

# ./vendor/bin/phpunit
PHPUnit 8.4.0 by Sebastian Bergmann and contributors.

......................                                            22 / 22 (100%)

Time: 52.56 seconds, Memory: 26.00 MB

OK (22 tests, 22 assertions)

このテストですが、DBへの接続が含まれたテストになります。
一つのテストケースで、2回データベースへのアクセスが含まれています。
また、trait であるRefreshDatabase をuseしているので、実際にはトランザクションのbegin および rollback が実行されていますので、各テストケースで一回を除き4回ほどデータベースへの接続が発生していると言えます。
この程度の数しかテストがないのであれば、並列化する必要もないのですが、これが5行6行と続くようになると、だんだん待ち時間が長くなります。

Paratestを導入する

PHPUnitを並列化するツールとして、Paratestというものがあります。これを導入することで、テストを並列化してみましょう。

インストール

現在のLaravelはPHPUnit 8.4 を使っているので、それに合わせたバージョンのParatestを入れます。

composer require --dev brianium/paratest:^3

あとは実行するだけです。


paratest

Running phpunit in 8 processes with /var/www/vendor/phpunit/phpunit/phpunit

Configuration read from /var/www/phpunit.xml

.E..E.E.EEE.EE..E.....

Time: 1.58 minutes, Memory: 6.00 MB

There were 9 errors:

1) Tests\Unit\User\Para10Test::testExample
Illuminate\Database\QueryException: SQLSTATE[42S02]: Base table or view not found: 1051 Unknown table 'homestead.failed_jobs,homestead.migrations,homestead.password_resets,homestead.users' (SQL: drop table `failed_jobs`,`migrations`,`password_resets`,`users`)
...

あれ?落ちてる
試しに並列数を1にすると

paratest -p 1

Running phpunit in 1 process with /var/www/vendor/phpunit/phpunit/phpunit

Configuration read from /var/www/phpunit.xml

......................

Time: 5.89 minutes, Memory: 6.00 MB

OK (22 tests, 22 assertions)

どうも並列化に問題がある模様。

RefreshDatabaseの問題

RefreshDatabaseはこれをuseしているすべてのテストケースの中で、一番はじめに実行されたものにおいて php artisan migrate:fresh を実行し、データベースをきれいにしてから、あとはその他のテストケースと同様に処理をtransaction で囲むようになります。コードは以下の部分です。

RefreshDatabase.php
    /**
     * Refresh a conventional test database.
     *
     * @return void
     */
    protected function refreshTestDatabase()
    {
        if (! RefreshDatabaseState::$migrated) {
            $this->artisan('migrate:fresh', [
                '--drop-views' => $this->shouldDropViews(),
                '--drop-types' => $this->shouldDropTypes(),
            ]);

            $this->app[Kernel::class]->setArtisan(null);

            RefreshDatabaseState::$migrated = true;
        }

        $this->beginDatabaseTransaction();
    }

一番初めにこれが動作したとき、RefreshDatabaseState::$migratedtrueになるので、以降はマイグレーションが走らないわけです。
こいつが各プロセスで独立して走るため、テスト中に別のプロセスによるマイグレーションにより、データベースがすっ飛んでいるために、テストに失敗しているんじゃないかって思いっきり推測し、それを検証してみます。

            $this->app[Kernel::class]->setArtisan(null);
            echo 'abc';

            RefreshDatabaseState::$migrated = true;

こんな感じで、プリントデバッグを仕込んでみます。

[2019-10-13 04:14:45] testing.DEBUG: migrate  
[2019-10-13 04:14:51] testing.DEBUG: migrate  
[2019-10-13 04:15:01] testing.DEBUG: migrate
...

普通に連発しています。
しかも、これ、ファイルの数だけ実行しています。
これをやらないようにすればちょっとは早くなるかもしれません。

Cacheの問題

Laravel6では、Bootstrapが用意されており、ここで、configなどのキャッシュを取るようになっています。

tests/Bootstrap.php
<?php

namespace Tests;

use Illuminate\Contracts\Console\Kernel;
use PHPUnit\Runner\AfterLastTestHook;
use PHPUnit\Runner\BeforeFirstTestHook;

class Bootstrap implements BeforeFirstTestHook, AfterLastTestHook
{
    /*
    |--------------------------------------------------------------------------
    | Bootstrap The Test Environment
    |--------------------------------------------------------------------------
    |
    | You may specify console commands that execute once before your test is
    | run. You are free to add your own additional commands or logic into
    | this file as needed in order to help your test suite run quicker.
    |
    */

    use CreatesApplication;

    public function executeBeforeFirstTest(): void
    {
        $console = $this->createApplication()->make(Kernel::class);

        $commands = [
            'config:cache',
            'event:cache',
        ];

        foreach ($commands as $command) {
            \Log::debug('bootstrap');
            $console->call($command);
        }
    }

    public function executeAfterLastTest(): void
    {
        array_map('unlink', glob('bootstrap/cache/*.phpunit.php'));
    }
}

コンフィグをいちいち読み込んだり、イベントディスカバリを毎回は知らせるのはコストが高いので、テスト開始前にキャッシュしちゃおうという手段ですが、Paratestではこれがプロセスごとに毎回走るようになります。
なので、ファイル書き込みや消込がかぶったりすると、

Warning: Uncaught ErrorException: require(/var/www/bootstrap/cache/config.p  
  hpunit.php): failed to open stream: No such file or directory in /var/www/v  
  endor/laravel/framework/src/Illuminate/Foundation/Console/ConfigCacheComman  
  d.php:67       

こんなエラーが出たりしてテストが停止します。

Paratest を Laravel でできるようにする

Laravelで無理矢理にでもテストを並列化するためには、どうすればよいでしょうか。
Patatestの内部的には、各テストファイルごとにPHPUnitプロセスを立ち上げて、処理をするというやり方を取っていますので、 各テストごとに初期化処理が走り、各テスト終了ごとに終了処理が走る ようになています。そのため、テストが一つのプロセス内で完結することを前提としている場合、上述したようなエラーを引き起こします。

PHPUniコマンドを使わない

LaravelのテストがPHPUnitのライフサイクルに依存している状態では、Paratestの導入はちょいと難しいです。とりあえず、PHPUnitコマンドを使わないでテストすると決意しましょう。

extension を抜く

phpunit.xmlextensions という項目がありますが、これが上述したBootstrapを呼び出すところなので、とりあえず抜いておきます。

phpunit.xml
    </filter>
    <extensions>
        <extension class="Tests\Bootstrap"/>
    </extensions>
    <php>

こいつの詳しい動きは参考にリンクを載せておくので、そっちを参照してください。
暇だったら記事を書くかもです。

RefreshDatabase -> DatabaseTransactions

悲しいですが、RefreshDatabaseの便利さはParatestではエラーの元凶です。初回のマイグレーションを入れないDatabaseTransactionsを代用するようにしましょう。

Para0Test.php
<?php

namespace Tests\Unit\User;

use App\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;

class Para0Test extends TestCase
{

    use DatabaseTransactions;

    /**
     * A basic unit test example.
     *
     * @return void
     */
    public function testExample()
    {
        $user = factory(User::class)->create();
        sleep(1);
        $this->assertEquals($user->name, User::find($user->id)->name);
    }
}

普通に一括変換で大丈夫です。

これでとりあえずは動くと思いますが、もとの想定していた動作を再現しているとは言えません。

Bootstrap を起動させる

先に述べたとおり、もともとのテストでは一番最初にBootstrapによるコンフィグのキャッシュが行われていました。こいつをテストの始めで動かすようにしちゃいましょう。
Paratestは独自のテストランナーを定義できるようになっているので、それを利用してBootstrapを使えるようにしましょう。

tests/MyRunner.php
<?php

namespace Tests;

use ParaTest\Runners\PHPUnit\WrapperRunner;

class MyRunner extends WrapperRunner
{

    protected $bootstrap;

    public function run()
    {
        $this->bootstrap = new Bootstrap($this->loadConfig());
        $this->bootstrap->executeBeforeFirstTest();
        parent::run();
    }

    protected function complete()
    {
        $this->bootstrap->executeAfterLastTest();
        parent::complete();
    }

    private function loadConfig(): void
    {
        $xml = simplexml_load_file(__DIR__ . '/../phpunit.xml');
        $ret = [];
        foreach ($xml->php->server as $env) {
            $_SERVER[(string)$env['name']] = (string)$env['value'];
        }
    }
}

ここでloadConfigは、phpunit.xmlの設定の反映が、実際のPHPUnitの起動時、つまり、Workerでテストをするときになっているため、MyRunnerで設定値を反映させるために、一旦phpunit.xmlを読み込ませています。
また、各テストごとにプロセス作り直すのがちょっとだるいなぁって思ったので、普通のRunnerではなく、WrapperRunnerという、ワーカープロセスを使い回すRunnerを拡張させて使います。
あとはテストを実行するだけですが、コマンドがちょっと面倒になるので、composer.jsonscriptsに以下の設定を加えます。

composer.json
"paratest": "paratest --runner \\\\Tests\\\\MyRunner"

これで、composer paratestで並列テストを実行できます。

# composer paratest
Do not run Composer as root/super user! See https://getcomposer.org/root for details
> paratest --runner \\Tests\\MyRunner

Running phpunit in 8 processes with /var/www/vendor/phpunit/phpunit/phpunit

Configuration read from /var/www/phpunit.xml

......................

Time: 34.21 seconds, Memory: 18.00 MB

OK (22 tests, 22 assertions)

いけますね。

データベースのマイグレーションを入れる

先にRefreshDatabaseDatabaseTransactionsに置き換えてしまっていたので、データベースのマイグレーションが発生しません。こうなると、新しくマイグレーションが発生した際には、手動でマイグレーションを走らせ、テスト用のDBを調整してからテストをすることになります。
これは若干面倒なので、先のBootstrapに一緒に突っ込んでしまいます。

tests/Bootstrap.php
        $commands = [
            'config:cache',
            'event:cache',
+           'migrate:fresh',
        ];

これで、テスト開始前に、まずデータベースを調整し、然る後に各テストをTransaction内で実行するようになります。そもそもデータベースを使わない場合はコメントアウトでもしておけばいいのです。

実行速度を見てみる

さて、最後にちょっとだけ動かして、実行速度を比較してみましょう

普通のユニットテスト

Paratest用にRefreshDatabaseを置き換えたり、Bootstrapをやめたりしていますが、普通のユニットテストを走らせることはできます。
普通に上述したextensionsの部分をphpunit.xml上で復活させれば、Paratestと同じ用に動作します。

# time composer test
Do not run Composer as root/super user! See https://getcomposer.org/root for details
> phpunit
PHPUnit 8.4.0 by Sebastian Bergmann and contributors.

......................                                            22 / 22 (100%)

Time: 51.18 seconds, Memory: 26.00 MB

OK (22 tests, 22 assertions)

real    0m58.107s
user    0m3.975s
sys     0m4.902s

Paratest

extensionsphpunit.xmlから抜いておきます。

# time composer paratest
Do not run Composer as root/super user! See https://getcomposer.org/root for details
> paratest --runner \\Tests\\MyRunner

Running phpunit in 8 processes with /var/www/vendor/phpunit/phpunit/phpunit

Configuration read from /var/www/phpunit.xml

......................

Time: 32.85 seconds, Memory: 18.00 MB

OK (22 tests, 22 assertions)

real    0m40.037s
user    0m9.166s
sys     0m17.028s

実行時間自体は2割強の改善となりますが、CPU時間が割とヘビーです。

テストの数を増やしたら

普通のユニットテスト

# time composer test
Do not run Composer as root/super user! See https://getcomposer.org/root for details
> phpunit
PHPUnit 8.4.0 by Sebastian Bergmann and contributors.

...............................................................  63 / 102 ( 61%)
.......................................                         102 / 102 (100%)

Time: 2.58 minutes, Memory: 42.00 MB

OK (102 tests, 102 assertions)

real    2m42.626s
user    0m8.170s
sys     0m6.855s

paratest

w# time composer paratest
Do not run Composer as root/super user! See https://getcomposer.org/root for details
> paratest --runner \\Tests\\MyRunner

Running phpunit in 8 processes with /var/www/vendor/phpunit/phpunit/phpunit

Configuration read from /var/www/phpunit.xml

...............................................................  63 / 102 ( 61%)
.......................................

Time: 48.43 seconds, Memory: 20.00 MB

OK (102 tests, 102 assertions)

real    0m55.716s
user    0m13.409s
sys     0m19.213s

どうも、開始時に時間を食っているだけで、動き始めりゃ早いみたい。

まとめ

ということで、Laravel 6 にParatestを導入しました。
システムが成長するにつれてテストの数が多くなってきますし、そうなったときに並列でテストを実行する利点はなかなかかと思います。
まぁ、Laravelはバージョンによってテストのやり方も変わっていますので、今回ここに述べたのはあくまでLaravel 6 のケーススタディってことで、他のバージョンでの導入のときには参考にでもしていただければ幸いです。

今回はこんなところです。

参考

Paratest
Laravelのテストを高速化するやり方集(英語: Paratesについてあっさり。。。)
PHPUnitの拡張#フック

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【PHP】オーバーライドについて

親クラスで定義されているメソッドを子クラスで同じ名前のメソッドを作って定義し直す(再定義)することです。
例えば

// 親クラス

class Goku {
  public function skill($name, $damage){
    print "名前 = {$name} : ダメージ = {$damage}". '<br>'; 
  }
}

// 子クラス 

class Gohan extends Goku {
  public function skill($name, $damage){
    print "悟飯の必殺技!名前は、{$name}!なんと{$damage}ダメージも敵に与える事ができるんだ!"; 
  }
}

子クラスのskill()メソッドをオーバーライドすることにしました。親クラスでは簡潔な情報のみを表示する冷めたメソッドから,子クラスではコロコロコミックの説明文みたいな情報を表示する熱いメソッドに定義し直すことにしました。

ではメソッドを実行します。

$dragonball = new Goku();
$dragonball->skill('かめはめ波', '500');
$dragonballZ = new Gohan();
$dragonballZ->skill('かめはめ波', '500');

すると結果、

名前 = かめはめ波 : ダメージ = 500
悟飯の必殺技!名前は、かめはめ波!なんと500ダメージも敵に与える事ができるんだ!

となります。最初の一行目が親クラスのskill()、その下が子クラスのskill()となっております。オーバーライドされていますね。

オーバーライドの利点ですが、色々な子クラスを作ってオーバーライドしたメソッドを用意しておけば、様々な状況に対応できるみたいです。

しかしオーバーライドをしてしまうと、同じ名前のメソッドが複数存在することになってしまいます。なんか紛らわしいし、目当てのメソッドを呼び出しそうとしたら違うメソッドを呼び出してしまうかもしれないとか思ってしまわれるかもしれません。しかし心配ご無用。特に変わった処理は必要ありません。上記のように普通にクラスのインスタンスを生成してメソッドを結びつけて呼びだすだけでいいそうです。

参考文献
PHPサーバーサイドプログラミングパーフェクトマスター 
著者 金城 俊哉

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

自己参照 多対多 関連アーティストテーブルに関連アーティストを紐つけて保存する

//関連アーティスト更新
//            dd((int)$request->artist_id[0]);
            if (isset($request->artist_id) && count($request->artist_id)) {
                for ($i = 0; $i < count($request->artist_id); $i++) {
                    $ArtistParams[] = [
                        'artist_id' => $request->id,
                        'related_artist_id' => $request->artist_id[$i],
                        'sort_order' => $i + 1,
                    ];
                    $this->model->related_artist()->sync($ArtistParams);

                }
            } else {
                $this->model->related_artist()->detach();
            }

$ArtistParams[]のとこ!!あと、saveじゃなくてsyncじゃないとうまくいかん。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ずんだの1桁足し算問題 PHP編

ずんだの1桁足し算問題 Ruby編 をPHPで書いたものです。

ちなみに, ずんだ成分ゼロです
※ずんだ=ずんだまるさん = https://twitter.com/edamametsubu318

<?php

for($i=1; $i<10; $i++) {
    for($j=1; $j<10; $j++) {
        $total = $i + $j;
        echo("{$i}+{$j}=" . str_repeat('~', $total) . $total . "\n");
    }
}

出力

1+1=~~2
1+2=~~~3
1+3=~~~~4
1+4=~~~~~5
1+5=~~~~~~6
1+6=~~~~~~~7
1+7=~~~~~~~~8
1+8=~~~~~~~~~9
1+9=~~~~~~~~~~10
2+1=~~~3
2+2=~~~~4
2+3=~~~~~5
2+4=~~~~~~6
2+5=~~~~~~~7
2+6=~~~~~~~~8
2+7=~~~~~~~~~9
2+8=~~~~~~~~~~10
2+9=~~~~~~~~~~~11
3+1=~~~~4
3+2=~~~~~5
3+3=~~~~~~6
3+4=~~~~~~~7
3+5=~~~~~~~~8
3+6=~~~~~~~~~9
3+7=~~~~~~~~~~10
3+8=~~~~~~~~~~~11
3+9=~~~~~~~~~~~~12
4+1=~~~~~5
4+2=~~~~~~6
4+3=~~~~~~~7
4+4=~~~~~~~~8
4+5=~~~~~~~~~9
4+6=~~~~~~~~~~10
4+7=~~~~~~~~~~~11
4+8=~~~~~~~~~~~~12
4+9=~~~~~~~~~~~~~13
5+1=~~~~~~6
5+2=~~~~~~~7
5+3=~~~~~~~~8
5+4=~~~~~~~~~9
5+5=~~~~~~~~~~10
5+6=~~~~~~~~~~~11
5+7=~~~~~~~~~~~~12
5+8=~~~~~~~~~~~~~13
5+9=~~~~~~~~~~~~~~14
6+1=~~~~~~~7
6+2=~~~~~~~~8
6+3=~~~~~~~~~9
6+4=~~~~~~~~~~10
6+5=~~~~~~~~~~~11
6+6=~~~~~~~~~~~~12
6+7=~~~~~~~~~~~~~13
6+8=~~~~~~~~~~~~~~14
6+9=~~~~~~~~~~~~~~~15
7+1=~~~~~~~~8
7+2=~~~~~~~~~9
7+3=~~~~~~~~~~10
7+4=~~~~~~~~~~~11
7+5=~~~~~~~~~~~~12
7+6=~~~~~~~~~~~~~13
7+7=~~~~~~~~~~~~~~14
7+8=~~~~~~~~~~~~~~~15
7+9=~~~~~~~~~~~~~~~~16
8+1=~~~~~~~~~9
8+2=~~~~~~~~~~10
8+3=~~~~~~~~~~~11
8+4=~~~~~~~~~~~~12
8+5=~~~~~~~~~~~~~13
8+6=~~~~~~~~~~~~~~14
8+7=~~~~~~~~~~~~~~~15
8+8=~~~~~~~~~~~~~~~~16
8+9=~~~~~~~~~~~~~~~~~17
9+1=~~~~~~~~~~10
9+2=~~~~~~~~~~~11
9+3=~~~~~~~~~~~~12
9+4=~~~~~~~~~~~~~13
9+5=~~~~~~~~~~~~~~14
9+6=~~~~~~~~~~~~~~~15
9+7=~~~~~~~~~~~~~~~~16
9+8=~~~~~~~~~~~~~~~~~17
9+9=~~~~~~~~~~~~~~~~~~18
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

laravelのマルチauthで認証済みの独自定義ユーザを取得する方法

php artisan make:authで作成できるユーザの認証状態はAuth::user()で取得できますが、
マルチauthでの独自に定義したユーザは上記の方法では認証状態を取得することはできません。

そこで、認証済み独自ユーザを取得する方法をadminという独自ユーザを例に解説します。
早速ですが、以下コードになります。

Auth::guard('admin')->user()

Authファサードのguardメソッドを使って、使用したい独自ユーザのガードを指定することで、
マルチauthでの認証済の独自ユーザの取得を実現しています。

今回はadminを指定しているので、認証済adminの取得になります。

マルチauth作成で参考にさせていただいたページ

https://qiita.com/PKunito/items/a8300db38ce7d6949106

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravelのマルチauthで認証済みの独自定義ユーザを取得する方法

php artisan make:authで作成できるユーザの認証状態はAuth::user()で取得できますが、
マルチauthでの独自に定義したユーザは上記の方法では認証状態を取得することはできません。

そこで、認証済み独自ユーザを取得する方法をadminという独自ユーザを例に解説します。
早速ですが、以下コードになります。

Auth::guard('admin')->user()

Authファサードのguardメソッドを使って、使用したい独自ユーザのガードを指定することで、
マルチauthでの認証済の独自ユーザの取得を実現しています。

今回はadminを指定しているので、認証済adminの取得になります。

利用例(Laravel × Nuxt)

実際に使うときは、routesのapi.phpなどで以下のように利用しています。

Route::get('/admin', function () {
  return Auth::guard('admin')->user();
})->name('admin');

Nuxtでログイン状態の維持のために以下のように使えます。

/store/index.js

export const actions = {
  async nuxtServerInit ({ commit }, { app }) {
    await app.$axios.$get('/admin')
      .then(user => commit('auth/setAdmin', admin))
      .catch(() => commit('auth/setAdmin', null))
  }
}

SSR時のログインチェックを行わせることで、Nuxt側で認証をstoreにセットして、ログイン状態を維持できるわけですね。

LaravelとNuxtでの認証周りの開発の参考になれば幸いです。

マルチauth作成で参考にさせていただいたページ

https://qiita.com/PKunito/items/a8300db38ce7d6949106

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravelのマルチauthで認証済みの独自定義ユーザを取得する方法・Nuxt SSR時のログイン状態維持

php artisan make:authで作成できるユーザの認証状態はAuth::user()で取得できますが、
マルチauthでの独自に定義したユーザは上記の方法では認証状態を取得することはできません。

そこで、認証済み独自ユーザを取得する方法をadminという独自ユーザを例に解説します。
早速ですが、以下コードになります。

Auth::guard('admin')->user()

Authファサードのguardメソッドを使って、使用したい独自ユーザのガードを指定することで、
マルチauthでの認証済の独自ユーザの取得を実現しています。

今回はadminを指定しているので、認証済adminの取得になります。

利用例(Laravel × Nuxt)

実際に使うときは、routesのapi.phpなどで以下のように利用しています。

Route::get('/admin', function () {
  return Auth::guard('admin')->user();
})->name('admin');

Nuxtでログイン状態の維持のために以下のように使えます。

/store/index.js

export const actions = {
  async nuxtServerInit ({ commit }, { app }) {
    await app.$axios.$get('/admin')
      .then(admin => commit('auth/setAdmin', admin))
      .catch(() => commit('auth/setAdmin', null))
  }
}

SSR時のログインチェックを行わせることで、Nuxt側で認証をstoreにセットして、ログイン状態を維持できるわけですね。

LaravelとNuxtでの認証周りの開発の参考になれば幸いです。

マルチauth作成で参考にさせていただいたページ

https://qiita.com/PKunito/items/a8300db38ce7d6949106

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

laravelのlogin処理のソースコードリーディング

login処理のソースコードリーディングをした過程を書いたのですが、ログイン周りをカスタムする必要性が出てきたら勉強すればよいと思います。

自分の場合は軽い気持ちで読んでみただけなので、カスタムする必要性が出てきてからでよかったなと思ってます。

本当にカスタムしようとすると、GuardだったりUserProviderも勉強しないといけないのでアレなんですけどね。

前提知識

認証を理解するにあたって知っておきたい知識を簡単に説明します。

Guard

認証情報の操作をするものです。このクラスはIlluminate\Contracts\Auth\Guardを実装する必要があります。

デフォルトではセッションを利用するものが使われます。

UserProvider

実際に認証処理を行うクラスです。idなどを利用してユーザーのデータを取得して返却します。

Illuminate\Contracts\Aurh\UserProviderを実装する必要があります。

デフォルトではDBからユーザー情報の取得を行い、DB接続に利用するものとしてeloquentかdatabaseの2つが用意されており、デフォルトではeloquentが利用されます。

Authenticatable

UserProviderでは引数や返り値としてIlluminate\Contracts\Auth\Authenticatableを実装したクラスを扱います。

デフォルトで使用されるUser.phpもextends先でこれを実装しています。

login処理

まずはじめに注意点として、掲載するコードは今回の説明に関係ないところを省略して書いていたりします。完全なコードが知りたい場合はlaravelのコードを見てください。

LoginControllerにはloginメソッドはなく、AuthenticatesUsersトレイトに実装されています。

public function login(Request $request)
{
    $this->validateLogin($request);

    // If the class is using the ThrottlesLogins trait, we can automatically throttle
    // the login attempts for this application. We'll key this by the username and
    // the IP address of the client making these requests into this application.
    if (method_exists($this, 'hasTooManyLoginAttempts') &&
        $this->hasTooManyLoginAttempts($request)) {
        $this->fireLockoutEvent($request);

        return $this->sendLockoutResponse($request);
    }

    if ($this->attemptLogin($request)) {
        return $this->sendLoginResponse($request);
    }

    // If the login attempt was unsuccessful we will increment the number of attempts
    // to login and redirect the user back to the login form. Of course, when this
    // user surpasses their maximum number of attempts they will get locked out.
    $this->incrementLoginAttempts($request);

    return $this->sendFailedLoginResponse($request);
}

validateLoginは入力された認証情報(標準ではメールアドレスとパスワード)をバリデーションしています。

attemptLoginの箇所以外はログイン試行回数が規定回数を超えたらロックするといった機能を実現するものなので、今回は割愛します。

二個目のif文の中の$this->attemptLoginを見ていきます。

protected function attemptLogin(Request $request)
{
    return $this->guard()->attempt(
        $this->credentials($request), $request->filled('remember')
    );
}

guard()メソッドを見ていきます。

protected function guard()
{
    return Auth::guard();
}

Authファサードでguardメソッドを呼び出しています。

Authファサードはauthという名前でサービスコンテナから解決をします。
config/app.phpからAuthServiceProviderが登録されているのがわかるので、その中身を見ればどの具象クラスが返ってくるかがわかります。

AuthファサードではIlluminate\Auth\AuthManagerのインスタンスを返却するので、その中のguardメソッドを呼び出していることになります。

なので上記のコードは以下のコードとほぼ同義になります。

protected function guard()
{
    return (new AuthManager())->guard();
}

ではguardメソッドの中身を見ていきましょう。

public function guard($name = null)
{
    $name = $name ?: $this->getDefaultDriver();

    return $this->guards[$name] ?? $this->guards[$name] = $this->resolve($name);
}

guardには引数を渡していないので、$this->getDefaultDriverが呼ばれます。

public function getDefaultDriver()
{
    return $this->app['config']['auth.defaults.guard'];
}

ここのコードの意味はconfig/auth.phpの中身のdefaults => guardの値を取り出すといった意味です。

auth.php
defaults' => [
    'guard' => 'web',
    'passwords' => 'users',
],

標準ではこのように記載されているので、webといった文字列を返却することになります。
なので$name = "web"となります。

次は$this->resolve($name)を見ていきます。

protected function resolve($name)
{
    $config = $this->getConfig($name);

    $driverMethod = 'create'.ucfirst($config['driver']).'Driver';

    if (method_exists($this, $driverMethod)) {
        return $this->{$driverMethod}($name, $config);
    }
}

getConfigは先程のgetDefaultDriverと同じ用にauth.phpから値を取ってきます。

['driver' => 'session', 'provider' => 'users']といった配列が返却されます。

$driverMethod = createSessionDriverとなります。if文の中でこの名前のメソッドを呼び出しています。
($this->createSessionDriver($name, $config))

public function createSessionDriver($name, $config)
{
    $provider = $this->createUserProvider($config['provider'] ?? null);

    $guard = new SessionGuard($name, $provider, $this->app['session.store']);

    return $guard;
}

$this->createUserProviderでUserProviderを取得し、それらを利用してSessionGuardを生成しています。

attemptLoginメソッド内の$this->guard()SessionGuardクラスのインスタンスを返すことがわかりました。

いろんなところにとびすぎたのでattemptLoginメソッドを再掲します。

protected function attemptLogin(Request $request)
{
    return $this->guard()->attempt(
        $this->credentials($request), $request->filled('remember')
    );

    // 以下と同値
    return (new SessionGuard()->attempt(
       $this->credentials($request), $request->filled('remember')
   );
}

Illuminate\Auth\SessionGuardattemptメソッドを見ていきます。

public function attempt(array $credentials = [], $remember = false)
{
    $this->lastAttempted = $user = $this->provider->retrieveByCredentials($credentials);

    if ($this->hasValidCredentials($user, $credentials)) {
        $this->login($user, $remember);

        return true;
    }
}

$this->provider->retrieveByCredentials($credentials)はメールアドレスを元にユーザー情報をDBから取得しています。

$this->hasValidCredentialsは主にパスワードの照合をしています。パスワードが一致すればloginメソッドが呼ばれます。

public function login(AuthenticatableContract $user, $remember = false)
{
    $this->updateSession($user->getAuthIdentifier());

    // If the user should be permanently "remembered" by the application we will
    // queue a permanent cookie that contains the encrypted copy of the user
    // identifier. We will then decrypt this later to retrieve the users.
    if ($remember) {
        $this->ensureRememberTokenIsSet($user);

        $this->queueRecallerCookie($user);
    }

    // If we have an event dispatcher instance set we will fire an event so that
    // any listeners will hook into the authentication events and run actions
    // based on the login and logout events fired from the guard instances.
    $this->fireLoginEvent($user, $remember);

    $this->setUser($user);
}

loginメソッドでは、セッションにユーザーIDを格納したり、remember_tokenをDBにセットしたりしています。

そうすることでログインという状態を維持しているわけです。

loginメソッドのあとにtrueを返却することで、一番最初のloginメソッドに戻り、ログインイベントが発火されるわけです。

if ($this->attemptLogin($request)) {
    return $this->sendLoginResponse($request);
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravel6.0で新規作成・編集・一覧・ページネーションを作成したメモ

概要

前回の続き。
Laravel 6.0 基本のタスクリストを参考に、CRUD操作の一部を作成した。
Bootstrapが4であったりという点が異なっている。

データベース

タグを今回は作成する

php artisan make:migration create_tags_table 
whitemap/database/migrations/create_tags_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateTagsTable extends Migration
{

    public function up()
    {
        Schema::create('tags', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->integer('create_user_id')->unsigned()->comment('作成者ID');
            $table->string('name');
            $table->integer('value')->default(0);
            $table->boolean('is_display')->default(true);
            $table->timestamp('created_at')->useCurrent();
            $table->timestamp('updated_at')->default(DB::raw('CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP'));

            $table->foreign('create_user_id')->references('id')->on('users');
        });
    }
    public function down()
    {
        Schema::dropIfExists('tags');
    }
}

アプリケーション

モデル

php artisan make:model Models/Tag 

上記コマンドで以下が作成される。
規約通りに作ると、Eloquentが自動的にO/Rマッピングしてくれる。

whitemap/app/Models/Tag.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class Tag extends Model
{
    //
}

コントローラ

php artisan make:controller Admin/TagController --resource --model=Models/Tag 

上記コマンドでモデルと紐づいたコントローラが作成される。
ただし、今回は新規作成画面や編集画面をそれぞれ作らない予定なので、createeditメソッドは割愛。

whitemap/app/Http/Controllers/Admin/TagController.php
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use App\Http\Requests\Admin\StoreTagPost;
use Illuminate\Http\Request;

class TagController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        $tags = Tag::paginate(config('const.Paginator.PER_PAGE'));
        return view('admin/tags', [
            'tags' => $tags
        ]);
    }

    /**
     * Store a newly created resource in storage.
     * 新規作成
     *
     * @param  App\Http\Requests\Admin\StoreTagPost  $request
     * @return \Illuminate\Http\Response
     */
    public function store(StoreTagPost $request)
    {
        $user = $request->user();
        $tag = new Tag();
        $tag->name = $request->name;
        $tag->create_user_id = $user->id;
        $tag->value = $request->value;
        $tag->save();

        return redirect('/tag');
    }

    /**
     * Update the specified resource in storage.
     * 変更の保存
     * Laravelはタイプヒントされた変数名とルートセグメント名が一致する場合、
     * ルートかコントローラアクション中にEloquentモデルが定義されていると、自動的に依存解決する。
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Models\Tag  $tag
     * @return App\Http\Requests\Admin\StoreTagPost
     */
    public function update(StoreTagPost $request, Tag $tag)
    {
        $tag->name = $request->name;
        $tag->value = $request->value;
        $tag->save();

        return redirect('/tag');
    }
}

ページネーション用に、1ページに何ページ表示するかの数字を定数にしている。

whitemap/config/const.php
<?php

return [
    // Couponsで使う定数
    'Coupons' => [
        'TYPE_GET' => 1,
        'TYPE_USE' => 2,
    ],
+    'Paginator'=>[
+        'PER_PAGE'=>30
+    ]
];

バリデーション

php artisan make:request Admin/StoreTagPost

バリデーションの設定を行う。
タイプヒントで指定することでコントローラに使用を伝える。
例:update(StoreTagPost $request)

whitemap/app/Http/Requests/Admin/StoreTagPost.php
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;

class StoreTagPost extends FormRequest
{
    public function authorize()
    {
        // そもそも管理者しかタグの更新は行わないためここでは判定しない
        return true;
    }

    public function rules()
    {
        return [
            'name' => 'required|max:255',
            'value' => 'required|integer'
        ];
    }

    public function messages()
    {
        return [
            'name.required' => '名前は必須です',
            'name.max' => '名前は255文字以内で入力してください',
            'value.required'  => '値は必須です',
        ];
    }
}

ルーティング

{tag}にプライマリーキーを受け取ることを想定して、コントローラで$tagで受け取ることで暗黙的なモデルバインディングを実現している。

whitemap/routes/web.php
Route::group(['middleware' => ['auth', 'can:admin-access']], function () {
    Route::get('/admin', function (Illuminate\Http\Request $request) {
        return view('admin/dashboard');
    });


+    Route::get('/tag','Admin\TagController@index');
+    Route::post('/tag','Admin\TagController@store');
+    Route::put('/tag/{tag}','Admin\TagController@update');
});

ビュー

fontawesomeは公式の<link href="https://use.fontawesome.com/releases/v5.11.2/css/all.css" rel="stylesheet">よりもjsdelivrのほうが数段早い。

whitemap/resources/views/layoutes/app.blade.php
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="description" content="異世界漂流TRPG ドリフトサヴァイブはサバイバルをして文明を築き上げるTRPGです。" />
    <meta name="keywords" content="Laravel,laradock,gcp" />
    <meta name="robots" content="index" />
    @yield('meta')
    <!-- CSRF Token -->
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>{{ config('app.name', 'Laravel') }} - @yield('title')</title>
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.11.2/css/all.min.css" integrity="sha256-+N4/V/SbAFiW1MPBCXnfnP9QSN3+Keu+NlB+0ev/YKQ=" crossorigin="anonymous">
    @yield('css')
    <link href="{{ asset('css/app.css') }}" rel="stylesheet">
+    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha256-pasqAKBDmFT4eHoN2ndd6lN370kFiGUFyTiUHWhU7k8=" crossorigin="anonymous"></script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
+    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="{{ mix('js/common/app.js') }}"></script>
    @yield('head-scripts')
    <!-- Google Tag Manager -->
    <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
    new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
    j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
    'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
    })(window,document,'script','dataLayer','GTM-PBSJTDV');</script>
    <!-- End Google Tag Manager -->
  </head>
  <body>
    <!-- Google Tag Manager (noscript) -->
    <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-PBSJTDV" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
    <!-- End Google Tag Manager (noscript) -->
    <div id="app">
        <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
                @guest
                @else
                    <a class="navbar-brand user-icon" href="{{ url('/') }}">
                      <i class="user-icon">
                        <img src="{{\Auth::user()->twitter_profile_image_url_https}}">
                      </i>
                    </a>
                @endguest
            <!-- <a class="navbar-brand" href="{{ url('/') }}">{{ config('app.name', 'Laravel') }}</a> -->
            <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <ul class="navbar-nav mr-auto">
                    <li class="nav-item {{str_replace(url('/'),'',url()->current()) === '/'     ? 'active' : ''}}"><a class="nav-link" href="{{ url('/') }}">トップ</span></a></li>
                    <li class="nav-item {{str_replace(url('/'),'',url()->current()) === '/home' ? 'active' : ''}}"><a class="nav-link" href="{{ url('/home') }}">マイページ</span></a></li>
                    <li class="nav-item dropdown">
                        <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                        サイト情報
                        </a>
                        <div class="dropdown-menu" aria-labelledby="navbarDropdown">
                            <a class="dropdown-item" href="/agreement">利用規約</a>
                            <a class="dropdown-item" href="/privacy-policy">プライバシーポリシー</a>
                            <div class="dropdown-divider"></div>
                            <a class="dropdown-item" href="https://github.com/hibohiboo/whitemap">github</a>
                        </div>
                    </li>
                </ul>
            </div>
            @guest
              <div class="my-2 my-lg-0">
                <ul class="nav navbar-nav navbar-right">
                    <li class="nav-item"><a href="{{ route('login') }}" class="nav-link">ログイン</a></li>
                </ul>
              </div>
            @else

            @endguest

        </nav>
        @yield('content')
    </div>
    <!-- Scripts -->
    <script src="https://cdn.jsdelivr.net/npm/lodash@4.17.15/lodash.min.js" integrity="sha256-VeNaFBVDhoX3H+gJ37DpT/nTuZTdjYro9yBruHjVmoQ=" crossorigin="anonymous"></script>
    @yield('scripts')
  </body>
</html>

bootstrap3のpanelがbootstrap4ではcardになっているなど微修正。

whitemap/resouces/vies/admin/tags.blade.php
@extends('layouts.app')

@section('content')
    {{-- このコメントはレンダ後のHTMLには現れない --}}
    {{-- Bootstrapは一度に1つのモーダルウィンドウしかサポートしない入れ子になったモーダルはユーザー経験が乏しいと思われるためサポートされていない--}}
    {{-- 可能であれば他の要素からの干渉を避けるためにモーダルHTMLを最上位に配置すること --}}
    <div id="editModal" class="modal fade" tabindex="-1" role="dialog">
      <form id="edit-form" action="{{ url('tag')}}" method="POST">
        @csrf
        @method('PUT')
        <div class="modal-dialog" role="document">
          <div class="modal-content">
            <div class="modal-header">
              <h5 class="modal-title">編集</h5>
              <button type="button" class="close" data-dismiss="modal" aria-label="閉じる">
                <span aria-hidden="true">&times;</span>
              </button>
            </div>{{-- /.modal-header --}}
            <div class="modal-body">
              {{-- タグ名 --}}
              <div class="form-group">
                <label for="edit-tag-name" class="col-sm-3 control-label">タグ名</label>
                <div class="col-sm-6">
                    <input type="text" name="name" id="edit-tag-name" class="form-control" value="{{ old('tag') }}">
                </div>
              </div>
              {{-- タグ値 --}}
              <div class="form-group">
                <label for="edit-tag-value" class="col-sm-3 control-label"></label>
                <div class="col-sm-6">
                    <input type="number" name="value" id="edit-tag-value" class="form-control" value="{{ old('tag') }}">
                </div>
              </div>
            </div>{{-- /.modal-body --}}
            <div class="modal-footer">
              <button type="button" class="btn btn-secondary" data-dismiss="modal">閉じる</button>
              <button type="submit" class="btn btn-primary">変更を保存</button>
            </div>{{-- /.modal-footer --}}
          </div>{{-- /.modal-content --}}
        </div>{{-- /.modal-dialog --}}
      </form>
    </div>{{-- /.modal --}}
    <main class="container">
        <div class="col-sm-offset-2 col-sm-8">
            <div class="card">
                <div class="card-header">新しいタグ</div>
                <div class="card-body">
                    {{-- バリデーションエラーの表示 --}}
                    @include('common.errors')

                    {{-- 新タグフォーム --}}
                    <form action="{{ url('tag')}}" method="POST" class="form-horizontal">
                        @csrf
                        {{-- タグ名 --}}
                        <div class="form-group">
                            <label for="tag-name" class="col-sm-3 control-label">タグ名</label>

                            <div class="col-sm-6">
                                <input type="text" name="name" id="tag-name" class="form-control" value="{{ old('tag') }}">
                            </div>
                        </div>

                        {{-- タグ値 --}}
                        <div class="form-group">
                            <label for="tag-value" class="col-sm-3 control-label"></label>

                            <div class="col-sm-6">
                                <input type="number" name="value" id="tag-value" class="form-control" value="{{ old('tag') }}">
                            </div>
                        </div>

                        {{-- タグ追加ボタン --}}
                        <div class="form-group">
                            <div class="col-sm-offset-3 col-sm-6">
                                <button type="submit" class="btn btn-primary">
                                    <i class="fa fa-btn fa-plus"></i> タグ追加
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
            </div>

            @if (count($tags) > 0)
                <div class="card">
                    <div class="card-header">タグ一覧</div>
                    <div class="card-body">
                        <table class="table table-striped tag-table">
                            {{-- テーブルヘッダ --}}
                            <thead>
                                <tr>
                                    <th>タグ</th>
                                    <th></th>
                                    <th>&nbsp;</th>
                                </tr>
                            </thead>
                            {{-- テーブル本体 --}}
                            <tbody>
                              @foreach ($tags as $tag)
                                <tr>
                                  <td class="table-text">
                                      <div>{{ $tag->name }}</div>
                                  </td>
                                  <td class="table-text">
                                      <div>{{ $tag->value }}</div>
                                  </td>
                                  <td>
                                    <button type="button" class="btn" data-toggle="modal" data-target="#editModal" 
                                            data-action="{{ url('tag/' . $tag->id) }}" data-name="{{$tag->name}}" data-value="{{$tag->value}}"
                                    >
                                    <i class="fa fa-btn fa-edit"></i>
                                  </button>
                                  </td>
                                </tr>
                              @endforeach
                            </tbody>
                        </table>
                    </div>
                </div>
            @endif
            {{ $tags->links() }}
        </div>
    </div>
@endsection
@section('scripts')
  <script src="{{ mix('js/admin/tag/index.js') }}"></script>
@endsection

mixで呼び出すjs用の設定が以下。externalsで外部からリソースを取得することを表す。

npm install --save-dev @types/bootstrap
whitemap/webpack.mix.js
const mix = require('laravel-mix');
mix.ts('resources/ts/common/app.ts', 'public/js/common')
    .sass('resources/sass/app.scss', 'public/css')
    .version();

mix.ts('resources/ts/welcome/index.ts', 'public/js/welcome').version();
mix.ts('resources/ts/home/index.ts', 'public/js/home').version();
mix.ts('resources/ts/login/index.ts', 'public/js/login').version();
mix.webpackConfig({
    externals: {
+        jquery: 'jQuery',
        firebase: 'firebase',
        firebaseui: 'firebaseui',
        axios: 'axios',
        lodash: 'lodash',
+        bootstrap: 'bootstrap',
+        popper: 'popper.js'
    }
});

+ mix.ts('resources/ts/admin/tag/index.ts', 'public/js/admin/tag').version();
resouces/admin/tag/index.ts
import { ModalEventHandler } from 'bootstrap';
$('#editModal').on('show.bs.modal', function(event: ModalEventHandler<HTMLElement>) {
    const target = event.relatedTarget;
    console.log('modal', target);
    if (target === undefined) {
        return;
    }
    const $button = $(target); // モーダル切替えボタン
    const action = $button.data('action'); // data-* 属性から情報を抽出
    const name = $button.data('name');
    const value = $button.data('value');

    // モーダルの内容を更新。ここではjQueryを使用するが、代わりにデータ・バインディング・ライブラリまたは他のメソッドを使用することも可能
    const $modal = $(this);
    $modal.find('#edit-tag-name').val(name);
    $modal.find('#edit-tag-value').val(value);
    $modal.find('#edit-form').attr('action', action);
});
npm run watch-poll

参考

Laravel 6.0 基本のタスクリスト
bootstrap3 -> 4
@types/bootstrap
fontawesome は公式より jsdeliver のほうが早い
FontAwesome の読み込み速度を公式サイトと CDN サービスで比較してみた
暗黙のモデル結合
Laravel 6.0 データベース:ペジネーション
Laravel 6.0 コントローラ
Laravel 6.0 バリデーション

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【63日目】'0.0' == '0'はtrueになる! PHPの比較演算における自動キャストに注意

PHPの型と自動キャストの注意点

PHPには全部で8つの型があります。
とはいえPHPは動的型付け言語なので、変数を利用する際に明示的に型を宣言する必要はありません。
このことによりプログラミングを型に縛られず柔軟に行うことができますが、一方で、処理によっては型を自動的にキャスト(変換)してしまうので、これを理解していなければ思わぬバグを引き起こすことになります。

例えば、自動キャストの仕組みによって

'0.0' == '0'

はtrueとなります。
明らかに異なる文字列同士を比べているので、falseかのように見えます。
しかしながら、自動キャストという仕組みによって、これが等しいことになるのです。
こういった想定外の結果による致命的なバグを避けるために、型と自動キャストについて理解しておく必要があります。

この仕組みについては最後に解説します。

ここではまず8つの型を簡単にまとめつつ、明示的なキャスト方法と、自動キャストについて説明します。

1 整数(int)

整数は10進数、16進数(0x~)、8進数(0~)で指定でき、正負の符号をつけることができます。
整数のサイズはプラットフォームに依存し、PHP_INT_SIZE定数で確認できます。
また、最大値もPHP_INT_MAX定数で得られます。
最大値を超えた整数は浮動小数点数型に自動キャストされます。

整数型への明示的なキャストは(int), (iteger), intval()を使います。

2 浮動小数点数型(float)

整数で表せない実数のことで、整数型の最大値を超えた整数も含まれます。
実数で指定する他に、指数表記で初期化することもできます。

明示的にキャストする場合には、(float), (double), floatval()を使います。(double)も使えるのは、実際には倍精度浮動小数点数(64bit長の浮動小数点数を格納できる)だからです。

また、環境によっては特定の計算結果として得られたfloat(10)のような浮動小数点数は内部的には9.9999999のように扱われていて、int型にキャストした場合に小数点以下が切りてられてint(9)になる場合があるようです。
なのでfloat型の値の比較がしたい場合はstring型で行うと良いようです。

3 文字列型

文字列は最も多く操作することになる型で、シングルクォート「'」またはダブルクォート「"」で初期化できます。
シングルの場合はその中の変数やエスケープ文字を展開しませんが、ダブルの場合は展開します。
また、ヒアドキュメントといって、複数行に渡る文字列を定義することもできます。

<?php
$foo = <<< EOI
<<< EOI から EOI; までの間であれば
このように複数行の

文字列の定義ができます
EOI;

またヒアドキュメントにおいて、変数を展開したくない場合は、終端識別子を ’EOI' とシングルクォートで括ります。

文字列型を明示的に指定するには(string), strval()を利用します。

3.1 文字列の自動キャストの注意点

また、自動キャストは非常によく発生しています。
例えばechoなどで出力する場合、すべて文字列にキャストされてから出力されるので、echoによって出力された文字をそのまま比較演算する場合などは注意が必要です。
あるいは、クライアントからのリクエストや、コマンドラインから実行した場合の引数も、数字や浮動小数点数に見えるものでも文字列です。
比較演算の際に例えば「==」を用いる場合、これは型の一致を求めないので、文字列と整数を比較しても、一旦全部整数型に自動キャストされています。
なので、引数として期待している値の型が決まっているのであればint($argv)などのように型を厳密に指定して、比較演算子には極力「===」を用いるのが安全なようです。

4 論理型

真偽値を扱うもので、trueかfalseしかありません。大文字小文字の区別はありません。
明示的なキャストには(bool)か( boolean)を用います。

4.1 論理型の自動キャスト

条件式や関数の引数で論理型が指定されている場所でtrueかfalse以外を使用した場合、論理型に自動キャストされます。
またこの時、PHPでは何らかの値があるものはほとんどがtrueとされますが、下記の7通りの場合のみfalseと扱われます。

  • falase (論理型)
  • 0 (整理数型)
  • 0.0 (浮動小数点数型)
  • 空の文字列 ("")、文字列のゼロ ("0")
  • 要素の数がゼロの配列
  • null、値がセットされていない空の変数
  • 空のタグから作成されたSimpleXMLオブジェクト

5 配列

配列は複数の値の集まりを保持するためのデータ構造です。
PHPでは添字配列と連想配列の区別はなくどちらも配列と呼ばれます。
配列の初期化は

array([要素[, ...]]); // 添字配列
array([key => 要素[, ...]]); //連想配列

で行います。

5.1.1 配列の自動キャスト

keyとして用いるのは整数型か文字列型となり、下記の型を用いた場合、この2つのどちらかにキャストされます。

  • 論理型はtrueは整数の1、falseは整数の2となります。
  • 浮動小数点数型は小数点以下が切り捨てられ、整数型になります。
  • nullは空の文字列("")となります。

6 オブジェクト

クラスをnew演算子によってインスタンス化したものです。
クラスやオブジェクトについての解説は省略します。

7 リソース

何らかの外部ソースへの参照を保存しているものです。
PHPではたくさんの外部ソースを使う拡張機能があるため、いくつもの種類があるようで、ここでは列記しません。
リソース型はその外部リソースの専用となるので、キャストすることはできません。
変数の持つリソース型を調べるには「get_resource_type()」関数を用いることができます。

8 null

ある変数が値を持たないことを表す特別な型です。下記の場合にnullとなります。

  • 明示的に定数 nullが代入されている場合
  • 値が何も代入されていない場合
  • unset() されている場合

よくあるエラーとなるケースは、まだ一度も使ったことのない変数を出力しようとしたり、関数の返り値を代入する場合に関数が何も返していない場合などがあります。

自動キャスト

自動キャストは次のような場合に起きていますので、注意します。

  • 異なる型同士で演算を行う場合
  • 演算子、制御構造、関数やメソッドが特定の型の引数を必要とする場合で、それとは異なる型の値を用いた場合
  • 両辺とも数値らしい文字列の比較演算の際、該当する数値型に自動キャストする

特に比較演算子を用いる場合によく生じます
比較演算子には左右の型が違う場合にキャストする==, != , <, >と
左右の方が違う場合キャストが行われない厳密な=== ,や!==もあります。
特に文字列同士の比較で数値型に変換したくない場合、整数型と浮動小数点数型を比較する可能性がある場合などには要注意です。

冒頭の問題に戻りましょう。

'0.0' == '0'

の挙動は下記の通りとなります。

1 '0.0'は浮動小数点数らしい文字列なので、0.0にキャストされる
2 '0'は整数型らしい文字列なので、0にキャストされる
3 0.0 == 0は異なる型同士の演算なので、整数0は浮動小数点数0.0にキャストされる
4 0.0 == 0.0となり、trueが返る

ここで==の代わりに===を用いると、上記3のキャストが生じないため
4' 0.0 === 0 となり、想定どおりflaseが返ります。

こういった思いも寄らない結果を引き起こさないため、比較演算子は可能な限り厳密な===や!==を用いることが推奨されています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravel6.0で管理者用画面を作ったメモ

概要

前回の続き。
今回は、ログインユーザに管理者権限を与えて、管理者専用画面を表示する。

データベース

ER図

今回、DBは以下のようにする。
管理者クーポンが発行されたユーザが管理者権限を持つとする。

image.png

マイグレーション

以下のコマンドでマイグレーションファイルを作成できる。

php artisan make:migration create_coupons_table

上記で作成したファイルに含まれる$table->timestamps();はnullableなcreated_atupdated_atを作成する。
DBに作ってもらいたかったので、作成時、更新時の時刻を挿入する設定にしている。

whitemap/database/migrations/create_coupons_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateCouponsTable extends Migration
{
    public function up()
    {
        Schema::create('coupons', function (Blueprint $table) {
            $table->string('id');
            $table->integer('type')->default(config('const.Coupons.TYPE_GET', 1))->comment('1:取得, 2:使用');
            $table->string('name');
            $table->boolean('is_display')->default(true);
            $table->timestamp('created_at')->useCurrent();
            $table->timestamp('updated_at')->default(DB::raw('CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP'));
        });
    }
    public function down()
    {
        Schema::dropIfExists('coupons');
    }
}

クーポン種別は定数で定義してみた。

whitemap/config/const.php
<?php

return [
    // Couponsで使う定数
    'Coupons' => [
        'TYPE_GET' => 1,
        'TYPE_USE' => 2,
    ],
];

ユーザとクーポンの紐づけテーブルでは外部キーを設定し、
ユーザテーブルやクーポンテーブルにないものは登録できないようにした。

whitemap/database/create_user_coupons_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateUserCouponsTable extends Migration
{
    public function up()
    {
        Schema::create('user_coupons', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('coupon_id')->comment('クーポンID');
            $table->integer('subscribe_user_id')->unsigned()->comment('受取ユーザID');
            $table->integer('publish_user_id')->unsigned()->comment('発行ユーザID');
            $table->dateTime('expire')->nullable()->comment('利用期限');
            $table->timestamp('created_at')->useCurrent();
            $table->timestamp('updated_at')->default(DB::raw('CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP'));
            $table->softDeletes();

            $table->foreign('subscribe_user_id')->references('id')->on('users');
            $table->foreign('publish_user_id')->references('id')->on('users');
            $table->foreign('coupon_id')->references('id')->on('coupons');
        });
    }

    public function down()
    {
        Schema::dropIfExists('user_coupons');
    }
}

php artisan migareteでデータベース更新。

シーダの設定

php artisan make:seeder UsersTableSeeder 

システム用のユーザと初期ユーザを作成。

whitemap/database/seeds/UserSeeder.php
<?php
use Illuminate\Database\Seeder;

class UserSeeder extends Seeder
{
    public function run()
    {
        $user = [
            'id' => 1,
            'firebase_uid' => 'system',
            'name' => 'システム管理者',
            'twitter_screen_name' => '',
            'twitter_profile_image_url_https' => '',
        ];
        DB::table('users')->insert($user);
        $user = [
            'id' => 2,
            'firebase_uid' => env('FIRST_USER_FIREBASE_UID'),
            'name' => env('FIRST_USER_NAME'),
            'twitter_screen_name' => env('FIRST_USER_TWITTER_SCREEN_NAME'),
            'twitter_profile_image_url_https' => env('FIRST_USER_TWITTER_PROFILE_IMAGE_URL'),
        ];
        DB::table('users')->insert($user);
    }
}

管理者クーポンを追加。

whitemap/database/seeds/Couponseeder.php
<?php

use Illuminate\Database\Seeder;
use App\Enums\Coupon\CouponIds;

class CouponsSeeder extends Seeder
{
    public function run()
    {        
        $coupon = [
            'id' => CouponIds::ADMIN(),
            'name' => '管理者クーポン',
            'is_display' => false,
        ];
        DB::table('coupons')->insert($coupon);
    }
}

クーポンIDはEnumで登録してみた。

composer require myclabs/php-enum
whitemap/app/Enums/Coupon.php
<?php
namespace App\Enums\Coupon;
use MyCLabs\Enum\Enum;

class CouponIds extends Enum
{
    const ADMIN = 'admin';
}

システムユーザから初期ユーザに向けて管理者クーポンを発行

whitemap/database/seeds/UserCouponsSeeder.php
<?php
use Illuminate\Database\Seeder;
use App\Enums\Coupon\CouponIds;

class UserCouponsSeeder extends Seeder
{
    public function run()
    {
        $user_coupon = [
            'id' => 1,
            'coupon_id' => CouponIds::ADMIN(),
            'subscribe_user_id' => 2,
            'publish_user_id' => 1,
        ];
        DB::table('user_coupons')->insert($user_coupon);
    }
}

実行するSeederを指定

whitemap/database/seeds/DatabaseSeeder.php
<?php
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        // $this->call(UsersTableSeeder::class);
        $this->call([
            UserSeeder::class,
            CouponsSeeder::class,
            UserCouponsSeeder::class,
        ]);
    }
}

シーダの実行

php artisan db:seed 

アプリケーション

モデルの設定

php artisan make:model Models/UserCoupon
whitemap/app/Models//UserCoupon.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;

class UserCoupon extends Model
{
    protected $table = 'user_coupons';
}

subscribe_user_idでユーザクーポンとユーザを紐づける。

whitemap/app/User.php
<?php
namespace App;
use Laravel\Passport\HasApiTokens; // 追加
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use App\Enums\Coupon\CouponIds;

class User extends Authenticatable
{
    use Notifiable, HasApiTokens; // HasApiTokens を追加
    public function __construct(array $attributes = []){
    }

    protected $fillable = [
        'name', 'twitter_screen_name','twitter_profile_image_url_https', 'firebase_uid'
    ];
    protected $hidden = [
        'password', 'remember_token',
    ];
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];


+    public function userCoupons(){
+        return $this->hasMany('App\Models\UserCoupon',  'subscribe_user_id');
+    }
}

ゲートの設定

whitemap/app/Gate/AdminAccess
<?php 
namespace App\Gate;
use App\User;
use App\Enums\Coupon\CouponIds;

final class AdminAccess
{
    public function __invoke(User $user): bool 
    {
        // 管理者用のクーポンを持っているかDBに問い合わせる。
        return $user->userCoupons()->where('coupon_id',CouponIds::ADMIN())->exists();
    }
}

上記で設定したゲートをadmin-accessの名前で登録する。

whitemap/app/Providers/AuthServiceProvider.php
<?php
namespace App\Providers;
use App\Auth\MySessionGuard; 
use App\Auth\MyEloquentUserProvider; 
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Auth; 
use App\Gate\UserAccess; 
use App\Gate\AdminAccess; 
use \Psr\Log\LoggerInterface;

class AuthServiceProvider extends ServiceProvider
{
    protected $policies = [
        // 'App\Model' => 'App\Policies\ModelPolicy',
    ];

    public function boot(LoggerInterface $logger)
    {
        $this->registerPolicies();

        Auth::provider('my_eloquent', function($app, array $config) {
            return new MyEloquentUserProvider($app['hash'], $config['model']);
        });


+        // 認可
+        Gate::define('admin-access', new AdminAccess);

        // 認可の前にロギング
        Gate::before(function ($user, $ability) use ($logger) {
            // Log::info("Hello my log,");
            $logger->info($ability, ['firebase_uid'=>$user->getAuthIdentifier()]);
        });
    }
}

ルーティング

whitemap/routes/web.php
Route::group(['middleware' => ['auth', 'can:admin-access']], function () {
    // この中は管理者権限の場合のみルーティングされる
    Route::get('/admin', function (Illuminate\Http\Request $request) {
        return view('admin/dashboard');
    });
});

管理者ページへのリンク

管理者権限があるときだけ、管理者ページへのリンクを表示。

whitemap/resources/views/home.blade.php
@extends('layouts.app') 
@section('title') マイページ @endsection
@section('head-scripts')
@if(Auth::check())
<script src="https://www.gstatic.com/firebasejs/7.2.0/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/7.2.0/firebase-auth.js"></script>
<script src="{{ mix('js/home/index.js') }}"></script>
@endif
@endsection

@section('content')
<main role="main" class="container">
<div class="starter-template">
    <h1>マイページ</h1>
    @if(Auth::check())
      <ul>
+        @can('admin-access')
+          <li><a href="/admin">管理者画面へ</a></li>
+        @endcan
        <li><a href="#" id="logout">ログアウト</a></li>
      </ul>
    @else 
      こんにちは!  ゲストさん <br />
      <a href="/login">ログイン</a>
    @endif
</div></main>
@endsection

参考

Laravel で定数をつかうよ
Laravel で Enum を使う
Laravel 6.0 Artisan コンソール
全 68 種類!Laravel 5.6 の artisan コマンドまとめ
【メモ】【Laravel】外部キー制約付き Migrate がさっぱり動かないときのチェック・ポイント(Mysql)
Laravel の DB migration で日付のデフォルトを指定
Laravel(Eloquent)の save メソッドを使ったら MySQL の timestamp 型で謎な挙動が発生した話
管理者クーポンによるタグ画面の制御
blade テンプレートでの切替
Laravel 6.0 基本のタスクリスト
laravel
readouble laravel
【Laravel】 認証や認可に関する補足資料
Laravel 6.0 認可
Laravel 6.0 ルーティング
Laravel 6.0 ミドルウェア
Laravel の Gate(ゲート)機能で権限(ロール)によるアクセス制限を実装する
Laravel 6.0 バリデーション

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む