Dev.to AI 🤖 Ai 👁 0 📖 7 min read

Building a Production-Ready CRUD API in Laravel: Menu Categories for Django Developers

Building a Laravel CRUD API: Menu Categories for Django Developers As a Django developer learning Laravel, one of the first things I wanted to understand was how Laravel handles CRUD APIs. In Django REST Framework, I w

Building a Laravel CRUD API: Menu Categories for Django Developers

As a Django developer learning Laravel, one of the first things I wanted to understand was how Laravel handles CRUD APIs.

In Django REST Framework, I would normally use a model, serializer, ViewSet, and router. Laravel has similar concepts, but they are organized differently.

In this article, I'll build a menu-categories API with:

Create
List
Retrieve
Update
Delete
Validation
Standard JSON responses
Pagination
Route model binding
Protected deletion
Database constraints

The goal is not just to make the endpoint work, but to structure it in a way that is maintainable in a production application.

Django vs Laravel

Coming from Django, this mapping helped me understand Laravel faster:

Django REST Framework Laravel
models.py app/Models/
Django Model Eloquent Model
serializers.py Form Request + API Resource
views.py / ViewSet Controller
urls.py routes/api.php
serializer.is_valid() Form Request validation
serializer.data API Resource
Response() response()->json()
ModelViewSet Controller CRUD methods
DRF Router Route::apiResource()
get_object_or_404() Route Model Binding

  1. Our MenuCategory Model

Suppose we already have the following model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class MenuCategory extends Model
{
protected $fillable = [
'name',
'warehouse_id',
'sort_order',
'kitchen_section_id',
'is_active',
];

protected $casts = [
    'is_active' => 'boolean',
    'sort_order' => 'integer',
];

public function warehouse(): BelongsTo
{
    return $this->belongsTo(Warehouse::class);
}

public function kitchenSection(): BelongsTo
{
    return $this->belongsTo(KitchenSection::class);
}

public function menuItems(): HasMany
{
    return $this->hasMany(MenuItem::class);
}

}

An important business rule here is:

warehouse_id = NULL

means the category can be available to all warehouses.

  1. Create the Laravel Files

Instead of manually creating everything, Laravel provides Artisan commands.

php artisan make:controller Api/MenuCategoryController
php artisan make:request StoreMenuCategoryRequest
php artisan make:request UpdateMenuCategoryRequest
php artisan make:resource MenuCategoryResource

Our structure becomes:

app/
├── Http/
│ ├── Controllers/
│ │ └── Api/
│ │ └── MenuCategoryController.php
│ ├── Requests/
│ │ ├── StoreMenuCategoryRequest.php
│ │ └── UpdateMenuCategoryRequest.php
│ └── Resources/
│ └── MenuCategoryResource.php
└── Models/
└── MenuCategory.php

routes/
└── api.php

  1. Validate Create Requests

Instead of putting validation inside the controller, Laravel allows us to use Form Requests.

StoreMenuCategoryRequest.php:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreMenuCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
    return [
        'name' => [
            'required',
            'string',
            'max:255',
        ],
        'warehouse_id' => [
            'nullable',
            'integer',
            'exists:warehouses,id',
        ],
        'sort_order' => [
            'nullable',
            'integer',
            'min:0',
        ],
        'kitchen_section_id' => [
            'nullable',
            'integer',
            'exists:kitchen_sections,id',
        ],
        'is_active' => [
            'sometimes',
            'boolean',
        ],
    ];
}

}

This is similar to validation in a Django REST Framework serializer.

  1. Validate Update Requests

UpdateMenuCategoryRequest.php:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UpdateMenuCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
    return [
        'name' => [
            'sometimes',
            'string',
            'max:255',
        ],
        'warehouse_id' => [
            'sometimes',
            'nullable',
            'integer',
            'exists:warehouses,id',
        ],
        'sort_order' => [
            'sometimes',
            'integer',
            'min:0',
        ],
        'kitchen_section_id' => [
            'sometimes',
            'nullable',
            'integer',
            'exists:kitchen_sections,id',
        ],
        'is_active' => [
            'sometimes',
            'boolean',
        ],
    ];
}

}

Using sometimes allows partial updates.

For example:

PATCH /api/v1/menu-categories/1

can update only:

{
"is_active": false
}

If your API treats PUT as a true full replacement, use stricter required validation for PUT and reserve partial updates for PATCH.

  1. Create an API Resource

An API Resource controls what we expose to the client.

MenuCategoryResource.php:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class MenuCategoryResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'warehouse_id' => $this->warehouse_id,
'sort_order' => $this->sort_order,
'kitchen_section_id' => $this->kitchen_section_id,
'is_active' => $this->is_active,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

This is similar to a DRF serializer response.

  1. Create the Controller

Now we can implement the CRUD operations.

MenuCategoryController.php:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\StoreMenuCategoryRequest;
use App\Http\Requests\UpdateMenuCategoryRequest;
use App\Http\Resources\MenuCategoryResource;
use App\Models\MenuCategory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class MenuCategoryController extends Controller
{
public function index(): AnonymousResourceCollection
{
$categories = MenuCategory::query()
->orderBy('sort_order')
->orderBy('name')
->paginate(20);

    return MenuCategoryResource::collection($categories);
}

public function store(
    StoreMenuCategoryRequest $request
): JsonResponse {
    $category = MenuCategory::create(
        $request->validated()
    );

    return response()->json([
        'success' => true,
        'message' => 'Menu category created successfully.',
        'data' => new MenuCategoryResource($category),
    ], 201);
}

public function show(
    MenuCategory $menuCategory
): JsonResponse {
    return response()->json([
        'success' => true,
        'message' => 'Menu category retrieved successfully.',
        'data' => new MenuCategoryResource($menuCategory),
    ]);
}

public function update(
    UpdateMenuCategoryRequest $request,
    MenuCategory $menuCategory
): JsonResponse {
    $menuCategory->update(
        $request->validated()
    );

    return response()->json([
        'success' => true,
        'message' => 'Menu category updated successfully.',
        'data' => new MenuCategoryResource($menuCategory),
    ]);
}

public function destroy(
    MenuCategory $menuCategory
): JsonResponse {
    if ($menuCategory->menuItems()->exists()) {
        return response()->json([
            'success' => false,
            'message' => 'Menu category cannot be deleted because it has menu items.',
            'data' => null,
        ], 409);
    }

    $menuCategory->delete();

    return response()->json([
        'success' => true,
        'message' => 'Menu category deleted successfully.',
        'data' => null,
    ]);
}

}

  1. Why Protect the Delete Operation?

Imagine a category has menu items:

Burgers
├── Beef Burger
├── Chicken Burger
└── Cheese Burger

Deleting Burgers without considering its menu items could leave the database in an invalid state.

Therefore, the API checks:

if ($menuCategory->menuItems()->exists())

and returns:

409 Conflict

with:

{
"success": false,
"message": "Menu category cannot be deleted because it has menu items.",
"data": null
}

The database should also enforce the relationship with an appropriate foreign-key delete rule such as restrictOnDelete() where the business rules require it.

The application check gives the client a useful error message; the database constraint provides the final integrity protection.

  1. Add the API Routes

In routes/api.php:

<?php

use App\Http\Controllers\Api\MenuCategoryController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')->group(function () {
    Route::apiResource(
        'menu-categories',
        MenuCategoryController::class
    );
});


Laravel automatically creates the CRUD routes.

Method Endpoint Purpose
GET /api/v1/menu-categories List categories
POST /api/v1/menu-categories Create category
GET /api/v1/menu-categories/{id} Get category
PUT/PATCH /api/v1/menu-categories/{id} Update category
DELETE /api/v1/menu-categories/{id} Delete category

You can verify them with:

php artisan route:list

  1. Route Model Binding

Notice that the controller doesn't manually search for the category:

public function show(MenuCategory $menuCategory)

Laravel automatically resolves the model from:

/api/v1/menu-categories/10

This is similar to using:

get_object_or_404(MenuCategory, id=10)

in Django.

If the record does not exist, Laravel automatically returns a 404.

  1. Standard API Responses

For successful operations, we can use:

{
"success": true,
"message": "Menu category created successfully.",
"data": {}
}

For errors:

{
"success": false,
"message": "Menu category cannot be deleted because it has menu items.",
"data": null
}

For validation errors, Laravel's default response can be customized at the application level so the entire API consistently follows the same structure.

For example:

{
"success": false,
"message": "Validation failed.",
"data": null,
"errors": {}
}

Centralizing this error handling is preferable to manually formatting every validation error in every controller.

  1. Database-Level Uniqueness

Suppose category names should be unique within a warehouse.

For example:

Warehouse A
Burgers

Warehouse B
Burgers

This should be allowed, but two Burgers categories in the same warehouse may not be.

The database can enforce this with:

$table->unique(
['warehouse_id', 'name'],
'menu_categories_warehouse_name_unique'
);

This is important because application validation alone is not enough to guarantee uniqueness under concurrent requests.

Also remember that warehouse_id = NULL has special behavior with SQL unique constraints. If global categories must also have unique names, that rule may require additional database logic depending on the database engine.

  1. HTTP Status Codes

Using the correct HTTP status code makes the API easier for clients to understand.

Status Meaning
200 Successful GET/update
201 Resource created
204 Successful deletion with no response body
400 Bad request
404 Resource not found
409 Resource conflict
422 Validation failed
500 Unexpected server error

For example, creating a category should return:

201 Created

rather than simply returning 200 OK.

  1. The Laravel API Flow

Coming from Django, this is how I now visualize the Laravel request flow:

HTTP Request
↓
Route
↓
Controller
↓
Form Request
↓
Validation
↓
Eloquent Model
↓
Database
↓
API Resource
↓
JSON Response

The equivalent Django REST Framework flow would look roughly like:

HTTP Request
↓
URL / Router
↓
ViewSet
↓
Serializer
↓
Validation
↓
Django Model
↓
Database
↓
Serializer Response

The concepts are very similar even though Laravel organizes them differently.

Testing the API

Create a category:

POST /api/v1/menu-categories
Content-Type: application/json
{
"name": "Burgers",
"warehouse_id": 1,
"sort_order": 1,
"kitchen_section_id": 2,
"is_active": true
}

Update it:

PATCH /api/v1/menu-categories/1
{
"is_active": false
}

Retrieve it:

GET /api/v1/menu-categories/1

List categories:

GET /api/v1/menu-categories

Delete it:

DELETE /api/v1/menu-categories/1
Final Thoughts

Coming from Django, Laravel initially felt unfamiliar because the project structure and terminology are different.

However, once I mapped the concepts, the architecture became much easier to understand:

Django Model → Laravel Eloquent Model
DRF Serializer → Form Request + API Resource
DRF ViewSet → Laravel Controller
DRF Router → Laravel apiResource
get_object_or_404() → Route Model Binding

The main lesson for me is that building a CRUD endpoint is not just about making POST, GET, PUT, and DELETE work.

A production-ready API should also consider:

Validation
HTTP status codes
Consistent responses
Pagination
Database constraints
Referential integrity
Protected deletion
Clear separation of responsibilities

This menu-categories endpoint is a small example, but the same architecture can be applied to larger Laravel applications.

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.