# Symfony 7 กับ API Platform: แนวทางปฏิบัติที่ดีที่สุดสำหรับ REST API > คู่มือเชิงลึกสำหรับการสร้าง REST API ด้วย Symfony 7 และ API Platform 4 ครอบคลุม Resource configuration, State Processor, Serialization Groups, Security และการปรับแต่ง performance ด้วยตัวอย่างโค้ดที่ใช้งานได้จริง - Published: 2026-01-12 - Updated: 2026-04-07 - Author: SharpSkill - Tags: symfony, api platform, php, rest api, api development - Reading time: 14 min --- API Platform 4 ที่ทำงานบน Symfony 7 ได้เปลี่ยนวิธีที่ทีมพัฒนา PHP สร้าง REST API ไปอย่างสิ้นเชิง แทนที่จะเขียน controller, serializer และ documentation ด้วยมือ API Platform จะสร้างทุกอย่างจาก PHP attribute เพียงไม่กี่บรรทัด บทความนี้วิเคราะห์แนวทางปฏิบัติที่ใช้ได้จริงในการสร้าง API ระดับ production ด้วย Symfony 7.2 และ API Platform 4.1 > **สิ่งที่ผู้สัมภาษณ์ทดสอบจริง** > > การสัมภาษณ์งาน Symfony ที่เน้น API Platform มักทดสอบความเข้าใจเรื่อง State Processor แทน Event Subscriber ที่ใช้ใน API Platform 2.x, การจัดการ Serialization Groups อย่างถูกต้อง และการออกแบบ security ด้วย Voters ผู้สมัครที่เข้าใจสถาปัตยกรรม Resource-centric ของ API Platform จะโดดเด่นกว่าผู้ที่ใช้เพียง annotation พื้นฐาน ## การตั้งค่า ApiResource: จุดเริ่มต้นที่ถูกต้อง การกำหนด `#[ApiResource]` attribute คือหัวใจของ API Platform ปัญหาที่พบบ่อยคือการตั้งค่า operation ทั้งหมดโดยไม่จำกัดขอบเขต ซึ่งทำให้ API มี endpoint ที่ไม่จำเป็นและเพิ่มพื้นที่ attack surface ```php ['product:list']] ), new Get( normalizationContext: ['groups' => ['product:read']] ), new Post( denormalizationContext: ['groups' => ['product:write']], processor: ProductStateProcessor::class ), new Patch( denormalizationContext: ['groups' => ['product:update']], security: "is_granted('PRODUCT_EDIT', object)" ), new Delete( security: "is_granted('PRODUCT_DELETE', object)" ), ] )] class Product { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['product:list', 'product:read'])] private ?int $id = null; #[ORM\Column(length: 255)] #[Assert\NotBlank] #[Assert\Length(min: 2, max: 255)] #[Groups(['product:list', 'product:read', 'product:write', 'product:update'])] private string $name = ''; #[ORM\Column(type: 'decimal', precision: 10, scale: 2)] #[Assert\Positive] #[Groups(['product:list', 'product:read', 'product:write', 'product:update'])] private string $price = '0.00'; #[ORM\Column(type: 'text', nullable: true)] #[Groups(['product:read', 'product:write'])] private ?string $description = null; // Getters and setters... } ``` การแยก normalization context ระหว่าง `product:list` และ `product:read` เป็นแนวทางสำคัญ collection endpoint ควรส่งข้อมูลที่น้อยกว่า single item endpoint เสมอเพื่อลด payload และ query complexity ## Serialization Groups: การควบคุม Payload อย่างละเอียด การออกแบบ Serialization Groups ที่ไม่ดีเป็นต้นตอของปัญหา performance ใน API Platform หลายโปรเจค ปัญหาที่พบบ่อยคือการ expose ข้อมูลที่ไม่จำเป็นหรือทำให้เกิด circular reference ```php ['order:list'], 'enable_max_depth' => true ] ), new Get( normalizationContext: [ 'groups' => ['order:read'], 'enable_max_depth' => true ] ), ] )] class Order { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['order:list', 'order:read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: Customer::class)] #[Groups(['order:list', 'order:read'])] #[MaxDepth(1)] private ?Customer $customer = null; #[ORM\OneToMany(targetEntity: OrderItem::class, mappedBy: 'order', cascade: ['persist'])] #[Groups(['order:read'])] #[MaxDepth(1)] private Collection $items; #[ORM\Column(type: 'decimal', precision: 12, scale: 2)] #[Groups(['order:list', 'order:read'])] private string $totalAmount = '0.00'; public function __construct() { $this->items = new ArrayCollection(); } // Getters and setters... } ``` `#[MaxDepth(1)]` ป้องกัน circular reference ที่เกิดจากความสัมพันธ์แบบ bidirectional การใช้ `enable_max_depth` ใน context ร่วมกับ `#[MaxDepth]` attribute เป็นวิธีที่ถูกต้องใน API Platform 4 > **API Platform 4: การเปลี่ยนแปลงสำคัญจาก 2.x** > > API Platform 4 ได้ยกเลิก `DataPersister` และ `DataProvider` ที่ใช้ใน 2.x แทนที่ด้วย `StateProcessor` และ `StateProvider` สถาปัตยกรรมใหม่นี้แยก concern ชัดเจนกว่าและรองรับ async processing ได้ดีกว่า ผู้สมัครที่ยังอ้างถึง DataPersister ในการสัมภาษณ์แสดงให้เห็นว่าไม่ได้ติดตาม ecosystem ปัจจุบัน ## State Processor: การแทนที่ DataPersister State Processor คือจุดที่ business logic ทั้งหมดควรอยู่ใน API Platform 4 การใส่ logic ใน Entity หรือ Controller ถือเป็น anti-pattern ที่ผู้สัมภาษณ์ระดับ senior จะตรวจจับได้ทันที ```php getSlug() === null) { $product->setSlug( $this->slugGenerator->generate($product->getName()) ); } // Initialize inventory record if ($product->getId() === null) { $this->inventoryService->initializeForProduct($product); } $this->entityManager->persist($product); $this->entityManager->flush(); $this->logger->info('Product processed', [ 'id' => $product->getId(), 'name' => $product->getName(), ]); return $product; } } ``` การ inject `EntityManagerInterface` โดยตรงใน State Processor เป็นวิธีที่ถูกต้อง API Platform 4 ได้ออกแบบให้ State Processor รับผิดชอบการ persist ข้อมูลเองแทนที่จะให้ framework จัดการโดยอัตโนมัติเหมือนใน 2.x ## State Provider: การ Customize Data Fetching เมื่อต้องการ data ที่ไม่ได้มาจาก Doctrine โดยตรง หรือต้องการ transform data ก่อนส่ง State Provider คือคำตอบที่ถูกต้อง ```php security->getUser(); // Different query logic based on user role if ($this->security->isGranted('ROLE_ADMIN')) { return $this->productRepository->findAll(); } if ($this->security->isGranted('ROLE_VENDOR')) { return $this->productRepository->findByVendor($user); } // Public users see only published products return $this->productRepository->findPublished(); } } ``` ตัวอย่างนี้แสดงให้เห็น separation of concerns ที่ชัดเจน: logic สำหรับการกำหนดว่าจะ fetch ข้อมูลอะไรอยู่ใน Provider ส่วน logic สำหรับการบันทึกข้อมูลอยู่ใน Processor ## Security ด้วย Voters: ระดับ Object-Level การใช้ `security` attribute ใน operation เพียงตรวจสอบ role ทั่วไปนั้นไม่เพียงพอสำหรับ API ระดับ production ควรใช้ Voter เพื่อตรวจสอบสิทธิ์ระดับ object ```php getUser(); if (!$user instanceof User) { return false; } /** @var Product $product */ $product = $subject; return match ($attribute) { self::VIEW => $this->canView($product, $user), self::EDIT => $this->canEdit($product, $user), self::DELETE => $this->canDelete($product, $user), default => false, }; } private function canView(Product $product, User $user): bool { // Published products are visible to all authenticated users if ($product->isPublished()) { return true; } // Unpublished products only visible to owner or admin return $product->getOwner() === $user || in_array('ROLE_ADMIN', $user->getRoles()); } private function canEdit(Product $product, User $user): bool { return $product->getOwner() === $user || in_array('ROLE_ADMIN', $user->getRoles()); } private function canDelete(Product $product, User $user): bool { return in_array('ROLE_ADMIN', $user->getRoles()); } } ``` > **ข้อผิดพลาดที่พบบ่อยใน Security** > > การใช้ `security="is_granted('ROLE_USER')"` เพียงอย่างเดียวใน operation ไม่ได้ป้องกัน user คนหนึ่งจากการแก้ไข resource ของ user อีกคน ต้องใช้ Voter ที่ตรวจสอบ ownership ระดับ object เสมอ นี่คือช่องโหว่ที่พบบ่อยใน API ที่สร้างด้วย API Platform โดยผู้ที่ไม่มีประสบการณ์เพียงพอ ## JWT Authentication: การตั้งค่ากับ LexikJWTAuthenticationBundle การรวม JWT กับ API Platform ต้องการการตั้งค่าที่ถูกต้องทั้งใน `security.yaml` และ `api_platform.yaml` เพื่อให้ OpenAPI documentation แสดง authentication scheme ได้ถูกต้อง ```yaml # config/packages/api_platform.yaml api_platform: title: 'Product API' version: '1.0.0' formats: jsonld: ['application/ld+json'] json: ['application/json'] html: ['text/html'] swagger: api_keys: JWT: name: Authorization type: header defaults: stateless: true cache_headers: vary: ['Content-Type', 'Authorization', 'Origin'] extra_properties: standard_put: true rfc_7807_compliant_errors: true ``` ```yaml # config/packages/security.yaml security: providers: app_user_provider: entity: class: App\Entity\User property: email firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false login: pattern: ^/api/auth/login stateless: true json_login: check_path: /api/auth/login username_path: email password_path: password success_handler: lexik_jwt_authentication.handler.authentication_success failure_handler: lexik_jwt_authentication.handler.authentication_failure api: pattern: ^/api stateless: true jwt: ~ access_control: - { path: ^/api/auth/login, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } ``` ## Custom Filter: การค้นหาที่ยืดหยุ่น API Platform มี filter ในตัวหลายประเภท แต่สำหรับ business logic ที่ซับซ้อน จำเป็นต้องสร้าง custom filter ตัวอย่างนี้แสดงการสร้าง filter สำหรับค้นหาสินค้าตามช่วงราคา ```php getRootAliases()[0]; if (isset($value['min'])) { $minParam = $queryNameGenerator->generateParameterName('price_min'); $queryBuilder ->andWhere(sprintf('%s.price >= :%s', $alias, $minParam)) ->setParameter($minParam, $value['min']); } if (isset($value['max'])) { $maxParam = $queryNameGenerator->generateParameterName('price_max'); $queryBuilder ->andWhere(sprintf('%s.price <= :%s', $alias, $maxParam)) ->setParameter($maxParam, $value['max']); } } public function getDescription(string $resourceClass): array { return [ 'price_range[min]' => [ 'property' => 'price', 'type' => 'float', 'required' => false, 'description' => 'Minimum price filter', ], 'price_range[max]' => [ 'property' => 'price', 'type' => 'float', 'required' => false, 'description' => 'Maximum price filter', ], ]; } } ``` การนำ filter ไปใช้กับ Entity ทำได้ด้วย attribute: ```php 'partial', 'category.name' => 'exact' ])] #[ApiFilter(OrderFilter::class, properties: ['name', 'price', 'createdAt'])] #[ApiFilter(PriceRangeFilter::class)] class Product { // ... } ``` > **URL Filter Format** > > หลังจาก register filter แล้ว API จะรองรับ query string ในรูปแบบ: `/api/products?price_range[min]=10&price_range[max]=100&order[price]=asc` API Platform จะสร้าง OpenAPI documentation สำหรับ filter เหล่านี้โดยอัตโนมัติผ่าน `getDescription()` method ## Pagination และ Performance Optimization การ configure pagination อย่างถูกต้องใน API Platform มีผลอย่างมากต่อ performance โดยเฉพาะเมื่อ collection มีข้อมูลจำนวนมาก ```php ['product:list']], paginationEnabled: true, paginationItemsPerPage: 20, paginationMaximumItemsPerPage: 100, paginationClientItemsPerPage: true, ), ] )] class Product { // ... } ``` สำหรับ collection ขนาดใหญ่ ควรใช้ cursor-based pagination แทน page-based pagination: ```php createQueryBuilder('p') ->where('p.published = :published') ->setParameter('published', true) ->orderBy('p.createdAt', 'DESC') ->getQuery() ->getResult(); } public function findByVendor(mixed $vendor): array { return $this->createQueryBuilder('p') ->where('p.owner = :vendor') ->setParameter('vendor', $vendor) ->orderBy('p.createdAt', 'DESC') ->getQuery() ->getResult(); } /** * Cursor-based pagination for large datasets. * @return Product[] */ public function findAfterCursor(int $cursor, int $limit = 20): array { return $this->createQueryBuilder('p') ->where('p.id > :cursor') ->setParameter('cursor', $cursor) ->orderBy('p.id', 'ASC') ->setMaxResults($limit) ->getQuery() ->getResult(); } } ``` ## HTTP Caching: การเพิ่ม Performance ด้วย Cache Headers API Platform 4 มีระบบ HTTP caching ที่ทรงพลังผ่าน cache headers และรองรับ Varnish หรือ Symfony's built-in HTTP cache ```php 60, 'shared_max_age' => 120, 'vary' => ['Authorization', 'Accept-Language'], ] ), new Get( cacheHeaders: [ 'max_age' => 300, 'shared_max_age' => 600, ] ), ] )] class Product { // ... } ``` สำหรับ resource ที่ต้องการ cache invalidation อัตโนมัติ API Platform รองรับ `PurgeHttpCacheListener` ที่ทำงานร่วมกับ Varnish: ```php [ 'vary' => ['Content-Type', 'Authorization'], ] ] )] class Product { // ... } ``` > **API Platform กับ Mercure Protocol** > > API Platform 4 มี built-in support สำหรับ Mercure protocol ซึ่งเปิดใช้งาน real-time update ผ่าน Server-Sent Events การเพิ่ม `#[ApiResource(mercure: true)]` จะทำให้ API ส่ง event ไปยัง Mercure hub โดยอัตโนมัติเมื่อ resource ถูกสร้าง แก้ไข หรือลบ เหมาะสำหรับแอปพลิเคชันที่ต้องการ live update ## Validation: การใช้ Symfony Constraints กับ Groups การแยก validation group ตาม operation ทำให้ควบคุม validation logic ได้ละเอียดกว่าการใช้ constraint เดียวกันทุก operation ```php ['user:create']], validationContext: ['groups' => ['Default', 'user:create']] ), new Patch( denormalizationContext: ['groups' => ['user:update']], validationContext: ['groups' => ['Default', 'user:update']], security: "is_granted('USER_EDIT', object)" ), ] )] class User { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 180, unique: true)] #[Assert\NotBlank] #[Assert\Email] #[Groups(['user:create', 'user:read'])] private string $email = ''; #[ORM\Column] #[Assert\NotBlank(groups: ['user:create'])] #[Assert\Length(min: 8, groups: ['user:create', 'user:update'])] #[Groups(['user:create', 'user:update'])] private string $plainPassword = ''; #[ORM\Column(length: 100)] #[Assert\NotBlank] #[Assert\Length(min: 2, max: 100)] #[Groups(['user:create', 'user:update', 'user:read'])] private string $displayName = ''; // Getters and setters... } ``` ## Error Handling: RFC 7807 Problem Details API Platform 4 รองรับ RFC 7807 (Problem Details for HTTP APIs) โดยค่าเริ่มต้น การ configure `rfc_7807_compliant_errors: true` ทำให้ error response มีรูปแบบมาตรฐานที่ client สามารถ parse ได้อย่างสม่ำเสมอ ```php productName; } } ``` ```php getItems() as $item) { $available = $this->stockService->getAvailableStock($item->getProduct()); if ($item->getQuantity() > $available) { throw new InsufficientStockException( $item->getProduct()->getName(), $item->getQuantity(), $available ); } } $this->entityManager->persist($order); $this->entityManager->flush(); return $order; } } ``` เมื่อ `InsufficientStockException` ถูก throw API Platform จะแปลงเป็น response ในรูปแบบ RFC 7807 โดยอัตโนมัติ: ```json { "@context": "/api/contexts/Error", "@type": "hydra:Error", "hydra:title": "An error occurred", "hydra:description": "Insufficient stock for product \"Widget Pro\": requested 10, available 3", "status": 422, "title": "Unprocessable Content", "detail": "Insufficient stock for product \"Widget Pro\": requested 10, available 3" } ``` ## Testing: Functional Tests กับ ApiTestCase การทดสอบ API Platform ที่มีประสิทธิภาพใช้ `ApiTestCase` ซึ่ง extend จาก `WebTestCase` ของ Symfony พร้อมกับ assertion methods ที่เฉพาะเจาะจงสำหรับ API ```php true]); ProductFactory::createMany(3, ['published' => false]); $response = static::createClient()->request('GET', '/api/products'); $this->assertResponseIsSuccessful(); $this->assertResponseHeaderSame( 'content-type', 'application/ld+json; charset=utf-8' ); $this->assertJsonContains([ '@context' => '/api/contexts/Product', '@type' => 'hydra:Collection', 'hydra:totalItems' => 5, ]); } public function testCreateProductRequiresAuthentication(): void { static::createClient()->request('POST', '/api/products', [ 'json' => [ 'name' => 'New Product', 'price' => '29.99', ], ]); $this->assertResponseStatusCodeSame(401); } public function testCreateProductAsAuthenticatedUser(): void { $user = UserFactory::createOne(['roles' => ['ROLE_VENDOR']]); $token = $this->generateJwtToken($user->object()); $response = static::createClient()->request('POST', '/api/products', [ 'auth_bearer' => $token, 'json' => [ 'name' => 'Premium Widget', 'price' => '49.99', 'description' => 'A high-quality widget', ], ]); $this->assertResponseStatusCodeSame(201); $this->assertJsonContains([ 'name' => 'Premium Widget', 'price' => '49.99', ]); } private function generateJwtToken(mixed $user): string { $jwtManager = static::getContainer()->get('lexik_jwt_authentication.jwt_manager'); return $jwtManager->create($user); } } ``` > **Zenstruck Foundry สำหรับ Test Fixtures** > > การใช้ `zenstruck/foundry` ร่วมกับ `ResetDatabase` trait ทำให้แต่ละ test เริ่มต้นด้วยฐานข้อมูลที่สะอาดเสมอ Factory pattern ของ Foundry ทำให้สร้าง test data ได้ง่ายและชัดเจน ซึ่งดีกว่าการใช้ fixtures แบบ static ที่ต้องดูแลรักษาแยกต่างหาก ## สรุป: แนวทางปฏิบัติที่ดีที่สุด การพัฒนา API ด้วย Symfony 7 และ API Platform 4 ที่มีคุณภาพสูงต้องยึดหลักสำคัญดังนี้: ใช้ State Processor แทน DataPersister, ออกแบบ Serialization Groups แยกตามความต้องการของแต่ละ operation, ใช้ Voter สำหรับ security ระดับ object, กำหนด validation groups ให้ตรงกับ operation และเขียน functional test ด้วย `ApiTestCase` เสมอ สถาปัตยกรรม Resource-centric ของ API Platform บังคับให้ developer คิดถึง API design ก่อนที่จะเขียน implementation ซึ่งส่งผลให้ได้ API ที่สอดคล้องกับมาตรฐาน REST และ Hydra/JSON-LD ตั้งแต่ต้น การทำความเข้าใจ architectural decision เหล่านี้ไม่ใช่แค่การสอบผ่านการสัมภาษณ์ แต่คือการสร้าง API ที่ maintainable ในระยะยาว --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/symfony/symfony-7-api-platform-best-practices