06: Building REST APIs: From Rails to Laravel

06: Building REST APIs: From Rails to Laravel Intermediate
Section titled “06: Building REST APIs: From Rails to Laravel Intermediate”Overview
Section titled “Overview”If you’ve built REST APIs in Rails, you’ll find Laravel’s API capabilities remarkably similar yet refreshingly elegant. Laravel provides excellent tools for API development, from resource transformations to authentication, all with a Rails-like developer experience.
This chapter explores how to translate your Rails API knowledge to Laravel, covering everything from basic JSON responses to full-featured API authentication with Laravel Sanctum.
Prerequisites
Section titled “Prerequisites”Before starting this chapter, you should have:
- Completion of Chapter 05: Working with Data: Eloquent ORM & Database Workflow or equivalent understanding
- Familiarity with building REST APIs in Rails (routes, controllers, serializers, authentication)
- Basic understanding of HTTP methods (GET, POST, PUT, DELETE) and status codes
- Laravel project set up (or ability to follow along with examples)
- Estimated Time: ~75-90 minutes
Verify your setup:
# Check if you have a Laravel projectphp artisan --version
# Or create a new Laravel project if neededcomposer create-project laravel/laravel my-api-projectWhat You’ll Build
Section titled “What You’ll Build”By the end of this chapter, you will have:
- A complete understanding of Laravel API routing and how it compares to Rails
- Knowledge of Laravel Form Requests for API validation (equivalent to Rails strong parameters)
- Ability to transform API responses using Laravel Resources (equivalent to ActiveModel Serializers)
- A working authentication system using Laravel Sanctum (equivalent to Devise + JWT)
- Understanding of API error handling, rate limiting, and CORS configuration
- Skills to test APIs using Laravel’s testing tools
- A complete blog API example with authentication, authorization, and resource transformations
You’ll be able to build production-ready REST APIs in Laravel using the same patterns you know from Rails.
Quick Start
Section titled “Quick Start”Here’s a complete API endpoint in 5 minutes:
# Create a new Laravel project (if needed)composer create-project laravel/laravel quick-apicd quick-api
# Create a model and migrationphp artisan make:model Post -m
# Add routes<?phpuse App\Http\Controllers\Api\PostController;
Route::apiResource('posts', PostController::class);<?phpnamespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;use App\Models\Post;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;
class PostController extends Controller{ public function index(): JsonResponse { return response()->json(Post::all()); }
public function store(Request $request): JsonResponse { $post = Post::create($request->validate([ 'title' => 'required|string', 'body' => 'required|string', ]));
return response()->json($post, 201); }}# Test itphp artisan servecurl http://localhost:8000/api/postsThat’s it! You have a working API. Now let’s dive deeper.
What You’ll Learn
Section titled “What You’ll Learn”- API routing: Rails routes vs Laravel routes
- JSON responses and error handling
- Resource transformations (Serializers vs Resources)
- API authentication (Devise vs Sanctum)
- API versioning strategies
- Rate limiting and throttling
- Testing APIs
- CORS configuration
- File uploads in APIs
- Consuming external APIs
- Webhook handling
- Real-world API patterns
📦 Code Samples
Section titled “📦 Code Samples”Complete API examples and documentation:
- Blog API Documentation — Full API reference with endpoints, cURL examples, and error handling
Production-ready API with all patterns:
- TaskMaster API — Complete REST API with authentication, authorization, resources, validation, and 16 test cases
Access code samples:
git clone https://github.com/dalehurley/codewithphp.gitcd codewithphp/code/rails-developers-love-laravel/chapter-06API Routing: RESTful Resources
Section titled “API Routing: RESTful Resources”Rails API Routes
Section titled “Rails API Routes”Rails.application.routes.draw do namespace :api do namespace :v1 do resources :posts do resources :comments end resources :users, only: [:index, :show] end endend
# Generates routes like:# GET /api/v1/posts# POST /api/v1/posts# GET /api/v1/posts/:id# PATCH /api/v1/posts/:id# DELETE /api/v1/posts/:idLaravel API Routes
Section titled “Laravel API Routes”<?php// routes/api.php (automatically prefixed with /api)use App\Http\Controllers\Api\V1\PostController;use App\Http\Controllers\Api\V1\CommentController;
Route::prefix('v1')->group(function () { // Resource routes Route::apiResource('posts', PostController::class); Route::apiResource('posts.comments', CommentController::class); Route::apiResource('users', UserController::class) ->only(['index', 'show']);});
// Generates routes like:// GET /api/v1/posts// POST /api/v1/posts// GET /api/v1/posts/{post}// PUT /api/v1/posts/{post}// DELETE /api/v1/posts/{post}Key Differences:
- Laravel’s
routes/api.phpis automatically prefixed with/api apiResourceexcludescreateandeditroutes (no forms needed)- Route model binding is automatic
- Cleaner nested resource syntax
Named API Routes
Section titled “Named API Routes”Rails:
namespace :api do namespace :v1 do get 'posts/trending', to: 'posts#trending', as: :trending_posts post 'posts/:id/publish', to: 'posts#publish', as: :publish_post endend
# Usage in controllerredirect_to api_v1_trending_posts_pathLaravel:
<?phpRoute::prefix('v1')->name('api.v1.')->group(function () { Route::get('posts/trending', [PostController::class, 'trending']) ->name('posts.trending'); Route::post('posts/{post}/publish', [PostController::class, 'publish']) ->name('posts.publish');});
// Usage in controllerreturn redirect()->route('api.v1.posts.trending');Nearly identical concepts with slightly different syntax.
Controllers: API Responses
Section titled “Controllers: API Responses”Rails API Controller
Section titled “Rails API Controller”module Api module V1 class PostsController < ApplicationController before_action :set_post, only: [:show, :update, :destroy]
def index @posts = Post.all render json: @posts end
def show render json: @post end
def create @post = Post.new(post_params)
if @post.save render json: @post, status: :created else render json: { errors: @post.errors }, status: :unprocessable_entity end end
def update if @post.update(post_params) render json: @post else render json: { errors: @post.errors }, status: :unprocessable_entity end end
def destroy @post.destroy head :no_content end
private
def set_post @post = Post.find(params[:id]) end
def post_params params.require(:post).permit(:title, :body, :published) end end endendLaravel API Controller
Section titled “Laravel API Controller”<?phpnamespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;use App\Models\Post;use App\Http\Requests\StorePostRequest;use App\Http\Requests\UpdatePostRequest;use Illuminate\Http\JsonResponse;
class PostController extends Controller{ public function index(): JsonResponse { $posts = Post::all(); return response()->json($posts); }
public function show(Post $post): JsonResponse { // Route model binding automatically loads $post return response()->json($post); }
public function store(StorePostRequest $request): JsonResponse { $post = Post::create($request->validated());
return response()->json($post, 201); }
public function update(UpdatePostRequest $request, Post $post): JsonResponse { $post->update($request->validated());
return response()->json($post); }
public function destroy(Post $post): JsonResponse { $post->delete();
return response()->json(null, 204); }}Key Differences:
- Route Model Binding: Laravel automatically finds the model
- Form Requests: Validation separated into dedicated classes
- Type Hints: Return types make intent explicit
- No
set_postneeded: Route model binding handles it
Request Validation
Section titled “Request Validation”Rails Strong Parameters
Section titled “Rails Strong Parameters”def post_params params.require(:post).permit(:title, :body, :published)end
# With custom validationdef create @post = Post.new(post_params)
if @post.save render json: @post, status: :created else render json: { errors: @post.errors }, status: :unprocessable_entity endendLaravel Form Requests
Section titled “Laravel Form Requests”<?phpnamespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePostRequest extends FormRequest{ public function authorize(): bool { return true; // or check permissions }
public function rules(): array { return [ 'title' => 'required|string|max:255', 'body' => 'required|string', 'published' => 'boolean', 'tags' => 'array', 'tags.*' => 'string|exists:tags,name', ]; }
public function messages(): array { return [ 'title.required' => 'A post title is required', 'body.required' => 'Post body cannot be empty', ]; }}
// Controller automatically validatespublic function store(StorePostRequest $request): JsonResponse{ // If we reach here, validation passed $post = Post::create($request->validated()); return response()->json($post, 201);}Advantages of Laravel Form Requests:
- ✅ Validation logic separated from controller
- ✅ Reusable across controllers
- ✅ Automatic error responses
- ✅ Authorization built-in
- ✅ Custom error messages
- ✅ Type-safe
Validation Error Response
Section titled “Validation Error Response”Rails Error Response:
{ "errors": { "title": ["can't be blank"], "body": ["can't be blank"] }}Laravel Error Response:
{ "message": "The title field is required. (and 1 more error)", "errors": { "title": [ "The title field is required." ], "body": [ "The body field is required." ] }}Both provide structured error responses automatically.
::: tip Pro Tip: Advanced Validation Rules Laravel has powerful built-in validation rules:
<?phppublic function rules(): array{ return [ // Conditional validation 'discount' => 'required_if:promo_code,null',
// Unique except current record (for updates) 'email' => 'required|email|unique:users,email,' . $this->user->id,
// Custom regex 'username' => ['required', 'regex:/^[a-zA-Z0-9_]+$/'],
// File validation 'avatar' => 'required|image|max:2048|dimensions:min_width=100,min_height=100',
// Date validation 'start_date' => 'required|date|after:today', 'end_date' => 'required|date|after:start_date',
// Array validation 'items' => 'required|array|min:1|max:10', 'items.*.quantity' => 'required|integer|min:1', 'items.*.price' => 'required|numeric|min:0', ];}Custom validation messages per field:
<?phppublic function messages(): array{ return [ 'items.*.quantity.required' => 'Each item must have a quantity', 'items.*.quantity.min' => 'Item quantity must be at least 1', ];}:::
::: warning Security: Always Validate, Never Trust Common API security mistakes:
<?php// ❌ DANGEROUS - Trusts all inputpublic function store(Request $request){ User::create($request->all()); // Can set ANY field including admin flags!}
// ✅ SAFE - Explicit validationpublic function store(StoreUserRequest $request){ User::create($request->validated()); // Only validated fields}Mass assignment protection:
<?phpclass User extends Model{ // Whitelist approach (recommended) protected $fillable = ['name', 'email', 'password'];
// Or blacklist approach protected $guarded = ['id', 'is_admin', 'created_at', 'updated_at'];}Always validate:
- File uploads (type, size, dimensions)
- Foreign keys (use
exists:table,column) - Email addresses (use
email:rfc,dnsfor strict validation) - URLs (use
urloractive_url) - Enum values (use
Rule::in()) :::
Resource Transformations
Section titled “Resource Transformations”Rails: ActiveModel Serializers
Section titled “Rails: ActiveModel Serializers”class PostSerializer < ActiveModel::Serializer attributes :id, :title, :body, :published_at, :created_at
belongs_to :user has_many :comments
def published_at object.published_at&.iso8601 endend
# app/serializers/user_serializer.rbclass UserSerializer < ActiveModel::Serializer attributes :id, :name, :emailend
# Controllerrender json: @posts, each_serializer: PostSerializerLaravel: API Resources
Section titled “Laravel: API Resources”<?phpnamespace App\Http\Resources;
use Illuminate\Http\Request;use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource{ public function toArray(Request $request): array { return [ 'id' => $this->id, 'title' => $this->title, 'body' => $this->body, 'published_at' => $this->published_at?->toIso8601String(), 'created_at' => $this->created_at->toIso8601String(),
// Relationships 'user' => new UserResource($this->whenLoaded('user')), 'comments' => CommentResource::collection($this->whenLoaded('comments')),
// Conditional attributes 'draft_notes' => $this->when(!$this->published, $this->draft_notes),
// Computed values 'excerpt' => $this->excerpt(), 'reading_time' => $this->readingTime(), ]; }}
// app/Http/Resources/UserResource.phpclass UserResource extends JsonResource{ public function toArray(Request $request): array { return [ 'id' => $this->id, 'name' => $this->name, 'email' => $this->email, ]; }}
// Controllerreturn PostResource::collection($posts);return new PostResource($post);Laravel Resources Advantages:
- ✅ More powerful conditional logic
- ✅ Nested resources with
whenLoaded - ✅ Pagination support built-in
- ✅ Type-safe with PHPStan
- ✅ Request context available
Resource Collections
Section titled “Resource Collections”Rails:
# Simple collectionrender json: @posts, each_serializer: PostSerializer
# Paginatedrender json: @posts.page(params[:page]), each_serializer: PostSerializerLaravel:
<?php// Simple collectionreturn PostResource::collection($posts);
// Paginated (automatic links and meta)return PostResource::collection( Post::paginate(15));
// Response structure:{ "data": [...], "links": { "first": "http://example.com/api/posts?page=1", "last": "http://example.com/api/posts?page=3", "prev": null, "next": "http://example.com/api/posts?page=2" }, "meta": { "current_page": 1, "from": 1, "to": 15, "total": 50, "per_page": 15, "last_page": 4 }}Laravel’s pagination includes links and metadata automatically.
Conditional Attributes
Section titled “Conditional Attributes”Rails:
class PostSerializer < ActiveModel::Serializer attributes :id, :title, :body, :draft_notes
def draft_notes object.draft_notes unless object.published? end
def attributes(*args) hash = super hash.delete(:draft_notes) if object.published? hash endendLaravel:
<?phpclass PostResource extends JsonResource{ public function toArray(Request $request): array { return [ 'id' => $this->id, 'title' => $this->title, 'body' => $this->body,
// Only include if not published 'draft_notes' => $this->when(!$this->published, $this->draft_notes),
// Only for admins 'internal_notes' => $this->when( $request->user()?->isAdmin(), $this->internal_notes ),
// Merge additional data conditionally $this->mergeWhen($request->user()?->isAdmin(), [ 'created_by' => $this->creator_id, 'ip_address' => $this->created_from_ip, ]), ]; }}Laravel’s when() and mergeWhen() make conditional attributes cleaner.
API Authentication
Section titled “API Authentication”Rails: Devise + JWT
Section titled “Rails: Devise + JWT”# Gemfilegem 'devise'gem 'devise-jwt'
# app/controllers/api/v1/sessions_controller.rbclass Api::V1::SessionsController < Devise::SessionsController respond_to :json
private
def respond_with(resource, _opts = {}) render json: { user: UserSerializer.new(resource), token: request.env['warden-jwt_auth.token'] } endend
# app/controllers/api/v1/base_controller.rbclass Api::V1::BaseController < ApplicationController before_action :authenticate_user!end
# Usageclass Api::V1::PostsController < Api::V1::BaseController def index @posts = current_user.posts render json: @posts endendLaravel: Sanctum (Token-Based)
Section titled “Laravel: Sanctum (Token-Based)”<?php// Install: composer require laravel/sanctum
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;use App\Models\User;use Illuminate\Http\Request;use Illuminate\Support\Facades\Hash;use Illuminate\Validation\ValidationException;
class AuthController extends Controller{ public function login(Request $request) { $request->validate([ 'email' => 'required|email', 'password' => 'required', ]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) { throw ValidationException::withMessages([ 'email' => ['The provided credentials are incorrect.'], ]); }
$token = $user->createToken('api-token')->plainTextToken;
return response()->json([ 'user' => $user, 'token' => $token, ]); }
public function logout(Request $request) { $request->user()->currentAccessToken()->delete();
return response()->json(['message' => 'Logged out successfully']); }
public function user(Request $request) { return response()->json($request->user()); }}
// routes/api.phpRoute::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () { Route::post('/logout', [AuthController::class, 'logout']); Route::get('/user', [AuthController::class, 'user']);
Route::apiResource('posts', PostController::class);});
// Protected controllerclass PostController extends Controller{ public function index(Request $request) { $posts = $request->user()->posts; return PostResource::collection($posts); }}Sanctum Features:
- ✅ Simple token-based authentication
- ✅ Token abilities (scopes)
- ✅ Multi-device support
- ✅ SPA authentication
- ✅ Mobile app support
- ✅ Built into Laravel
Token Abilities (Scopes)
Section titled “Token Abilities (Scopes)”Laravel Sanctum Abilities:
<?php// Create token with specific abilities$token = $user->createToken('mobile-app', [ 'post:read', 'post:create', 'comment:read',])->plainTextToken;
// Check abilities in controllerpublic function store(Request $request){ if (!$request->user()->tokenCan('post:create')) { abort(403, 'Insufficient permissions'); }
// Create post...}
// Or use middlewareRoute::middleware(['auth:sanctum', 'ability:post:create']) ->post('/posts', [PostController::class, 'store']);Multiple Tokens Per User
Section titled “Multiple Tokens Per User”<?php// Create multiple tokens for different devices$webToken = $user->createToken('web-app')->plainTextToken;$mobileToken = $user->createToken('mobile-app')->plainTextToken;$apiToken = $user->createToken('third-party-api')->plainTextToken;
// Revoke specific token$request->user()->currentAccessToken()->delete();
// Revoke all tokens$user->tokens()->delete();
// List user's tokens$tokens = $user->tokens;Rails requires more setup for this functionality.
::: tip Pro Tip: API Authentication Best Practices Token Expiration:
<?php// Set token expiration in config/sanctum.php'expiration' => 60, // minutes (null = never expires)
// Or per-token:$token = $user->createToken('mobile-app', ['*'], now()->addDays(30));Rate Limiting Per User:
<?php// Different limits based on user typeRateLimiter::for('api', function (Request $request) { return $request->user()?->isPremium() ? Limit::perMinute(1000) : Limit::perMinute(60);});Token Naming Convention:
<?php// Use descriptive names to track where tokens are used$user->createToken('iPhone 14 Pro')->plainTextToken;$user->createToken('Web Dashboard')->plainTextToken;$user->createToken('Android App v2.1')->plainTextToken;
// Later, revoke specific device$user->tokens()->where('name', 'iPhone 14 Pro')->delete();:::
::: warning Security: API Authentication Pitfalls Common security mistakes:
<?php// ❌ DON'T: Never send passwords in GET requestsRoute::get('/login', function (Request $request) { // Passwords in URL = logged in server logs!});
// ✅ DO: Always use POST for authenticationRoute::post('/login', [AuthController::class, 'login']);
// ❌ DON'T: Return raw tokens in error messagescatch (Exception $e) { return response()->json(['error' => $e->getMessage()], 500);}
// ✅ DO: Sanitize error messagescatch (Exception $e) { Log::error('Auth error', ['message' => $e->getMessage()]); return response()->json(['error' => 'Authentication failed'], 401);}
// ❌ DON'T: Use the same secret for all environments// .envAPP_KEY=same_key_everywhere
// ✅ DO: Different keys per environment// production .env: APP_KEY=random_production_key// staging .env: APP_KEY=random_staging_keyRate limiting is CRITICAL:
<?php// Prevent brute force attacksRoute::middleware('throttle:5,1')->post('/login', [AuthController::class, 'login']);// Only 5 login attempts per minuteHTTPS in production:
<?php// Force HTTPS in productionpublic function boot(){ if (app()->environment('production')) { URL::forceScheme('https'); }}:::
Error Handling
Section titled “Error Handling”Rails Error Responses
Section titled “Rails Error Responses”class Api::V1::BaseController < ApplicationController rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
private
def not_found(exception) render json: { error: exception.message }, status: :not_found end
def unprocessable_entity(exception) render json: { errors: exception.record.errors }, status: :unprocessable_entity endendLaravel Error Handling
Section titled “Laravel Error Handling”<?phpnamespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;use Illuminate\Database\Eloquent\ModelNotFoundException;use Illuminate\Validation\ValidationException;use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class Handler extends ExceptionHandler{ public function render($request, Throwable $exception) { if ($request->expectsJson()) { // Model not found if ($exception instanceof ModelNotFoundException) { return response()->json([ 'message' => 'Resource not found', ], 404); }
// Validation error if ($exception instanceof ValidationException) { return response()->json([ 'message' => 'Validation failed', 'errors' => $exception->errors(), ], 422); }
// Generic error return response()->json([ 'message' => 'An error occurred', 'error' => config('app.debug') ? $exception->getMessage() : null, ], 500); }
return parent::render($request, $exception); }}Laravel’s exception handler automatically formats errors for JSON requests.
Custom API Exceptions
Section titled “Custom API Exceptions”Laravel:
<?phpnamespace App\Exceptions;
use Exception;use Illuminate\Http\JsonResponse;
class ApiException extends Exception{ public function __construct( public string $message, public int $statusCode = 400, public array $errors = [] ) { parent::__construct($message); }
public function render(): JsonResponse { return response()->json([ 'message' => $this->message, 'errors' => $this->errors, ], $this->statusCode); }}
// Usage in controllerthrow new ApiException('Post cannot be deleted', 403);
throw new ApiException( 'Validation failed', 422, ['title' => ['Title is required']]);Rate Limiting
Section titled “Rate Limiting”Rails Rate Limiting
Section titled “Rails Rate Limiting”# Gemfilegem 'rack-attack'
Rack::Attack.throttle('api/ip', limit: 60, period: 1.minute) do |req| req.ip if req.path.start_with?('/api/')end
Rack::Attack.throttle('api/user', limit: 100, period: 1.hour) do |req| req.env['warden'].user.id if req.env['warden'].userendLaravel Rate Limiting
Section titled “Laravel Rate Limiting”<?phpuse Illuminate\Cache\RateLimiting\Limit;use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());});
// Custom rate limiterRateLimiter::for('uploads', function (Request $request) { return $request->user() ? Limit::perMinute(100)->by($request->user()->id) : Limit::perMinute(10)->by($request->ip());});
// routes/api.phpRoute::middleware(['auth:sanctum', 'throttle:api'])->group(function () { Route::apiResource('posts', PostController::class);});
Route::middleware(['auth:sanctum', 'throttle:uploads']) ->post('/upload', [UploadController::class, 'store']);Rate Limit Response:
{ "message": "Too Many Attempts."}Headers included:
X-RateLimit-Limit: 60X-RateLimit-Remaining: 59Retry-After: 60
API Versioning
Section titled “API Versioning”Rails Versioning
Section titled “Rails Versioning”namespace :api do namespace :v1 do resources :posts end
namespace :v2 do resources :posts endend
# app/controllers/api/v1/posts_controller.rbmodule Api module V1 class PostsController < ApplicationController # V1 implementation end endend
# app/controllers/api/v2/posts_controller.rbmodule Api module V2 class PostsController < ApplicationController # V2 implementation with breaking changes end endendLaravel Versioning
Section titled “Laravel Versioning”<?php// Version 1Route::prefix('v1')->name('api.v1.')->group(function () { Route::apiResource('posts', Api\V1\PostController::class);});
// Version 2Route::prefix('v2')->name('api.v2.')->group(function () { Route::apiResource('posts', Api\V2\PostController::class);});
// Or use route files// routes/api/v1.php// routes/api/v2.php
// app/Http/Controllers/Api/V1/PostController.phpnamespace App\Http\Controllers\Api\V1;
class PostController extends Controller{ // V1 implementation}
// app/Http/Controllers/Api/V2/PostController.phpnamespace App\Http\Controllers\Api\V2;
class PostController extends Controller{ // V2 implementation}Both frameworks handle versioning through namespacing.
CORS Configuration
Section titled “CORS Configuration”Rails CORS
Section titled “Rails CORS”# Gemfilegem 'rack-cors'
Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins 'example.com', 'localhost:3000' resource '/api/*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] endendLaravel CORS
Section titled “Laravel CORS”<?phpreturn [ 'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
'allowed_origins' => ['https://example.com', 'http://localhost:3000'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,];Laravel includes CORS configuration out of the box.
Testing APIs
Section titled “Testing APIs”Rails API Tests
Section titled “Rails API Tests”require 'rails_helper'
RSpec.describe 'Api::V1::Posts', type: :request do let(:user) { create(:user) } let(:headers) { { 'Authorization' => "Bearer #{user.token}" } }
describe 'GET /api/v1/posts' do it 'returns all posts' do create_list(:post, 3)
get '/api/v1/posts', headers: headers
expect(response).to have_http_status(:ok) expect(JSON.parse(response.body).size).to eq(3) end end
describe 'POST /api/v1/posts' do it 'creates a post' do post_params = { post: { title: 'Test', body: 'Content' } }
post '/api/v1/posts', params: post_params, headers: headers
expect(response).to have_http_status(:created) expect(JSON.parse(response.body)['title']).to eq('Test') end
it 'returns validation errors' do post_params = { post: { title: '' } }
post '/api/v1/posts', params: post_params, headers: headers
expect(response).to have_http_status(:unprocessable_entity) expect(JSON.parse(response.body)['errors']).to be_present end endendLaravel API Tests
Section titled “Laravel API Tests”<?phpnamespace Tests\Feature\Api;
use App\Models\User;use App\Models\Post;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase;
class PostTest extends TestCase{ use RefreshDatabase;
public function test_can_list_posts(): void { $user = User::factory()->create(); Post::factory()->count(3)->create();
$response = $this->actingAs($user, 'sanctum') ->getJson('/api/v1/posts');
$response->assertOk() ->assertJsonCount(3, 'data'); }
public function test_can_create_post(): void { $user = User::factory()->create();
$response = $this->actingAs($user, 'sanctum') ->postJson('/api/v1/posts', [ 'title' => 'Test Post', 'body' => 'Test content', ]);
$response->assertCreated() ->assertJson([ 'title' => 'Test Post', ]);
$this->assertDatabaseHas('posts', [ 'title' => 'Test Post', ]); }
public function test_validation_fails_for_invalid_data(): void { $user = User::factory()->create();
$response = $this->actingAs($user, 'sanctum') ->postJson('/api/v1/posts', [ 'title' => '', ]);
$response->assertUnprocessable() ->assertJsonValidationErrors(['title', 'body']); }
public function test_unauthorized_without_token(): void { $response = $this->getJson('/api/v1/posts');
$response->assertUnauthorized(); }}Laravel Testing Advantages:
- ✅ Fluent JSON assertions
- ✅
actingAs()for easy authentication - ✅ Database assertions built-in
- ✅ JSON structure testing
- ✅ Type-safe
Complete API Example
Section titled “Complete API Example”Let’s build a complete blog API with authentication:
Laravel Implementation
Section titled “Laravel Implementation”<?phpRoute::post('/register', [AuthController::class, 'register']);Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->group(function () { Route::get('/user', [AuthController::class, 'user']); Route::post('/logout', [AuthController::class, 'logout']);
Route::apiResource('posts', PostController::class); Route::post('posts/{post}/publish', [PostController::class, 'publish']); Route::apiResource('posts.comments', CommentController::class);});
// app/Http/Controllers/Api/PostController.phpclass PostController extends Controller{ public function index(Request $request) { $posts = Post::with(['user', 'tags']) ->when($request->published, fn($q) => $q->where('published', true)) ->latest() ->paginate(15);
return PostResource::collection($posts); }
public function store(StorePostRequest $request) { $post = $request->user()->posts()->create($request->validated());
return new PostResource($post); }
public function show(Post $post) { $post->load(['user', 'comments.user', 'tags']);
return new PostResource($post); }
public function update(UpdatePostRequest $request, Post $post) { $this->authorize('update', $post);
$post->update($request->validated());
return new PostResource($post); }
public function destroy(Post $post) { $this->authorize('delete', $post);
$post->delete();
return response()->json(null, 204); }
public function publish(Post $post) { $this->authorize('publish', $post);
$post->update(['published' => true, 'published_at' => now()]);
return new PostResource($post); }}
// app/Http/Resources/PostResource.phpclass PostResource extends JsonResource{ public function toArray(Request $request): array { return [ 'id' => $this->id, 'title' => $this->title, 'slug' => $this->slug, 'body' => $this->body, 'excerpt' => $this->excerpt, 'published' => $this->published, 'published_at' => $this->published_at?->toIso8601String(), 'created_at' => $this->created_at->toIso8601String(), 'updated_at' => $this->updated_at->toIso8601String(),
'user' => new UserResource($this->whenLoaded('user')), 'comments' => CommentResource::collection($this->whenLoaded('comments')), 'tags' => TagResource::collection($this->whenLoaded('tags')),
'links' => [ 'self' => route('api.posts.show', $this->id), ], ]; }}
// app/Policies/PostPolicy.phpclass PostPolicy{ public function update(User $user, Post $post): bool { return $user->id === $post->user_id; }
public function delete(User $user, Post $post): bool { return $user->id === $post->user_id; }
public function publish(User $user, Post $post): bool { return $user->id === $post->user_id; }}This complete example shows:
- Authentication with Sanctum
- Resource controllers
- Authorization policies
- Resource transformations
- Pagination
- Relationship loading
- HATEOAS links
Filtering, Searching, and Sorting
Section titled “Filtering, Searching, and Sorting”Rails Filtering
Section titled “Rails Filtering”def index @posts = Post.all @posts = @posts.where(published: true) if params[:published].present? @posts = @posts.where('title LIKE ?', "%#{params[:search]}%") if params[:search].present? @posts = @posts.order(params[:sort] || :created_at)
render json: @postsendLaravel Filtering
Section titled “Laravel Filtering”<?phppublic function index(Request $request): JsonResponse{ $query = Post::query();
// Filter by published status if ($request->has('published')) { $query->where('published', $request->boolean('published')); }
// Search in title and body if ($request->has('search')) { $search = $request->get('search'); $query->where(function ($q) use ($search) { $q->where('title', 'like', "%{$search}%") ->orWhere('body', 'like', "%{$search}%"); }); }
// Sort $sortBy = $request->get('sort_by', 'created_at'); $sortOrder = $request->get('sort_order', 'desc'); $query->orderBy($sortBy, $sortOrder);
// Paginate $posts = $query->paginate($request->get('per_page', 15));
return PostResource::collection($posts);}Advanced Filtering with Scopes
Section titled “Advanced Filtering with Scopes”<?phpclass Post extends Model{ public function scopePublished($query) { return $query->where('published', true); }
public function scopeSearch($query, string $term) { return $query->where(function ($q) use ($term) { $q->where('title', 'like', "%{$term}%") ->orWhere('body', 'like', "%{$term}%"); }); }
public function scopeByTag($query, string $tag) { return $query->whereHas('tags', function ($q) use ($tag) { $q->where('name', $tag); }); }}
// Controllerpublic function index(Request $request): JsonResponse{ $posts = Post::query() ->when($request->published, fn($q) => $q->published()) ->when($request->search, fn($q, $search) => $q->search($search)) ->when($request->tag, fn($q, $tag) => $q->byTag($tag)) ->latest() ->paginate(15);
return PostResource::collection($posts);}Query Parameter Validation
Section titled “Query Parameter Validation”<?phpclass IndexPostRequest extends FormRequest{ public function rules(): array { return [ 'published' => 'sometimes|boolean', 'search' => 'sometimes|string|max:255', 'tag' => 'sometimes|string|exists:tags,name', 'sort_by' => 'sometimes|string|in:created_at,title,updated_at', 'sort_order' => 'sometimes|string|in:asc,desc', 'per_page' => 'sometimes|integer|min:1|max:100', ]; }}
// Controllerpublic function index(IndexPostRequest $request): JsonResponse{ // All query parameters are validated $posts = Post::query() ->when($request->validated('published'), fn($q) => $q->published()) ->when($request->validated('search'), fn($q, $search) => $q->search($search)) ->orderBy($request->validated('sort_by', 'created_at'), $request->validated('sort_order', 'desc')) ->paginate($request->validated('per_page', 15));
return PostResource::collection($posts);}Usage:
# Filter published postsGET /api/posts?published=true
# SearchGET /api/posts?search=laravel
# SortGET /api/posts?sort_by=title&sort_order=asc
# Combine filtersGET /api/posts?published=true&search=api&sort_by=created_at&per_page=20API Response Standardization
Section titled “API Response Standardization”Consistent Response Format
Section titled “Consistent Response Format”Rails:
# Custom response formatdef index @posts = Post.all render json: { success: true, data: @posts, meta: { count: @posts.count } }endLaravel:
<?phppublic function index(): JsonResponse{ $posts = Post::paginate(15);
return response()->json([ 'success' => true, 'data' => PostResource::collection($posts), 'meta' => [ 'current_page' => $posts->currentPage(), 'total' => $posts->total(), ], ]);}Response Trait for Consistency
Section titled “Response Trait for Consistency”<?phpnamespace App\Traits;
use Illuminate\Http\JsonResponse;
trait ApiResponse{ protected function success($data, string $message = null, int $code = 200): JsonResponse { return response()->json([ 'success' => true, 'message' => $message, 'data' => $data, ], $code); }
protected function error(string $message, int $code = 400, $errors = null): JsonResponse { return response()->json([ 'success' => false, 'message' => $message, 'errors' => $errors, ], $code); }}
// Controllerclass PostController extends Controller{ use ApiResponse;
public function index(): JsonResponse { $posts = Post::paginate(15); return $this->success(PostResource::collection($posts)); }
public function store(StorePostRequest $request): JsonResponse { $post = Post::create($request->validated()); return $this->success(new PostResource($post), 'Post created successfully', 201); }
public function destroy(Post $post): JsonResponse { $post->delete(); return $this->success(null, 'Post deleted successfully', 204); }}Response format:
{ "success": true, "message": "Post created successfully", "data": { "id": 1, "title": "My Post", ... }}File Uploads in APIs
Section titled “File Uploads in APIs”Rails File Uploads
Section titled “Rails File Uploads”def create @post = Post.new(post_params) @post.image.attach(params[:post][:image]) if params[:post][:image]
if @post.save render json: @post, status: :created else render json: { errors: @post.errors }, status: :unprocessable_entity endend
private
def post_params params.require(:post).permit(:title, :body, :image)endLaravel File Uploads
Section titled “Laravel File Uploads”<?phpnamespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePostRequest extends FormRequest{ public function rules(): array { return [ 'title' => 'required|string|max:255', 'body' => 'required|string', 'image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', 'attachments' => 'nullable|array|max:5', 'attachments.*' => 'file|mimes:pdf,doc,docx|max:5120', ]; }}
# filename: app/Http/Controllers/Api/V1/PostController.phppublic function store(StorePostRequest $request): JsonResponse{ $post = Post::create($request->only(['title', 'body']));
// Store single image if ($request->hasFile('image')) { $path = $request->file('image')->store('posts', 'public'); $post->update(['image_path' => $path]); }
// Store multiple attachments if ($request->hasFile('attachments')) { foreach ($request->file('attachments') as $file) { $path = $file->store('attachments', 'public'); $post->attachments()->create(['path' => $path]); } }
return response()->json(new PostResource($post), 201);}File Upload with Storage
Section titled “File Upload with Storage”<?phpuse Illuminate\Support\Facades\Storage;
public function store(StorePostRequest $request): JsonResponse{ $post = Post::create($request->only(['title', 'body']));
if ($request->hasFile('image')) { // Store in 'public' disk (accessible via URL) $path = $request->file('image')->store('posts', 'public');
// Or store in S3 // $path = $request->file('image')->store('posts', 's3');
$post->update(['image_path' => $path]); }
return response()->json([ 'post' => new PostResource($post), 'image_url' => $post->image_path ? Storage::url($post->image_path) : null, ], 201);}Resource with File URLs
Section titled “Resource with File URLs”<?phppublic function toArray(Request $request): array{ return [ 'id' => $this->id, 'title' => $this->title, 'body' => $this->body, 'image_url' => $this->image_path ? Storage::url($this->image_path) : null, 'attachments' => $this->attachments->map(function ($attachment) { return [ 'id' => $attachment->id, 'name' => $attachment->name, 'url' => Storage::url($attachment->path), 'size' => Storage::size($attachment->path), ]; }), ];}Testing file uploads:
<?phppublic function test_can_upload_image(): void{ $user = User::factory()->create(); Storage::fake('public');
$file = UploadedFile::fake()->image('post.jpg', 800, 600);
$response = $this->actingAs($user, 'sanctum') ->postJson('/api/v1/posts', [ 'title' => 'Test Post', 'body' => 'Content', 'image' => $file, ]);
$response->assertCreated(); Storage::disk('public')->assertExists('posts/' . $file->hashName());}Consuming External APIs
Section titled “Consuming External APIs”Rails HTTP Client
Section titled “Rails HTTP Client”# Gemfilegem 'faraday'gem 'httparty'
# app/services/weather_service.rbclass WeatherService def self.get_forecast(city) response = HTTParty.get( "https://api.weather.com/v1/forecast", query: { city: city, api_key: ENV['WEATHER_API_KEY'] }, headers: { 'Accept' => 'application/json' } )
JSON.parse(response.body) endendLaravel HTTP Client
Section titled “Laravel HTTP Client”<?phpnamespace App\Services;
use Illuminate\Support\Facades\Http;
class WeatherService{ public function getForecast(string $city): array { $response = Http::withHeaders([ 'Accept' => 'application/json', ])->get('https://api.weather.com/v1/forecast', [ 'city' => $city, 'api_key' => config('services.weather.api_key'), ]);
if ($response->successful()) { return $response->json(); }
throw new \Exception('Weather API request failed'); }}Advanced HTTP Client Usage
Section titled “Advanced HTTP Client Usage”<?phpnamespace App\Services;
use Illuminate\Support\Facades\Http;use Illuminate\Support\Facades\Log;
class PaymentService{ public function processPayment(array $data): array { $response = Http::timeout(30) ->retry(3, 100) // Retry 3 times with 100ms delay ->withToken(config('services.payment.api_key')) ->withHeaders([ 'X-Request-ID' => uniqid(), ]) ->post('https://api.payment.com/charge', $data);
if ($response->successful()) { return $response->json(); }
// Log error Log::error('Payment API failed', [ 'status' => $response->status(), 'body' => $response->body(), ]);
throw new \Exception('Payment processing failed'); }
public function getPaymentStatus(string $paymentId): array { return Http::withToken(config('services.payment.api_key')) ->get("https://api.payment.com/payments/{$paymentId}") ->throw() // Throw exception on HTTP error ->json(); }}HTTP Client Macros
Section titled “HTTP Client Macros”<?phpuse Illuminate\Support\Facades\Http;
public function boot(): void{ // Create reusable HTTP client macro Http::macro('github', function () { return Http::withHeaders([ 'Accept' => 'application/vnd.github.v3+json', 'Authorization' => 'token ' . config('services.github.token'), ])->baseUrl('https://api.github.com'); });}
// Usage$response = Http::github()->get('/user/repos');Handling Webhooks
Section titled “Handling Webhooks”<?phpnamespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;use Illuminate\Support\Facades\Log;
class WebhookController extends Controller{ public function handle(Request $request, string $provider): JsonResponse { // Verify webhook signature if (!$this->verifySignature($request, $provider)) { return response()->json(['error' => 'Invalid signature'], 401); }
// Process webhook based on provider match ($provider) { 'stripe' => $this->handleStripeWebhook($request), 'github' => $this->handleGithubWebhook($request), default => throw new \Exception('Unknown webhook provider'), };
return response()->json(['status' => 'processed'], 200); }
private function handleStripeWebhook(Request $request): void { $event = $request->input('type');
match ($event) { 'payment_intent.succeeded' => $this->processPayment($request->input('data.object')), 'customer.subscription.deleted' => $this->cancelSubscription($request->input('data.object')), default => Log::info("Unhandled Stripe event: {$event}"), }; }
private function verifySignature(Request $request, string $provider): bool { return match ($provider) { 'stripe' => $this->verifyStripeSignature($request), 'github' => $this->verifyGithubSignature($request), default => false, }; }}Routes:
<?phpRoute::post('/webhooks/{provider}', [WebhookController::class, 'handle']) ->middleware('verify.webhook'); // Custom middleware for signature verificationPerformance Optimization
Section titled “Performance Optimization”Eager Loading (N+1 Prevention)
Section titled “Eager Loading (N+1 Prevention)”Rails:
# Bad - N+1 queries@posts = Post.all@posts.each do |post| puts post.user.name # Separate query for each postend
# Good - eager loading@posts = Post.includes(:user, :comments)Laravel:
<?php// Bad - N+1 queries$posts = Post::all();foreach ($posts as $post) { echo $post->user->name; // Separate query for each post}
// Good - eager loading$posts = Post::with(['user', 'comments'])->get();
// Even better - count relationships without loading$posts = Post::withCount('comments')->get();
// Conditional loading$posts = Post::query() ->when($request->include_user, fn($q) => $q->with('user')) ->when($request->include_comments, fn($q) => $q->with('comments')) ->get();API Response Caching
Section titled “API Response Caching”Laravel:
<?phppublic function index(Request $request){ $cacheKey = "posts.index.{$request->page}.{$request->per_page}";
$posts = Cache::remember($cacheKey, now()->addMinutes(10), function () { return Post::with(['user', 'tags']) ->latest() ->paginate(15); });
return PostResource::collection($posts);}Key Takeaways
Section titled “Key Takeaways”- Similar Patterns - API development in Laravel feels like Rails
- Better Type Safety - Form requests and type hints catch errors early
- Sanctum is Elegant - Token authentication is simpler than JWT setup
- Resources > Serializers - More powerful and flexible transformations
- Built-in Features - Rate limiting, CORS, pagination included
- Testing is Clean - Fluent assertions make API tests readable
- Performance Tools - Eager loading and caching built-in
Practice Exercises
Section titled “Practice Exercises”Exercise 1: Build a Task API
Section titled “Exercise 1: Build a Task API”Goal: Create a complete task management API with authentication and resource transformations.
Create a task management API with:
- User authentication using Laravel Sanctum
- CRUD operations for tasks (create, read, update, delete)
- Filter tasks by status (pending/completed)
- Mark tasks as complete with a custom endpoint
- Resource transformations using API Resources
- Form Request validation for all endpoints
- Authorization (users can only manage their own tasks)
Validation: Test your API with:
# Register a usercurl -X POST http://localhost:8000/api/register \ -H "Content-Type: application/json" \ -d '{"name":"Test User","email":"test@example.com","password":"password"}'
# Login and get tokencurl -X POST http://localhost:8000/api/login \ -H "Content-Type: application/json" \ -d '{"email":"test@example.com","password":"password"}'
# Create a task (use token from login)curl -X POST http://localhost:8000/api/tasks \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"title":"Complete API exercise","description":"Build task API"}'
# List taskscurl -X GET http://localhost:8000/api/tasks \ -H "Authorization: Bearer YOUR_TOKEN"
# Mark task as completecurl -X POST http://localhost:8000/api/tasks/1/complete \ -H "Authorization: Bearer YOUR_TOKEN"Expected output should include proper JSON responses with status codes and resource transformations.
Exercise 2: Add Rate Limiting
Section titled “Exercise 2: Add Rate Limiting”Goal: Implement custom rate limiting for different user types and endpoints.
Add custom rate limits to your API:
- 100 requests/hour for authenticated users
- 20 requests/hour for guests (unauthenticated)
- 10 requests/hour for login endpoint (prevent brute force)
- 200 requests/hour for premium users (if you add a user type)
Validation: Test rate limiting:
# Make 21 requests as guest (should fail on 21st)for i in {1..21}; do curl -X GET http://localhost:8000/api/tasksdone
# Check response headers for rate limit infocurl -I http://localhost:8000/api/tasks# Should include: X-RateLimit-Limit, X-RateLimit-RemainingExercise 3: API Versioning
Section titled “Exercise 3: API Versioning”Goal: Create a V2 version of your API with backward compatibility.
Create V2 of your API with:
- Different response structure (e.g., wrap data in
datakey for V2, direct array for V1) - New fields added to resources
- Maintain V1 endpoints for backward compatibility
- Use route versioning:
/api/v1/tasksand/api/v2/tasks
Validation: Both versions should work:
# V1 endpoint (original structure)curl http://localhost:8000/api/v1/tasks
# V2 endpoint (new structure)curl http://localhost:8000/api/v2/tasksWrap-up
Section titled “Wrap-up”You’ve now mastered building REST APIs in Laravel and how they compare to Rails:
- ✓ API Routing - Laravel’s
apiResourceroutes work just like Railsresources, with automatic/apiprefix - ✓ Controllers - Similar structure to Rails, with route model binding eliminating the need for
set_postcallbacks - ✓ Request Validation - Form Requests provide cleaner separation than Rails strong parameters
- ✓ Resource Transformations - Laravel Resources are more powerful than ActiveModel Serializers with better conditional logic
- ✓ API Authentication - Laravel Sanctum is simpler to set up than Devise + JWT, with built-in token management
- ✓ Error Handling - Centralized exception handling with automatic JSON formatting
- ✓ Rate Limiting - Built-in rate limiting with flexible configuration (no gem needed)
- ✓ API Versioning - Clean versioning through route prefixes and namespaces
- ✓ CORS Configuration - Built-in CORS support (no gem needed)
- ✓ Testing - Fluent JSON assertions make API testing more readable than Rails
- ✓ Performance - Eager loading and caching patterns identical to Rails
- ✓ Filtering & Searching - Query scopes and conditional filtering with clean syntax
- ✓ Response Standardization - Traits and helpers for consistent API response formats
- ✓ File Uploads - Handle single and multiple file uploads with validation and storage
- ✓ External API Integration - Laravel’s HTTP client makes consuming APIs simple and elegant
- ✓ Webhooks - Process incoming webhooks with signature verification
The core API development patterns are nearly identical between Rails and Laravel. The main advantages of Laravel are built-in features (Sanctum, rate limiting, CORS) that require gems in Rails, and more powerful resource transformations.
You now have everything you need to build production-ready REST APIs in Laravel!
Further Reading
Section titled “Further Reading”- Laravel API Resources — Official documentation for transforming API responses
- Laravel Sanctum — Complete guide to API authentication with Sanctum
- Laravel Form Requests — Documentation for request validation
- Laravel Rate Limiting — Guide to implementing rate limiting
- Laravel API Testing — Comprehensive guide to testing APIs
- Laravel CORS Configuration — CORS setup for APIs
- REST API Design Best Practices — General REST API design principles
- JSON:API Specification — Standard for building APIs in JSON
- Laravel API Versioning — Patterns for API versioning
- Laravel Query Scopes — Reusable query logic for filtering
- Laravel API Documentation — Documenting your APIs
- Laravel Scribe — Automatic API documentation generator for Laravel
- OpenAPI/Swagger — Industry standard for API documentation
- Laravel HTTP Client — Making HTTP requests to external APIs
- Laravel File Storage — File uploads and storage management
- Laravel File Validation — Validating file uploads
What’s Next?
Section titled “What’s Next?”Now that you can build REST APIs in Laravel, the next chapter covers testing, deployment, and DevOps practices to ship your Laravel applications to production.
::: tip Continue Learning Move on to Chapter 07: Testing, Deployment, DevOps to learn how to test and deploy Laravel applications. :::