sena9718
@sena9718

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

検索や登録などのDB操作をモデルに切り出したいです。

解決したいこと

自動販売機の売上管理システムをつくっています。
検索や登録などの操作をコントローラーに全て書いていたのですが、
そういったDB操作をモデルに切り出そうと考えているのですが、
いまいち書き方が分からず行き詰まっています。

また、新規登録・更新・削除処理にはDBトランザクションを使用したいのですが、
書き方的にこれでいいのかも見ていただけたら幸いです。
なにかアドバイスや解決策があればご教示ください。

該当するソースコード

ProductController.php
<?php

namespace App\Http\Controllers;

use App\Models\Product;
use App\Models\Company;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class ProductController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index(Request $request)
    {
        $query = Product::query();

        if($search = $request->search) {
            $query->where('product_name', 'LIKE', "%{$search}%");
        }
        if($company_id = $request->company_id) {
            $query->where('company_name', 'LIKE', "%{$company_name}%");
        }

        $products = $query->paginate(10);
        return view('products.index', compact('products'));
    }

    /**
     * Show the form for creating a new resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function create()
    {
        $companies = Company::all();

        return view('products.create', compact('companies'));
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(ProductStoreRequest $request)
    {
        $product = new Product([
            'product_name' => $request->get('product_name'),
            'company_id' => $request->get('company_id'),
            'price' => $request->get('price'),
            'stock' => $request->get('stock'),
            'comment' => $request->get('comment'),
        ]);

        if($request->hasFile('img_path')){ 
            $filename = $request->img_path->getClientOriginalName();
            $filePath = $request->img_path->storeAs('products', $filename, 'public');
            $product->img_path = '/storage/' . $filePath;
        }

        DB::transaction(function () use($request) {
            Product::store([
                'product_name' => $request->product_name,
                'company_id' => $request->company_id,
                'price' => $request->price,
                'stock' => $request->stock,
                'comment' => $request->comment,
                'img_path' => $request->img_path,
            ]);
        });

        $product->save();

        return redirect('products');
    }

    /**
     * Display the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function show(Product $product)
    {
        $products = Product::all();

        return view('products.show', compact('product'));
    }

    /**
     * Show the form for editing the specified resource.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function edit(Product $product)
    {
        $companies = Company::all();

        return view('products.edit', compact('product', 'companies'));
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function update(ProductUpdateRequest $request, Product $product)
    {
        // $product->product_name = $request->product_name;
        // $product->price = $request->price;
        // $product->stock = $request->stock;
        // $product->save();

        DB::transaction(function () use($request) {
            Product::update([
                'product_name' => $request->product_name,
                'price' => $request->price,
                'stock' => $request->stock,
            ]);
        });

        return redirect()->route('products.index')
            ->with('success', 'Product updated successfully');
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  int  $id
     * @return \Illuminate\Http\Response
     */
    public function destroy(Product $product)
    {
        $product->delete();

        DB::transaction(function () use($request) {
            Product::delete([
                'product_name' => $request->product_name,
                'company_id' => $request->company_id,
                'price' => $request->price,
                'stock' => $request->stock,
                'comment' => $request->comment,
                'img_path' => $request->img_path,
            ]);
        });

        return redirect('/products');
    }
}

ルーティング

web.php
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ProductController;
// use Illuminate\Support\Fecades\Auth;

Route::get('/', function () {
    if(Auth::check()) {
        return redirect()->route('products.index');
    } else {
        return redirect()->route('login');
    }
});

Auth::routes();

Route::group(['middleware' => 'auth'], function() {
    Route::resource('products', ProductController::class);
});

モデル

Product.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    use HasFactory;

    protected $table = 'products';

    protected $fillable = [
        'product_name',
        'price',
        'stock',
        'company_id',
        'comment',
        'img_path',
    ];

    public function sales() {
        return $this->hasMany(Sale::class);
    }

    public function company() {
        return $this->belongsTo(Company::class);
    }
}
Sale.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Sale extends Model
{
    use HasFactory;

    protected $table = 'sales';

    public function product() {
        return $this->belongsTo(Product::class);
    }
}
Company.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Company extends Model
{
    use HasFactory;

    protected $table = 'companies';

}

Views

index.blade.php
@extends('layouts.app')

@section('content')
<div class="container">
    <h1 class="mb-4">商品一覧画面</h1>

    <div class="search mt-5">
        <form action="{{ route('products.index') }}" method="GET" class="row g-3">
            <div class="col-sm-12 col-md-3">
                <input type="text" class="form-control" placeholder="検索キーワード" name="search" value="@if (isset($search)) {{ $search }} @endif">
            </div>
            <div class="col-sm-12 col-md-3">
                <input type="text" class="form-control" placeholder="メーカー名" name="company_name" value="@if (isset($company_name)) {{ $company_name }} @endif">
            </div>
            <div class="col-sm-12 col-md-1">
                <button class="btn btn-outline-secondary" type="submit">検索</button>
            </div>
        </form>
    </div>

        <div class="products mt-5">
        <table class="table table-striped">
            <thead>
                <tr>
                    <th>ID</th>
                    <th>商品画像</th>
                    <th>商品名</th>
                    <th>価格</th>
                    <th>在庫数</th>
                    <th>メーカー名</th>
                    <th><a href="{{ route('products.create') }}" class="btn btn-warning">新規登録</a></th>           
                </tr>
            </thead>
            <tbody>
            @foreach ($products as $product)
                <tr>
                    <td>{{ $product->id }}</td>
                    <td><img src="{{ asset($product->img_path) }}" alt="商品画像" width="100"></td>
                    <td>{{ $product->product_name }}</td>
                    <td>{{ $product->price }}</td>
                    <td>{{ $product->stock }}</td>
                    <td>{{ $product->company->company_name }}</td>
                    <td>
                        <a href="{{ route('products.show', $product) }}" class="btn btn-info btn-sm mx-1">詳細</a>
                        <form method="POST" action="{{ route('products.destroy', $product) }}" class="d-inline">
                            @csrf
                            @method('DELETE')
                            <button type="submit" class="btn btn-danger btn-sm mx-1">削除</button>
                        </form>
                    </td>
                </tr>
            @endforeach
            </tbody>
        </table>
        </div>
    </form>

{{ $products->appends(request()->query())->links() }} 

</div>
@endsection
create.blade.php
@extends('layouts.app')

@section('content')
<div class="container">
    <h1 class="mb-4">商品新規登録画面</h1>

    @if($errors->any())
    <div class="alert alert-danger mt-3">
        <ul>
            @foreach($errors->all() as $error)
            <li>{{ $error }}</li>
            @endforeach
        </ul>
    </div>
    @endif

    <form method="POST" action="{{ route('products.store') }}" enctype="multipart/form-data">

        @csrf

        <div class="mb-3">
            <label for="product_name" class="form-label">商品名<span class="text-danger">*</span></label>
            <input id="product_name" type="text" name="product_name" class="form-control" required>
        </div>

        <div class="mb-3">
            <label for="company_id" class="form-label">メーカー<span class="text-danger">*</span></label>
            <select class="form-select" id="company_id" name="company_id">
                @foreach($companies as $company)
                    <option value="{{ $company->id }}">{{ $company->company_name }}</option>
                @endforeach
            </select>
        </div>

        <div class="mb-3">
            <label for="price" class="form-label">価格<span class="text-danger">*</span></label>
            <input id="price" type="text" name="price" class="form-control" required>
        </div>

        <div class="mb-3">
            <label for="stock" class="form-label">在庫数<span class="text-danger">*</span></label>
            <input id="stock" type="text" name="stock" class="form-control" required>
        </div>

        <div class="mb-3">
            <label for="comment" class="form-label">コメント</label>
            <textarea id="comment" name="comment" class="form-control" rows="3" required></textarea>
        </div>

        <div class="mb-3">
            <label for="img_path" class="form-label">商品画像</label>
            <input id="img_path" type="file" name="img_path" class="form-control" required>
        </div>
        
        <div class="container mt-4">
            <div>
                <button type="submit" class="btn btn-warning">新規登録</button>
                <button type="button" class="border border-0"><a href="{{ route('products.index') }}" class="btn btn-primary">戻る</a></button>
            </div>
        </div>
    </form>

</div>
@endsection
show.blade.php
@extends('layouts.app')

@section('content')
<div class="container">
    <h1 class="mb-4">商品情報詳細画面</h1>

    

    <dl class="row mt-3" >
        <dt class="col-sm-3">ID</dt>
        <dd class="col-sm-9">{{ $product->id }}</dd>

        <dt class="col-sm-3">商品画像</dt>
        <dd class="col-sm-9">{{ $product->product_name }}</dd>

        <dt class="col-sm-3">メーカー名</dt>
        <dd class="col-sm-9">{{ $product->company->name }}</dd>

        <dt class="col-sm-3">価格</dt>
        <dd class="col-sm-9">{{ $product->price }}</dd>

        <dt class="col-sm-3">在庫数</dt>
        <dd class="col-sm-9">{{ $product->stock }}</dd>

        <dt class="col-sm-3">コメント</dt>
        <dd class="col-sm-9">{{ $product->comment }}</dd>

        <dt class="col-sm-3">商品画像</dt>
        <dd class="col-sm-9"><img src="{{ asset($product->img_path) }}" width="300"></dd>
    </dl>

    <div class="container mt-4">
        <div>
            <button type="button" class="border border-0"><a href="{{ route('products.edit', $product) }}" class="btn btn-warning">編集</a></button>
            <button type="button" class="border border-0"><a href="{{ route('products.index') }}" class="btn btn-primary ml-5">戻る</a></button>
        </div>
    </div>
</div>
@endsection
edit.blade.php
@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <div class="card">
                    <div class="card-header"><h2>商品情報編集画面</h2></div>

                    @if($errors->any())
                    <div class="alert alert-danger mt-3">
                        <ul>
                            @foreach($errors->all() as $error)
                            <li>{{ $error }}</li>
                            @endforeach
                        </ul>
                    </div>
                    @endif

                    <div class="card-body">
                        <form method="POST" action="{{ route('products.update', $product) }}" enctype="multipart/form-data">
                            @csrf
                            @method('PUT')

                            <div class="mb-3">
                                <label for="id" class="form-label">ID.</label>
                                <p>{{ $product->id }}</p>
                            </div>

                            <div class="mb-3">
                                <label for="product_name" class="form-label">商品名<span class="text-danger">*</span></label>
                                <input type="text" class="form-control" id="product_name" name="product_name" value="{{ $product->product_name }}" required>
                            </div>

                            <div class="mb-3">
                                <label for="company_id" class="form-label">メーカー名<span class="text-danger">*</span></label>
                                <select class="form-select" id="company_id" name="company_id">
                                    @foreach($companies as $company)
                                        <option value="{{ $company->id }}" {{ $product->company_id == $company->id ? 'selected' : '' }}>{{ $company->company_name }}</option>
                                    @endforeach
                                </select>
                            </div>

                            <div class="mb-3">
                                <label for="price" class="form-label">価格<span class="text-danger">*</span></label>
                                <input type="number" class="form-control" id="price" name="price" value="{{ $product->price }}" required>
                            </div>

                            <div class="mb-3">
                                <label for="stock" class="form-label">在庫数<span class="text-danger">*</span></label>
                                <input type="number" class="form-control" id="stock" name="stock" value="{{ $product->stock }}" required>
                            </div>

                            <div class="mb-3">
                                <label for="comment" class="form-label">コメント</label>
                                <textarea id="comment" name="comment" class="form-control" rows="3">{{ $product->comment }}</textarea>
                            </div>

                            <div class="mb-3">
                                <label for="img_path" class="form-label">商品画像:</label>
                                <input id="img_path" type="file" name="img_path" class="form-control">
                                <img src="{{ asset($product->img_path) }}" alt="商品画像" class="product-image">
                            </div>
                            
                            <div class="container mt-4">
                                <button type="submit" class="btn btn-warning">更新</button>
                                <button type="button" class="border border-0"><a href="{{ route('products.index') }}" class="btn btn-primary">戻る</a></button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

作りたいものが下記記事のものと酷似していたので、
このコードは本記事を基に作成しました。
本記事とテーブルとカラムは全く同じです。

0

3Answer

マイグレーションの仕方を理解してはどうでしょう。

laravelの癖でもありますがこのハードルを超えないと先に進めません。

私は時代遅れのSQLをクラスまたはプロバイダーサーバーで実装して利用してます。

0Like

Comments

  1. @sena9718

    Questioner

    コメントありがとうございます。
    本日改めてマイグレーションの理解を深め、少し今自分の抱えている課題への理解も深まった気がします。

何となく分かるのですが、ぼんやりしている部分があるので教えてください。

DB操作をモデルに切り出そうと考えている

モデルに切り出したいというのは、どういった動機があるのでしょうか?

また、モデルに処理を切り出した結果、コントローラーでどのように使いたいのかイメージを示せますか?
(具体的なコードレベルのイメージがあるとなお良いです)

新規登録・更新・削除処理にはDBトランザクションを使用したい

トランザクションを使用したいのはどういった動機があるのでしょうか?

わかる範囲で構いませんので回答いただければと思います。

0Like

Comments

  1. @sena9718

    Questioner

    コメントありがとうございます。
    現在、研修課題として本システムを作成しています。
    コントローラーに全て書き出す形で課題提出をしたところ、指導者の方からもっと大きいシステムを作るときに可読性、メンテナンス性が損なわれるのでモデルに処理を切り出すこととトランザクションを使用するよう指摘があったのが動機になります。

    コントローラーでどのように使いたいかに関しては、動作のイメージが私自身も完全にまだ理解できていないので、具体的に示せないです。申し訳ございません。
    またイメージが湧けば、ご連絡させていただきます。

  2. 大きいシステムを作るときに可読性、メンテナンス性が損なわれるのでモデルに処理を切り出す

    動機としては正しいものですが、「可読性やメンテナンス性が損なわれた状態」というのを経験していないとなかなかイメージが湧かないですよね。
    学習レベルのコードだと可読性やメンテナンス性の問題を体感するのは難しいものです。

    処理を切り出すにあたっては関数やクラスについての知識が必要になりますが、「どの部分を切り出すか」というのを考える必要があります。
    どのように切り出せば様々なところで便利に使えそうか、という観点で考えるのが良いかもしれませんね。

    例えば検索をこのように変更すると切り出す部分が見えやすいかもしれません。

    ProductControllerより抜粋、編集
        public function index(Request $request)
        {
            $search = $request->search;
            $company_id = $request->company_id;
            $perPage = 10;
            
            $query = Product::query();
    
            if($search) {
                $query->where('product_name', 'LIKE', "%{$search}%");
            }
            if($company_id) {
                $query->where('company_name', 'LIKE', "%{$company_name}%");
            }
    
            $products = $query->paginate($perPage);
    
            return view('products.index', compact('products'));
        }
    

その現場が採用している設計手法にもよるのですが、基本的にMVC(Model, View, Controller)フレームワークを採用している場合、Controllerにはロジックをあれこれ書かない、という原則があります。
その理由として、
・再利用がしづらい
・自動テストが書きづらい
・関心事の分離ができていないためコードが読みづらく、メンテナンスもしづらい
などなど、色々なデメリットがあるため基本的には良しとされていません。

ではどうするのかというと、Controllerは基本的には「受け取った値をModel(またはService、これは後述します)に渡し、返ってきた値をViewに渡す」ことに専念させるようにします。

また、Laravelにはscopeと呼ばれる便利な機能があり、これを用いることで絞り込み処理を綺麗に書けるようになります。

Product
class Product extends Model
{
    // 中略

    public function scopeProductName($query, $productName)
    {
        return $query->where('product_name', 'LIKE', '%' . $productName . '%');
    }
}
Controller
class ProductController extends Controller
{
    // 中略

    public function index($request)
    {
        $products = Product::productName($request->product_name)->paginate(10);

        return view('products.index', compact('products'));
    }
}

これで検索処理をControllerからModelへ移すことができました。
関心事も分離できて読みやすいコードになっているかと思います。

またこれは現場によるのですが、ある程度規模の大きい実務ではService-Repositoryパターンというものを採用し、実際のDB処理はService層を介しRepositoryに任せる、という設計になっていることが多いです。
(少なくとも過去私の居た現場ではほぼこのパターンを採用していました)

Controller
class ProcutController extends Controller
{
    protected $productService;

    public function __construct(ProductService $productService)
    {
        $this->productService = $productService;
    }

    public function store(ProductStoreRequest $productStoreRequest)
    {
        $this->productService->storeProduct($request);

        return redirect('products');
    }
}
Service
class ProductService
{
    protected ProductRepository $productRepository;

    public function __construct(ProductRepository $productRepository)
    {
        $this->productRepository = $productRepository;
    }

    public function storeProduct($request)
    {
        $this->storeProductImage($request);

        return $this->productRepository->storeProduct($request);
    }

    public function storeProductImage($request)
    {
        // 画像保存処理
    }
}
Repository
class ProductRepository
{
    public function storeProduct($request)
    {
        return Product::store([
            // 中略
        ]);
    }
}

商品を新規登録する際の処理のうち、「商品画像をサーバーに保存する」処理はService層に任せ、「商品データをDBに保存する」処理はRepository層で行います。
ファイル数は増えますがControllerからそれぞれの処理が分離し、可読性やメンテナンス性が向上しているかと思います。

研修課題とのことなので、研修がんばってください!
なにか不明点がありましたら追記いただければ回答します。

参考:
【Laravel】モデルのスコープの使い方:https://qiita.com/akko_merry/items/5a6db8045a8b6c218b2e
Laravelでスコープ:https://qiita.com/mikaku/items/09ff1adecb2dfb97269d
MVC+Service+Repositoryの概念:https://www.id-frontier.jp/blog/tech/mvcservicerepository%E3%81%AE%E6%A6%82%E5%BF%B5/

0Like

Your answer might help someone💌