Hướng Dẫn Xây Dựng REST API Hoàn Chỉnh Với Symfony 7
Giới Thiệu
Trong bài viết này, chúng ta sẽ tìm hiểu cách xây dựng một REST API hiện đại bằng Symfony 7. Chúng ta sẽ từ việc xác thực dữ liệu với DTOs, đến việc viết controller sạch và những thực tiễn tốt nhất để duy trì mã nguồn. Ví dụ trong bài viết này sẽ là một API về cocktail 🍸.
Bạn sẽ học cách:
- ✅ Sử dụng DTOs để xác thực các yêu cầu đầu vào
- ✅ Ánh xạ yêu cầu vào một Entity với ObjectMapper
- ✅ Giữ cho các controller gọn gàng và dễ kiểm thử
Tạo Cocktail (Phương Thức POST)
Khi xây dựng các API hiện đại, việc sử dụng DTOs (Data Transfer Objects) và xác thực sẽ giúp mã nguồn của bạn sạch, an toàn và dễ bảo trì.
DTO Với Xác Thực
DTO này đảm bảo rằng bất kỳ yêu cầu nào gửi đến API của bạn đều tuân theo các quy tắc đúng (ví dụ: độ dài tên, URL hợp lệ, ít nhất một thành phần).
php
<?php
namespace App\Dto;
use App\Entity\Cocktail;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;
#[Map(target: Cocktail::class)]
final readonly class CreateCocktailRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 255)]
public string $name,
#[Assert\NotBlank]
#[Assert\Length(min: 10)]
public string $description,
#[Assert\NotBlank]
public string $instructions,
#[Assert\NotBlank]
#[Assert\Count(min: 1)]
public array $ingredients,
#[Assert\Range(min: 1, max: 5)]
public int $difficulty,
public bool $isAlcoholic,
#[Assert\Url]
public ?string $imageUrl,
) {
}
}
Controller
Nhờ vào thành phần ObjectMapper, việc ánh xạ giữa các DTO và entities trở nên dễ dàng.
php
<?php
namespace App\Controller\Api;
use App\Dto\CreateCocktailRequest;
use App\Entity\Cocktail;
use App\Repository\CocktailRepository;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
use Symfony\Component\Routing\Attribute\Route;
class CreateCocktailController
{
public function __construct(
private readonly CocktailRepository $cocktailRepository,
private readonly ObjectMapperInterface $objectMapper
) {
}
#[Route('/api/cocktails', name: 'api.cocktails.create', methods: ['POST'])]
public function __invoke(#[MapRequestPayload] CreateCocktailRequest $request): Response
{
$cocktail = $this->objectMapper->map($request, Cocktail::class);
$this->cocktailRepository->save($cocktail);
return new Response(null, Response::HTTP_CREATED);
}
}
Liệt Kê Tất Cả Các Cocktail (Phương Thức GET)
Sau khi tạo cocktail, chúng ta hãy xây dựng endpoint để liệt kê chúng với các bộ lọc.
Sử dụng Symfony 7, chúng ta có thể giữ mọi thứ sạch sẽ và an toàn với DTO, bộ lọc repository và ánh xạ truy vấn tự động.
Query DTO
DTO này làm cho các tham số truy vấn trở nên rõ ràng (tên, có rượu, độ khó, phân trang…).
php
<?php
namespace App\Dto;
final readonly class ListCocktailsQuery
{
public function __construct(
public ?string $name = null,
public ?bool $isAlcoholic = null,
public ?int $difficulty = null,
public int $page = 1,
public int $itemsPerPage = 10,
) {
}
}
Repository
Ở đây, repository xử lý việc lọc + phân trang một cách đơn giản và có thể tái sử dụng.
php
/**
* @return Cocktail[]
*/
public function findAllWithFilters(ListCocktailsQuery $query): array
{
$qb = $this->createQueryBuilder('cocktail');
if ($query->name) {
$qb
->andWhere('cocktail.name LIKE :name')
->setParameter('name', "%$query->name%");
}
if (null !== $query->isAlcoholic) {
$qb
->andWhere('cocktail.isAlcoholic = :isAlcoholic')
->setParameter('isAlcoholic', $query->isAlcoholic);
}
if ($query->difficulty) {
$qb
->andWhere('cocktail.difficulty = :difficulty')
->setParameter('difficulty', $query->difficulty);
}
$offset = ($query->page - 1) * $query->itemsPerPage;
$qb
->setFirstResult($offset)
->setMaxResults($query->itemsPerPage);
return $qb->getQuery()->getResult();
}
Controller
Nhờ vào #[MapQueryString], Symfony 7 tự động ánh xạ các tham số truy vấn vào DTO 💡.
php
<?php
namespace App\Controller\Api;
use App\Dto\ListCocktailsQuery;
use App\Repository\CocktailRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
class ListCocktailsController
{
public function __construct(
private readonly CocktailRepository $cocktailRepository,
private readonly SerializerInterface $serializer,
) {
}
#[Route('/api/cocktails', name: 'api.cocktails.list', methods: ['GET'])]
public function __invoke(#[MapQueryString] ListCocktailsQuery $filter): Response
{
$cocktails = $this->cocktailRepository->findAllWithFilters($filter);
$data = $this->serializer->serialize($cocktails, 'json', [
'groups' => ['cocktail:read']
]);
return JsonResponse::fromJsonString($data);
}
}
Hiển Thị Một Cocktail (GET)
Sau khi liệt kê cocktail, hãy thêm endpoint để lấy một cocktail đơn lẻ theo ID của nó.
Với Symfony 7, điều này trở nên cực kỳ sạch sẽ nhờ vào #[MapEntity]:
Controller
php
<?php
namespace App\Controller\Api;
use App\Entity\Cocktail;
use App\Repository\CocktailRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
class ShowCocktailController
{
public function __construct(
private readonly SerializerInterface $serializer,
) {
}
#[Route('/api/cocktails/{id}', name: 'api.cocktails.show', methods: ['GET'])]
public function __invoke(#[MapEntity(expr: 'repository.find(id)', message: 'Not found')] Cocktail $cocktail): Response
{
$data = $this->serializer->serialize($cocktail, 'json', [
'groups' => ['cocktail:read']
]);
return JsonResponse::fromJsonString($data);
}
}
Cập Nhật Cocktail
Thời gian để làm cho API của chúng ta có thể chỉnh sửa.
Với Symfony 7, chúng ta có thể chấp nhận các cập nhật một phần (PATCH) và thay thế hoàn toàn (PUT) bằng cách sử dụng DTO cập nhật dành riêng + ObjectMapper để giữ cho các controller gọn gàng và an toàn.
Update DTO
php
<?php
namespace App\Dto;
use App\Entity\Cocktail;
use Symfony\Component\ObjectMapper\Attribute\Map;
use Symfony\Component\Validator\Constraints as Assert;
#[Map(target: Cocktail::class)]
final readonly class UpdateCocktailRequest
{
public function __construct(
#[Assert\Length(max: 255)]
public ?string $name = null,
#[Assert\Length(min: 10)]
public ?string $description = null,
#[Assert\NotBlank]
public ?string $instructions = null,
#[Assert\NotBlank]
#[Assert\Count(min: 1)]
public ?array $ingredients = null,
#[Assert\Range(min: 1, max: 5)]
public ?int $difficulty = null,
public ?bool $isAlcoholic = null,
#[Assert\Url]
public ?string $imageUrl = null,
) {}
}
Controller
php
<?php
namespace App\Controller\Api;
use App\Dto\UpdateCocktailRequest;
use App\Entity\Cocktail;
use App\Repository\CocktailRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
class UpdateCocktailController
{
public function __construct(
private readonly CocktailRepository $cocktailRepository,
private readonly ObjectMapperInterface $objectMapper,
private readonly SerializerInterface $serializer
) {
}
#[Route('/api/cocktails/{id}', name: 'api.cocktails.update', methods: ['PUT', 'PATCH'])]
public function __invoke(
#[MapEntity(expr: 'repository.find(id)', message: 'Not found')] Cocktail $cocktail,
#[MapRequestPayload] UpdateCocktailRequest $request
): JsonResponse {
$updatedCocktail = $this->objectMapper->map($request, $cocktail);
$this->cocktailRepository->save($updatedCocktail);
$data = $this->serializer->serialize($updatedCocktail, 'json', [
'groups' => ['cocktail:read']
]);
return JsonResponse::fromJsonString($data);
}
}
Xóa Cocktail (Phương Thức DELETE)
Phần cuối cùng của API CRUD của chúng ta: xóa một tài nguyên.
Với Symfony 7, nó vẫn giữ được sự sạch sẽ và tối giản nhờ vào #[MapEntity].
Controller
php
<?php
namespace App\Controller\Api;
use App\Entity\Cocktail;
use App\Repository\CocktailRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class DeleteCocktailController
{
public function __construct(
private readonly CocktailRepository $cocktailRepository
) {
}
#[Route('/api/cocktails/{id}', name: 'api.cocktails.delete', methods: ['DELETE'])]
public function __invoke( #[MapEntity(expr: 'repository.find(id)', message: 'Not found')] Cocktail $cocktail): Response
{
$this->cocktailRepository->remove($cocktail);
return new Response(null, Response::HTTP_NO_CONTENT);
}
}
Xác Thực API Key
Sau khi CRUD đã sẵn sàng, bước tiếp theo là bảo mật API.
Dưới đây là cách triển khai một xác thực API key tùy chỉnh bằng cách sử dụng hệ thống Security của Symfony 7.
Xác Thực Tùy Chỉnh
php
<?php
namespace App\Security;
use App\Entity\User;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
class ApiKeyAuthenticator extends AbstractAuthenticator
{
public function supports(Request $request): ?bool
{
return $request->headers->has('API-KEY');
}
public function authenticate(Request $request): Passport
{
$apiKey = $request->headers->get('API-KEY');
if ($apiKey !== 'secret') {
throw new AuthenticationException('Invalid API key');
}
return new SelfValidatingPassport(
new UserBadge($apiKey, fn() => (new User())->setUsername('API-USER'))
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null;
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
$data = [
'message' => strtr($exception->getMessageKey(), $exception->getMessageData()),
];
return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
}
}
Cấu Hình security.yaml
yaml
firewalls:
main:
custom_authenticators:
- App\Security\ApiKeyAuthenticator
lazy: true
provider: app_user_provider
stateless: true
Kết Luận
Vậy là chúng ta đã xây dựng xong một REST API CRUD hoàn chỉnh với Symfony 7 🚀
- Tạo cocktail (POST)
- Đọc cocktail (GET tất cả / GET theo id)
- Cập nhật cocktail (PUT/PATCH)
- Xóa cocktail (DELETE)
Trong suốt quá trình này, chúng ta đã sử dụng:
- ✅ DTOs cho việc xác thực yêu cầu sạch sẽ
- ✅ ObjectMapper cho việc ánh xạ giữa DTOs và entities
- ✅ Serializer với các nhóm cho phản hồi JSON có cấu trúc
- ✅ MapEntity / MapRequestPayload / MapQueryString cho các controller sạch hơn
Cách tiếp cận này giữ cho các controller tối thiểu, mã nguồn dễ bảo trì và APIs dễ dự đoán.
👉 Để tìm hiểu thêm, hãy xem video hướng dẫn đầy đủ với các bước bổ sung (xác thực, bộ lọc, triển khai trên Cloudways): Video Hướng Dẫn