API Versioning Strategies trong Laravel
API là một hợp đồng. Khi bạn thay đổi nó, clients bị hỏng. Mobile app đã ship trên App Store không thể tự cập nhật code xử lý response mới. API versioning cho phép bạn phát triển API trong khi giữ clients cũ hoạt động bình thường.
Bài viết này so sánh ba versioning strategies phổ biến, đi sâu vào implementation URI-based (phổ biến nhất), và chia sẻ patterns thực tế cho chia sẻ code giữa versions và deprecation.
Ba Strategies
| Strategy | Ví dụ | Ưu điểm | Nhược điểm |
|---|---|---|---|
| URI-based | /api/v1/users |
Đơn giản, dễ thấy, cacheable | URL dài, "ô nhiễm" namespace |
| Header-based | Accept: vnd.api.v2+json |
URL sạch, RESTful thuần | Khó test (curl/browser), khó cache |
| Query param | /api/users?version=2 |
Dễ implement | Lộn xộn, khó cache, ít ai dùng |
Khuyến nghị: URI-based cho hầu hết projects. Đơn giản, dễ hiểu, dễ debug, dễ documentation. Hầu hết các API lớn (Stripe, GitHub, Twilio) đều dùng URI-based.
URI-Based Versioning
Route Structure
// routes/api.php
use App\Http\Controllers\Api\V1;
use App\Http\Controllers\Api\V2;
// V1 — version hiện tại đang active
Route::prefix('v1')->group(function () {
Route::apiResource('users', V1\UserController::class);
Route::apiResource('posts', V1\PostController::class);
Route::get('posts/{post}/comments', [V1\CommentController::class, 'index']);
});
// V2 — version mới với breaking changes
Route::prefix('v2')->group(function () {
Route::apiResource('users', V2\UserController::class);
Route::apiResource('posts', V2\PostController::class);
Route::get('posts/{post}/comments', [V2\CommentController::class, 'index']);
});
Thư Mục Controllers
app/Http/Controllers/Api/
├── V1/
│ ├── UserController.php
│ ├── PostController.php
│ └── CommentController.php
└── V2/
├── UserController.php
├── PostController.php
└── CommentController.php
API Resources Mỗi Version
API Resources là nơi khác biệt chính giữa versions — cùng data, khác format:
// V1: Response đơn giản — format hiện tại mà clients đang dùng
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name, // V1 dùng single "name" field
'email' => $this->email,
'created_at' => $this->created_at->toISOString(),
];
}
}
// V2: Response tách name thành first/last, thêm stats
namespace App\Http\Resources\V2;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'first_name' => $this->first_name, // V2: tách thành 2 fields
'last_name' => $this->last_name,
'email' => $this->email,
'avatar_url' => $this->avatar_url,
'stats' => [
'posts_count' => $this->posts_count,
'comments_count' => $this->comments_count,
],
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
Giải thích: V1 → V2 breaking change: field name bị tách thành first_name + last_name. Clients V1 vẫn nhận name. Clients V2 nhận format mới. Cả hai hoạt động song song.
Chia Sẻ Code Giữa Versions
Đừng duplicate mọi thứ. Business logic phải share — chỉ response format thay đổi. Dùng Actions hoặc shared base:
Strategy 1: Shared Actions
// app/Actions/GetUserList.php — shared giữa versions
class GetUserList
{
public function execute(int $perPage = 15, bool $withStats = false): LengthAwarePaginator
{
$query = User::latest();
if ($withStats) {
$query->withCount(['posts', 'comments']);
}
return $query->paginate($perPage);
}
}
// V1 controller — dùng shared action, wrap với V1 resource
namespace App\Http\Controllers\Api\V1;
class UserController extends Controller
{
public function index(GetUserList $action)
{
return V1\UserResource::collection($action->execute(perPage: 15));
}
}
// V2 controller — cùng action, khác resource
namespace App\Http\Controllers\Api\V2;
class UserController extends Controller
{
public function index(GetUserList $action)
{
return V2\UserResource::collection($action->execute(perPage: 20, withStats: true));
}
}
Strategy 2: Base Controller
namespace App\Http\Controllers\Api;
abstract class BaseUserController extends Controller
{
public function __construct(
protected GetUserList $getUserList,
protected GetUser $getUser,
) {}
// Shared logic
protected function findUserOrFail(int $id): User
{
return User::findOrFail($id);
}
}
// V1 extends base
namespace App\Http\Controllers\Api\V1;
class UserController extends BaseUserController
{
public function index()
{
$users = $this->getUserList->execute();
return V1\UserResource::collection($users);
}
public function show(int $id)
{
return new V1\UserResource($this->findUserOrFail($id));
}
}
Quy tắc: Version API Resources (response format), KHÔNG version Models hay business logic. Action GetUserList luôn chỉ có một bản duy nhất.
Header-Based Versioning
Nếu bạn cần URL sạch:
// app/Http/Middleware/ApiVersion.php
class ApiVersion
{
public function handle(Request $request, Closure $next): mixed
{
$accept = $request->header('Accept', '');
// Parse: "application/vnd.myapp.v2+json"
if (preg_match('/vnd\.myapp\.v(\d+)\+json/', $accept, $matches)) {
$request->attributes->set('api_version', (int) $matches[1]);
} else {
$request->attributes->set('api_version', 1); // Default v1
}
return $next($request);
}
}
// Trong controller — dùng cùng controller, switch resource theo version
class UserController extends Controller
{
public function index(Request $request, GetUserList $getUserList)
{
$version = $request->attributes->get('api_version');
$users = $getUserList->execute();
return match ($version) {
1 => V1\UserResource::collection($users),
2 => V2\UserResource::collection($users),
default => V1\UserResource::collection($users),
};
}
}
Lưu ý: Header-based đơn giản hơn về route structure nhưng khó test. Bạn không thể paste URL vào trình duyệt để test — phải dùng Postman/curl với custom headers.
Deprecation Strategy
Khi muốn loại bỏ version cũ, đặt sunset date:
// app/Http/Middleware/DeprecatedApiVersion.php
class DeprecatedApiVersion
{
public function handle(Request $request, Closure $next, string $sunsetDate): mixed
{
$response = $next($request);
// RFC 8594: Deprecation header
$response->headers->set('Deprecation', 'true');
$response->headers->set('Sunset', $sunsetDate);
$response->headers->set('Link', '<https://docs.myapp.com/migration/v1-to-v2>; rel="deprecation"');
return $response;
}
}
// routes/api.php
Route::prefix('v1')
->middleware('deprecated:2026-12-31')
->group(function () {
Route::apiResource('users', V1\UserController::class);
});
Giải thích headers:
Deprecation: true— cho clients biết version này sắp bị xóaSunset: 2026-12-31— deadline cụ thể khi version bị tắtLink— URL tài liệu migration
Tracking API Version Usage
// app/Http/Middleware/TrackApiVersionUsage.php
class TrackApiVersionUsage
{
public function handle(Request $request, Closure $next, string $version): mixed
{
// Log để biết ai còn dùng version cũ
if ($request->user()) {
Cache::increment("api_usage:v{$version}:{$request->user()->id}");
}
return $next($request);
}
}
Dùng data này để liên hệ clients còn dùng version cũ trước khi tắt.
Khi Nào Cần Version Mới
Cần version mới (Breaking Changes):
- Xóa field khỏi response
- Thay đổi kiểu data của field (string → object)
- Đổi URL structure
- Đổi authentication method
- Thay đổi error response format
- Đổi pagination format
Không cần (Backward Compatible):
- Thêm field mới vào response
- Thêm endpoint mới
- Thêm query params optional
- Thêm optional request body fields
- Fix bugs (behavior đúng hơn)
- Performance improvements
Tip: Thiết kế API additive-first — luôn thêm, ít khi xóa. Điều này giảm nhu cầu tạo version mới.
Testing Versioned APIs
class ApiVersioningTest extends TestCase
{
use RefreshDatabase;
public function test_v1_returns_single_name_field(): void
{
$user = User::factory()->create(['first_name' => 'John', 'last_name' => 'Doe']);
$response = $this->getJson("/api/v1/users/{$user->id}");
$response->assertOk()
->assertJsonStructure(['data' => ['id', 'name', 'email', 'created_at']])
->assertJsonMissing(['first_name', 'last_name']); // V1 không có split name
}
public function test_v2_returns_split_name_fields(): void
{
$user = User::factory()->create(['first_name' => 'John', 'last_name' => 'Doe']);
$response = $this->getJson("/api/v2/users/{$user->id}");
$response->assertOk()
->assertJsonStructure([
'data' => ['id', 'first_name', 'last_name', 'email', 'stats', 'created_at', 'updated_at'],
])
->assertJsonPath('data.first_name', 'John')
->assertJsonPath('data.last_name', 'Doe');
}
public function test_v1_deprecated_endpoint_includes_sunset_header(): void
{
$response = $this->getJson('/api/v1/users');
$response->assertHeader('Deprecation', 'true')
->assertHeader('Sunset');
}
public function test_both_versions_return_same_data_different_format(): void
{
$user = User::factory()->create();
$v1 = $this->getJson("/api/v1/users/{$user->id}")->json('data');
$v2 = $this->getJson("/api/v2/users/{$user->id}")->json('data');
// Cùng user, cùng ID
$this->assertEquals($v1['id'], $v2['id']);
// Khác format
$this->assertArrayHasKey('name', $v1);
$this->assertArrayNotHasKey('name', $v2);
$this->assertArrayHasKey('first_name', $v2);
}
}
Tổng Kết: API Versioning Decision Framework
Bạn cần phá API contract hiện tại?
├── Không → Đừng tạo version mới. Thêm field/endpoint thôi.
└── Có →
Team size nhỏ, clients ít?
├── Có → URI-based versioning (đơn giản nhất)
└── Không →
Cần URL sạch cho public API?
├── Có → Header-based
└── Không → URI-based (vẫn là default tốt nhất)
Kết Luận
Bắt đầu với URI-based — đơn giản và rõ ràng nhất. Chia sẻ logic qua Actions hoặc base classes. Version API Resources, không phải models.
Quy tắc vàng:
- Đừng version cho đến khi cần — mỗi version là maintenance burden
- Giữ tối thiểu versions (2-3 max active) — mỗi version cần test, document, maintain
- Chia sẻ code giữa versions tích cực — duplicate resource format, share business logic
- Đặt sunset dates và enforce chúng — version cũ phải chết
- Track usage — biết ai dùng gì để communicate migration timeline
- Design additive — thêm fields thay vì đổi/xóa, giảm nhu cầu breaking changes