Kashif Wahaj's Blog

CRUD Multi Pattern

By Kashif Wahaj on Dec 11, 2024
Image post 6

CRUD Multi Pattern

A Brief Explanation

The CRUD Multi Pattern is a structured approach to building Laravel applications that promotes code reusability, testability, and maintainability. By leveraging key components like a base controller, service classes, and standardized resource patterns, this architecture simplifies development across multiple models.

Components of the Pattern

  1. BaseController:

    • A generic controller implementing Laravel’s resource actions (index, store, show, update, and destroy).
    • Provides shared functionality for all controllers, reducing code duplication.
    • Extends the controller and uses dependency injection to work seamlessly with specific model services.
  2. BaseController Interface:

    • Ensures consistency by defining a contract for any customizations needed in controllers.
    • Encourages strongly-typed controllers that follow a defined standard.
    filename:BaseControllerInterface.php
       <?php
    
       namespace App\Http\Interfaces;
    
       use Illuminate\Foundation\Http\FormRequest;
       use Illuminate\Http\JsonResponse;
       use Illuminate\Http\RedirectResponse;
       use Illuminate\Http\Request;
       use Illuminate\Http\Response;
    
       interface BaseControllerInterface
       {
           public function index(Request $request): Response | RedirectResponse | JsonResponse;
    
           public function show(mixed $id): Response | RedirectResponse | JsonResponse;
    
           public function store(FormRequest $request): Response | RedirectResponse | JsonResponse;
    
           public function update(FormRequest $request, $id): Response | RedirectResponse | JsonResponse;
    
           public function destroy($model): Response | RedirectResponse | JsonResponse;
       }
  3. Form Requests (Store and Update):

    • Custom request classes (StoreRequest, UpdateRequest) to validate incoming data.
    • Keeps the controller lightweight by moving validation logic to dedicated files.
  4. Resources and Collections:

    • ProductResource: Transforms a single model instance into a clean and structured JSON response.
    • ProductCollection: Formats a collection of models (e.g., paginated data) into a standardized JSON structure.
    • Promotes separation of concerns by keeping API responses consistent across the app.
  5. Service Layer:

    • ProductService: Handles all business logic for a given model.
    • Promotes reusability and testability by separating database interactions and logic from the controller.
    • Includes methods for common operations such as filtering, searching, sorting, and pagination.
       filename: BaseServiceInterface.php
       
       <?php
    
       namespace App\Http\Interfaces;
    
       interface BaseServiceInterface
       {
           public function getAll(
               array $filters = [],
               string $search = '',
               string $sortBy = 'id',
               string $sortOrder = 'asc',
               int $perPage = 15
           );
    
           public function create(array $data);
    
           public function find(int | string $id);
    
           public function update(int | string $id, array $data);
    
           public function delete(int | string $id);
    
           /**
            * Get the view name for the index page.
           *
           * @return string
           */
           public function getIndexView(): string;
    
           /**
            * Get the view name for the show page.
           *
           * @return string
           */
           public function getShowView(): string;
    
           /**
            * Get the view name for the edit page.
           *
           * @return string
           */
           public function getEditView(): string;
       }
    
    • Example ProductService:
    filename:ProductService.php
      <?php
    
        namespace App\Http\Services;
    
        use App\Models\Product;
        use App\Http\Interfaces\BaseServiceInterface;
        use Illuminate\Contracts\Pagination\LengthAwarePaginator;
        use Illuminate\Support\Str;
        use Illuminate\Support\Facades\DB;
    
        class ProductService implements BaseServiceInterface
        {
            protected string $baseViewFolderName;
    
            public function __construct(protected Product $model)
            {
                $modelName = class_basename($this->model);
                $this->baseViewFolderName = Str::plural(strtolower($modelName));
            }
    
            /**
             * Retrieve all records with optional filters, search, and sorting.
            */
            public function getAll(
                array $filters = [],
                string $search = '',
                string $sortBy = 'id',
                string $sortOrder = 'asc',
                int $perPage = 15
            ): LengthAwarePaginator {
                $query = $this->model->query();
                // Apply filters
                if (!empty($filters)) {
                    foreach ($filters as $key => $value) {
                        $query->where($key, $value);
                    }
                }
    
                // Apply search
                if ($search) {
                    $query->where(function ($q) use ($search) {
                        $q->where('name', 'like', "%$search%")
                            ->orWhere('description', 'like', "%$search%");
                    });
                }
    
                // Apply sorting
                $query->orderBy($sortBy, $sortOrder);
    
                // Return paginated results
                return $query->paginate($perPage);
            }
    
            /**
             * Create a new record.
            */
            public function create(array $data)
            {
                DB::beginTransaction();
    
                try {
                    $product = $this->model->create($data);
                    DB::commit();
                    return $product;
                } catch (\Exception $e) {
                    DB::rollBack();
                    throw $e;
                }
            }
    
            /**
             * Find a record by its ID.
            */
            public function find($id)
            {
                return $this->model->findOrFail($id);
            }
    
            /**
             * Update an existing record.
            */
            public function update($model, array $data)
            {
                DB::beginTransaction();
    
                try {
                    $model->update($data);
                    DB::commit();
                    return $model;
                } catch (\Exception $e) {
                    DB::rollBack();
                    throw $e;
                }
            }
    
            /**
             * Delete a record.
            */
            public function delete($model)
            {
                DB::beginTransaction();
    
                try {
                    $model->delete();
                    DB::commit();
                    return true;
                } catch (\Exception $e) {
                    DB::rollBack();
                    throw $e;
                }
            }
    
            /**
             * Get the index view path.
            */
            public function getIndexView(): string
            {
                return $this->baseViewFolderName . '.index';
            }
    
            /**
             * Get the show view path.
            */
            public function getShowView(): string
            {
                return $this->baseViewFolderName . '.show';
            }
    
            /**
             * Get the edit view path.
            */
            public function getEditView(): string
            {
                return $this->baseViewFolderName . '.edit';
            }
        }
    
  6. Test Cases:

    • Comprehensive test coverage ensures functionality for services, controllers, and validation layers.
    • Tests include scenarios for filters, search, pagination, and transactional operations (like creating or updating models).

Replicating the Pattern for Other Models

To apply this pattern to other models (e.g., Category, Order, etc.), follow these steps:

  1. Create a New Service Class:

    • Use the existing ProductService as a blueprint.
    • Define business logic specific to the model.
  2. Generate Store and Update Requests:

    • Use Laravel’s artisan command to create new request classes:
      filename: command
      php artisan make:request StoreProductRequest
      php artisan make:request UpdateProductRequest
    • Add validation rules relevant to the model.
  3. Build Resources and Collections:

    • Create a new resource class for single model instances:
      filename: command
      php artisan make:resource ProductResource
    • Similarly, create a collection for lists of model instances.
  4. Extend the BaseController:

    • Add Routes

      filename: routes / api.php
      
        <?php
      
        use App\Http\Controllers\Admin\ProductController;
        use Illuminate\Http\Request;
        use Illuminate\Support\Facades\Route;
      
        Route::middleware(['auth:sanctum'])->get('/user', function (Request $request) {
            return $request->user();
        });
      
        Route::resource('product', controller: ProductController::class);
      
    • Use the BaseController to scaffold your controller:

        filename:BaseController.php
        <?php
      
        namespace App\Http\Controllers\Admin;
      
        use App\Http\Controllers\Controller;
        use App\Http\Interfaces\BaseControllerInterface;
        use App\Http\Interfaces\BaseServiceInterface;
        use Illuminate\Foundation\Http\FormRequest;
        use Illuminate\Http\JsonResponse;
        use Illuminate\Http\RedirectResponse;
        use Illuminate\Http\Resources\Json\JsonResource;
        use Illuminate\Http\Resources\Json\ResourceCollection;
        use Illuminate\Http\Response;
        use Illuminate\Http\Request;
      
        abstract class BaseController extends Controller implements BaseControllerInterface
        {
            /**
             * The service class for the resource.
            * 
            * @var BaseServiceInterface
            */
            protected BaseServiceInterface $service;
      
            /**
             * The resource class for individual items.
            * 
            * @var JsonResource
            */
            protected JsonResource $resourceClass;
      
            /**
             * The resource collection class for multiple items.
            * 
            * @var ResourceCollection|null
            */
            protected ?ResourceCollection $collectionClass;
      
      
            protected ?string $storeRequestClass; // Accepts class name as a string
            protected ?string $updateRequestClass; //
            /**
             * BaseController constructor.
            *
            * @param BaseServiceInterface $service
            * @param JsonResource $resourceClass
            * @param ResourceCollection|null $collectionClass
            * @param ?string | null $storeRequestClass
            * @param ?string | null $updateRequestClass
            */
      
            public function __construct(
                BaseServiceInterface $service,
                JsonResource $resourceClass,
                ?ResourceCollection $collectionClass = null,
                ?string $storeRequestClass = null,
                ?string $updateRequestClass = null
            ) {
      
                $this->service = $service;
                $this->resourceClass = $resourceClass;
                $this->collectionClass = $collectionClass;
                $this->storeRequestClass = $storeRequestClass;
                $this->updateRequestClass = $updateRequestClass;
            }
      
            protected function resolveRequest(string $requestClass)
            {
                if (class_exists($requestClass)) {
                    return app($requestClass); // Resolves the FormRequest dynamically
                }
      
                throw new \InvalidArgumentException("Request class {$requestClass} does not exist.");
            }
      
            /**
             * Display a paginated list of resources.
            *
            * @return  Response | RedirectResponse | JsonResponse
            */
            public function index(Request $request): Response | RedirectResponse | JsonResponse
            {
                $filters = $request->get('filters', []);
                $search = $request->get('search', '');
                $sortBy = $request->get('sort_by', 'created_at');
                $sortOrder = $request->get('sort_order', 'desc');
                $perPage = $request->get('per_page', 15);
                $resources = $this->service->getAll($filters, $search, $sortBy, $sortOrder, $perPage);
      
                $response = $this->collectionClass
                    ? new $this->collectionClass($resources)
                    : JsonResource::collection($resources);
      
                return $this->generateResponse($response, $this->service->getIndexView());
            }
      
            /**
             * Store a newly created resource.
            *
            * @param FormRequest $request
            * @return  Response | RedirectResponse | JsonResponse
            */
            public function store(FormRequest $request): Response | RedirectResponse | JsonResponse
            {
                $request = $this->resolveRequest($this->storeRequestClass);
                $data = $request->validated();
      
                $resource = $this->service->create($data);
      
                return $this->generateResponse(
                    new $this->resourceClass($resource),
                    $this->service->getIndexView(),
                    [],
                    201
                );
            }
      
            /**
             * Display the specified resource.
            *
            * @param mixed $id
            * @return Response | RedirectResponse | JsonResponse
            */
            public function show(mixed $id): Response | RedirectResponse | JsonResponse
            {
                $resource = $this->service->find($id);
      
                return $this->generateResponse(
                    new $this->resourceClass($resource),
                    $this->service->getShowView()
                );
            }
      
            /**
             * Update the specified resource.
            *
            * @param FormRequest $request
            * @param mixed $id
            * @return  Response | RedirectResponse | JsonResponse
            */
            public function update(FormRequest $request, mixed $id): Response | RedirectResponse | JsonResponse
            {
                $request = $this->resolveRequest($this->updateRequestClass);
                $data = $request->validated();
      
                $resource = $this->service->find($id);
                $this->service->update($resource, $data);
      
                return $this->generateResponse(
                    new $this->resourceClass($resource),
                    $this->service->getShowView(),
                    [],
                    204
                );
            }
      
            /**
             * Remove the specified resource.
            *
            * @param mixed $id
            * @return  Response | RedirectResponse | JsonResponse
            */
            public function destroy(mixed $id): Response | RedirectResponse | JsonResponse
            {
                $resource = $this->service->find($id);
                $this->service->delete($resource);
      
                return response()->noContent();
            }
      
            /**
             * Generate a response for different content types.
            *
            * @param JsonResource|ResourceCollection $resource
            * @param string $view
            * @param array $additionalData
            * @param int $statusCode
            * @return  Response | RedirectResponse | JsonResponse
            */
            protected function generateResponse(
                JsonResource|ResourceCollection $resource,
                string $view,
                array $additionalData = [],
                int $statusCode = 200
            ): Response | RedirectResponse | JsonResponse {
                $returnType = request()->header('X-Return-Type', default: 'json');
      
                if ($returnType === 'view') {
                    return response()->view($view, array_merge($resource->resolve(), $additionalData), $statusCode);
                } elseif ($returnType === 'redirect') {
                    return redirect()->route($view)->with($additionalData);
                } elseif ($returnType === 'inertia') {
                    // For Inertia.js response, we use Inertia::render to send the data to the frontend.
                    return Inertia::render($view, array_merge($resource->resolve(), $additionalData))
                        ->setStatusCode($statusCode);
                }
      
                return $resource->response()->setStatusCode($statusCode);
            }
        }
      
  • Example ProductController :
    filename:ProductController.php
     <?php
    
     namespace App\Http\Controllers\Admin;
    
     use App\Http\Controllers\Admin\BaseController;
     use App\Http\Requests\StoreProductRequest;
     use App\Http\Requests\UpdateProductRequest;
     use App\Http\Resources\ProductResource;
     use App\Http\Resources\ProductCollection;
     use App\Http\Services\ProductService;
    
     class ProductController extends BaseController
     {
         public function __construct(ProductService $productService)
         {
             parent::__construct(
                 $productService,
                 new ProductResource([]),
                 new ProductCollection([]),
                 StoreProductRequest::class,
                 UpdateProductRequest::class
             );
         }
    
    
    
    
         // Add custom methods if needed
     }
    
  1. Write Unit Tests:
    • Use the ProductServiceTest as a base.
    • Test filters, search, pagination, and sorting for the new model.
    filename: test / Unit / ProductServiceTest.php
    <?php
    
    namespace Tests\Unit;
    
    use App\Models\Product;
    use App\Http\Services\ProductService;
    use Illuminate\Foundation\Testing\RefreshDatabase;
    use Tests\TestCase;
    
    class ProductServiceTest extends TestCase
    {
        use RefreshDatabase;
    
    
        /**
         * Test for getting all products with filters and pagination.
         */
        public function test_get_all_products_with_filters_and_pagination()
        {
            // Create some products
            Product::factory()->count(5)->create();
    
            // Mock ProductService
            $productService = app(ProductService::class);
    
            $filters = [];
            $search = '';
            $sortBy = 'name';
            $sortOrder = 'asc';
            $perPage = 5;
    
            $products = $productService->getAll($filters, $search, $sortBy, $sortOrder, $perPage);
            // Assert products are paginated
            $this->assertCount(5, $products->items());
        }
    
        /**
         * Test for creating a product.
         */
        public function test_create_product()
        {
            // Mock data
            $data = [
                'name' => 'Product 1',
                'description' => 'Product 1 Description',
                'price' => 100.00,
            ];
    
            // Mock ProductService
            $productService = app(ProductService::class);
    
            // Call create method
            $product = $productService->create($data);
    
            // Assert the product was created
            $this->assertDatabaseHas('products', [
                'name' => 'Product 1',
                'description' => 'Product 1 Description',
                'price' => 100.00,
            ]);
        }
    
        /**
         * Test for finding a product by ID.
         */
        public function test_find_product_by_id()
        {
            $product = Product::factory()->create();
    
            $productService = app(ProductService::class);
    
            // Call the find method
            $foundProduct = $productService->find($product->id);
    
            // Assert that the correct product is returned
            $this->assertEquals($product->id, $foundProduct->id);
        }
    
        /**
         * Test for updating a product.
         */
        public function test_update_product()
        {
            $product = Product::factory()->create();
    
            // Data to update
            $data = [
                'name' => 'Updated Product Name',
                'description' => 'Updated description',
                'price' => 150.00,
            ];
    
            $productService = app(ProductService::class);
    
            // Call the update method
            $updatedProduct = $productService->update($product, $data);
    
            // Assert that the product was updated
            $this->assertDatabaseHas('products', [
                'id' => $product->id,
                'name' => 'Updated Product Name',
                'description' => 'Updated description',
                'price' => 150.00,
            ]);
        }
    
        /**
         * Test for deleting a product.
         */
        public function test_delete_product()
        {
            $product = Product::factory()->create();
    
            $productService = app(ProductService::class);
    
            // Call the delete method
            $productService->delete($product);
    
            // Assert that the product is deleted
            $this->assertModelMissing($product);
        }
    
        public function test_filter_products_by_price()
        {
            // Create two products with different prices
            Product::factory()->create(['name' => 'Product 1', 'price' => 100]);
            Product::factory()->create(['name' => 'Product 2', 'price' => 200]);
    
            $productService = app(ProductService::class);
    
            // Apply filter to get products with price = 100
            $filters = ['price' => 100];
            $products = $productService->getAll($filters);
    
            $this->assertCount(1, $products->items());
            $this->assertEquals('Product 1', $products->items()[0]['name']);
        }
        public function test_search_products_by_name_or_description()
        {
            // Create two products with different names and descriptions
            Product::factory()->create(['name' => 'Product 1', 'description' => 'First product']);
            Product::factory()->create(['name' => 'Product 2', 'description' => 'Second product']);
    
            $productService = app(ProductService::class);
    
            // Search for products with 'first' in the name or description
            $search = 'first';
            $products = $productService->getAll([], $search);
    
            $this->assertCount(1, $products->items());
            $this->assertEquals('Product 1', $products->items()[0]['name']);
        }
    
        public function test_sort_products_by_name()
        {
            // Create two products
            Product::factory()->create(['name' => 'Product 1']);
            Product::factory()->create(['name' => 'Product 2']);
    
            $productService = app(ProductService::class);
    
            // Sort products by name in ascending order
            $sortBy = 'name';
            $sortOrder = 'asc';
            $products = $productService->getAll([], '', $sortBy, $sortOrder);
    
            $this->assertEquals('Product 1', $products->items()[0]['name']);
            $this->assertEquals('Product 2', $products->items()[count($products->items()) - 1]['name']);
        }
    
        public function test_sort_products_in_descending_order_by_name()
        {
            // Create two products
            Product::factory()->create(['name' => 'Product 1']);
            Product::factory()->create(['name' => 'Product 2']);
    
            $productService = app(ProductService::class);
    
            // Sort products by name in descending order
            $sortBy = 'name';
            $sortOrder = 'desc';
            $products = $productService->getAll([], '', $sortBy, $sortOrder);
            $this->assertEquals('Product 2', $products->items()[0]['name']);
            $this->assertEquals('Product 1', $products->items()[count($products->items()) - 1]['name']);
        }
    
        public function test_paginate_products()
        {
            // Create 20 products
            Product::factory()->count(20)->create();
    
            $productService = app(ProductService::class);
    
            // Request products per page
            $perPage = 5;
            $products = $productService->getAll([], '', 'id', 'asc', $perPage);
    
            $this->assertCount(5, $products->items()); // Assert 5 products per page
            $this->assertEquals(20, $products->total()); // Assert total products count is 20
        }
    }
    

Benefits of the CRUD Multi Pattern

  • Code Reusability: Shared logic in the BaseController and BaseService minimizes repetitive code.
  • Consistency: Standardized JSON responses and validation layers ensure a consistent API experience.
  • Testability: Business logic is separated into services, making unit testing easier and more reliable.
  • Maintainability: Adding new models or features becomes straightforward by following this pattern.

By using the CRUD Multi Pattern, your Laravel projects will be cleaner, more modular, and easier to maintain over time.

Recent Posts
Image post 6

What is Action Class ?

Dec 26, 2024
A SRP implemenation for better code
Image post 6

CRUD Multi Pattern

Dec 11, 2024
Pattern to better code quality, testability, and reusability
Image post 6

Client Server Architecture

Mar 11, 2024
Checklist for better code quality of React / Javascript Code.