

Laravel API Versioning - ดีไซน์ API ให้รองรับอนาคต
การวางแผนการจัดการ API ไม่ใช่เรื่องไกลตัว แต่เป็นเรื่องที่ต้องวางแผนจัดการตั้งแต่แรก เพื่อรองรับการเปลี่ยนแปลงในระยะยาว
หากคุณเป็น developer ที่เขียน Laravel อยู่แล้ว การจะสร้าง API ขึ้นมาใช้งานคงไม่ใช่เรื่องยากและถ้าผู้ใช้งานเป็น developer ในทีมเดียวกันด้วยแล้วก็คงจะไม่เกิดปัญหาอะไร แต่ถ้า API ที่เราเขียน จำเป็นต้องให้บริการแก่ client หลายคนหรือหลายฝ่าย เช่น web frontend หรือ mobile application อาจมีโอกาสที่ client ทั้ง 2 นี้ต้องการข้อมูลที่แตกต่างกัน
หากเราอัปเดต Version ของ API ผิดพลาด ผลก็คืออาจทำให้ client ที่ใช้งานอยู่บางส่วนพังได้ทันทีโดยไม่รู้ตัว และคงจะเป็นเรื่องยากยิ่งกว่าเดิมแน่ ๆ หากระบบเรามีความซับซ้อนมากยิ่งขึ้น โอกาสที่การแก้ไขของเราจะทำให้ client พังก็ยิ่งมีมากขึ้นไปอีก นั่นทำให้การวางแผนเรื่อง API Versioning จึงเป็นเรื่องที่สำคัญและไม่ควรมองข้าม
💡 API Versioning คืออะไร#
API Versioning คือ การกำหนดรุ่น หรือ Version ให้กับ API แต่ละเส้นของเรา เพื่อให้ผู้ใช้งานสามารถแยกการใช้งานและกำหนดคุณลักษณะของ client ที่เรียกใช้แยกออกจากกัน เช่น web frontend หรือ mobile application เป็นต้น เพื่อให้ client แต่ละตัวสามารถทำงานได้ต่อเนื่องแม้ว่าทาง backend จะมีการอัปเดตเวอร์ชันนั่นเอง
📑 รูปแบบของ API Versioning#
การกำหนดเวอร์ชันให้กับ API สามารถทำได้หลายรูปแบบ เช่น
- URI Versioning - เป็นการกำหนดเวอร์ชันของ API ผ่าน path ของ endpoint เช่น
/api/v1/users และ /api/v2/users
ข้อดีคือเห็นเวอร์ชันได้ชัดเจน เรียกใช้ผ่าน browser ได้ทันที และ cache ง่าย ส่วนข้อเสียคือขัดกับหลัก REST ที่มองว่า URI ควรอ้างอิงถึง resource เดียวกันไม่ว่าเวอร์ชันใด เหมาะกับงานทั่วไปที่ต้องการความเรียบง่ายและสื่อสารกับทีมได้ง่าย
- Header Versioning - เป็นการกำหนดเวอร์ชันของ API ผ่าน request header เช่น
X-API-Version: 2 และ Accept: application/vnd.example.v2+json
ข้อดีคือ URI สะอาดและตรงตามหลัก REST มากกว่า ส่วนข้อเสียคือทดสอบผ่าน browser ตรง ๆ ได้ยาก ต้องใช้เครื่องมืออย่าง Postman หรือ curl ช่วย เหมาะกับ API ที่ออกแบบมาเพื่อ client ที่เป็นโปรแกรมโดยเฉพาะ
- Query Parameter - เป็นการกำหนดเวอร์ชันของ API ผ่าน query parameters ของ endpoint เช่น
/api/users?version=1 และ /api/users?version=2
ข้อดีคือเพิ่มเข้าไปได้ง่ายโดยไม่ต้องแก้โครงสร้าง route แต่ข้อเสียคือ version param ปะปนกับ query param อื่น ๆ เช่น filter หรือ pagination ทำให้สับสนได้ โดยทั่วไปจึงแนะนำให้เลี่ยงหากไม่จำเป็นจริง ๆ
จะเห็นว่าแต่ละวิธีที่เลือกใช้ก็จะมีข้อดีและข้อเสียแตกต่างกัน ขึ้นอยู่กับว่าผู้พัฒนาต้องการผลลัพธ์แบบใด
🛠️ การออกแบบ API Versioning บน Laravel#
ในส่วนนี้จะอธิบายวิธีบริหารจัดการ API Versioning ในรูปแบบ URI Versioning ซึ่งเป็นรูปแบบที่พบได้บ่อยที่สุด และตัว Laravel เองก็มีฟีเจอร์สำหรับแยก prefix และ group ของ API อยู่แล้วจึงทำให้สามารถบริหารจัดการได้ง่าย
โดยหัวข้อที่เกี่ยวกับการพัฒนาแบ่งออกเป็นส่วน ๆ ดังนี้
การใช้ Route Group และ Prefix#
ในขั้นแรกของการจัดการ API Versioning เราต้องทำการแยก route ของ API แต่ละเวอร์ชันออกจากกันเสียก่อน
โดยใช้ Route::prefix() คู่กับ ->group() เพื่อจัดกลุ่ม route ของแต่ละเวอร์ชัน แล้วชี้ไปยัง Controller ที่เราจัดเก็บแยกตามโฟลเดอร์/namespace ของ class ตามเวอร์ชันของ API (เช่น App\Http\Controllers\V1 และ App\Http\Controllers\V2) ตัวอย่างเช่น
// routes/api.php
Route::prefix('v1')->group(function () {
Route::get('/users', [App\Http\Controllers\V1\UserController::class, 'index']);
});
Route::prefix('v2')->group(function () {
Route::get('/users', [App\Http\Controllers\V2\UserController::class, 'index']);
});phpจากตัวอย่างจะเห็นว่า เรามี endpoint 2 เส้นคือเส้น v1 และ v2 ซึ่งถูกบริหารจัดการด้วย Controller ที่แตกต่างกัน
แยกไฟล์ route ตามเวอร์ชันเมื่อ route เริ่มเยอะ#
วิธีเขียน route ของทุกเวอร์ชันรวมไว้ใน routes/api.php ไฟล์เดียวจะใช้งานได้ดีตอนที่ route ยังมีไม่มาก แต่เมื่อแต่ละเวอร์ชันเริ่มมี endpoint จำนวนมาก ไฟล์เดียวก็จะยาวจนอ่านยากและดูแลลำบาก
ทางออกที่ scale ได้ดีกว่าคือแยก route ของแต่ละเวอร์ชันออกเป็นไฟล์ของตัวเอง เช่น routes/api_v1.php และ routes/api_v2.php แล้วค่อยใช้ require base_path(...) ดึงเข้ามาภายใน prefix group อีกที
// routes/api.php
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::prefix('v2')->group(base_path('routes/api_v2.php'));php// routes/api_v1.php
use App\Http\Controllers\V1\UserController;
use Illuminate\Support\Facades\Route;
Route::get('/users', [UserController::class, 'index']);php// routes/api_v2.php
use App\Http\Controllers\V2\UserController;
use Illuminate\Support\Facades\Route;
Route::get('/users', [UserController::class, 'index']);phpวิธีนี้ทำให้ route ภายในไฟล์ api_v1.php ทุกเส้นถูกครอบด้วย prefix v1 ให้อัตโนมัติ (กลายเป็น /api/v1/users) โดยที่ภายในไฟล์ไม่ต้องเขียน prefix('v1') ซ้ำอีก
ผลที่ได้คือแต่ละไฟล์โฟกัสเฉพาะ route ของเวอร์ชันตัวเอง เวลามีเวอร์ชันใหม่ก็เพียงเพิ่มไฟล์และเพิ่มอีกหนึ่งบรรทัดใน routes/api.php เท่านั้น
การใช้ Service Layer เพื่อลดความซ้ำซ้อนของโค้ดใน Controller#
หากเราแยก Controller ออกเป็น 2 เวอร์ชันแล้ว อาจมีโค้ดบางส่วนที่เหมือนเดิมซึ่งทำให้ยากต่อการบำรุงรักษาในอนาคต ในจุดนี้เราจะใช้ Service Layer เข้ามาช่วยลดความซ้ำซ้อนของโค้ดบางส่วนลง เช่น
// app/Services/UserService.php
class UserService {
public function getUsers() {
return User::all();
}
}php// app/Http/Controllers/V1/UserController.php
namespace App\Http\Controllers\V1;
use App\Http\Controllers\Controller;
use App\Services\UserService;
class UserController extends Controller {
public function index(UserService $service) {
// V1: ส่งข้อมูล user แบบดิบทั้งก้อน
return response()->json($service->getUsers());
}
}php// app/Http/Controllers/V2/UserController.php
namespace App\Http\Controllers\V2;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Services\UserService;
class UserController extends Controller {
public function index(UserService $service) {
// V2: ห่อ response ด้วย API Resource และเพิ่ม field ใหม่ (breaking change)
return UserResource::collection($service->getUsers());
}
}phpจากตัวอย่างจะเห็นว่า แม้เราจะแยก Controller ออกเป็น 2 ไฟล์แล้วก็ตาม แต่ตรรกะหลักในการดึงข้อมูลถูกย้ายไปไว้ที่ Service Layer ทำให้ทั้ง 2 เวอร์ชันเรียกใช้ UserService ตัวเดียวกันได้โดยไม่ต้องเขียน query ซ้ำ
ส่วนที่แตกต่างกันจริง ๆ จะเหลือเพียงรูปแบบ response เท่านั้น เช่นในตัวอย่างนี้ V1 ส่งข้อมูลแบบดิบ ขณะที่ V2 ห่อด้วย API Resource เพื่อปรับโครงสร้าง JSON ใหม่ ซึ่งถือเป็น breaking change ที่ทำให้ต้องแยกเวอร์ชันนั่นเอง
ทางเลือก Header Versioning ด้วย middleware#
ในหัวข้อ “รูปแบบของ API Versioning” ด้านบนได้พูดถึง Header Versioning ไว้ในเชิงแนวคิด ในส่วนนี้จะลองทำให้เห็นภาพว่าเอามาใช้งานจริงบน Laravel ได้อย่างไร
แนวคิดคือเราจะใช้ middleware อ่านค่าเวอร์ชันจาก request header (เช่น custom header X-API-Version หรือ Accept ตามแบบ REST) แล้วแปลงเป็น prefix v1/v2 ให้ตรงกับ route ที่มีอยู่เดิม ทำให้ client เลือกเวอร์ชันผ่าน header ได้โดยที่ URI ยังสะอาด
// app/Http/Middleware/ResolveApiVersion.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ResolveApiVersion
{
public function handle(Request $request, Closure $next): Response
{
// อ่านเวอร์ชันจาก custom header ถ้าไม่ระบุให้ default เป็น v1
$version = $request->header('X-API-Version', '1');
// map เลขเวอร์ชันไปยัง prefix แล้ว rewrite path ของ request
$prefix = 'v' . $version;
$request->server->set('REQUEST_URI', '/api/' . $prefix . $request->getRequestUri());
return $next($request);
}
}phpจากนั้นจึงนำ middleware ไปลงทะเบียนใน bootstrap/app.php (โครงสร้างของ Laravel เวอร์ชัน 11 เป็นต้นไป) เพื่อให้ทำงานกับ request ที่เข้ามายัง API ทุกเส้น
// bootstrap/app.php
use App\Http\Middleware\ResolveApiVersion;
use Illuminate\Foundation\Configuration\Middleware;
->withMiddleware(function (Middleware $middleware): void {
$middleware->api(prepend: [
ResolveApiVersion::class,
]);
})phpด้วยวิธีนี้ client ที่ส่ง X-API-Version: 2 มาจะถูกส่งต่อไปยัง route ของ v2 ที่เราเขียนไว้แล้วโดยที่ไม่ต้องเปลี่ยน URI เลย
ข้อดีคือเรานำ route group แบบ URI Versioning ที่มีอยู่เดิมกลับมาใช้ซ้ำได้ทั้งหมด เพียงเพิ่ม middleware ชั้นเดียวเข้าไปเป็นด่านแปลง header เท่านั้น
หมายเหตุ: ตัวอย่างนี้เลือกวิธี rewrite path เพื่อให้เข้ากับ route แบบ prefix ที่บทความใช้เป็นหลัก หากไม่อยากแก้
REQUEST_URIตรง ๆ อีกแนวทางที่ทำได้คือให้ middleware นำค่าเวอร์ชันไปผูกกับ Service ที่เหมาะสมผ่าน service container แล้วให้ Controller เดียวเรียกใช้ implementation ตามเวอร์ชันที่ถูกเลือกแทน
กำหนด default version ให้ client ที่ไม่ระบุเวอร์ชัน#
อีกประเด็นที่มักถูกมองข้ามคือ แล้ว client ที่ไม่ได้ส่งเวอร์ชันมาเลยควรได้อะไร จุดนี้ควรตัดสินใจให้ชัดเจนตั้งแต่ออกแบบ เพราะหากปล่อยให้ undefined ก็เสี่ยงที่ client เก่าจะพังเมื่อเราออกเวอร์ชันใหม่
จากตัวอย่าง middleware ด้านบนเราใช้ $request->header('X-API-Version', '1') ซึ่งกำหนด default ไว้ที่ '1' อยู่แล้ว หมายความว่าถ้า client ไม่ส่ง header มาจะถูก route ไปยัง v1 เสมอ
แนวทางที่นิยมคือให้เวอร์ชันแรก (เวอร์ชันที่ stable ที่สุด) เป็น default เพื่อรักษาความเข้ากันได้ย้อนหลัง แล้วบังคับให้ client ที่ต้องการฟีเจอร์ใหม่ระบุเวอร์ชันเองอย่างชัดเจน
✔️ Best Practices และแนวทางที่ควรทำ#
แม้ว่าการแยก API Versioning ออกเป็นหลายเวอร์ชัน จะเป็นสิ่งที่ดีและควรทำ แต่หากไม่บริหารจัดการให้ดีก็อาจทำให้เกิดปัญหาที่คาดไม่ถึงขึ้นมาในภายหลังได้ Best Practices ที่ควรทำควบคู่กันไปมีดังนี้
- เปลี่ยนเวอร์ชัน เมื่อมี breaking changes เท่านั้น
แม้ว่าการเปลี่ยนเวอร์ชันใน Laravel จะสามารถทำได้โดยง่าย แต่หากเราต้องเปลี่ยนเวอร์ชันทุกครั้งที่มีการแก้ไขเล็ก ๆ น้อย ๆ ก็อาจทำให้โค้ดของเราบวมขึ้นมาได้ และจะกลายเป็นว่าเวอร์ชันที่เพิ่มขึ้นมานั้นทำให้ยากต่อการบำรุงรักษาเสียเอง
แนะนำให้เปลี่ยนเวอร์ชันเมื่อโครงสร้างของ API เปลี่ยนจริง ๆ เช่น request parameters หรือ JSON response (หรือการเปลี่ยนแปลงที่ทำให้ client เดิมพัง — breaking change — ตามที่ทีมตกลงร่วมกัน)
- เขียนเอกสารกำกับให้ชัดเจน
กรณีที่ API มีหลายเส้นและหลายเวอร์ชัน อาจทำให้ client หรือผู้ใช้งานเกิดความสับสนถึงโครงสร้างของ request และ response ที่อาจเปลี่ยนแปลงไปได้ เราจึงควรมีเอกสารกลางไว้กำกับคุณสมบัติของ API แต่ละเวอร์ชันเพื่อให้การสื่อสารระหว่างผู้พัฒนา API และผู้ใช้งานมีความชัดเจนมากยิ่งขึ้น
- วางแผน Deprecation
บางครั้ง API เวอร์ชันเก่าที่โครงสร้างไม่รองรับแล้วจำเป็นต้องหยุดให้บริการด้วยเหตุผลต่าง ๆ เช่น ความปลอดภัยหรือประสิทธิภาพของระบบ ในกรณีนี้ผู้พัฒนาควรวางแผน Deprecation และแจ้งผู้ใช้งานก่อนที่จะหยุดให้บริการ เพื่อให้ผู้ใช้งานสามารถปรับปรุง website หรือ application ให้รองรับกับ API เวอร์ชันปัจจุบันได้
นอกจากการแจ้งผ่านเอกสารหรืออีเมลแล้ว เรายังแจ้งเตือนแบบอัตโนมัติผ่าน HTTP header ได้ด้วย เพื่อให้ตัว client รับรู้ได้เองตั้งแต่ระดับโค้ดว่าเวอร์ชันที่เรียกใช้กำลังจะถูกเลิกใช้
มาตรฐานที่ใช้คู่กันคือ header Deprecation (บอกว่า endpoint นี้ deprecated แล้ว) และ Sunset ตาม RFC 8594 ↗ ซึ่งระบุวันเวลาที่ endpoint จะหยุดให้บริการ
โดยค่าของ Sunset ต้องอยู่ในรูปแบบ HTTP-date (IMF-fixdate) เช่น Sat, 31 Dec 2025 23:59:59 GMT เราใส่ header เหล่านี้ให้ทุก response ของเวอร์ชันเก่าได้ด้วย middleware ตัวเดียว
// app/Http/Middleware/AddSunsetHeaders.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AddSunsetHeaders
{
public function handle(Request $request, Closure $next): Response
{
// ปล่อยให้ request วิ่งเข้าไปในแอปก่อน แล้วค่อยเติม header ใส่ response ที่กลับมา
$response = $next($request);
// Deprecation: true แจ้งว่า endpoint นี้ deprecated แล้ว
$response->headers->set('Deprecation', 'true');
// Sunset: วันเวลาที่จะหยุดให้บริการ ในรูปแบบ HTTP-date (RFC 8594)
$response->headers->set('Sunset', 'Sat, 31 Dec 2025 23:59:59 GMT');
return $response;
}
}phpจากนั้นนำไปผูกเฉพาะกับ route group ของเวอร์ชันที่ต้องการ deprecate เช่น v1 เท่านั้น
// routes/api.php
use App\Http\Middleware\AddSunsetHeaders;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->middleware(AddSunsetHeaders::class)->group(function () {
Route::get('/users', [App\Http\Controllers\V1\UserController::class, 'index']);
});phpเพียงเท่านี้ ทุก response ของ v1 จะแนบ header แจ้งเตือนกลับไปให้ client โดยอัตโนมัติ ทำให้ฝั่งที่เขียน client สามารถ log หรือแจ้งเตือนทีมได้ทันทีว่าควรย้ายไปใช้เวอร์ชันใหม่ก่อนถึงวัน Sunset
- วางแผน Test API แต่ละเวอร์ชันแยกกัน
แม้การทำงานของ API แต่ละเวอร์ชันจะคล้ายกัน แต่เมื่อมีการพัฒนาก็ย่อมมีความแตกต่างกันอย่างแน่นอน โดยเฉพาะเมื่อเราแยกเวอร์ชันก็เพราะมี breaking change อยู่แล้ว ทำให้การทดสอบ API แต่ละเวอร์ชันควรทำแยกกัน เพื่อให้มั่นใจได้ว่า API แต่ละเวอร์ชันยังคงสามารถทำงานได้อย่างถูกต้องแม้จะมีการเพิ่มเติมหรือแก้ไขใด ๆ
ตัวอย่างด้านล่างเป็นการเขียน test ด้วย Pest ยิงไปที่ endpoint ของแต่ละเวอร์ชันแยกกัน แล้ว assert ว่าโครงสร้าง response ต่างกันจริงตามที่ออกแบบไว้ (V1 ส่งข้อมูลดิบเป็น array ส่วน V2 ห่อด้วย key data)
// tests/Feature/UserApiVersionTest.php
use function Pest\Laravel\getJson;
test('v1 ส่งข้อมูล user แบบ array ดิบ', function () {
getJson('/api/v1/users')
->assertOk()
->assertJsonStructure([
'*' => ['id', 'name', 'email'],
]);
});
test('v2 ห่อข้อมูลด้วย key data', function () {
getJson('/api/v2/users')
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'email'],
],
]);
});phpหากทีมใช้ PHPUnit แทน Pest ก็เขียนในรูปแบบ method ของ test class ได้เช่นกัน โดยใช้ assertion ชุดเดียวกัน
// tests/Feature/UserApiVersionTest.php
namespace Tests\Feature;
use Tests\TestCase;
class UserApiVersionTest extends TestCase
{
public function test_v1_returns_raw_array(): void
{
$this->getJson('/api/v1/users')
->assertOk()
->assertJsonStructure([
'*' => ['id', 'name', 'email'],
]);
}
public function test_v2_wraps_response_in_data_key(): void
{
$this->getJson('/api/v2/users')
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'email'],
],
]);
}
}phpการแยก test แบบนี้ช่วยให้มั่นใจว่าเมื่อแก้ไข v2 แล้ว โครงสร้าง response ของ v1 ที่ client เก่ายังใช้อยู่จะไม่ถูกกระทบโดยไม่ตั้งใจ
👩💻 บทสรุป#
แม้ว่าเรื่อง API Versioning จะดูเป็นเรื่องเล็กน้อยแต่ก็เป็นสิ่งสำคัญที่จะช่วยให้ระบบสามารถพัฒนาและเปลี่ยนแปลงได้โดยไม่กระทบผู้ใช้เดิม และเมื่อวางแผนอย่างเป็นระบบตั้งแต่ต้น ก็จะช่วยลดปัญหาในอนาคต และสร้างความมั่นใจให้ทั้งทีมพัฒนาและผู้ใช้งาน API อีกด้วย