Devfavor

Back

Laravel API Versioning - ดีไซน์ API ให้รองรับอนาคตBlur image

หากคุณเป็น 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 อีกด้วย

Code, coffee, and calm — the holy trinity of a good day. ☕

Laravel API Versioning - ดีไซน์ API ให้รองรับอนาคต
Author Coffee Stack
Published at 25 สิงหาคม 2025