はじめに
リバネス開発チームのトミー(@tomyf)です。
前回「Laravel9 の app ディレクトリ構成はこれに決まりだ!」という記事を出したのですが、慣れていない人がいきなり理解するには難しいと思いましたので、段階的に実装できるように解説していきます。
最近社内でバックエンド開発に携わる人が増えてきたので、参考書となるように頑張ります!
環境
- Docker v20.10.17
- Laravel v9.25.1
レベル 1
特定のユーザの記事一覧を取得するというAPIを例に解説していきます。
以下の動作をするAPIを考えていきます。
- APIパスは
/users/{id}/articles
で定義し、id
にはユーザIDを受け取ります。 - 受け取った
id
がユーザテーブルに存在しない場合は404エラーを返します。 - ユーザが存在していても、ユーザの
state
がpublic
でない場合は403エラーを返します。
最初はルーティングとコントローラとモデルのみを使用するシンプルな構成です。コントローラ内に全ての処理を書くので非常にシンプルになる反面、コード量が増えると可読性が悪くなっていきます。
データの取得はモデルを通して行います。
laravel/
├─ app/
│ ├─ Http/
│ │ └─ Controllers/
│ │ └─ UserArticleController.php
│ └─ Models/
│ ├─ Article.php
│ └─ User.php
└─ routes/
└─ api.php
use App\Http\Controllers\UserArticleController;
use Illuminate\Support\Facades\Route;
Route::get('users/{id}/articles', [UserArticleController::class, 'index']);
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Article;
use App\Models\User;
use Illuminate\Http\Request;
class UserArticleController extends Controller
{
/**
* Display a listing of the resource.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\JsonResponse
*/
public function index(Request $request, int $id)
{
// ユーザを取得、ユーザの取得に失敗すると404を返す
$user = User::findOrFail($id);
// ユーザが公開設定にしていない場合は403を返す
if ($user->state !== 'public') {
return abort(403);
}
// ユーザの公開状態の記事を取得する
$articles = Article::query()
->where('user_id', $id)
->where('state', 'public')
->latest()
->paginate($request->per_page ?? 15);
return response()->json($articles);
}
}
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'users';
}
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Article extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'articles';
}
レベル 2
レベル 1 で作成したファイルを元に改修を行っていきます。
コントローラからデータに関するバリデーション処理をフォームリクエストに分離させ、レスポンスのデータ整形をAPIリソースに分離します。
リクエストは Laravel バリデーション の機能を使用し、レスポンスは Laravel APIリソース の機能を使用していきます。
laravel/
├─ app/
│ ├─ Http/
│ │ ├─ Controllers/
│ │ │ └─ UserArticleController.php // Changed
│ │ ├─ Requests/
│ │ │ └─ UserArticle/
│ │ │ └─ IndexRequest.php // New
│ │ └─ Resources/
│ │ ├─ ArticleCollection.php // New
│ │ └─ ArticleResource.php // New
│ └─ Models/
│ ├─ Article.php
│ └─ User.php
└─ routes/
└─ api.php
ユーザの存在チェックと公開設定チェックをフォームリクエストに分離したため、コントローラはデータを取得する処理だけになります。
また、リクエストのパラメータを直接取得していた per_page
をフォームリクエストに定義した関数を経由して取得するようにします。このようにすることで、関数側で初期値の設定ができ、値が存在しない時の処理をコントローラに実装しなくて良くなります。
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Http\Requests\UserArticle\IndexRequest;
use App\Http\Resources\ArticleCollection;
use App\Models\Article;
use App\Models\User;
use Illuminate\Http\Request;
class UserArticleController extends Controller
{
/**
* Display a listing of the resource.
*
* @param \App\Http\Requests\UserArticle\IndexRequest $request
* @param int $id
* @return \App\Http\Resources\ArticleCollection
*/
public function index(IndexRequest $request, int $id)
{
// リクエストで定義した関数でper_pageを取得する
$perPage = $request->getPerPage();
// ユーザの公開状態の記事を取得する
$articles = Article::query()
->where('user_id', $id)
->where('state', 'public')
->latest()
->paginate($perPage);
return new ArticleCollection($articles);
}
}
ユーザの存在チェックと公開設定チェックを行います。フォームリクエストの処理はコントローラの前に処理されるため、ロジックに必要なデータの前処理はここで行います。
フォームリクエストの実行順は以下のようになっています。
- prepareForValidation()
- prepareForValidation() に定義したコードが実行される
- passesAuthorization()
- authorize() に定義したコードが実行される
- Falseが返ってきた場合 failedAuthorization() に定義したコードが実行される
- fails()
- rules() で返した配列を元にバリデーションされる
- バリデーションエラーになった場合 failedValidation() に定義したコードが実行される
- passedValidation()
- passedValidation() に定義したコードが実行される
authorize()
でFalseを返すと403エラーにできるので、ここにユーザの公開設定チェックを行います。
その前の段階でユーザの取得を行いたいので prepareForValidation()
を利用します。
namespace App\Http\Requests\UserArticle;
use App\Models\User;
class IndexRequest extends FormRequest
{
/**
* Prepare the data for validation.
*
* @return void
*/
protected function prepareForValidation()
{
// ユーザを取得、ユーザの取得に失敗すると404を返す
$user = User::findOrFail($this->id);
$this->merge([
'user' => $user
]);
}
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
// ユーザが公開設定の場合はTrueを返す
// authorize()は返り値がFalseなら403を返す
return $this->user->state === 'public';
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'page' => ['required', 'integer', 'min:1'],
'per_page' => ['required', 'integer', 'min:1'],
];
}
/**
* Get per page.
*
* @return int
*/
public function getPerPage()
{
return $this->validated('per_page', 15);
}
}
レスポンスデータの整形をAPIリソースで行います。 $wrap
を設定すると、そのパラメータ名でデータをまとめることができます。APIを呼び出した側でどんなでデータの集合かパラメータ名で判断できるようになります。
このAPIリソースの場合はArticleモデルから
{
"article": {
"id": 1,
"user_id": "1",
"title": "タイトル",
"posted_at": "2023-08-01T00:00:00Z"
"edited_at": "2023-08-01T09:30:00Z"
}
}
のようなレスポンスを作成することができます。
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class ArticleResource extends JsonResource
{
/**
* The "data" wrapper that should be applied.
*
* @var string|null
*/
public static $wrap = 'article';
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
*/
public function toArray($request)
{
return [
'id' => $this->id,
'user_id' => $this->user_id,
'title' => $this->title,
'posted_at' => $this->posted_at->toIso8601ZuluString(),
'edited_at' => $this->edited_at->toIso8601ZuluString(),
];
}
}
レベル 3
レベル 2 で作成したファイルを元に改修を行っていきます。
Laravel にはメソッドインジェクションの機能でタイプヒントにより、型を指定することができます。
コントローラの引数にUser型を指定すると自動で User::findOrFail($this->id)
の処理を行ってくれるようになります。
また、テーブルが持つ state
の値を都度記述するとタイポの可能性があり、どのような種類が存在するか分からないので、Userモデルに定数を定義していきます。ここではユーザの state
は public
と limit
と private
の3種類で運用されていると仮定します。
条件分岐をするときは必ず定数を利用するようにします。
laravel/
├─ app/
│ ├─ Http/
│ │ ├─ Controllers/
│ │ │ └─ UserArticleController.php // Changed
│ │ ├─ Requests/
│ │ │ └─ UserArticle/
│ │ │ └─ IndexRequest.php // Changed
│ │ └─ Resources/
│ │ ├─ ArticleCollection.php
│ │ └─ ArticleResource.php
│ └─ Models/
│ ├─ Article.php // Changed
│ └─ User.php // Changed
└─ routes/
└─ api.php // Changed
ルートパラメータの変数名を id
から user
に変更します。これは型を指定すると自動でユーザモデルのインスタンスが作成されるためです。
use App\Http\Controllers\UserArticleController;
use Illuminate\Support\Facades\Route;
Route::get('users/{user}/articles', [UserArticleController::class, 'index']); // {id}から{user} に変更する
引数の $id
を User $user
に変更することで、Laravelは自動でユーザモデルを取得してきます。この時ユーザが存在しない場合は404エラーになります。
また、記事モデルに定数 STATE_PUBLIC
を定義して User::STATE_PUBLIC
で呼び出すようにします。
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Http\Requests\UserArticle\IndexRequest;
use App\Http\Resources\ArticleCollection;
use App\Models\Article;
use App\Models\User;
use Illuminate\Http\Request;
class UserArticleController extends Controller
{
/**
* Display a listing of the resource.
*
* @param \App\Http\Requests\UserArticle\IndexRequest $request
* @param \App\Models\User $user
* @return \App\Http\Resources\ArticleCollection
*/
public function index(IndexRequest $request, User $user) // $idから$userに変更する
{
// リクエストで定義した関数でper_pageを取得する
$perPage = $request->getPerPage();
// ユーザの公開状態の記事を取得する
$articles = Article::query()
->where('user_id', $user->id) // $userからidを取得する
->where('state', Article::STATE_PUBLIC) // 記事モデルの定数を使用
->latest()
->paginate($perPage);
return new ArticleCollection($articles);
}
}
メソッドインジェクションにより自動で user
パラメータにユーザ情報が格納されるようになり、 prepareForValidation()
が不要になったため削除します。
namespace App\Http\Requests\UserArticle;
use App\Models\User;
class IndexRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
// ユーザが公開設定の場合はTrueを返す
// authorize()は返り値がFalseなら403を返す
return $this->user->state === User::STATE_PUBLIC; // ユーザモデルの定数を使用
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, mixed>
*/
public function rules()
{
return [
'page' => ['required', 'integer', 'min:1'],
'per_page' => ['required', 'integer', 'min:1'],
];
}
/**
* Get per page.
*
* @return int
*/
public function getPerPage()
{
return $this->validated('per_page', 15);
}
}
ユーザモデルに state
の定数を定義します。
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'users';
/**
* Const state public
*
* @var string
*/
const STATE_PUBLIC = 'public';
/**
* Const state limit
*
* @var string
*/
const STATE_LIMIT = 'limit';
/**
* Const state private
*
* @var string
*/
const STATE_PRIVATE = 'private';
}
記事モデルに state
の定数を定義します。
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Article extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'articles';
/**
* Const state public
*
* @var string
*/
const STATE_PUBLIC = 'public';
/**
* Const state limit
*
* @var string
*/
const STATE_LIMIT = 'limit';
/**
* Const state private
*
* @var string
*/
const STATE_PRIVATE = 'private';
}
レベル 4
レベル 3 で作成したファイルを元に改修を行っていきます。
ここまででルーティングとコントローラとモデルのみを使用するシンプルな構成から、フォームリクエストとAPIリソースを使用し、さらにメソッドインジェクションにより、ルートパラメータから自動でユーザモデルインスタンスの作成を行うようにしました。
ここまで読まれた方なら次に何をするのか想像できると思います。
最後は、ユーザの記事を取得する処理を分離することで、コントローラは各種関数を呼び出してレスポンスを返す役割に集中することができます。
APIの根幹に関わってくるロジックはユースケースとして、1ファイル1関数の形で実装を行っていきます。
何を行うか想像がつくように GetPublicArticlesUsingUser
というファイル名で作成していきます。
コントローラで使用する時はメソッドインジェクションで引数に GetPublicArticlesUsingUser $getPublicArticlesUsingUser
を追加するとそのまま利用することができます。
また、Laravelにはリレーション関数を定義するとテーブル同士を結合して、結合先のデータを取得するクエリを作成できる Eloquent リレーション 機能があります。これを利用すると、ユーザモデルから user_id
に紐づく記事のリストを取得することができます。
laravel/
├─ app/
│ ├─ Http/
│ │ ├─ Controllers/
│ │ │ └─ UserArticleController.php // Changed
│ │ ├─ Requests/
│ │ │ └─ UserArticle/
│ │ │ └─ IndexRequest.php
│ │ └─ Resources/
│ │ ├─ ArticleCollection.php
│ │ └─ ArticleResource.php
│ ├─ Models/
│ │ ├─ Article.php
│ │ └─ User.php // Changed
│ └─ UseCases/
│ └─ Article/
│ └─ GetPublicArticlesUsingUser.php // New
└─ routes/
└─ api.php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Http\Requests\UserArticle\IndexRequest;
use App\Http\Resources\ArticleCollection;
use App\Models\Article;
use App\Models\User;
use App\UseCases\Article\GetPublicArticlesUsingUser;
use Illuminate\Http\Request;
class UserArticleController extends Controller
{
/**
* Display a listing of the resource.
*
* @param \App\Http\Requests\UserArticle\IndexRequest $request
* @param \App\Models\User $user
* @param \App\UseCases\Article\GetPublicArticlesUsingUser $getPublicArticlesUsingUser
* @return \App\Http\Resources\ArticleCollection
*/
public function index(IndexRequest $request, User $user, GetPublicArticlesUsingUser $getPublicArticlesUsingUser) // GetPublicArticlesUsingUser を追加
{
// リクエストで定義した関数でper_pageを取得する
$perPage = $request->getPerPage();
// ユーザの公開状態の記事を取得する
$articles = $getPublicArticlesUsingUser($perPage, $user); // 分離した関数を使用して記事を取得する
return new ArticleCollection($articles);
}
}
ユーザモデルにリレーションを定義していると $user->articles()->paginate()
のようにユーザモデルから記事のリストを取得することができます。今回は公開設定の記事だけ取得したいので、クエリビルダを繋げていきます。
namespace App\UseCases\Article;
use App\Models\Article;
use App\Models\User;
class GetPublicArticlesUsingUser
{
/**
* Handle GetPublicArticlesUsingUser.
*
* @param int $perPage
* @return \Illuminate\Pagination\LengthAwarePaginator
*/
public function __invoke(int $perPage, User $user)
{
// ユーザの公開状態の記事を取得する
$articles = $user->articles()
->where('state', Article::STATE_PUBLIC) // 記事モデルの定数を使用
->latest()
->paginate($perPage);
return $articles;
}
}
ユーザモデルにリレーション articles
を定義します。ユーザと記事は1対多の関係なので hasMany
関数を使用します。
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'users';
/**
* Const state public
*
* @var string
*/
const STATE_PUBLIC = 'public';
/**
* Const state limit
*
* @var string
*/
const STATE_LIMIT = 'limit';
/**
* Const state private
*
* @var string
*/
const STATE_PRIVATE = 'private';
/**
* Define a one-to-many relationship.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function articles() // リレーション articles を定義
{
return $this->hasMany(Article::class, 'user_id', 'id');
}
}
おわりに
お疲れ様でした!最終的には以下のようなファイルが出来ていると思います。
laravel/
├─ app/
│ ├─ Http/
│ │ ├─ Controllers/
│ │ │ └─ UserArticleController.php
│ │ ├─ Requests/
│ │ │ └─ UserArticle/
│ │ │ └─ IndexRequest.php
│ │ └─ Resources/
│ │ ├─ ArticleCollection.php
│ │ └─ ArticleResource.php
│ ├─ Models/
│ │ ├─ Article.php
│ │ └─ User.php
│ └─ UseCases/
│ └─ Article/
│ └─ GetPublicArticlesUsingUser.php
└─ routes/
└─ api.php
色々ファイルを作りましたが基本的な流れは変わらずに、APIパスをルーティングして、コントローラでレスポンスを返します。
クライアント
↓ HTTP GET /api/users/1/articles
ルーティング (api.php)
↓
コントローラ (UserArticleController.php)
↓ ├ フォームリクエスト (IndexRequest.php)
↓ ├ ユースケース (GetPublicArticlesUsingUser.php)
↓ └ APIリソース (ArticleResource.php / ArticleCollection.php)
↓
クライアント
APIのバリデーションや根幹となるロジック部分を分離したことで、コントローラは各種関数にデータ処理を任せて返すだけ、というシンプルなコードになりました。
use App\Http\Controllers\UserArticleController;
use Illuminate\Support\Facades\Route;
Route::get('users/{user}/articles', [UserArticleController::class, 'index']);
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Http\Requests\UserArticle\IndexRequest;
use App\Http\Resources\ArticleCollection;
use App\Models\User;
use App\UseCases\Article\GetPublicArticlesUsingUser;
class UserArticleController extends Controller
{
/**
* Display a listing of the resource.
*
* @param \App\Http\Requests\UserArticle\IndexRequest $request
* @param \App\Models\User $user
* @param \App\UseCases\Article\GetPublicArticlesUsingUser $getPublicArticlesUsingUser
* @return \App\Http\Resources\ArticleCollection
*/
public function index(IndexRequest $request, User $user, GetPublicArticlesUsingUser $getPublicArticlesUsingUser)
{
// リクエストで定義した関数でper_pageを取得する
$perPage = $request->getPerPage();
// ユーザの公開状態の記事を取得する
$articles = $getPublicArticlesUsingUser($perPage, $user);
return new ArticleCollection($articles);
}
}
社内の勉強会用に書きましたが、 Laravel を使ってプロジェクトを立ち上げる予定の方の助けになれば嬉しいです。