Symfony 7 กับ API Platform: แนวทางปฏิบัติที่ดีที่สุดสำหรับ REST API

คู่มือเชิงลึกสำหรับการสร้าง REST API ด้วย Symfony 7 และ API Platform 4 ครอบคลุม Resource configuration, State Processor, Serialization Groups, Security และการปรับแต่ง performance ด้วยตัวอย่างโค้ดที่ใช้งานได้จริง

Symfony 7 และ API Platform 4 สำหรับการพัฒนา REST API แบบ professional

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
<?php
// src/Entity/Product.php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Patch;
use App\State\ProductStateProcessor;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource(
    operations: [
        new GetCollection(
            normalizationContext: ['groups' => ['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
<?php
// src/Entity/Order.php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\MaxDepth;

#[ORM\Entity]
#[ApiResource(
    operations: [
        new GetCollection(
            normalizationContext: [
                'groups' => ['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
<?php
// src/State/ProductStateProcessor.php
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Product;
use App\Service\InventoryService;
use App\Service\SlugGeneratorService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;

class ProductStateProcessor implements ProcessorInterface
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly InventoryService $inventoryService,
        private readonly SlugGeneratorService $slugGenerator,
        private readonly LoggerInterface $logger
    ) {}

    public function process(
        mixed $data,
        Operation $operation,
        array $uriVariables = [],
        array $context = []
    ): Product {
        /** @var Product $product */
        $product = $data;

        // Generate slug from name
        if ($product->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
<?php
// src/State/ProductCollectionProvider.php
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Product;
use App\Repository\ProductRepository;
use Symfony\Bundle\SecurityBundle\Security;

class ProductCollectionProvider implements ProviderInterface
{
    public function __construct(
        private readonly ProductRepository $productRepository,
        private readonly Security $security
    ) {}

    public function provide(
        Operation $operation,
        array $uriVariables = [],
        array $context = []
    ): iterable {
        $user = $this->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
<?php
// src/Security/Voter/ProductVoter.php
namespace App\Security\Voter;

use App\Entity\Product;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class ProductVoter extends Voter
{
    public const EDIT = 'PRODUCT_EDIT';
    public const DELETE = 'PRODUCT_DELETE';
    public const VIEW = 'PRODUCT_VIEW';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, [self::EDIT, self::DELETE, self::VIEW])
            && $subject instanceof Product;
    }

    protected function voteOnAttribute(
        string $attribute,
        mixed $subject,
        TokenInterface $token
    ): bool {
        $user = $token->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
<?php
// src/Filter/PriceRangeFilter.php
namespace App\Filter;

use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;

final class PriceRangeFilter extends AbstractFilter
{
    protected function filterProperty(
        string $property,
        mixed $value,
        QueryBuilder $queryBuilder,
        QueryNameGeneratorInterface $queryNameGenerator,
        string $resourceClass,
        Operation $operation = null,
        array $context = []
    ): void {
        if ($property !== 'price_range') {
            return;
        }

        if (!isset($value['min']) && !isset($value['max'])) {
            return;
        }

        $alias = $queryBuilder->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
<?php
// src/Entity/Product.php — เพิ่ม filter attribute
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use App\Filter\PriceRangeFilter;

#[ApiResource(/* ... */)]
#[ApiFilter(SearchFilter::class, properties: [
    'name' => '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
<?php
// src/Entity/Product.php — Pagination configuration
#[ApiResource(
    operations: [
        new GetCollection(
            normalizationContext: ['groups' => ['product:list']],
            paginationEnabled: true,
            paginationItemsPerPage: 20,
            paginationMaximumItemsPerPage: 100,
            paginationClientItemsPerPage: true,
        ),
    ]
)]
class Product
{
    // ...
}

สำหรับ collection ขนาดใหญ่ ควรใช้ cursor-based pagination แทน page-based pagination:

php
<?php
// src/Repository/ProductRepository.php
namespace App\Repository;

use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class ProductRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Product::class);
    }

    public function findPublished(): array
    {
        return $this->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
<?php
// src/Entity/Product.php — Cache configuration
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;

#[ApiResource(
    operations: [
        new GetCollection(
            cacheHeaders: [
                'max_age' => 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
<?php
// src/Entity/Product.php — เพิ่ม cache invalidation tag
use ApiPlatform\Metadata\ApiResource;
use Symfony\Component\HttpFoundation\Response;

#[ApiResource(
    extraProperties: [
        'cache_headers' => [
            '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
<?php
// src/Entity/User.php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Patch;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource(
    operations: [
        new Post(
            denormalizationContext: ['groups' => ['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
<?php
// src/Exception/InsufficientStockException.php
namespace App\Exception;

use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;

class InsufficientStockException extends UnprocessableEntityHttpException
{
    public function __construct(
        private readonly string $productName,
        private readonly int $requested,
        private readonly int $available
    ) {
        parent::__construct(
            sprintf(
                'Insufficient stock for product "%s": requested %d, available %d',
                $productName,
                $requested,
                $available
            )
        );
    }

    public function getProductName(): string
    {
        return $this->productName;
    }
}
php
<?php
// src/State/OrderStateProcessor.php
namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Order;
use App\Exception\InsufficientStockException;
use App\Service\StockService;
use Doctrine\ORM\EntityManagerInterface;

class OrderStateProcessor implements ProcessorInterface
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly StockService $stockService
    ) {}

    public function process(
        mixed $data,
        Operation $operation,
        array $uriVariables = [],
        array $context = []
    ): Order {
        /** @var Order $order */
        $order = $data;

        foreach ($order->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
<?php
// tests/Functional/ProductApiTest.php
namespace App\Tests\Functional;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\Product;
use App\Factory\ProductFactory;
use App\Factory\UserFactory;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;

class ProductApiTest extends ApiTestCase
{
    use ResetDatabase;
    use Factories;

    public function testGetProductCollection(): void
    {
        ProductFactory::createMany(5, ['published' => 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 ในระยะยาว

แท็ก

#symfony
#api platform
#php
#rest api
#api development

แชร์

บทความที่เกี่ยวข้อง