Chào các bạn, trong bài viết này như tiêu đề mình sẽ hướng dẫn các bạn cách áp dụng repository pattern vào dự án Laravel. Để hiểu được bài viết này mình hy vọng các bạn đã có chút vốn kiến thức về Laravel, hoặc không thì các bạn có thể tham khảo trang chủ của nó qua link này. Mình sẽ làm một project demo để các bạn có thể nắm được khái quát hơn cách triển khai Pattern này trong dự án Laravel, nếu rảnh các bạn có thể làm theo để hiểu rõ hơn nhé ^^.

I. Khái niệm

Khái niệm một repository là một lớp trung gian giữa Domain và Persistent Layer. Domain ở đây nghĩa là tầng xử lý logic và Persistent Layer là tầng liên quan tới thao tác với cơ sở dữ liệu. Trong repository sẽ cung cấp một interface để truy cập vào data trong database, file, hoặc các service khác.

Đúng với khái niệm, việc sử dụng Repository Pattern trong Laravel mục đích là để tạo ra 1 cầu nối giữa model và controller. Nói 1 cách khác, để tách riêng sự phụ thuộc của model với controller. Chắc bạn đã biết, model lẽ ra không nên chịu trách nhiệm cho việc giao tiếp và truyền tải dữ liệu từ database, nó chỉ nên được dùng với tư cách như là một "người đại diện" cho 1 bảng/1 đối tượng. Hay việc truy xuất database được viết trong controller cũng không tốt khi nó mang đến khá nhiều vấn đề liên quan đến bảo mật và code xấu. Việc sử dụng repository sẽ khiến cho code của chúng ta an toàn và gọn gàng hơn rất nhiều. Sau đây là 1 số lợi ích:

  • Tập trung code logic truy cập dữ liệu giúp code dễ bảo trì hơn.
  • Viết unit test riêng biệt cho phần nghiệp vụ và phần logic truy cập dữ liệu database.
  • Giảm việc lặp code
  • Code ít có những lỗi tiềm tàng hơn

Chắc hẳn trong hầu hết các tutorial Laravel, các bạn đã qúa quen thuộc với 1 function như thế này:

II. Vấn đề của việc implement thông thường

class PostsController extends Controller
{
   public function index()
   {
       $posts = Post::all();

       return view('post.index', [
           'posts' => $posts
       ]);
   }
} 

Thoạt nhìn, đoạn code này không hề có vấn đề gì cả và nó chạy tốt trên hệ thống hiện tại của bạn. Bùm!!! Bỗng một ngày đẹp trời, khách hàng đến cười vào mặt bạn và nói rằng: Giờ chúng tôi sẽ không lưu trữ data trên MySql nữa, chúng tôi muốn chuyển sang sử dụng một bên khác. Bạn nghe tên data engine này khá lạ và toát mồ hôi hột khi Eloquent của Laravel hiện tại không hỗ trợ data engine này. Điều này có nghĩ là, không chỉ ở function này, mà các function khác bạn đang implement phải viết lại hoàn toàn code. Vậy nên, giờ bạn đã hiểu tại sao cách viết code này lại nguy hiểm rồi đấy. Đây là lúc mà việc sử dụng interface trong repository pattern phát huy tác dụng, bạn chỉ cần tạo 1 class khác cho data engine mới và implement interface thay vì viết lại code xử lí data cho cả dự án trong từng hàm trong controller. Đây là đoạn code sau khi được áp dụng Repository Pattern:

class PostsController extends Controller
{
   private $postRepository;
  
   public function __construct(PostRepositoryInterface $postRepository)
   {
       $this->postRepository = $postRepository;
   }

   public function index()
   {
       $users = $this->postRepository->all();

       return view('users.index', [
           'posts' => $posts
       ]);
   }
}

Như ta có thể thấy, với cách implement này, nếu có thay đổi cấu trúc dữ liệu, nó cũng khá là dễ để chỉnh sửa. Việc chúng ta làm, như đã nói ở trên, chúng ta tạo 1 class implement interface PostRepositoryInterface và chứa logic xử lý theo cách xử lý của data engine mới. Vấn đề được giải quyết trọn vẹn, chúng ta cùng đi vào thực hành để hiểu rõ về cách sử dụng này nhé.

III. Implement vào dự án

Mình sẽ tập trung vào việc chính là implement design pattern này trong dự án, nên các bước khác mình sẽ không đi vào cụ thể nhé.

1. Tạo và config project

Trước hết, chúng ta cần có 1 project Laravel và môi trường đã được cài trên máy, có rất nhiều cách để cài ứng dụng trên trang chủ ở đây. Do mình đã cài docker trên máy nên mình tạo một project mới 1 cách đơn giản = cách chạy 3 câu lệnh sau:

curl -s https://laravel.build/repository-pattern-example | bash
cd repository-pattern-example
./vendor/bin/sail up

Bạn check ứng dụng thành công chưa trên url http://localhost/

Nhớ đừng quên tạo 1 database mới trên môi trường của bạn rồi config lại thông tin database trong file .env nhé. Ở đây mình tạo database tên trùng với project.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=repository-pattern-example
DB_USERNAME=<YOUR_DATABASE_USERNAME>
DB_PASSWORD=<YOUR_DATABASE_PASSWORD>

2. Tạo model, migrations và tạo dữ liệu mẫu (seeding)

Chúng ta sẽ xây dựng một hệ thống xoay quanh việc quản lý những bài post, để tạo ra các file cần thiết chúng ta sẽ dùng lệnh sau cho nhanh:

php artisan make:model Post -a

Với việc sử dụng flag -a, Artisan biết được rằng chúng ta muốn tạo ra đầy đủ các file liên quan tới model Post. Với bản laravel 9.x mình đang sử dụng, lệnh trên sẽ tao ra các file như sau:

  1. File model: app/Models/PostController.php
  2. File controller: app/Http/Controllers/PostController.php
  3. File migration dùng để tạo bảng: database/migrations/YYYY_MM_DD_HHMMSS_create_posts_table.php
  4. File seeder để tạo dữ liệu mẫu: database/seeders/PostSeeder.php
  5. Model factory dùng để sinh ra lượng lớn dữ liệu mẫu trong database: database/factories/PostFactory.php
  6. File policy để phân quyền liên quan tới Model hoặc Resource: app/Policies/PostPolicy.php
  7. File request để define parameter cho việc tạo mới dữ liệu cho Post: app/Http/Requests/StorePostRequest.php  
  8. File request để define parameter cho việc update dữ liệu cho Post: app/Http/Requests/UpdatePostRequest.php

Trong file migration, chúng ta update function up như sau:

	/**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->text('content');
            $table->boolean('is_published')->default(false);
            $table->timestamps();
        });
    }

Như được định nghĩa ở trên, bảng posts của chúng ta có các trường:

  1. primary key ID.
  2. tiêu đề bài post.
  3. nội dung post.
  4. trường is_published, cho biết bài post này đã được xuất bản hay chưa.
  5. created_at và updated_at.

Tiếp đến, hãy update file PostFactory.php để tạo dữ liệu dummy để kết hợp với file seed ở bước tiếp theo, chúng ta update function definition:

	/**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition()
    {
        return [
            'title'         => $this->faker->sentence(1, true),
            'content'       => $this->faker->text(300),
            'is_published' => $this->faker->boolean(),

        ];
    }

Cập nhật function run trong PostSeeder.php:

    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        Post::factory()->times(50)->create();
    }

Điều này có nghĩa là chúng ta sẽ sử dụng PostFactory để tạo ra 50 dummy record trong database. Đừng quên rằng để chạy được class PostSeeder, hãy update vào file src/database/seeders/DatabaseSeeder.php:

$this->call(
    [
        PostSeeder::class
    ]
); 

Vậy là việc set up mọi thứ trong bước này đã xong, việc cuối cùng cần làm là chúng ta chạy câu lệnh:

php artisan migrate --seed

Sau đó, các bạn check lại trong database bản posts để thấy được thành quả

3. Tạo Repository

Trước khi chúng ta tạo Repository cho Post, hãy định nghĩa 1 interface và khai báo toàn bộ các method cần thiết. Như vậy thay vì phụ thuộc trực tiếp vào class repository, controller (và có thể là các phần liên quan đến Post phát triển trong tương lai) sẽ phụ thuộc vào interface. Như đã nói ở trên khi có bài toán thay đổi như ở trên, tất cả việc chúng ta cần làm là khai báo một class implement interface này mà không cần thay đổi 1 dòng code trong controller.

Trong app/, tạo một foler tên là Interfaces, và trong đó tạo file PostRepositoryInterface.php và khai báo các method cần thiết:

<?php

namespace App\Interfaces;

use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;

interface PostRepositoryInterface 
{
    public function getAllPost();
    public function getPostById($postId);
    public function getPublishedPosts();
    public function createPost(StorePostRequest $storePostRequest);
    public function updatePost($postId, UpdatePostRequest $updatePostRequest);
    public function deletePost($postId);
}

Sau đó, chúng ta sẽ tạo folder Repositories trong app và tạo file PostRepository.php:

<?php

namespace App\Repositories;

use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Interfaces\PostRepositoryInterface;
use App\Models\Post;

class PostRepository implements PostRepositoryInterface 
{
    private Post $post;
    public function __construct(Post $post) 
    {
        $this->post = $post;
    }

    public function getAllPost()
    {
        return $this->post->all();
    }

    public function getPostById($postId)
    {
        return $this->post->findOrFail($postId);
    }

    public function getPublishedPosts()
    {
        return $this->post->where('is_published', true)->get();
    }

    public function createPost(StorePostRequest $storePostRequest)
    {
        return $this->post->create($storePostRequest->toArray());
    }

    public function updatePost($postId, UpdatePostRequest $updatePostRequest)
    {
        $post = $this->post->find($postId);
        if($post) {
            $post->update($updatePostRequest->toArray());
        }
    }


    public function deletePost($postId)
    {
        $this->post->destroy($postId);
    }
}

Với việc viết code truy xuất dữ liệu vào trong 1 file Repository như vậy thay vì viết trong Controller, chẳng hạn khi bạn thay đổi logic truy xuất dữ liệu của việc get tất cả các post, thay vì phải search từng chỗ trong các controller Post::all(); để thay đổi, tất cả việc bạn phải làm chỉ là chỉnh sửa trong hàm getAllPost(). Rất tiện lợi phải không.

4. Tạo controller

Repository đã sẵn sàng, hãy update các function trong file PostController.php:

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Interfaces\PostRepositoryInterface;
use Illuminate\Http\Request;

class PostController extends Controller
{
    private PostRepositoryInterface $postRepository;

    public function __construct(PostRepositoryInterface $postRepository) 
    {
        $this->postRepository = $postRepository;
    }

    /**
     * Display a listing of the resource.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function index(Request $request)
    {

        $data = $request->is_published === "true" ? 
            $this->postRepository->getPublishedPosts() :
            $this->postRepository->getAllPost();

        return response()->json([
            "message" => 'success',
            'data' => $data
        ]);
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \App\Http\Requests\StorePostRequest  $request
     * @return \Illuminate\Http\Response
     */
    public function store(StorePostRequest $request)
    {
        return response()->json(
            [
                "message" => 'success',
                'data' => $this->postRepository->createPost($request)
            ],
        );
    }

    /**
     * Display the specified resource.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function show(Request $request)
    {
        return response()->json([
            "message" => 'success',
            'data' => $this->postRepository->getPostById($request->route('id'))
        ]);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \App\Http\Requests\UpdatePostRequest  $request
     * @return \Illuminate\Http\Response
     */
    public function update(UpdatePostRequest $request)
    {
        return response()->json([
            "message" => 'success',
            'data' => $this->postRepository->updatePost($request->route('id'), $request)
        ]);
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function destroy(Request $request)
    {
        $this->postRepository->deletePost($request->route('id'));

        return response()->json([
            "message" => 'success',
        ]);
    }
}

Chúng ta inject PostRepositoryInterface instance thông qua constructor. Thông qua các hàm controller, chúng ta có thể biết được mục đích các hàm sử dụng:

  • Trong hàm index(), chúng ta sẽ check tham số is_unsearchable để trả về tất cả post hay các post đã được xuất bản thông qua các method trong PostRepository, dữ liệu trả về là JSON.
  • Hàm store() gọi hàm createPost() trong PostRepository để tạo ra 1 record Post mới. Tham số truyền vào là 1 object StorePostRequest, dữ liệu trả về dưới dạng JSON.
  • Hàm show() sẽ nhận được unique id của post từ trên routes và pass vào hàm getPostById() như một param. Sẽ trả về 1 bản ghi của của 1 post cụ thể dưới dạng JSON.
  • Hàm update() cũng nhận unique id của post và pass vào hàm updatePost() của PostRepository với 2 tham số id của post và nội dung cập nhật post đó.
  • Hàm destroy() nhận unique id của post và xóa nó khỏi db thông qua hàm deletePost().

5. Add Routers

Thêm các routers vào file routes/api.php.

Route::controller(PostController::class)->prefix('posts')->group(function () {
    Route::get('/', 'index');
    Route::get('/{id}','show');
    Route::post('/create', 'store');
    Route::post('/update/{id}', 'update');
    Route::delete('/{id}', 'destroy');
});

6. Bind interface

Và việc cuối cùng cũng là việc quan trọng nhất, chúng ta cần phải bind PostRepository với PostRepositoryInterface trong Service Container của Laravel. Bước này sẽ biết được khi chúng ta gọi các hàm trong interface thì nó sẽ biết đối chiếu đến repository nào để xử lý. Chúng ta làm việc này thông qua Service Provider. Tạo RepositoryServiceProvider thông qua câu lệnh:

php artisan make:provider RepositoryServiceProvider

Mở app/Providers/RepositoryServiceProvider.php và update hàm register như sau:

    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->bind(PostRepositoryInterface::class, PostRepository::class);
    }

Cuối cùng thêm service vừa mới tạo vào array providers trong config/app.php:

'providers' => [
    // ...other declared providers
    App\Providers\RepositoryServiceProvider::class,
];

7. Thành quả

Và xong, sau tất cả các bước chúng ta đã thực hiện. Mình sẽ test thông qua Postman. It works like a charm!!!

Vậy là thành công rồi. Giờ bạn đã biết flow để áp dụng cho các model tương tự rồi.

IV. Một vài lưu ý

Việc sử dụng Repository Pattern khá đơn giản, nhưng bạn cũng nên chú ý một vài điều sau để áp dụng cho đúng:

  • Mỗi repository nên có 1 interface riêng biệt.  Đừng tạo 1 interface dùng cho nhiều repo.
  • Sử dụng Dependency Injection (thông qua interface mình pass qua constructor như PostController) thay vì cứ mỗi lần sử dụng lại sử dụng new để tạo một instance của Repository. Việc sử dụng Dependency Injection sẽ làm code tốt hơn và dễ dàng viết unit test cho cả repository lẫn controller.
  • Hãy cố gắng làm code tối ưu nhất có thể. Nếu các repo có chung các hàm (chẳng hạn CRUD) thì hãy tạo một BaseRepository nhé.
  • Trong repository, inject model vào thông qua constructor (như cách mình đang làm với PostRepository), đừng khởi tạo instance hay sử dụng static. Việc này sẽ dễ dàng hơn khi bạn viết Unit test đấy.

V. Tổng kết.

Trên đây mình đã trình bày cho các bạn về Repository Pattern trong Laravel với ví dụ cụ thể. Để tham khảo code đầy đủ hơn và chạy thử các function khác, các bạn có thể xem qua tại đây. Cảm ơn bạn đã theo dõi!