Symfony 7: Build a Complete REST API (Serializer, Validation & Authentication)



This content originally appeared on DEV Community and was authored by Pentiminax

Learn how to build a modern REST API with Symfony 7 – from data validation with DTOs, to clean controllers, and best practices for maintainability.

In this example, we create a cocktails API 🍸

You’ll see how to:

✅ Use DTOs to validate incoming requests
✅ Map the request into an Entity with the ObjectMapper
✅ Keep controllers lean and easy to test

Create a cocktail (POST method)

When building modern APIs, using DTOs (Data Transfer Objects) and validation keeps your code clean, secure, and maintainable.

DTO with Validation

This DTO ensures that any request sent to your API follows the right rules (e.g. name length, valid URL, at least one ingredient).

<?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

Thanks to the ObjectMapper component, mapping between DTOs and entities is effortless.

<?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);
    }
}

List all cocktails (GET method)

After creating cocktails, let’s build the endpoint to list them with filters.
Using Symfony 7, we can keep things clean and type-safe with a DTO, repository filters, and automatic query mapping.

Query DTO

This DTO makes query parameters explicit (name, isAlcoholic, difficulty, pagination…).

<?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

Here, the repository handles filtering + pagination in a simple, reusable way.

    /**
     * @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

Thanks to #[MapQueryString], Symfony 7 automatically maps query parameters into the DTO 💡.

<?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\MapQueryParameter;
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);
    }
}

This approach keeps your controllers slim, your queries explicit, and your API predictable.

Show one cocktail (GET)

After listing cocktails, let’s add the endpoint to fetch a single cocktail by its ID.

With Symfony 7, this becomes extremely clean thanks to #[MapEntity]:

Controller

<?php

namespace App\Controller\Api;

use App\Dto\ListCocktailsQuery;
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\HttpKernel\Attribute\MapQueryString;
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);
    }
}

💡 With #[MapEntity], Symfony automatically fetches the Cocktail entity based on the {id} parameter.
If the entity does not exist, it returns a 404 Not Found automatically with your custom message.

Update a cocktail

Time to make our API editable.
With Symfony 7, we can accept partial updates (PATCH) and full replacements (PUT) using a dedicated Update DTO + the ObjectMapper to keep controllers slim and safe.

Update DTO

<?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

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);
    }
}

Delete a cocktail (DELETE method)

The last piece of our CRUD API: deleting a resource.
With Symfony 7, it stays clean and minimal thanks to #[MapEntity].

Controller

<?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);
    }
}

API Key Authentication

Once our CRUD is ready, the next step is securing the API.
Here’s how to implement a custom API key authenticator using Symfony 7’s Security system.

Custom Authenticator

<?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);
    }
}

security.yaml

    firewalls:
        main:
            custom_authenticators:
                - App\Security\ApiKeyAuthenticator
            lazy: true
            provider: app_user_provider
            stateless: true

Conclusion

And that’s it — we’ve just built a complete CRUD REST API with Symfony 7 🚀

  • Create a cocktail (POST)
  • Read cocktails (GET all / GET by id)
  • Update a cocktail (PUT/PATCH)
  • Delete a cocktail (DELETE)

Along the way, we used:

✅ DTOs for clean request validation
✅ ObjectMapper for mapping between DTOs and entities
✅ Serializer with groups for structured JSON responses
✅ MapEntity / MapRequestPayload / MapQueryString for cleaner controllers

This approach keeps controllers minimal, code maintainable, and APIs predictable.

👉 Full tutorial with extra steps (auth, filters, deployment on Cloudways): https://www.youtube.com/watch?v=Cd_9K749KfY


This content originally appeared on DEV Community and was authored by Pentiminax