🐈

Laravel-generatorを使って爆速でtwitterを作るぞ!バックエンド編

2021/01/12に公開

※この記事はバックエンドまでです。要望があればフロントエンド編も作るかも?

年末年始にLaravelを使って何か作りたいな〜と思っていた時にこんな記事を見つけました
https://qiita.com/ayasamind/items/a5bfa65ab0b8effb26a8

どうやらコマンドから作るだけでCRUDからテストまで作ってくれるらしい。。。すごい!
という事でLaravel-generatorを使ってtwitter風サイトを作ってみました

実際に作ってみたサイト

2022/7/7 停止中

IInetter

いいねがいっぱいできるtwitter風SNS
https://iinetter.herokuapp.com

テストアカウント
Email:test@example.com
Password:password
ご自由に触ってもらってOKです

管理画面

https://adm-iinetter.herokuapp.com/login

Swagger API

https://adm-iinetter.herokuapp.com/api/docs

GitHub

タグはv1参照
https://github.com/okdyy75/iinetter-docker

簡単な仕様

  • ツイートするにはログイン必須
  • 各ユーザーのタイムラインは未ログインで閲覧可能
  • アカウント名・メールアドレスの変更。アカウントの削除が不可(ツイートは削除可能)
  • 出来ること:アカウント登録・ツイート・リツイート・返信・いいね
  • 出来ないこと:フォロー・ツイート検索・DM・ブックマーク・etc...(終わりが見えなかったので)
  • 管理画面はadmin権限を付与したユーザーのみログイン可

使用環境・ツール

  • PostgreSQL 12.5
  • PHP 8.0
  • Laravel 8.12
  • laravel-generator 8.0.x-dev
  • adminlte-templates 8.0.x-dev
  • Swagger 2.0
  • Nuxt 2.14 (Vue 2.6)
  • ※Heroku(本番)だとapacheを使用

Dockerを作る

詳細はGitHubを見てください

docker-compose.yml
version: "3"
services:
  # Nginx ################################################
  nginx:
    build: ./nginx
    environment:
      TZ: Asia/Tokyo
    expose:
      - "80"
    depends_on:
      - php-fpm
    volumes:
      - "./web:/var/www/web:cached"
      - "./log/nginx:/var/log/nginx"
    ports:
      - "80:80"
  # php-fpm ################################################
  php-fpm:
    build: ./php-fpm
    expose:
      - "9000"
    depends_on:
      - mysql
    volumes:
      - "./web:/var/www/web:cached"
  postgres:
    build: ./postgres
    environment:
      POSTGRES_DB: db
      POSTGRES_USER: root
      POSTGRES_PASSWORD: root
    volumes:
      - "./.data/postgres:/var/lib/postgresql/data"
    ports:
      - "5432:5432"

バックエンドを作る

①プロジェクト作成&基本設定

dockerのphp-fpmに入ってLaravelプロジェクト作成

composer create-project laravel/laravel iinetter

.env.exampleをコピーして.envを作成&修正しておきます

.env
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=db
DB_USERNAME=root
DB_PASSWORD=root

基本的にはinfyomのドキュメントに従います
https://www.infyom.com/open-source/laravelgenerator/docs/8.0/installation

必要なパッケージをインストール&アップデート

composer require infyomlabs/laravel-generator=^8.0.x-dev
composer require laravelcollective/html=^6.2
composer require infyomlabs/adminlte-templates=^2.0
composer update

app.phpエイリアス追記

config/app.php
	'Form'      => Collective\Html\FormFacade::class,
	'Html'      => Collective\Html\HtmlFacade::class,
	'Flash'     => Laracasts\Flash\Flash::class,

コマンド実行(infyomのconfig作成)

php artisan vendor:publish --provider="InfyOm\Generator\InfyOmGeneratorServiceProvider"

APIルート更新

app/Providers/RouteServiceProvider.php
Route::prefix('api')
        ->middleware('api')
        ->as('api.')
        ->namespace($this->app->getNamespace().'Http\Controllers\API')
        ->group(base_path('routes/api.php'));

コマンド実行(infyomの用のBaseController等作成)

php artisan infyom:publish

たまにGitHubのAPIリミットに引っかかるのでHead toのリンクから「Personal access token」を作成して「Token (hidden): 」に入力してください

GitHub API limit (0 calls/hr) is exhausted, could not fetch https://api.github.com/repos/InfyOmLabs/swaggervel. Create a GitHub OAuth token to go over the API rate limit. You can also wait until ? for the rate limit to reset.

Head to https://github.com/settings/tokens/new?scopes=repo&description=xxxxxxxxxxxxxxxxxxxxxxxx
to retrieve a token. It will be stored in "/root/.composer/auth.json" for future use by Composer.
Token (hidden): 

②ログイン画面作成

必要なパッケージをインストール&コマンド実行

composer require laravel/ui:^3.0

# ログイン画面作成
php artisan ui bootstrap --auth

# infyomのフォーマットに上書き
php artisan infyom.publish:layout --localized

エラーが起きるのでadminlte-templatesのバージョンを8.0.x-devに合わせてみる

   ErrorException 

  copy(/var/www/web/iinetter/vendor/infyomlabs/adminlte-templates/templates/scaffold/auth/email_locale.stub): Failed to open stream: No such file or directory

再実行するとviewsファイルがlocalized形式で上書きされます。

composer require infyomlabs/adminlte-templates=^8.0.x-dev
php artisan infyom.publish:layout --localized
# 全部y(yes)

viewファイルが一通り揃ったのでひとまず日本語化
https://readouble.com/laravel/8.x/ja/validation-php.html

php -r "copy('https://readouble.com/laravel/8.x/ja/install-ja-lang-files.php', 'install-ja-lang.php');"
php -f install-ja-lang.php
php -r "unlink('install-ja-lang.php');"

laravel-generatorで自動生成したviewファイル対応のresourceが無いのでvendorから拾ってくる。日本語フォーマットはないので適宜日本語を設定。

cp ./vendor/infyomlabs/laravel-generator/locale/en/* ./resources/lang/ja

ログイン画面周りは一部日本語化してみた

resources/lang/ja/auth.php
<?php

return [

    /*
    |--------------------------------------------------------------------------
    | 認証言語行
    |--------------------------------------------------------------------------
    |
    | 以下の言語行は認証時にユーザーに対し表示する必要のある
    | 様々なメッセージです。アプリケーションの必要に合わせ
    | 自由にこれらの言語行を変更してください。
    |
    */

    'failed' => 'ログイン情報が登録されていません。',
    'password' => '入力されたパスワードが正しくありません。',
    'throttle' => 'ログインに続けて失敗しています。:seconds秒後に再度お試しください。',

    'full_name'        => '氏名',
    'email'            => 'メールアドレス',
    'password'         => 'パスワード',
    'confirm_password' => 'パスワード確認',
    'remember_me'      => 'Remember Me',
    'sign_in'          => 'ログイン',
    'sign_out'         => 'ログアウト',
    'register'         => '登録',

    'login' => [
        'title'               => 'ログイン',
        'forgot_password'     => 'パスワードを忘れた',
        'register_membership' => '新規登録',
    ],

    'registration' => [
        'title'           => '新規登録',
        'i_agree'         => '同意する',
        'terms'           => '規約',
        'have_membership' => '既に登録済みです',
    ],

    'forgot_password' => [
        'title'          => 'パスワードを初期化するには、Eメールを入力してください',
        'send_pwd_reset' => 'メール送信',
    ],

    'reset_password' => [
        'title'         => 'パスワードをリセット',
        'reset_pwd_btn' => 'パスワードリセット',
    ],

    'emails' => [
        'password' => [
            'reset_link' => 'クリックしてパスワードをリセット',
        ],
    ],

    'app' => [
        'member_since' => 'Member since',
        'messages'     => 'Messages',
        'settings'     => 'Settings',
        'lock_account' => 'Lock Account',
        'profile'      => 'Profile',
        'online'       => 'Online',
        'search'       => 'Search',
        'create'       => 'Create',
        'export'       => 'Export',
        'print'        => 'Print',
        'reset'        => 'Reset',
        'reload'       => 'Reload',
    ],
];

configの設定を日本に

config/app.php

-    'timezone' => 'UTC',
+    'timezone' => 'Asia/Tokyo',
 
-    'locale' => 'en',
+    'locale' => 'ja',
 
-    'faker_locale' => 'en_US',
+    'faker_locale' => 'ja_JP',
 

マイグレーションを走らせて

php artisan migrate

ログインしてみるとこんなログイン画面
http://localhost/login

③Swagger導入

せっかくの自動生成なのでswaggerも導入します
https://www.infyom.com/open-source/laravelgenerator/docs/8.0/generator-options#swagger

composerにrepositories追記

"repositories": [
    {
        "type": "vcs",
        "url":  "git@github.com:InfyOmLabs/swaggervel.git"
    }
],

必要なパッケージをインストール&アップデート

composer require appointer/swaggervel=dev-master
composer require infyomlabs/swagger-generator=dev-master
composer update

コマンド実行(swagger用のconfigやjsが作成される)

php artisan vendor:publish --provider="Appointer\Swaggervel\SwaggervelServiceProvider"

swagger設定をonにして

config/infyom/laravel_generator.php
	'add_on.swagger' => true

swaggerの画面を確認するとこんな感じ
http://localhost/api/docs

④auth周り作成

作成するauthは「api」と「web」の二種類作成していきます

④-1.apiのauth

基本的にはLaravelが用意した方式に合わせて作っていきます
https://readouble.com/laravel/5.8/ja/api-authentication.html

最新だとPassportやSanctumを使った方式の方が良さそうです。。。
https://readouble.com/laravel/8.x/ja/authentication.html

api_tokenカラム追加

php artisan make:migration add_column_api_token_to_users
Schema::table('users', function ($table) {
    $table->string('api_token', 80)->after('password')
                        ->unique()
                        ->nullable()
                        ->default(null);
});

config/auth.phpのhashをtrueに

'api' => [
    'driver' => 'token',
    'provider' => 'users',
    'hash' => true,
],

ドキュメントの通りにAPIトークン管理用のAPIを作る。V1配下なのはswaggerに合わせるため

php artisan make:controller API/V1/ApiTokenController
app/Http/Controllers/API/V1/ApiTokenController.php
<?php

namespace App\Http\Controllers\API\V1;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class ApiTokenController extends Controller
{
    /**
     * 認証済みのユーザーのAPIトークンを更新する
     *
     * @param  \Illuminate\Http\Request  $request
     * @return JsonResponse
     * 
     * @SWG\Put(
     *      path="/api_token",
     *      summary="Update token",
     *      tags={"Auth"},
     *      description="Update token",
     *      produces={"application/json"},
     *      security={{"apiToken":{}}},
     *      @SWG\Response(
     *          response=200,
     *          description="successful operation",
     *          @SWG\Schema(
     *              type="object",
     *              @SWG\Property(
     *                  property="token",
     *                  description="token",
     *                  type="string"
     *              )
     *          )
     *      )
     * )
     * @SWG\SecurityScheme(
     *      securityDefinition="apiToken",
     *      type="apiKey",
     *      name="api_token",
     *      in="query"
     * )
     */
    public function update(Request $request)
    {
        return new JsonResponse(self::updateApiToken($request), 200);
    }

    /**
     * update api_token
     *
     * @param Request $request
     * @return array
     */
    public static function updateApiToken(Request $request): array
    {
        $token = Str::random(60);

        $request->user()->forceFill([
            'api_token' => hash('sha256', $token),
        ])->save();

        return ['token' => $token];
    }
}

routeを更新

routes/api.php
Route::group(['prefix' => 'v1', 'namespace' => 'V1'], function () {
    Route::group(['middleware' => 'auth:api'], function () {
        Route::put('api_token', 'ApiTokenController@update');
    });
});

このままだとログイン出来ないので、ユーザー登録・ログインAPIを作成していきます。

まずはユーザー登録API

php artisan make:controller API/V1/RegisterController

Laravelのauth周りは大体Overrideすればなんとかなります。
swagger用にdocコメントも追記

app/Http/Controllers/API/V1/RegisterController.php
<?php

namespace App\Http\Controllers\API\V1;

class RegisterController extends \App\Http\Controllers\Auth\RegisterController 
{
    /**
     * 新規ユーザー作成
     * 
     * @SWG\Post(
     *      path="/register",
     *      summary="Store a newly created User in storage",
     *      tags={"Auth"},
     *      description="Store User",
     *      produces={"application/json"},
     *      @SWG\Parameter(
     *          name="name",
     *          description="name of User",
     *          type="string",
     *          required=true,
     *          in="formData"
     *      ),
     *      @SWG\Parameter(
     *          name="email",
     *          description="email of User",
     *          type="string",
     *          required=true,
     *          in="formData"
     *      ),
     *      @SWG\Parameter(
     *          name="password",
     *          description="password of User",
     *          type="string",
     *          required=true,
     *          in="formData"
     *      ),
     *      @SWG\Parameter(
     *          name="password_confirmation",
     *          description="password_confirmation of User",
     *          type="string",
     *          required=true,
     *          in="formData"
     *      ),
     *      @SWG\Response(
     *          response=201,
     *          description="successful operation"
     *      )
     * )
     */
}


次にログインAPI

php artisan make:controller API/V1/LoginController

app/Http/Controllers/Auth/LoginController.phpをマネして作成。ログイン時にapi_tokenを返すようにしておきます。

app/Http/Controllers/API/V1/LoginController.php
<?php

namespace App\Http\Controllers\API\V1;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class LoginController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Login Controller
    |--------------------------------------------------------------------------
    |
    | This controller handles authenticating users for the application and
    | redirecting them to your home screen. The controller uses a trait
    | to conveniently provide its functionality to your applications.
    |
    */

    use AuthenticatesUsers;

    /**
     * apiログイン。api_tokenを返す
     * 
     * @SWG\Post(
     *      path="/login",
     *      tags={"Auth"},
     *      summary="Login",
     *      description="Login",
     *      produces={"application/json"},
     *      @SWG\Parameter(
     *          name="email",
     *          description="email of User",
     *          type="string",
     *          required=true,
     *          in="formData"
     *      ),
     *      @SWG\Parameter(
     *          name="password",
     *          description="password of User",
     *          type="string",
     *          required=true,
     *          in="formData"
     *      ),
     *      @SWG\Response(
     *          response=200,
     *          description="successful operation",
     *          @SWG\Schema(
     *              type="object",
     *              @SWG\Property(
     *                  property="token",
     *                  description="token",
     *                  type="string"
     *              )
     *          )
     *      )
     * )
     */

    /**
     * Send the response after the user was authenticated.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\JsonResponse
     */
    protected function sendLoginResponse(Request $request)
    {
        $this->clearLoginAttempts($request);

        if ($response = $this->authenticated($request, $this->guard()->user())) {
            return $response;
        }

        return new JsonResponse(ApiTokenController::updateApiToken($request), 200);
    }
}

routeを更新

routes/api.php
Route::group(['prefix' => 'v1', 'namespace' => 'V1'], function () {
    Route::post('register', 'RegisterController@register');
    Route::post('login', 'LoginController@login');

    Route::group(['middleware' => 'auth:api'], function () {
        Route::put('api_token', 'ApiTokenController@update');
    });
});

swaggerからユーザー登録&ログイン

「Authorize」ボタンからapi_tokenをセット

auth認証できてる!

④-2.webのauth

is_adminのカラムを追加して、trueの場合のみ管理画面(web)にログインできるようにします。

is_adminカラム追加

php artisan make:migration add_column_is_admin_to_users
        Schema::table('users', function (Blueprint $table) {
            $table->boolean('is_admin')->default(false);
        });

ログイン条件を追加して

app/Http/Controllers/Auth/LoginController.php
    /**
     * Get the needed authorization credentials from the request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    protected function credentials(Request $request)
    {
        $credentials = $request->only($this->username(), 'password');
        // 管理者ユーザーのみログイン可
        $credentials['is_admin'] = true;
        return $credentials;
    }

ユーザー登録後に必ずログインさせるようにして

app/Http/Controllers/Auth/RegisterController.php
    /**
     * The user has been registered.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  mixed  $user
     * @return mixed
     */
    protected function registered(Request $request, $user)
    {
        // ユーザー登録後に必ずログインさせるので、一旦ログアウト
        Auth::logout();
    }

初期データ投入時に管理者ユーザーを入れておきます

database/seeders/DatabaseSeeder.php
        User::create([
            'name' => 'admin',
            'email' => 'admin@example.com',
            'password' => Hash::make('password'),
            'is_admin' => 1,
        ]);

これで管理画面側はadmin@example.comしかログイン出来なくなりました。
他のユーザーにも管理者権限を付与できるようにUserのCRUD画面を作っていきます!

その前に一旦初期化しておきます

php artisan migrate:fresh --seed

⑤CRUD作成

ここからいよいよlaragel-genaratorの出番

基本的には下記コマンドで進めていきます
infyom:apiがapiのCRUDを作成
infyom:scaffoldが画面のCRUDを作成

# とりあえずCRUD作成
php artisan infyom:scaffold Xxxxx

# スキーマからCRUD作成
php artisan infyom:scaffold Xxxxx --fieldsFile=Xxxxx.json

# スキーマからapi作成
php artisan infyom:api Xxxxx --fieldsFile=Xxxxx.json --prefix=v1

# テーブルからCRUD作成
php artisan infyom:scaffold Xxxxx --fromTable --tableName=xxxxx

コマンドを作る際には必ずmodel_schemasから生成するようにしていきます

参考URL
https://www.infyom.com/open-source/laravelgenerator/docs/8.0/getting-started

サンプルスキーマも参考になります
vendor/infyomlabs/laravel-generator/samples/fields_sample.json

リレーション周りはソースを参考に
vendor/infyomlabs/laravel-generator/src/Common/GeneratorFieldRelation.php

schema作成時のプロパティはざっくり以下の通り

{
    // カラム名
    "name": "user_id",
    // カラム定義
    "dbType": "bigInteger:unsigned:foreign,users,id",
    // 入力フォームのタイプ
    "htmlType": "",
    // バリデーション
    "validations": "",
    // トップの検索から引っかかるか
    "searchable": false,
    // 入力許可
    "fillable": true,
    // プライマリーキーであるか
    "primary": false,
    // フォーム表示するか
    "inForm": false,
    // 一覧ページに表示するか
    "inIndex": true,
    // 詳細ページに表示するか
    "inView": true
},

とりあえずざっくりテーブル設計

tweetsテーブルはそのままツイートを保存しておき、tweet_typeでリツイート・リプライ等を判別します。ref_tweet_idで参照元のツイートIDを保存しておきます。

users
------------------
id
name
email
email_verified_at
password
remember_token
created_at
updated_at

tweets
------------------
id
user_id
ref_tweet_id
tweet_type
tweet_text
reply_count
retweet_count
favorite_count
created_at

⑤-1.Userの画面用CRUD作成

usersテーブルのスキーマ定義を作成して

resources/model_schemas/User.json
[
    {
        "name": "id",
        "dbType": "bigInteger,true,true",
        "htmlType": "",
        "validations": "",
        "searchable": false,
        "fillable": false,
        "primary": true,
        "inForm": false,
        "inIndex": true,
        "inView": true
    },
    {
        "name": "name",
        "dbType": "string:unique",
        "htmlType": "text",
        "validations": "required|string|max:255|regex:\/^[0-9a-zA-Z]+$\/|unique:users,name",
        "searchable": true,
        "fillable": true,
        "primary": false,
        "inForm": true,
        "inIndex": true,
        "inView": true
    },
    {
        "name": "email",
        "dbType": "string:unique",
        "htmlType": "email",
        "validations": "required|email|max:255|unique:users,email",
        "searchable": true,
        "fillable": true,
        "primary": false,
        "inForm": true,
        "inIndex": true,
        "inView": true
    },
    {
        "name": "email_verified_at",
        "dbType": "timestamp:nullable",
        "htmlType": "",
        "validations": "",
        "searchable": false,
        "fillable": false,
        "primary": false,
        "inForm": false,
        "inIndex": false,
        "inView": false
    },
    {
        "name": "password",
        "dbType": "string",
        "htmlType": "password",
        "validations": "required|string|confirmed|min:8|regex:\/^[0-9a-zA-Z]+$\/",
        "searchable": false,
        "fillable": true,
        "primary": false,
        "inForm": true,
        "inIndex": false,
        "inView": false
    },
    {
        "name": "api_token",
        "dbType": "string,80:nullable:unique:defalut,null",
        "htmlType": "",
        "validations": "",
        "searchable": false,
        "fillable": false,
        "primary": false,
        "inForm": false,
        "inIndex": false,
        "inView": false
    },
    {
        "name": "remember_token",
        "dbType": "string,100:nullable",
        "htmlType": "",
        "validations": "",
        "searchable": false,
        "fillable": false,
        "primary": false,
        "inForm": false,
        "inIndex": false,
        "inView": false
    },
    {
        "name": "created_at",
        "dbType": "timestamp",
        "htmlType": "",
        "validations": "",
        "searchable": false,
        "fillable": false,
        "primary": false,
        "inForm": false,
        "inIndex": false,
        "inView": false
    },
    {
        "name": "updated_at",
        "dbType": "timestamp",
        "htmlType": "",
        "validations": "",
        "searchable": false,
        "fillable": false,
        "primary": false,
        "inForm": false,
        "inIndex": false,
        "inView": false
    },
    {
        "name": "is_admin",
        "dbType": "boolean:default,false",
        "htmlType": "checkbox,1",
        "validations": "nullable|boolean",
        "searchable": false,
        "fillable": true,
        "primary": false,
        "inForm": true,
        "inIndex": true,
        "inView": true
    }
]

コマンド実行

php artisan infyom:scaffold User --fieldsFile=User.json

User.json上書きするか、マイグレーションを実行するか聞かれますが適宜答えます。今回はどちらもno

User.json already exists. Do you want to overwrite it? [y|N] (yes/no) [no]:
 > 

Do you want to migrate database? [y|N] (yes/no) [no]:
 > 

これでマイグレーションファイル、モデル、コントローラー、フォームリクエスト、リポジトリー、ファクトリー、ビューがいい感じに作られます。

今回はUserのマイグレーションファイルは要らないので削除。

Userモデルは通常のモデルと違うので修正します

app/Models/User.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;

/**
 * @SWG\Definition(
 *      definition="User",
 *      required={"name", "email", "password"},
 *      @SWG\Property(
 *          property="id",
 *          description="id",
 *          type="integer",
 *          format="int32"
 *      ),
 *      @SWG\Property(
 *          property="name",
 *          description="name",
 *          type="string"
 *      ),
 *      @SWG\Property(
 *          property="email",
 *          description="email",
 *          type="string"
 *      ),
 *      @SWG\Property(
 *          property="email_verified_at",
 *          description="email_verified_at",
 *          type="string",
 *          format="date-time"
 *      ),
 *      @SWG\Property(
 *          property="password",
 *          description="password",
 *          type="string"
 *      ),
 *      @SWG\Property(
 *          property="api_token",
 *          description="api_token",
 *          type="string"
 *      ),
 *      @SWG\Property(
 *          property="remember_token",
 *          description="remember_token",
 *          type="string"
 *      ),
 *      @SWG\Property(
 *          property="created_at",
 *          description="created_at",
 *          type="string",
 *          format="date-time"
 *      ),
 *      @SWG\Property(
 *          property="updated_at",
 *          description="updated_at",
 *          type="string",
 *          format="date-time"
 *      ),
 *      @SWG\Property(
 *          property="is_admin",
 *          description="is_admin",
 *          type="boolean"
 *      )
 * )
 */
class User extends Authenticatable
{
    use HasFactory;

    public $table = 'users';
    


    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'is_admin'
    ];

    /**
     * The attributes that should be hidden for arrays.
     *
     * @var array
     */
    protected $hidden = [
        'password',
        'email_verified_at',
        'api_token',
        'remember_token',
    ];

    /**
     * The attributes that should be cast to native types.
     *
     * @var array
     */
    protected $casts = [
        'id' => 'integer',
        'name' => 'string',
        'email' => 'string',
        'password' => 'string',
        'api_token' => 'string',
        'remember_token' => 'string',
        'is_admin' => 'boolean'
    ];

    /**
     * Validation rules
     *
     * @var array
     */
    public static $rules = [
        'name' => 'required|string|max:255|regex:/^[0-9a-zA-Z]+$/|unique:users,name',
        'email' => 'required|email|max:255|unique:users,email',
        'password' => 'required|string|confirmed|min:8|regex:/^[0-9a-zA-Z]+$/',
        'is_admin' => 'nullable|boolean'
    ];
}

コントローラーもpasswordがそのまま登録・更新されてしまうので、Hash化してから保存するように修正

app/Http/Controllers/UserController.php
    /**
     * Store a newly created User in storage.
     *
     * @param CreateUserRequest $request
     *
     * @return Response
     */
    public function store(CreateUserRequest $request)
    {
        $input = $request->all();

+       if (isset($input['password'])) {
+           $input['password'] = Hash::make($input['password']);
+       }

        $user = $this->userRepository->create($input);

        Flash::success('User saved successfully.');

        return redirect(route('users.index'));
    }
〜〜〜
    /**
     * Update the specified User in storage.
     *
     * @param int $id
     * @param UpdateUserRequest $request
     *
     * @return Response
     */
    public function update($id, UpdateUserRequest $request)
    {
        $user = $this->userRepository->find($id);

        if (empty($user)) {
            Flash::error('User not found');

            return redirect(route('users.index'));
        }

        $input = $request->all();

+       if (isset($input['password'])) {
+           $input['password'] = Hash::make($input['password']);
+       }

        $user = $this->userRepository->update($input, $id);

        Flash::success('User updated successfully.');

        return redirect(route('users.index'));
    }

routeもログインユーザーのみ、Userの登録・更新・削除が出来るようにします

routes/web.php
<?php

use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Auth::routes();

Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');

Route::group(['middleware' => ['auth:web']], function () {
    Route::resource('users', App\Http\Controllers\UserController::class);
});

passwordの確認フォームも必要なので追記しておきます

resources/views/users/fields.blade.php

<!-- Password Field -->
<div class="form-group col-sm-6">
    {!! Form::label('password', 'Password:') !!}
    {!! Form::password('password', ['class' => 'form-control','minlength' => 8]) !!}
</div>

<!-- Password Confirmation Field -->
<div class="form-group col-sm-6">
    {!! Form::label('password_confirmation', 'Password Confirmation:') !!}
    {!! Form::password('password_confirmation', ['class' => 'form-control','minlength' => 8]) !!}
</div>

何故か画像のリンク切れてる。。。今回はadmin-templateを使いましたが、core-uiの方が良いかも?

これで管理画面からuserの登録・更新・削除はできるようになりました。かんたんですね。
現状だと誰でも登録・更新・削除できてしまうので、ロールやポリシーを追加すると良いです。

⑤-2.Tweetの画面用CRUD作成

php artisan infyom:scaffold Tweet --fieldsFile=Tweet.json

routeを更新

routes/web.php
Route::group(['middleware' => ['auth:web']], function () {
    Route::resource('users', App\Http\Controllers\UserController::class);
    Route::resource('tweets', App\Http\Controllers\TweetController::class);
});

ツイート登録時にuser_idが必要なのでログインユーザーのuser_idを登録しておきます

app/Http/Controllers/TweetController.php
    /**
     * Store a newly created Tweet in storage.
     *
     * @param CreateTweetRequest $request
     *
     * @return Response
     */
    public function store(CreateTweetRequest $request)
    {
        $input = $request->all();
+       $input['user_id'] = auth('web')->id();

        $tweet = $this->tweetRepository->create($input);

        Flash::success('Tweet saved successfully.');

        return redirect(route('tweets.index'));
    }

ツイートカウント系をそのまま登録するとエラーになるので、初期値に0を設定しておきます

resources/views/tweets/fields.blade.php
<!-- Reply Count Field -->
<div class="form-group col-sm-6">
    {!! Form::label('reply_count', 'Reply Count:') !!}
    {!! Form::number('reply_count', 0, ['class' => 'form-control','min' => 0]) !!}
</div>

<!-- Retweet Count Field -->
<div class="form-group col-sm-6">
    {!! Form::label('retweet_count', 'Retweet Count:') !!}
    {!! Form::number('retweet_count', 0, ['class' => 'form-control','min' => 0]) !!}
</div>

<!-- Favorite Count Field -->
<div class="form-group col-sm-6">
    {!! Form::label('favorite_count', 'Favorite Count:') !!}
    {!! Form::number('favorite_count', 0, ['class' => 'form-control','min' => 0]) !!}
</div>

はい、ツイートも登録できました

⑤-3.TweetのAPI用CRUD作成

次はapiを作ります。(prefixを付けているのはswaggerに合わせるためです)

php artisan infyom:api Tweet --fieldsFile=Tweet.json --prefix=v1

これでAPI用のコントローラー、フォームリクエスト、レスポンスリソース、テストコード、swaggerが作成されます。
v1のprefixを付けたのでコントローラーが見ているリポジトリ・モデルなどは前回作ったv1のついていない方を向けるように修正してください。そしてV1/に作成されたモデル、リポジトリは削除します。
自動生成されるAPIの改修が大きい場合はコントローラー名等を変えて別APIとして作った方が良さそうです。

ログインユーザーのみツイートできるようにして

routes/api.php
Route::group(['prefix' => 'v1', 'namespace' => 'V1'], function () {
    Route::post('register', 'RegisterController@register');
    Route::post('login', 'LoginController@login');

    Route::group(['middleware' => 'auth:api'], function () {
        Route::put('api_token', 'ApiTokenController@update');
        
        Route::resource('tweets', TweetAPIController::class);
    });
});

ログインユーザーのuser_idをセットするように修正

app/Http/Controllers/API/V1/TweetAPIController.php
    public function store(CreateTweetAPIRequest $request)
    {
        $input = $request->all();
+       $input['user_id'] = auth('api')->id();

        $tweet = $this->tweetRepository->create($input);

        return $this->sendResponse(new TweetResource($tweet), 'Tweet saved successfully');
    }

apiにauth認証が必要な場合は、swaggerのコメントにsecurity={{"apiToken":{}}}を追記します

app/Http/Controllers/API/V1/TweetAPIController.php
    /**
     * @param Request $request
     * @return Response
     *
     * @SWG\Get(
     *      path="/tweets",
     *      summary="Get a listing of the Tweets.",
     *      tags={"Tweet"},
     *      description="Get all Tweets",
     *      produces={"application/json"},
+    *      security={{"apiToken":{}}},
	     〜〜
     * )
     */
    public function index(Request $request)

swaggerを確認するとこんな感じにAPIが生えます

とりあえずテストを通すようにする

# test用env作成
cp .env .env.testing

# テスト用DBを作成してそっちを向かせる
DB_DATABASE=db_test

# テスト用DBマイグレーション
php artisan migrate --env=testing

このままだとテストが通らないのでfactory修正

database/factories/TweetFactory.php
<?php

namespace Database\Factories;

use App\Models\Tweet;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class TweetFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = Tweet::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'user_id' => function () {
                return User::factory()->create()->id;
            },
            'ref_tweet_id' => null,
            'tweet_type' => $this->faker->randomElement(['tweet', 'retweet', 'reply']),
            'tweet_text' => $this->faker->word,
            'reply_count' => $this->faker->randomDigitNotNull,
            'retweet_count' => $this->faker->randomDigitNotNull,
            'favorite_count' => $this->faker->randomDigitNotNull,
        ];
    }
}

自動生成したテストコードも走らせるように追記

phpunit.xml
         <testsuite name="Unit">
             <directory suffix="Test.php">./tests/Unit</directory>
         </testsuite>
+        <testsuite name="Repositories">
+            <directory suffix="Test.php">./tests/Repositories</directory>
+        </testsuite>
         <testsuite name="Feature">
             <directory suffix="Test.php">./tests/Feature</directory>
         </testsuite>
+        <testsuite name="APIs">
+            <directory suffix="Test.php">./tests/APIs</directory>
+        </testsuite>
     </testsuites>

ログインユーザーでテストが通るように修正

tests/APIs/TweetApiTest.php
<?php namespace Tests\APIs;

use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
use Tests\ApiTestTrait;
use App\Models\Tweet;
use App\Models\User;

class TweetApiTest extends TestCase
{
    use ApiTestTrait, WithoutMiddleware, DatabaseTransactions;

    /**
     * @test
     */
    public function test_create_tweet()
    {
        $user = User::factory()->create();
        $tweet = Tweet::factory()->make(['user_id' => $user->id, 'tweet_type' => 'tweet'])->toArray();

        $this->response = $this->actingAs($user, 'api')->json(
            'POST',
            '/api/v1/tweets', $tweet
        );

        $this->assertApiResponse($tweet);
    }

    /**
     * @test
     */
    public function test_read_tweet()
    {
        $user = User::factory()->create();
        $tweet = Tweet::factory()->create(['user_id' => $user->id, 'tweet_type' => 'tweet']);

        $this->response = $this->actingAs($user, 'api')->json(
            'GET',
            '/api/v1/tweets/'.$tweet->id
        );

        $this->assertApiResponse($tweet->toArray());
    }

    /**
     * @test
     */
    public function test_update_tweet()
    {
        $user = User::factory()->create();
        $tweet = Tweet::factory()->create(['user_id' => $user->id]);
        $editedTweet = Tweet::factory()->make(['tweet_type' => 'tweet'])->toArray();

        $this->response = $this->actingAs($user, 'api')->json(
            'PUT',
            '/api/v1/tweets/'.$tweet->id,
            $editedTweet
        );

        $this->assertApiResponse($editedTweet);
    }

    /**
     * @test
     */
    public function test_delete_tweet()
    {
        $user = User::factory()->create();
        $tweet = Tweet::factory()->create(['user_id' => $user->id, 'tweet_type' => 'tweet']);

        $this->response = $this->actingAs($user, 'api')->json(
            'DELETE',
             '/api/v1/tweets/'.$tweet->id
         );

        $this->assertApiSuccess();
        $this->response = $this->actingAs($user, 'api')->json(
            'GET',
            '/api/v1/tweets/'.$tweet->id
        );

        $this->response->assertStatus(404);
    }
}

テストを走らせると

php artisan test

OK!


  PASS  Tests\Unit\ExampleTest
  ✓ basic test

   PASS  Tests\Repositories\TweetRepositoryTest
  ✓ create tweet
  ✓ read tweet
  ✓ update tweet
  ✓ delete tweet

   PASS  Tests\Feature\ExampleTest
  ✓ basic test

   PASS  Tests\APIs\TweetApiTest
  ✓ create tweet
  ✓ read tweet
  ✓ update tweet
  ✓ delete tweet

  Tests:  10 passed
  Time:   1.33s

説明がちょっと長くなってしまいましたが、こんな感じで作っていきます!

⑥まとめ

1テーブルに対してAPIとSwaggerとCRUD画面とテストコードを作るとしたら一週間くらいはかかりそうなものですが、Laravel-generatorを使うと慣れれば2〜3時間で作れてしまうのでとても良いですね!

あまり複雑ではないシステムや社内で使うツールなんかを作るのに向いていそうです!

逆に複雑なシステムだと素のLaravelを使った方が早そうです。pivot(中間テーブル)が多い場合や、テーブル更新が1対1では出来ない場合は向いていない感じがしました。(システムに合わせるか、サービスに合わせるかですね!)

GitHubにソース上げてますので、参考にしてみてください
https://github.com/okdyy75/iinetter-docker

Discussion