Devfavor

Back

Laravel API Resource Patterns - ยกระดับการจัดการ Response ให้ทรงพลังBlur image

หากเราต้องการเขียน API ด้วย Laravel ตัว framework ของ Laravel เองก็มีเครื่องมืออำนวยความสะดวกให้เราใช้อยู่แล้วนั่นก็คือ API Resource ซึ่งทำให้เราสามารถจัดการกับ API response ได้อย่างง่ายดาย

แต่เมื่อระบบของเราใหญ่ขึ้น มีความซับซ้อนขึ้น รวมถึงความสัมพันธ์ระหว่าง Model เองก็ตาม การใช้ Laravel API Resource โดยปกติอาจจะไม่เพียงพอต่อความต้องการ จนทำให้โค้ดเกิดความซับซ้อนขึ้นได้

🧱 เริ่มจากพื้นฐาน สร้าง JsonResource ตัวแรก#

ก่อนจะเข้าสู่ pattern ขั้นสูง เรามาทบทวนพื้นฐานของ API Resource กันสักนิด เพราะหลาย pattern ในบทความนี้ต่อยอดมาจากตรงนี้ทั้งสิ้น

เริ่มจากสร้าง resource ด้วย Artisan command

terminal
php artisan make:resource UserResource
bash

Laravel จะสร้างไฟล์ไว้ที่ app/Http/Resources/UserResource.php โดย resource จะ extend จาก JsonResource และมี method หลักเพียงตัวเดียวคือ toArray ซึ่งทำหน้าที่แปลง model ให้กลายเป็น array ที่พร้อมส่งออกเป็น JSON

app/Http/Resources/UserResource.php
<?php

namespace App\Http\Resources;

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,
            'email' => $this->email,
            'created_at' => $this->created_at,
        ];
    }
}
php

จุดที่สะดวกคือภายใน resource เราเข้าถึง property ของ model ผ่าน $this ได้ตรง ๆ (เช่น $this->id) เพราะ resource จะ proxy การเข้าถึง property ไปยัง model ที่ห่อไว้ให้อัตโนมัติ

เมื่อมี resource แล้ว ก็เรียกใช้จาก controller หรือ route ได้เลย

routes/api.php
use App\Http\Resources\UserResource;
use App\Models\User;

Route::get('/users/{id}', function (string $id) {
    return new UserResource(User::findOrFail($id));
});
php

หากต้องการส่งกลับเป็น collection ก็ใช้ method collection แทน

routes/api.php
Route::get('/users', function () {
    return UserResource::collection(User::all());
});
php

เท่านี้เราก็ได้ API response ที่มี transformation layer คั่นระหว่าง model กับ JSON เรียบร้อย pattern ทั้งหมดในบทความนี้ต่อยอดมาจากจุดนี้

🔀 Conditional Attributes ใส่ค่าตามเงื่อนไขแบบ native#

ก่อนจะกระโดดไปเขียน logic เพิ่มเองทั้งหมด มีเครื่องมือ native ของ Laravel ที่ช่วยจัดการ “ใส่หรือไม่ใส่ field ตามเงื่อนไข” ให้อยู่แล้ว ซึ่งช่วยให้โค้ดสะอาดกว่าการเขียน if ต่อ array เอง

when() ใช้เพิ่ม attribute เข้า response เฉพาะเมื่อเงื่อนไขเป็นจริง ถ้าเงื่อนไขเป็นเท็จ Laravel จะตัด key นั้นออกจาก response ให้ก่อนส่งกลับ

app/Http/Resources/UserResource.php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'secret' => $this->when($request->user()->isAdmin(), 'secret-value'),
        'created_at' => $this->created_at,
    ];
}
php

argument ที่สองของ when() รับ closure ได้ด้วย ทำให้ค่าจะถูกคำนวณก็ต่อเมื่อเงื่อนไขเป็นจริงเท่านั้น (ช่วยเลี่ยงการคำนวณที่ไม่จำเป็น)

'secret' => $this->when($request->user()->isAdmin(), function () {
    return 'secret-value';
}),
php

ถ้ามีหลาย attribute ที่ใช้เงื่อนไขเดียวกัน ใช้ mergeWhen() รวมเข้าไปทีเดียวได้

app/Http/Resources/UserResource.php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        $this->mergeWhen($request->user()->isAdmin(), [
            'email' => $this->email,
            'last_login' => $this->last_login,
        ]),
        'created_at' => $this->created_at,
    ];
}
php

หมายเหตุ: mergeWhen() ไม่ควรใช้ใน array ที่ผสม string key กับ numeric key หรือใน array ที่มี numeric key เรียงไม่ต่อเนื่อง เพราะจะทำให้ลำดับ key เพี้ยน

นอกจากนี้ยังมี whenHas() (ใส่ค่าเมื่อ attribute มีอยู่จริงบน model) และ whenNotNull() (ใส่ค่าเมื่อไม่เป็น null) ให้เลือกใช้ตามสถานการณ์อีกด้วย

เก็บ pattern เหล่านี้ไว้ในใจ เพราะในหัวข้อ Contextual Resource ที่จะถึงต่อไป เราจะเขียน if เองเพื่อปรับ response ตาม context ซึ่งหลายกรณีสามารถแทนด้วย when() / mergeWhen() แบบ native ได้เลยโดยไม่ต้องเขียนเงื่อนไขเอง

🎨 Presenter / Transformer ตัวช่วยแปลงค่า แยก logic ให้ชัดเจน#

บางครั้งใน API Resource ที่เขียนอาจไม่ได้มีแค่การส่งค่าเพียงอย่างเดียว แต่ยังมี business logic แฝงอยู่ใน response ที่ส่งค่ากลับไปด้วย หากเรานำ business logic เหล่านี้เขียนไว้ใน Resource ตรง ๆ ก็อาจจะส่งผลเสียให้ business logic เหล่านี้ไม่สามารถ reuse ได้ และอาจเกิด duplicate code ในกรณีที่มีการส่งค่าลักษณะคล้ายกันซ้ำ ๆ

ในกรณีนี้เราจะใช้ Layer ของ Presenter / Transformer มาช่วยในการแปลงค่าบางอย่างของ model หรือ collection ก่อนที่จะส่งค่ากลับ เช่น

app/Presenters/UserPresenter.php
class UserPresenter {
    public function transform(User $user): array {
        return [
            'id' => $user->id,
            'full_name' => "{$user->first_name} {$user->last_name}",
            'status' => $user->is_active ? 'active' : 'inactive',
        ];
    }
}

class UserResource extends JsonResource {
    public function toArray(Request $request): array {
        return (new UserPresenter())->transform($this->resource);
    }
}
php

จากตัวอย่างจะเห็นว่าเราสามารถแยก business logic บางอย่างออกมาจาก Resource ได้ และยังสามารถนำ logic เหล่านี้ไป reuse ใช้กับส่วนอื่น ๆ ในโปรเจกต์ได้อีกด้วย

🧩 Contextual Resource เปลี่ยน Resource ไปตาม context#

บางครั้งเราอาจต้องการ API ที่มีลักษณะคล้ายกันหรือดึงข้อมูลแบบเดียวกัน แต่อาจมีค่าบางอย่างเปลี่ยนไปตาม context ของระบบที่เรียกใช้ เช่น permission ของ user หรือ module ที่เรียกใช้งาน เป็นต้น การแสดงผลเหล่านี้แม้จะมีความใกล้เคียงกันแต่ก็อาจมีบางส่วนแตกต่างกันตาม context เราจึงสามารถประยุกต์ใช้ API Resource ของเราได้ ดังนี้

app/Http/Resources/UserResource.php
use Illuminate\Http\Request;

class UserResource extends JsonResource {
    protected $context;

    public function __construct($resource, $context = []) {
        parent::__construct($resource);
        $this->context = $context;
    }

    public function toArray(Request $request): array {
        $data = [
            'id' => $this->id,
            'name' => $this->name,
        ];

        if (($this->context['role'] ?? null) === 'admin') {
            $data['email'] = $this->email;
            $data['last_login'] = $this->last_login;
        }

        return $data;
    }
}
php

จะเห็นว่าเมื่อ context เปลี่ยน ค่าบางอย่างของ API Resource จะเปลี่ยนไป แต่ค่าหลัก ๆ ยังคงอยู่เหมือนเดิม ทำให้ผลลัพธ์มีความยืดหยุ่นมากยิ่งขึ้น

หมายเหตุ: เทคนิครับ context ผ่าน constructor แบบนี้เหมาะกับ single resource เป็นหลัก ไม่สามารถใช้กับ UserResource::collection() ได้ตรง ๆ เพราะ Laravel จะส่ง key ของแต่ละ item เป็น argument ที่สองของ constructor ให้แทน context ที่เราต้องการ

แต่ก่อนจะลงทุนเขียน if และ constructor เองแบบนี้ ลองถามตัวเองก่อนว่าเงื่อนไขที่ใช้นั้นอ้างอิงจาก request หรือ user ปัจจุบันหรือไม่ ถ้าใช่ หลายกรณีสามารถแทนด้วย when() / mergeWhen() แบบ native ที่กล่าวไปข้างต้นได้เลย โดยไม่ต้องรับ context เข้ามาเองและไม่ติดข้อจำกัดเรื่อง collection()

app/Http/Resources/UserResource.php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        // ใช้ native แทน if เมื่อเงื่อนไขอิงจาก user ปัจจุบัน
        $this->mergeWhen($request->user()?->isAdmin() ?? false, [
            'email' => $this->email,
            'last_login' => $this->last_login,
        ]),
    ];
}
php

กล่าวคือ pattern Contextual Resource ผ่าน constructor จะเหมาะเป็นพิเศษเมื่อ context นั้น ไม่ได้ มาจาก request โดยตรง (เช่น module ภายในที่เรียกใช้ หรือค่าที่คำนวณจาก service อื่น) แต่ถ้าเงื่อนไขผูกกับ request/user อยู่แล้ว การใช้ native จะสั้นและดูแลง่ายกว่ามาก

📦 Response Envelope Pattern หุ้มทุก Response ด้วยรูปแบบเดียวกัน#

กรณีที่ระบบที่พัฒนามีนักพัฒนาหลายคน และเป็นระบบใหญ่ การที่ทุก API Resource จะมีมาตรฐานเดียวกันเป็นสิ่งที่ดี แต่หากทุกคนต้องมาคอยเขียน response structure ให้เหมือนกัน แม้จะมีเอกสารกลางคอยควบคุมแต่ก็คงเป็นเรื่องยุ่งยากอยู่ดี การมีตัวช่วยห่อหุ้ม response ไว้จะช่วยให้การจัดการง่ายขึ้นอย่างมาก

สมมุติว่าในโปรเจกต์ของเราต้องการ response structure ที่มีโครงสร้างแบบนี้

{
  "status": "success",
  "data": {...},
  "meta": {
    "request_id": "xxx",
    "timestamp": 123456
  }
}
json

สิ่งที่เราต้องการคือ BaseResource ที่ช่วยห่อหุ้มโครงสร้างดังกล่าวและส่งข้อมูลเพียงแค่ data ที่ต้องการ ดังนี้

app/Http/Resources/BaseResource.php
class BaseResource {
    public static function success($data, $meta = []) {
        return response()->json([
            'status' => 'success',
            'data' => $data,
            'meta' => array_merge($meta, [
                'request_id' => Str::uuid(),
                'timestamp' => now()->timestamp,
            ]),
        ]);
    }
}
php

จากนั้นเมื่อเรียกใช้งาน BaseResource ที่ตั้งค่าไว้ก็จะทำให้ response ของเรามีโครงสร้างพื้นฐานตามที่กำหนด

หมายเหตุ: ถ้าต้องการแค่ใส่ meta data ระดับ top-level Laravel เองก็มี method with() (ใส่ใน Resource class) และ additional() (เรียกตอนสร้าง resource) ให้ใช้แบบ native อยู่แล้ว เป็นอีกทางเลือกหนึ่งก่อนจะเขียน envelope เองทั้งหมด

⚠️ ระวัง Data Wrapping ของ Laravel ซ้อนกับ Envelope ที่เขียนเอง#

ก่อนจะตัดสินใจเขียน envelope เองทั้งหมด มีพฤติกรรม native ที่ต้องเข้าใจก่อน นั่นคือ Laravel จะ ห่อ resource ชั้นนอกสุดด้วย key data ให้อัตโนมัติ อยู่แล้ว ตัวอย่างเช่นเมื่อ return UserResource::collection(...) จะได้ผลลัพธ์หน้าตาแบบนี้

{
  "data": [
    { "id": 1, "name": "Eladio Schroeder Sr.", "email": "[email protected]" },
    { "id": 2, "name": "Liliana Mayert", "email": "[email protected]" }
  ]
}
json

หากเราไม่ต้องการให้ห่อด้วย data ชั้นนอกสุด สามารถปิดได้ด้วยการเรียก withoutWrapping() ที่ boot ของ service provider (เช่น AppServiceProvider)

app/Providers/AppServiceProvider.php
use Illuminate\Http\Resources\Json\JsonResource;

public function boot(): void
{
    JsonResource::withoutWrapping();
}
php

จุดที่ต้องระวังคือ ถ้าเรานำ data ที่ห่อมาแล้วของ resource ไปใส่ใน envelope ที่เขียนเอง (เช่นส่ง UserResource::collection(...) เป็นค่า data เข้า BaseResource::success()) ก็มีโอกาสได้ double-wrap คือ data ซ้อน data แบบนี้

{
  "status": "success",
  "data": {
    "data": [ ... ]
  }
}
json

ทางแก้คือเลือกอย่างใดอย่างหนึ่งให้ชัด ระหว่าง (1) ปล่อยให้ Laravel จัดการ wrapping แล้วใช้ with() / additional() เติม status กับ meta ระดับ top-level แทน หรือ (2) ถ้ายืนยันจะเขียน envelope เอง ก็ควรเรียก withoutWrapping() เพื่อปิด data ชั้นนอกของ Laravel ไม่ให้ไปซ้อนกับโครงสร้างของเรา

ข้อควรรู้: withoutWrapping() มีผลกับ resource ชั้นนอกสุดเท่านั้น และไม่ลบ key data ที่เราใส่เองใน collection อีกทั้ง Laravel จะไม่ปล่อยให้ nested resource โดน double-wrap เองโดยอุบัติเหตุ และในกรณี paginated response Laravel จะยังห่อด้วย data ให้เสมอแม้เรียก withoutWrapping() ไปแล้วก็ตาม เพราะต้องมี key meta และ links กำกับสถานะของ paginator

📝 Polymorphic Resource เมื่อ Resource เปลี่ยนตาม Model#

แม้ Resource ของ Laravel โดยปกติจะแยกตาม Model อยู่แล้ว แต่เราก็สามารถออกแบบโครงสร้างของ Resource ใหม่ให้สามารถใช้ Resource ร่วมกัน แต่แบ่งหน้าที่การจัดการ format ของ Model ไปที่ Presenter ได้ เช่น

app/Http/Resources/ContentResource.php
class ContentResource extends JsonResource {
    public function toArray(Request $request): array {
        return match (get_class($this->resource)) {
            Post::class => (new PostPresenter())->transform($this->resource),
            Video::class => (new VideoPresenter())->transform($this->resource),
            Podcast::class => (new PodcastPresenter())->transform($this->resource),
            default => throw new \InvalidArgumentException('Unsupported content type'),
        };
    }
}
php

หมายเหตุ: ควรใส่ default ใน match เสมอ เพื่อกัน UnhandledMatchError เมื่อเจอ type ที่ไม่ได้รองรับ และเทคนิคนี้เป็นการ resolve presenter ตาม type ของ object ใน PHP เท่านั้น ไม่เกี่ยวกับ database polymorphism (morphTo) ของ Eloquent

จากตัวอย่างจะเห็นว่า ทั้ง Post, Video และ Podcast เป็น Resource ที่อยู่ในกลุ่มเดียวกัน และมักจะมี logic ในการ response ที่คล้ายกัน แต่ก็อาจจะมีบาง attributes ที่ต้องการรายละเอียดต่างกัน ในจุดนี้เราสามารถประยุกต์ใช้ Presenter เข้ามาช่วยได้ขึ้นอยู่กับสถานการณ์

🚀 Caching Layer ให้ response ได้เร็วขึ้น#

บางครั้ง API ที่เรียกไม่ได้เปลี่ยนแปลงข้อมูลบ่อย การใช้ cache เข้ามาช่วยเป็นเทคนิคพื้นฐานที่ช่วยให้ Laravel สามารถรองรับ traffic ที่สูงขึ้นได้ และในกรณีนี้เราสามารถนำ cache มาใช้ได้ตั้งแต่ Resource Layer เลย

app/Http/Resources/CachedResource.php
class CachedResource extends JsonResource {
    public function toArray(Request $request): array {
        return Cache::remember("resource:{$this->id}", 60, function() use ($request) {
            return parent::toArray($request);
        });
    }
}
php

เพียงเท่านี้ API ของเราก็สามารถรองรับ traffic ที่มากขึ้นได้แล้ว

🛠️ Repository Pattern Integration จัดการ Model อย่างมีประสิทธิภาพ#

แม้ตัวของ Resource เองจะจัดการ Model relations ได้อยู่แล้ว แต่เมื่อระบบใหญ่ขึ้น เรามักจะใช้ Repository Pattern เข้ามาช่วยจัดการโครงสร้างของ Model อยู่แล้ว

เราจึงสามารถนำ Repository Pattern นี้มาต่อยอดใช้กับ API Resource ได้เลยโดยที่ไม่ต้องให้ Resource จัดการ Model relations เอง

$users = $this->userRepository->getWithRelations();
return UserResource::collection($users);
php

ด้วย Pattern นี้จะทำให้เราสามารถนำ Repository Pattern มา reuse ใช้ได้อย่างมีประสิทธิภาพมากยิ่งขึ้น

🔗 whenLoaded เลี่ยง N+1 ตั้งแต่ภายใน Resource#

ไม่ว่าจะใช้ Repository หรือไม่ก็ตาม เมื่อ resource ต้องส่ง relationship ออกไปด้วย ปัญหาคลาสสิกที่มักเจอคือ N+1 query กล่าวคือถ้าใน toArray เราเรียก $this->posts ตรง ๆ ขณะที่ controller ยังไม่ได้ eager load มา Laravel จะวิ่ง query เพิ่มทีละ record ให้แทบทุกตัว

Laravel มี method whenLoaded() ไว้จัดการตรงนี้แบบ native คือจะใส่ relationship เข้า response ก็ต่อเมื่อ relationship นั้นถูก load มาแล้วเท่านั้น ถ้ายังไม่ load ก็จะตัด key ออกให้ ไม่ไปยิง query เพิ่มเอง

app/Http/Resources/UserResource.php
use App\Http\Resources\PostResource;

public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        // ใส่ posts เฉพาะเมื่อ controller eager load มาแล้ว
        'posts' => PostResource::collection($this->whenLoaded('posts')),
        'posts_count' => $this->whenCounted('posts'),
        'created_at' => $this->created_at,
    ];
}
php

วิธีนี้ทำให้ controller เป็นฝ่ายตัดสินใจว่าจะ load relationship ไหน (เช่น User::with('posts')->get()) ส่วน resource เพียงสะท้อนสิ่งที่ load มาเท่านั้น นี่คือ pattern native หลักในการเลี่ยง N+1 ภายใน resource

ในตัวอย่างยังมี whenCounted('posts') แถมมาด้วย ใช้คู่กับการ load count ใน controller (เช่น $user->loadCount('posts')) เพื่อใส่ posts_count เข้า response เฉพาะเมื่อมีการนับมาแล้ว โดยไม่ต้องดึง record ทั้งหมดมานับเอง

ปิดท้ายด้วยเทคนิคที่ได้มาฟรี ๆ เมื่อ return collection ของ resource จาก paginator โดยส่ง paginator instance เข้า collection ได้ตรง ๆ

routes/api.php
use App\Http\Resources\UserResource;
use App\Models\User;

Route::get('/users', function () {
    return UserResource::collection(User::paginate());
});
php

Laravel จะเติม key links และ meta ที่บอกสถานะของ paginator ให้อัตโนมัติ โดยที่เราไม่ต้องเขียนเอง

{
  "data": [
    { "id": 1, "name": "Eladio Schroeder Sr.", "email": "[email protected]" }
  ],
  "links": {
    "first": "http://example.com/users?page=1",
    "last": "http://example.com/users?page=1",
    "prev": null,
    "next": null
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 1,
    "path": "http://example.com/users",
    "per_page": 15,
    "to": 10,
    "total": 10
  }
}
json

ดังที่กล่าวในหัวข้อ Data Wrapping ไปแล้วว่ากรณี paginated นี้ Laravel จะห่อด้วย data ให้เสมอ แม้เราจะเรียก withoutWrapping() ไว้ก็ตาม เพราะต้องมีที่ให้ meta และ links อยู่ ฉะนั้นถ้าจะออกแบบ envelope ของตัวเอง อย่าลืมเผื่อ key เหล่านี้ไว้ในแผนด้วย

🎯 บทสรุป#

แม้ Laravel Resource จะเป็นเครื่องมือที่ทรงพลังในตัวเองอยู่แล้ว แต่จะเห็นได้ว่าเรายังสามารถประยุกต์และแก้ไขเพิ่มเติมบางอย่าง เพื่อให้การพัฒนาสามารถทำได้ง่ายและยืดหยุ่นมากยิ่งขึ้น อีกทั้งยังช่วยเพิ่ม performance ให้แก่ระบบอีกด้วย

ซึ่งสิ่งที่นำมาประยุกต์ใช้นั้น ก็ล้วนแล้วแต่เป็นพื้นฐานในการเขียน Laravel ทั้งสิ้น หวังว่าบทความนี้จะช่วยให้เห็นประโยชน์ของการออกแบบโค้ดในเชิงพื้นฐานและการประยุกต์ใช้เพื่อต่อยอดให้เกิดประโยชน์สูงสุด

ขอให้มีความสุขกับการเขียนโค้ด 🥰 ☕

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

Laravel API Resource Patterns - ยกระดับการจัดการ Response ให้ทรงพลัง
Author Coffee Stack
Published at 1 กันยายน 2025