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.
BaseController:
index
, store
, show
, update
, and destroy
).BaseController Interface:
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;
}
Form Requests (Store and Update):
StoreRequest
, UpdateRequest
) to validate incoming data.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.Service Layer:
ProductService
: Handles all business logic for a given model. 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;
}
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';
}
}
Test Cases:
To apply this pattern to other models (e.g., Category
, Order
, etc.), follow these steps:
Create a New Service Class:
ProductService
as a blueprint.Generate Store and Update Requests:
filename: command
php artisan make:request StoreProductRequest
php artisan make:request UpdateProductRequest
Build Resources and Collections:
filename: command
php artisan make:resource ProductResource
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);
}
}
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
}
ProductServiceTest
as a base.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
}
}
BaseController
and BaseService
minimizes repetitive code.By using the CRUD Multi Pattern, your Laravel projects will be cleaner, more modular, and easier to maintain over time.