Introduction
Most API projects start with good intentions but drift into inconsistent response formats, mixed concerns in controllers, and HTTP status code guessing games. A Laravel REST API tutorial: practical tips and trade-offs isn't about following doctrine—it's about building APIs that your team maintains confidently six months from now. We'll walk through the patterns that work at scale: resource controllers to structure routes, Form Requests to validate cleanly, API Resources to shape JSON explicitly, and proper status codes to communicate intent to clients.
Resource Controllers: Structuring Your Routes
When you generate a resource controller with php artisan make:controller ProductController --api, Laravel scaffolds seven RESTful endpoints automatically. This flag matters: without --api, you get create and edit methods that return HTML forms—noise for JSON APIs.
Your controller gets these methods mapped to standard routes:
index()– GET /productsstore()– POST /productsshow()– GET /products/{id}update()– PUT/PATCH /products/{id}destroy()– DELETE /products/{id}
Register it once in routes/api.php:
Route::apiResource('products', ProductController::class);This single line replaces five manual route declarations. The trade-off: you're committed to the REST convention. If your domain demands custom actions—say, bulk export or price recalculation—you can add explicit routes around the resource:
Route::post('products/bulk-export', [ProductController::class, 'bulkExport']);
Route::apiResource('products', ProductController::class);Keep custom actions minimal and specific. If you're adding more than two or three, your domain model might need rethinking.
Validation: Form Requests Keep Rules Out of Controllers
Early-stage API code often validates inline:
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'price' => 'required|numeric|min:0.01',
'sku' => 'required|unique:products',
]);
return Product::create($validated);
}This works, but validation rules scatter across methods. Form Requests centralize this logic and make testing clearer.
php artisan make:request StoreProductRequestThen populate it:
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreProductRequest extends FormRequest
{
public function authorize(): bool
{
return true; // or $this->user()->can('create', Product::class);
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'price' => 'required|numeric|min:0.01',
'sku' => 'required|unique:products',
];
}
}Your controller becomes cleaner:
public function store(StoreProductRequest $request)
{
return Product::create($request->validated());
}The validated data is already guaranteed to pass rules or the request fails with a 422 response before the method runs. For updates, create an UpdateProductRequest with identical rules—or add conditional logic using $this->isMethod('post') if rules differ only by context (e.g., unique constraints on update).
Practical trade-off: Form Requests add a file per action type. In small projects, this feels verbose. In teams, this prevents validation logic from drifting.
API Resources: Shape JSON Output Explicitly
Without explicit shaping, your controller might return a model directly:
public function show(Product $product)
{
return $product;
}Laravel auto-converts it to JSON, but you expose every database column—including timestamps, soft-delete flags, and internal relationships you didn't intend to publish. API Resources give you control.
php artisan make:resource ProductResourceDefine what the client sees:
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProductResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'price' => $this->price,
'sku' => $this->sku,
'category' => $this->category?->name,
'created_at' => $this->created_at->toIso8601String(),
];
}
}Use it in your controller:
public function show(Product $product)
{
return new ProductResource($product);
}
public function index()
{
return ProductResource::collection(Product::paginate(15));
}Now your JSON is intentional. You control what fields appear, their names, transformations (ISO dates instead of Carbon objects), and related data depth. If a client needs a "lite" version with fewer fields, create ProductLiteResource and route accordingly.
Real-world scenario: You publish an API for partner integrations. A field gets marked for deprecation. Instead of removing it immediately, your Resource can conditionally include it for three months while you notify consumers.
Status Codes: Communicate Intent Clearly
HTTP status codes are your API's vocabulary. Using them correctly prevents client-side confusion and reduces debugging time.
- 200 OK – GET, safe updates that need a response body
- 201 Created – POST that created a new resource; include the resource in the response
- 204 No Content – DELETE or updates where there's nothing to return
- 400 Bad Request – Malformed request (invalid JSON, missing headers)
- 422 Unprocessable Entity – Validation failed; include error details
- 401 Unauthorized – Missing or invalid authentication
- 403 Forbidden – Authenticated but not permitted
- 404 Not Found – Resource doesn't exist
- 409 Conflict – Business logic violation (e.g., duplicate unique field)
Here's how your controller methods respond:
public function store(StoreProductRequest $request)
{
$product = Product::create($request->validated());
return new ProductResource($product)
->response()
->setStatusCode(201);
}
public function update(UpdateProductRequest $request, Product $product)
{
$product->update($request->validated());
return new ProductResource($product); // 200 is default
}
public function destroy(Product $product)
{
$product->delete();
return response()->noContent(); // 204
}For validation errors, Laravel handles this automatically via Form Requests—it returns 422 with a structured error payload. Validation exceptions include a errors object mapping field names to error messages.
Trade-off to consider: Some teams prefer 200 for all successful responses, treating the response body as the source of truth. This simplifies client middleware but loses semantic clarity. Stick with proper codes; they help logging, monitoring, and client-side retry logic.
Common Mistakes
Mixing concerns in controllers. A controller that validates, transforms, handles authorization, and queries the database becomes a god object. Lean on Form Requests, Resources, and service classes to split responsibility.
Inconsistent date formats. One endpoint returns "2024-01-15T10:30:00Z", another returns "2024-01-15 10:30:00". Use Resources to enforce ISO 8601 everywhere. Call toIso8601String() on Carbon dates consistently.
Returning errors without structure. A client gets { "message": "Something went wrong" } but can't act on it. Always pair errors with field-level details or error codes the client can switch on.
Pagination without metadata. Returning 15 items is fine, but clients need the total count and page info. Laravel's paginate() includes this by default—use it.
Skipping resource wrapping for nested data. If a product has a category, wrap it in its own Resource to keep transformation logic in one place and enable reuse.
Key Takeaways
- Use resource controllers (
--apiflag) to scaffold REST routes; add custom routes only for genuine domain actions outside the seven standard methods. - Move validation to Form Requests; they centralize rules, enable authorization checks, and keep controllers thin.
- Wrap responses in API Resources to control JSON shape, prevent accidental data leaks, and enable easy field transformations and future versioning.
- Return proper HTTP status codes: 201 for creation, 204 for deletion, 422 for validation errors. These aren't pedantry—they're signals your clients and monitoring systems depend on.
- Keep common date formats (ISO 8601), error structures, and pagination metadata consistent across all endpoints.






Comments
Loading comments…